46 Commits

Author SHA1 Message Date
Danny Kim 0920a4e91b Use the unscoped eslint-plugin names in the notes
Check / check (pull_request) Successful in 38s
2026-10-18 22:18:55 +09:00
Danny Kim 627458fb5f Limit the parent-path ban in the notes to imports
Check / check (pull_request) Successful in 1m24s
2026-10-18 03:50:14 +09:00
Danny Kim 50df193a8c Limit the #testing note to tests that use its helpers
Check / check (pull_request) Successful in 58s
2026-10-18 03:40:16 +09:00
Danny Kim 5b568f9ea6 Turn off unreachable code and unused labels in tsconfig
Check / check (pull_request) Successful in 39s
2026-10-18 03:18:25 +09:00
Danny Kim 60c63dee98 Leave the plugin engines floor to the plugins in the toolchain note
Check / check (pull_request) Successful in 3m12s
2026-10-18 03:11:31 +09:00
Danny Kim 0a31dbb820 Drop engines and let the job image pin Node
Check / check (pull_request) Successful in 3m6s
2026-10-18 03:09:55 +09:00
Danny Kim 6ad859be62 Set the agreed tsconfig strictness flags and drop exactOptionalPropertyTypes
Check / check (pull_request) Successful in 44s
2026-10-18 03:05:15 +09:00
Danny Kim 8b5c249248 Note the planned modules plugin and fix a sentence case in the alias notes
Check / check (pull_request) Successful in 38s
2026-10-17 23:14:27 +09:00
Danny Kim 5af53b0882 Wrap the long doc comment lines in the project lint plugin 2026-10-17 23:14:27 +09:00
Danny Kim eba9b9c57c Keep main's one-line guards where no rule needs braces
Check / check (pull_request) Successful in 37s
2026-10-17 18:52:49 +09:00
Danny Kim 3cbe4171ea Describe the Deno size limits in the notes 2026-10-17 18:50:24 +09:00
Danny Kim cd1864e1a9 Restore helper layout in auth, isolation, runtime, and profiles from main 2026-10-17 18:49:28 +09:00
Danny Kim 0c5059e3b4 Restore post.ts from main 2026-10-17 18:48:15 +09:00
Danny Kim c6fc183ad6 Restore run.ts from main 2026-10-17 18:46:52 +09:00
Danny Kim 1f54518320 Match Deno size limits 2026-10-17 18:46:52 +09:00
Danny Kim 999a089374 Prune notes that don't apply to bot-agents and keep aliases on direct src subfolders
Check / check (pull_request) Successful in 38s
2026-10-17 18:12:47 +09:00
Danny Kim 166476dee9 State alias rule v2 in AGENTS.md
Check / check (pull_request) Successful in 37s
2026-10-17 16:02:20 +09:00
Danny Kim c778c00068 Allow .mts and .mjs imports in no-asset-imports
Check / check (pull_request) Successful in 42s
2026-10-17 15:59:45 +09:00
Danny Kim 6b87ff66b9 Fix the quoted old rule name in the alias notes
Check / check (pull_request) Successful in 2m30s
2026-10-17 15:46:05 +09:00
Danny Kim d2d3cbffdb Switch to the renamed @cunarist/eslint-plugin-modules
Check / check (pull_request) Successful in 2m10s
2026-10-17 15:43:47 +09:00
Danny Kim bfa8269801 Route tests through a #testing alias, ban ../ imports, and document the alias ownership rule
Check / check (pull_request) Successful in 46s
2026-10-17 14:45:52 +09:00
Danny Kim 15fe481181 Prune migration notes and align the notes with the current lint setup
Check / check (pull_request) Successful in 39s
2026-10-16 23:41:04 +09:00
Danny Kim 662e946fad Drop the scripts lint override and accept Oxfmt's standard package.json order 2026-10-16 23:41:04 +09:00
Danny Kim ffb7cb9a75 Rename no-non-ts-imports to no-asset-imports, allow .js subpaths, and compare node types by enum member 2026-10-16 23:41:04 +09:00
Danny Kim 2e09945a6a Document that scripts is flat with no subfolders or index files
Check / check (pull_request) Successful in 39s
2026-10-16 23:19:03 +09:00
Danny Kim 934a3cfd03 Remove Deno lint references from the project lint plugin comments
Check / check (pull_request) Successful in 38s
2026-10-16 23:15:37 +09:00
Danny Kim b170f25c7d Document that package.json aliases may target only direct subfolders of src
Agents / agent (pull_request) Has been skipped
Check / check (pull_request) Successful in 1m18s
2026-10-16 22:08:23 +09:00
Danny Kim 006bbe6b0c 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>
2026-10-16 12:43:37 +00:00
Danny Kim f001a71c61 Recognize sign-in failures for every agent and fall through (#19)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-12 02:51:28 +00:00
Danny Kim ea20f6d629 Show the selected bot profile and add OpenRouter fallback (#18)
Uses Codex first, then Claude, then OpenCode through OpenRouter when quota is exhausted.

Every generated comment and review now identifies the selected profile and model in a final quote. The dependent action interface remains one master key.

Verified formatting, lint, type checks, encrypted profile decryption, current OpenCode CLI flags, and 45 tests.

Reviewed-on: #18
Reviewed-by: bot <noreply@capsulizers.com>
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-24 01:39:49 +00:00
Danny Kim 4ecdf5adc0 Require AI-bot review on every PR (#17)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-23 20:43:02 +00:00
Danny Kim 3626db0876 Recognize Claude weekly limits (#16)
Recognize Claude’s actual weekly-limit message and continue to the next configured profile.

The regression uses the production message from the failed migration review. Root suite: 38 passed.

Reviewed-on: #16
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-23 20:27:47 +00:00
Danny Kim 535a7a486c Fix the packaged prompt path (#15)
Resolve the packaged prompt from the repository root after TypeScript moved into `src/`.

Adds a regression test that verifies every module-relative runtime asset exists. Root suite: 38 passed.

Reviewed-on: #15
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-23 20:23:40 +00:00
Danny Kim e56ea246bf Centralize encrypted bot profiles (#14)
Centralize ordered bot profiles and encrypted credentials in `bot-agents`.

- Exposes one action input: `master-key`.
- Uses Gitea’s automatic job token internally; dependent repositories provide no author/reviewer/profile inputs.
- Keeps encrypted Claude and reviewer credentials in `profiles.yml`; tokenless Codex uses its persisted subscription login.
- Falls back only on recognized quota exhaustion, with a distinct UID/home per profile and descendant cleanup before fallback.
- Separates decryption, agent execution, and reviewer posting so privileged credentials never enter an agent process.
- Installs `obra/superpowers` with the standard Skills CLI; removes custom discovery code.
- Adds root-level isolation, rollback, symlink, fallback, posting, YAML, and self-workflow CI coverage.

Checks: formatting, lint, all entrypoint type checks, 35 tests as root, diff check, secret scan, and repository diagnostics.
Reviewed-on: #14
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-23 20:15:29 +00:00
Danny Kim ba9e0c0787 Load Superpowers review instructions (#13)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-16 12:16:09 +00:00
Danny Kim 58d3c12c25 Restore Superpowers reviews (#12)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-16 04:27:22 +00:00
Danny Kim dcacac18e8 Make bot review setup self-contained (#11)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-14 08:49:49 +00:00
bot 70d71aa4fd Inline comments (#10)
A finding about one line now lands on that line of the diff: the agent writes anchors as JSON, and the review posts them with the body in one call. Bad entries fail the run, and an anchor Gitea refuses falls back to posting the body alone.

Verified with deno fmt, lint and check.

Reviewed-on: #10
Co-authored-by: bot <temeddix@gmail.com>
Co-committed-by: bot <temeddix@gmail.com>
2026-09-14 00:11:35 +00:00
bot 2b51793c92 Review on request (#9)
A pull request comment that asks for a review now posts a real review, not a plain comment: the review file decides, the event no longer does.

Verified with deno fmt, lint and check.

Reviewed-on: #9
Co-authored-by: bot <temeddix@gmail.com>
Co-committed-by: bot <temeddix@gmail.com>
2026-09-13 23:48:04 +00:00
Danny Kim 4ed8b48b7a Verdict words (#8)
The review file starts with `Approved` or `Changes requested` instead of `Yes`/`No`/`With fixes`. The match is still the whole trimmed first line; `run.ts` prepends ✅, 🛑, or 💬 when posting, so the mark never takes part in the match.

Verified: `deno fmt`, `deno lint`, `deno check` pass; a scratch run of the matcher maps `Approved` → APPROVED, `Changes requested` → REQUEST_CHANGES, and `Approved, mostly`, `✅ Approved`, `Yes` → COMMENT.
Reviewed-on: #8
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-13 17:21:34 +00:00
Danny Kim 0d109ebec8 Review file (#7)
The reviewer writes its review to a file whose first line is the verdict, instead of relying on a narration-free final message. Sonnet put `Yes` after three paragraphs of narration on memona #936 (review 595), which the fail-closed verdict posted as a plain comment.

Reviewed-on: #7
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-13 16:54:54 +00:00
Danny Kim a2bc4c9541 Model input (#6)
Optional `model` input. Defaults move to the mid tiers, `claude-sonnet-5` and `gpt-5.6-terra`, since a run follows a fixed template plus the project's checks; a workflow can still pass a bigger model.

Reviewed-on: #6
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-13 16:48:13 +00:00
Danny Kim 75d3593376 Author replies (#5)
The reviewer drops a finding the author has answered in the PR comments as intended or a false alarm, once the code or docs make that clear. Pairs with memona's merge-branch gate loop.

Reviewed-on: #5
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-13 15:35:17 +00:00
Danny Kim 439f2b4e77 Checked-out head (#4)
Checked-out head (#4)

Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-13 15:06:27 +00:00
Danny Kim f0506adfea Fail-closed verdict (#3)
Fail-closed verdict (#3)

Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-13 14:57:01 +00:00
Danny Kim b8d0093ef2 Superpowers review (#2)
Superpowers review (#2)

Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-09-13 14:30:29 +00:00
59 changed files with 7502 additions and 260 deletions
+24
View File
@@ -0,0 +1,24 @@
---
description: How codex, claude, and opencode report rejected credentials, and how the runner detects them
---
Observed 2026-10 (codex 0.153/0.162, Claude Code 2.1.28x, opencode 1.18/1.19):
- Codex: `codex login status` only reads the local auth.json, so a
server-revoked ChatGPT login passes it. `codex exec` then exits 1 with stderr
ERROR lines such as `refresh_token_invalidated` / "Your session has ended.
Please log in again.", `auth error code: token_revoked`, `401 Unauthorized`; a
bad API key gives `invalid_api_key`. Codex echoes the whole prompt and tool
output to stderr, so `isCodexAuthFailure` only matches ERROR lines and the
runner strips the prompt first.
- Claude: a bad `CLAUDE_CODE_OAUTH_TOKEN` yields a stream-json `result` with
`is_error: true`, `api_error_status: 401`, and "Failed to authenticate. API
Error: 401 OAuth access token is invalid."; exit code 1, empty stderr.
- OpenCode: a bad OpenRouter key exits 1 with empty stderr and a stdout JSON
event
`{"type":"error","error":{"name":"APIError","data":{"statusCode":401,
"message":"User not found."}}}`.
An empty key gives an opaque UnknownError.
Sign-in failures become `AgentAuthError` and fall through to the next profile
like usage exhaustion; the failure comment lists only fixed labels per profile.
+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]]).
@@ -0,0 +1,21 @@
---
description: bot-agents lint has prefer-readonly-parameter-types and the size/complexity rules off (Deno had no limits) and no overrides; nothing else
---
The repo follows [[lint-config-shape]] with one recorded exception, present
when the migration PR was approved:
- `typescript/prefer-readonly-parameter-types` is `"off"` in
`scripts/project-lint.ts`, as in the explicit-types template, because
Node/web types (`Response`, `Stats`, `RequestInit`) can never satisfy it.
- `vite.config.ts` has no `overrides`. The `scripts/**` override for
`typescript/no-unsafe-enum-comparison` was removed (2026-10-16):
`scripts/project-lint.ts` compares node types with `AST_NODE_TYPES` members
from `@typescript-eslint/types` instead of string literals
([[lint-overrides-policy]]).
Size and complexity limits match Deno, which had none here, so every Oxlint
limit rule is `"off"` ([[lint-config-shape]]). `run.ts` and `post.ts` keep
main's single-file layout (2026-10-17 revert of the size splits). Sequential
awaits in loops became recursion or a `reduce` chain (`no-await-in-loop`) to
stay sequential. Add no other opt-out without the user.
@@ -0,0 +1,36 @@
---
description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, Deno kept only for agents, no eslint-plugin-* packages 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 free of runtime
dependencies (`@eslint/core` and the other devDependencies serve only the
tooling).
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. There is no
`engines` (user decision 2026-10-18): the `node:24-bookworm` job image
decides the version ([[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 `eslint-plugin-*` packages** (`eslint-plugin-explicit-types`,
`eslint-plugin-import-structure`, `eslint-plugin-lit-robust`; unscoped
since 2026-10-18, formerly `@cunarist/...`). The Deno setup enabled no
plugin, so this repo depends on none and CI passes without them being
published. Planned (2026-10-17): once the plugins are on npm, add
`eslint-plugin-import-structure` (rule prefix `import-structure/`) here for
its import checks.
- **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]]).
@@ -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`. That image is the only Node
pin (`package.json` has no `engines`); `check.yml` prints `node --version`
to confirm the cached image's minor.
- **Root-owned source copy:** `scripts/protect-source.sh` copies the action
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. Runtime code in `src/` is flat; a subfolder would
not be copied. The only subfolder, `src/testing/`, is test-only.
- `tests/entrypoints.test.ts` runs each phase under real Node with a cleared
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. 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,25 @@
---
description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 61 run; verify isolation changes in node:24-bookworm as root
---
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 `#testing`), 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/index.ts` holds the shared test helpers (user IDs, temp dirs,
`runCommand`) behind the `#testing` alias. Every test imports it as
`#testing`. It imports no source module, so `tests/` (which may not use
`../`) never reaches internals. Tests follow
[[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in
`tests/`).
+51
View File
@@ -0,0 +1,51 @@
---
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.
- 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.
+104
View File
@@ -0,0 +1,104 @@
---
description: An alias owns its target (folder index.ts = folder and below, any other file = that file); no overlap; outside an area only the exact alias reaches it; ../ is banned in every file; locally, aliases target only direct src/ subfolders
---
## `#` aliases own their targets
Shared code is imported through Node subpath imports declared in
`package.json` `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.
One rule for every file, never tied to folder or file names (user rule
2026-10-17; it replaces the earlier "aliases only point at direct `src/`
subfolders" rule as the general rule, and the "import-structure plugin
checks only aliased folders" scope; this repo keeps the stricter rule as a
local convention, see the last section):
- **An alias may point anywhere** and owns its target. An alias to a
folder's `index.ts` owns that folder and everything below it; an alias to
any other file owns only that file.
- **No overlap.** No file may be owned by 2 aliases: no alias inside another
alias's area, and no 2 aliases with the same target. The import-structure
plugin's `enforce-layer-order` reports it once at the top of every file 2
aliases claim. Fix by merging the aliases or moving files apart.
- **Outside an area, only the exact alias reaches it.** A deeper alias
specifier (`#a/one.ts`, `no-barrel-bypass`) and a relative path into the
area (`./a/one.ts`, `no-relative-bypass`; `./b/f.ts` to a file alias,
`prefer-alias-import`, fixed to the alias) are bypasses.
- **Inside an area, relative imports of siblings and subfolders are free.**
- **A `../` import is banned in every file**, owned or not (`no-parent-import`).
Runtime file URLs (`new URL("../prompt.md", import.meta.url)`) are not
imports and are allowed.
Anything above a file needs an alias.
- **Files no alias owns** need no `index.ts` and no alias, and import `./`
siblings and subfolders freely.
- Unchanged: no `/*` subpath patterns, the key order of `imports` is the
layer order `enforce-layer-order` checks (add a new alias at the right
layer), and entry files are `index.ts` only.
```jsonc
"imports": {
"#a": "./a/index.ts", // owns a/**
"#f": "./b/f.ts", // owns only b/f.ts
"#a-sub": "./a/sub/index.ts", // no: inside #a
"#a2": "./a/index.ts" // no: same target as #a
}
```
```ts
// in main.ts, which no alias owns
import { a } from "#a"; // yes
import { one } from "#a/one.ts"; // no: deeper alias specifier
import { one } from "./a/one.ts"; // no: relative path into #a
import { f } from "#f"; // yes
import { f } from "./b/f.ts"; // no: relative path to a file alias
import { g } from "./b/g.ts"; // yes: unowned
// in a/one.ts, owned by #a
import { two } from "./two.ts"; // yes: sibling
import { deep } from "./sub/deep.ts"; // yes: subfolder of the same area
import { x } from "../x.ts"; // no: no ../ anywhere
```
- Aliases point at `.ts` files only. There is no `#package` alias
([[ts-only-files-and-imports]]).
- Lint `jsPlugins` entries in `vite.config.ts` stay path strings, even when
the file has an alias.
- Keep test-only helpers in their own alias (e.g. `#testing`) so they never
land in a bundle.
## Barrels
- An area used from outside exposes its API through its alias target, and
callers import the alias (`#ui`), not files inside it.
- Barrels are also the standard fix for `import/max-dependencies`: one barrel
import replaces many direct ones ([[lint-overrides-policy]]).
- `import/no-cycle` is on; a barrel must not create a cycle.
Relative imports are written with the `.ts` extension.
## `scripts/` is flat (user rule, 2026-10-16)
- `scripts/` has no subfolders and no `index.ts`.
- Each tool is one file, `scripts/<name>.ts`, imported directly by file:
`import projectLint from "./scripts/project-lint.ts";` in
`vite.config.ts`. No alias owns `scripts/`, so these `./` imports are free.
- Code in a former `scripts/<tool>/` folder is merged or renamed into flat
`scripts/<name>.ts` files. Non-TS assets (HTML, images, fixtures) move to
where they belong (next to the tool's output, `.agents/`, or a gitignored
scratch dir). A subfolder that is really an app moves under an alias.
## Local convention: aliases target direct `src/` subfolders
This is a convention of this repo only, stricter than the general rule above
(which the published import-structure plugin checks and which allows more).
It is not a plugin rule.
- Every `package.json` `imports` alias points only at the `index.ts` of a
direct subfolder of `src/`, e.g. `"#helpers": "./src/helpers/index.ts"`.
No alias to a single file, a deeper folder, or anything outside `src/`.
Here the one alias is `"#testing": "./src/testing/index.ts"`.
- Code outside `src/` (`scripts/`, `tests/`, `vite.config.ts`) gets no alias
of its own and imports its own files by `./` relative paths. It reaches
`src/` only through the aliases, since `../` is banned.
- No lint rule checks this; check `imports` by hand when adding an alias.
+48
View File
@@ -0,0 +1,48 @@
---
description: vite.config.ts lint uses Oxlint categories at error with Deno size limits, names no rule itself, and spreads configs from the plugins and scripts/project-lint.ts
---
The lint config is short and category-driven (user decisions 2026-10-14/15):
turn on whole categories, then list only what parity or a real need adds.
```ts
lint: {
plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["eslint-plugin-explicit-types", "./scripts/project-lint.ts"],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: { ...explicitTypes.configs.concrete.rules, ...projectLint.configs.project.rules },
ignorePatterns: ["src/generated/**"], // generated files only, see below
options: { typeAware: true, typeCheck: true, maxWarnings: 0, reportUnusedDisableDirectives: "error" },
},
```
- **Categories:** `correctness`, `suspicious`, `pedantic`, `perf` at `error`.
`style` and `restriction` are never turned on wholesale; pick single rules
from them in `configs.project`. Extra rules that categories bring are fine.
- **Size limits match Deno** (2026-10-17, replaces the 2026-10-16 "default
limits stay"): repos behave as they did on Deno. bot-agents ran `deno lint`
with no config, so it had no size or complexity limit, and every Oxlint
limit rule (`complexity`, `max-lines`, `max-params`, `id-length`,
`no-nested-ternary`, `max-lines-per-function`, `max-classes-per-file`,
`max-depth`, `max-nested-callbacks`, `max-statements`,
`import/max-dependencies`, `unicorn/max-nested-calls`) is `"off"` in
`configs.project`. No file is split for size. Do not add new opt-outs
anywhere without asking the user.
- **`vite.config.ts` names no rule.** Every rule id, built-in ones included,
lives in `configs.project` of `scripts/project-lint.ts`
([[project-lint-plugin]]). `vite.config.ts` only spreads configs.
- **Published plugins are consumed through their configs**: ESLint flat config
uses `plugin.configs.<name>`; Oxlint lists the package in `jsPlugins` and
spreads `plugin.configs.<name>.rules`, since Oxlint plugins carry no configs.
Use the configs equivalent to what the repo enabled under Deno.
- **`maxWarnings: 0`** keeps Deno's "every diagnostic fails" behaviour.
- **`ignorePatterns`** is only for generated files (compiled output, codegen).
Hand-written code is never ignored.
- **No lint disable comments**: [[lint-no-disable-comments]].
- **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]].
- Don't add a second import sorter; the import-structure plugin's
`enforce-import-order` is the only one, and Oxfmt's `sortImports` stays off.
`perf` came last: in the template it flagged nine `.includes` on constant
arrays, fixed as `ReadonlySet` + `.has()` rather than by turning a rule off.
That is the expected response to any new rule: change the code.
@@ -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.
+33
View File
@@ -0,0 +1,33 @@
---
description: No lint overrides for source files; narrow ones only for scripts/**; generated files go in ignorePatterns; size limits match Deno, so nothing is split for size
---
Final policy (2026-10-16). The user considers per-file `overrides` bad
practice.
- **Source files: never.** No `overrides` entry may target `src/**` or any
other hand-written production or test code.
- **The one exception** is in lint-plugin repos: `import/max-dependencies`
off for `src/rules/index.ts`, the rule registry, which must import every
rule. It is not a precedent for apps.
- **`scripts/**`:** a narrow override is allowed when a tooling script truly
needs it, e.g. `no-console` in a CLI script.
- **Generated files:** list them in `ignorePatterns`, never `overrides`.
- **Anything else needs the user's approval first.** When the user is away,
don't add it: refactor, or leave the violation failing and log the question.
## Fix the code instead
Category rule violations are fixed in the code without behavior change,
covered by the tests, inside the same migration PR; e.g. `prefer-set-has`
becomes `const K: ReadonlySet<string> = new Set([...])` with `.has`.
Size and complexity limits are not refactored for: they match what Deno
enforced (2026-10-17, replacing the earlier "refactor to Oxlint's defaults"),
which for bot-agents is none, so those rules are `"off"`
([[lint-config-shape]]).
Alias rule: an alias may point anywhere and owns its target (a folder's
`index.ts` owns the folder and below, any other file owns only itself); no
two aliases may own the same file, and `../` is banned everywhere
([[imports-aliases-barrels]]).
@@ -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`.
- **deno-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.
+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.
+40
View File
@@ -0,0 +1,40 @@
---
description: package.json keys follow Oxfmt's standard order (vp check --fix); standard fields only; no peerDependencies or packageManager
---
## Key order (2026-10-16)
Oxfmt's standard `package.json` sorting is accepted: there is no
`fmt: { sortPackageJson: false }`, and `vp check --fix` sets the order. This
replaces the earlier hand-written order (metadata, `scripts`, `exports`,
`imports`, `devDependencies`, `dependencies`).
The `imports` key order can carry meaning: the import-structure plugin's
`enforce-layer-order` reads it as the layer order. Never reorder it casually.
## Fields
- **Standard fields only.** App repos have no `engines` field (user decision
2026-10-18; images pin Node, [[toolchain-node-vite-plus]]). Published
packages keep `engines` with only `node` (never `engines.bun`).
A tool-owned key (`eslint-doc-generator`) is allowed only because the tool
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-robust 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.
Alias rule: an alias may point anywhere and owns its target (a folder's
`index.ts` owns the folder and everything below it; any other file owns only
itself). No two aliases may own the same file, outside an area only the exact
alias reaches it, and `../` is banned in every file
([[imports-aliases-barrels]]).
+56
View File
@@ -0,0 +1,56 @@
---
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-asset-imports`; bot-agents has no others.
`scripts/` stays flat; don't add `scripts/rules/`. If the plugin ever needs
more files, they sit flat beside it and share the prefix
(`project-lint-modules.ts`, ...); `scripts/project-lint.ts` keeps the plugin
object and the rule list.
```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]].
+39
View File
@@ -0,0 +1,39 @@
---
description: Vitest via vp test; unit tests sit next to code as *.test.ts, regression/integration tests in tests/ via the public entry (through its # alias, never ../); scripts/ has no tests
---
Decided 2026-10-14 for all repos.
- **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, imported through its `#` alias (e.g.
`#plugin` for `./src/plugin/index.ts`), never internals and never `../`,
which is banned in every file. `vp test tests` runs just these
([[imports-aliases-barrels]]). In bot-agents, `tests/` imports only
`#testing` (`./src/testing/index.ts`), plain helpers that import no source
module.
- **No in-source tests** (`import.meta.vitest`).
- **`scripts/` gets no tests** (2026-10-15): it is repo tooling, not
production code (`project-lint.ts`, `protect-source.sh`).
- 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,61 @@
---
description: Node.js (no engines in apps; images pin 24) with Vite+ (vp) and npm; only package.json, tsconfig.json, vite.config.ts plus a committed package-lock.json
---
## The stack
- **Runtime:** Node.js. App repos have no `engines` field (user decision
2026-10-18): the container or runner image decides the version, so the
Containerfile base image (`node:24-alpine`) and CI's `node-version: "24"`
are the pins. Published packages (the lint plugins) keep their own
`engines.node` floor. `npm run lit-check` runs
`node scripts/lit-check.ts`, which needs Node 22.18+ (unflagged type
stripping and `module.registerHooks`); Node 24 satisfies it. Without
`engines` or a `.node-version`, local `vp` uses its default Node
(`vp env default`). bot-agents has no Containerfile or setup step: the
self-hosted runner's `node:24-bookworm` job image runs the action and its
checks, and `src/post.ts` needs 24.2+ (`import.meta.main`).
- **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
`vp` brings its own Node, the way uv brings its own Python.
- **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; that order is accepted, so
there is no `fmt` option for it ([[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,59 @@
---
description: Every file is .ts (no js/mjs/cjs/jsx/tsx, configs included); imports name only .ts files or .js/.mts/.mjs package subpaths; CSS comes from HTML, JSON/SVG become .ts modules
---
The user's rule, from the Deno days and restated for Node (2026-10-12): "No
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 exactly this strictness set (user
decision, 2026-10-18): `strict` (written out), `noUncheckedIndexedAccess`,
`noPropertyAccessFromIndexSignature`, `noImplicitReturns`, `noUnusedLocals`,
`noUnusedParameters`, `noImplicitOverride`, `noFallthroughCasesInSwitch`,
`erasableSyntaxOnly`, and `allowUnreachableCode: false` plus
`allowUnusedLabels: false` (user decision, 2026-10-18; on in the Deno setup),
plus the mechanical `verbatimModuleSyntax`, `allowImportingTsExtensions`, and
`noEmit` (packages also keep `declaration` and `isolatedDeclarations`).
`exactOptionalPropertyTypes` is off everywhere: write `{ key: maybeUndefined }`
rather than leaving the key out to please it. If a tool or framework seems to
need a JS or TSX file, stop and flag it instead of adding one.
## No asset imports (2026-10-14, widened 2026-10-16 and 2026-10-17)
`project/no-asset-imports` (in `scripts/project-lint.ts`; it was
`no-non-ts-imports` before 2026-10-16) fails:
- a relative, absolute, `#`, or package-subpath specifier whose last segment
has an extension other than `.ts`, `.js`, `.mts`, or `.mjs`: `.css`,
`.json`, `.svg`, `.jsx`, `.tsx`, and every other asset;
- any `?query` suffix (`?raw`, `?url`, `?inline`);
- any import attributes (`with { type: "json" }`, `with { type: "text" }`).
`.js`, `.mts`, and `.mjs` are allowed (user decision, 2026-10-17) for package
subpaths such as `lit/decorators.js` or `pdfjs-dist/legacy/build/pdf.mjs`;
our own files are still `.ts` only. `.jsx` and `.tsx` stay banned. Bare
packages (`lit`, `@types/json-schema`, `lodash.debounce`), `#` aliases, and
`node:` builtins pass. `@fortawesome/x/svgs/a.svg` fails.
```ts
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 the markup.
- **`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 CLI file:** prefer the package entry or spawning the package's
bin over importing `pkg/cli.js`, which runs the CLI as a side effect.
+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.
+7
View File
@@ -0,0 +1,7 @@
---
description: Workflow and container YAML stays declarative; operational shell belongs in separate scripts
---
Do not embed multiline operational scripts in workflow, Compose, or container
YAML. Keep YAML declarative and move shell logic into a small, named `.sh` file
that can be tested directly.
+1
View File
@@ -0,0 +1 @@
*
+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.
+48
View File
@@ -0,0 +1,48 @@
name: Agents
run-name: >-
AI bot at
#${{ gitea.event.issue.number || gitea.event.pull_request.number }}
on:
issues:
types: [opened]
pull_request:
types: [opened, review_requested]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
concurrency:
group: >-
agents-${{ gitea.event.sender.login == 'bot' && gitea.run_id ||
gitea.event.issue.number || gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
agent:
if: >-
gitea.actor != 'bot' && gitea.event.sender.login != 'bot' &&
((gitea.event_name == 'issues' &&
contains(gitea.event.issue.body, '@bot')) ||
(gitea.event_name == 'pull_request' &&
((gitea.event.action == 'opened' &&
contains(gitea.event.pull_request.body, '@bot')) ||
gitea.event.requested_reviewer.login == 'bot')) ||
(gitea.event_name == 'issue_comment' &&
contains(gitea.event.comment.body, '@bot')) ||
(gitea.event_name == 'pull_request_review_comment' &&
contains(gitea.event.review.content, '@bot')))
permissions:
code: write
issues: write
pull-requests: write
runs-on: self-hosted
container:
volumes:
- bot-home:/root
steps:
- name: Run AI bot
uses: https://git.capsulizers.com/commons/bot-agents@main
with:
master-key: ${{ secrets.BOT_AGENTS_MASTER_KEY }}
+9 -4
View File
@@ -5,13 +5,18 @@ on:
branches: [main]
jobs:
deno:
check:
runs-on: self-hosted
steps:
- 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: deno fmt --check . && deno lint . && deno check run.ts
# Formatting, lint, and type checks, then every test. The job runs as
# root, so the isolation tests run rather than skip.
- run: npx vp check
- run: npx vp test
+2
View File
@@ -0,0 +1,2 @@
node_modules/
coverage/
+53
View File
@@ -0,0 +1,53 @@
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.
# Pull requests
Land every change through a pull request. Immediately after creating one,
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
explicit permission.
# Runtime and checks
The action's TypeScript runs directly on the job image's Node.js (24.2 or
newer; `package.json` has no `engines`, the `node:24-bookworm` image pins it),
which strips the types itself: there is no build step and no runtime
dependency. Keep the sources to erasable syntax and `.ts` imports, which
`tsconfig.json` enforces. Its strictness set is fixed and leaves
`exactOptionalPropertyTypes` off
([.agents/memory/ts-only-files-and-imports.md](.agents/memory/ts-only-files-and-imports.md)).
Checks use Vite+: `vp check` formats, lints, and type-checks, and `vp test`
runs Vitest. Unit tests sit next to the code as `src/*.test.ts`;
repository-level tests live in `tests/`.
A `#` alias in `package.json` `imports` may point anywhere and owns its
target: an alias to a folder's `index.ts` owns that folder and everything
below it; an alias to any other file owns only that file. No overlap: no file
may be owned by 2 aliases, so no alias inside another alias's area and no 2
aliases with the same target. Outside an area, only the exact alias reaches
it; a deeper alias specifier or a relative path into the area is a bypass.
Inside an area, relative imports of siblings and subfolders are free. A `../`
import is banned in every file, owned or not
(`import/no-relative-parent-imports`); runtime file URLs such as
`new URL("../prompt.md", import.meta.url)` are paths, not imports, and stay.
Files no alias owns need no `index.ts` and no alias, and import `./` siblings
and subfolders freely. The one alias is `#testing` (`./src/testing/index.ts`):
tests that use its helpers import it by alias, and `tests/` imports `src/`
only through it. Local convention, stricter than the general rule above: in
this repo, aliases point only at a direct subfolder of `src/`
(`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses `./`
relative imports. `scripts/` is flat: one file per tool, no subfolders and no `index.ts`, imported directly by file path
([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)).
The only config files are `package.json`, `tsconfig.json`, and
`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule
list live in `scripts/project-lint.ts`. Lint disable comments are not allowed;
fix the source instead.
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.
+47 -25
View File
@@ -2,43 +2,65 @@ name: Bot agents
description: Run a coding agent for approved Gitea issue/PR automation
inputs:
bot-type:
description: Which bot to run, either `codex` or `claude`
required: true
author-token:
master-key:
description: >-
Gitea token of the account that commits, pushes, and opens pull requests,
with the `read:user`, `write:issue`, and `write:repository` scopes. The
agent sees it as `GITEA_TOKEN`.
Base64url-encoded 256-bit key used to decrypt the central bot profiles
and reviewer credential.
required: true
reviewer-token:
description: >-
Gitea token of the bot account that posts comments and pull request
reviews, with the `write:issue` and `write:repository` scopes. Never
exposed to the agent, so it must differ from the author.
required: true
bot-token:
description: API key or token for the selected bot
required: false
runs:
using: composite
steps:
# This step assumes this is `node:24-bookworm` container.
# The agent works on the event's commit with full history, as the author.
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ gitea.token }}
# 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
shell: bash
run: npm install -g @openai/codex @anthropic-ai/claude-code deno
- name: Run bot
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno
# A local action lives inside the writable checkout. Execute a root-owned
# copy so an agent changing this repository cannot replace a later phase.
- name: Protect action source
id: source
shell: bash
run: deno run -A "${ACTION_PATH}/run.ts"
env:
ACTION_PATH: ${{ gitea.action_path }}
BOT_TYPE: ${{ inputs.bot-type }}
BOT_TOKEN: ${{ inputs.bot-token }}
RUNNER_TEMP: ${{ runner.temp }}
run: bash "${{ gitea.action_path }}/scripts/protect-source.sh"
- name: Allow isolated profiles to edit the checkout
shell: bash
run: chmod -R a+rwX "${GITHUB_WORKSPACE}"
- name: Prepare credentials
id: prepare
shell: bash
run: node "${{ steps.source.outputs.path }}/src/prepare.ts"
env:
ACTION_PATH: ${{ steps.source.outputs.path }}
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
RUNNER_TEMP: ${{ runner.temp }}
- name: Run bot
id: agent
shell: bash
run: node "${{ steps.source.outputs.path }}/src/run.ts"
env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_TOKEN: ${{ gitea.token }}
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ inputs.author-token }}
REVIEWER_TOKEN: ${{ inputs.reviewer-token }}
EVENT_NAME: ${{ gitea.event_name }}
ISSUE_INDEX: ${{ gitea.event.issue.number || gitea.event.pull_request.number }}
COMMENT: ${{ toJSON(gitea.event.comment || gitea.event.review) }}
- name: Post result and remove credentials
id: post
if: always() && steps.prepare.outputs.manifest != ''
shell: bash
run: node "${{ steps.source.outputs.path }}/src/post.ts"
env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
EVENT_NAME: ${{ gitea.event_name }}
ISSUE_INDEX: ${{ gitea.event.issue.number || gitea.event.pull_request.number }}
+3080
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "bot-agents",
"private": true,
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
"type": "module",
"imports": {
"#testing": "./src/testing/index.ts"
},
"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"
}
]
}
+50 -11
View File
@@ -13,17 +13,56 @@ response ends, so background monitors, scheduled wake-ups, and queued tasks
never resume. Never promise future action and never claim to be waiting on a
notification.
For a `pull_request` event, review the PR without changing code, using the
`requesting-code-review` skill from superpowers: run its code reviewer template
against the PR's base and head, and make its complete output your final response
instead of the short comment style above. Check the whole repository against the
code rules at the end of this prompt, not only the diff; a violation is at least
Important even when the diff did not cause it. Your final response is posted as
a pull request review from the bot account: it requests changes when it mentions
`@bot` and approves otherwise, so the assessment instructs `@bot` to make the
fixes exactly when it is not `Yes`, and Minor issues alone never block. For UI
changes, check that the result is aligned, clean, and pixel-perfect, and that
included screenshots prove the intended result was achieved.
The repository is checked out in the working directory at the event's commit,
the head of the pull request when there is one, with full history and the
author's push credentials. Read the code there, run its checks and tests when
they bear on the task, and push from there.
For a `pull_request` event, and for a comment on a pull request that asks you to
review it, use the installed `requesting-code-review` skill before inspecting
the PR. You are the reviewer that has already been dispatched, so run the
skill's code reviewer template yourself instead of dispatching another reviewer.
Use the pull request and triggering instruction as its description and
requirements, and review the exact base and head SHAs without changing code. Run
the project's required checks on the head and treat a real failure as at least
Important. The bot automation workflow itself is not a project check: ignore its
skipped or canceled duplicate/automatic runs, and never reject a PR because the
current review run is unfinished. Only a failed required check for the reviewed
head blocks approval. Check the whole repository against the code rules at the
end of this prompt, not only the diff; a violation is at least Important even
when the diff did not cause it. Write the complete review, and nothing else, to
the file `${REVIEW_PATH}`: it is posted verbatim as a pull request review from
the bot account, and your final response is not posted at all. The file's first
line must be exactly the verdict and nothing else: `Approved` when the head is
ready to merge, `Changes requested` otherwise. The mark in front of it is added
when posting, so write the words alone; any other first line is posted as a
plain comment, which wastes the run. Minor issues alone never block, and neither
does a finding the author has answered in the comment history below as intended
or a false alarm, once the code or docs make that clear. When the verdict is
`Changes requested`, the second line names what must change in one line,
addressed to the author; the author's own agent picks the fixes up, so never ask
`@bot` to make them. For UI changes, check that the result is aligned, clean,
and pixel-perfect, and that included screenshots prove the intended result was
achieved.
Every finding that belongs to one line of the diff goes on that line instead of
into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry
`{"path": "<path from the repository root>", "line": <number>, "side": "new" |
"old", "body": "<the finding>"}`.
`side` is `new` for a line in the head file and `old` for one only in the base
file; `line` is that file's own line number, and it must be a line the diff
touches, or Gitea refuses the anchor. Write the file only when there is
something to anchor, and keep each body to the what, the why, and the how, with
no `file:line` prefix; the line carries that.
The review body must read at a glance: everything outside `<details>` blocks
totals under 512 bytes. Only core information stays visible: the verdict, the
summary line, and the section headings. Anything verbose goes into a `<details>`
block whose `<summary>` is a few words, such as the title of an issue with the
what, why, and how inside; the same for each strength, each recommendation, the
reasoning, and any compliance notes. A finding you anchored belongs there only
as its title, since its detail is on the line. Details blocks are top-level,
never inside a list item, because Gitea breaks them there.
For an `issue_comment` or `pull_request_review_comment` event, treat the `body`
in the triggering comment payload below as the user's exact instruction.
-220
View File
@@ -1,220 +0,0 @@
// Runs a coding agent for one Gitea issue or pull request event and posts its
// final response back. The agent works as the author account through
// GITEA_TOKEN; everything this script posts goes through the reviewer token,
// so it appears as the bot account.
import { TextLineStream } from "jsr:@std/streams@1/text-line-stream";
type GiteaUser = { login: string; email: string };
type GiteaComment = { user: GiteaUser; created_at: string; body: string };
const env = (name: string): string => {
const value = Deno.env.get(name);
if (value === undefined) throw new Error(`${name} is not set`);
return value;
};
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 REVIEWER_TOKEN = env("REVIEWER_TOKEN");
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
// The reviewer token is withheld so the agent cannot approve as the bot.
const { REVIEWER_TOKEN: _, ...agentEnv } = Deno.env.toObject();
async function gitea(
token: string,
path: string,
body?: unknown,
): Promise<Response> {
const response = await fetch(`${API}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `token ${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;
}
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
const stripAnsi = (text: string): string => text.replace(ANSI, "");
async function postComment(body: string): Promise<void> {
await gitea(REVIEWER_TOKEN, `repos/${REPO}/issues/${INDEX}/comments`, {
body: stripAnsi(body),
});
}
// A pull request event is a review request, so the response becomes a review:
// changes are requested when the agent asked @bot to fix something, a failed
// run only comments, and anything else approves.
async function postResult(body: string): Promise<void> {
if (EVENT !== "pull_request") return postComment(body);
const event = body.includes("@bot")
? "REQUEST_CHANGES"
: body.startsWith("Bot failed:")
? "COMMENT"
: "APPROVED";
await gitea(REVIEWER_TOKEN, `repos/${REPO}/pulls/${INDEX}/reviews`, {
body: stripAnsi(body),
event,
});
}
async function run(command: string, args: string[]): Promise<void> {
const { success, code } = await new Deno.Command(command, { args }).output();
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
// Commits belong to the same account as the pull request they end up in.
async function configureGitAuthor(): Promise<void> {
const user: GiteaUser = await (await gitea(AUTHOR_TOKEN, "user")).json();
for (const [key, value] of [["name", user.login], ["email", user.email]]) {
await run("git", ["config", "--global", `user.${key}`, value]);
}
}
// The superpowers plugin gives the agent its skills, including the code review
// one that the prompt asks for. Both installs are idempotent on the persisted
// home.
async function installSuperpowers(bot: string): Promise<void> {
const commands = bot === "claude"
? [
["plugin", "marketplace", "add", "obra/superpowers-marketplace"],
["plugin", "install", "-y", "superpowers@superpowers-marketplace"],
]
: [["plugin", "add", "superpowers@openai-curated-remote"]];
for (const args of commands) await run(bot, args);
}
async function renderPrompt(): Promise<string> {
const comments: GiteaComment[] = await (await gitea(
REVIEWER_TOKEN,
`repos/${REPO}/issues/${INDEX}/comments?limit=100`,
)).json();
const values: Record<string, string> = {
COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments
.map((c) => `## ${c.user.login} at ${c.created_at}\n\n${c.body}\n`)
.join("\n"),
CODE_RULES: await (await gitea(REVIEWER_TOKEN, RULES_PATH)).text(),
EVENT_NAME: EVENT,
GITEA_API_URL: API,
GITEA_REPOSITORY: REPO,
ISSUE_INDEX: INDEX,
};
const template = await Deno.readTextFile(
new URL("prompt.md", import.meta.url),
);
return template.replace(
/\$\{(\w+)\}/g,
(match, name) => values[name] ?? match,
);
}
async function runClaude(prompt: string): Promise<string> {
const token = Deno.env.get("BOT_TOKEN");
if (!token) {
await postComment(
"Run `claude setup-token` locally and set the `bot-token` action input.",
);
Deno.exit(1);
}
await installSuperpowers("claude");
const claude = new Deno.Command("claude", {
args: [
"--print",
"--dangerously-skip-permissions",
"--model",
"claude-fable-5",
"--output-format",
"stream-json",
"--verbose",
prompt,
],
// Claude refuses --dangerously-skip-permissions as root outside a sandbox.
env: { ...agentEnv, CLAUDE_CODE_OAUTH_TOKEN: token, IS_SANDBOX: "1" },
clearEnv: true,
stdout: "piped",
}).spawn();
let result = "Bot failed: no result";
// Print events as they stream so the runner does not kill the job as a zombie.
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.result ?? `Bot failed: ${event.subtype}`;
}
}
await claude.status;
return result;
}
// Posts the device code so a human can finish the login on the persisted home.
async function codexDeviceLogin(): Promise<void> {
const login = new Deno.Command("codex", {
args: ["login", "--device-auth"],
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)]);
await new Promise((resolve) => setTimeout(resolve, 3000));
await postComment(shown);
await Promise.all([login.status, drained]);
const status = await new Deno.Command("codex", { args: ["login", "status"] })
.output();
await postComment(new TextDecoder().decode(status.stdout));
}
async function runCodex(prompt: string): Promise<string> {
const loggedIn = await new Deno.Command("codex", {
args: ["login", "status"],
})
.output();
if (!loggedIn.success) await codexDeviceLogin();
await installSuperpowers("codex");
const file = await Deno.makeTempFile();
const status = await new Deno.Command("codex", {
args: [
"exec",
"--model",
"gpt-5.5",
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
file,
prompt,
],
env: agentEnv,
clearEnv: true,
stdout: "inherit",
stderr: "inherit",
}).spawn().status;
if (!status.success) throw new Error(`codex exited with ${status.code}`);
return await Deno.readTextFile(file);
}
await configureGitAuthor();
const prompt = await renderPrompt();
const result = env("BOT_TYPE") === "claude"
? await runClaude(prompt)
: await runCodex(prompt);
await postResult(result);
+266
View File
@@ -0,0 +1,266 @@
/**
* `project` — lint rules this repository holds its own source to, on top of
* the Oxlint categories in `vite.config.ts`. Its rules of its own are
* `no-disable-comments` and `no-asset-imports`.
*
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
*
* @module
*/
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core";
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types";
import type { DummyRuleMap } from "vite-plus/lint";
/** 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;
/**
* Script module extensions an import may name: our `.ts` files and `.js`,
* `.mts`, or `.mjs` package subpaths. `.cts`, `.cjs`, `.tsx`, and `.jsx` are
* not among them.
*/
const SCRIPT_EXTENSIONS: ReadonlySet<string> = new Set([".ts", ".js", ".mts", ".mjs"]);
/**
* Whether `source` names an asset rather than a script module: a query string
* such as `?raw`, or a final path segment whose extension, from its last dot,
* is not `.ts`, `.js`, `.mts`, or `.mjs`. Bare package names are left alone,
* dots and all, as are extensionless paths.
*/
function isAssetSource(source: string): boolean {
if (source.includes("?")) {
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;
}
const extension: string = lastSegment.slice(lastSegment.lastIndexOf("."));
return !SCRIPT_EXTENSIONS.has(extension);
}
/**
* `no-asset-imports` — rejects importing anything but a script module: a
* source path, static or a dynamic `import()` with a string literal, ending in
* an extension other than `.ts`, `.js`, `.mts`, or `.mjs` (`.css`, `.json`,
* `.svg`, `.jsx`, `.tsx`, `.cjs`) or carrying a query string (`?raw`, `?url`),
* and any import or export with import attributes. `.js`, `.mts`, and `.mjs`
* cover package subpaths such as `lit/decorators.js` or
* `pdfjs-dist/legacy/build/pdf.mjs`; our own files are still `.ts` only. Bare
* package names, `#` aliases, and `node:` builtins
* without an extension pass; package subpaths are checked like paths.
*/
const noAssetImportsRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow importing assets instead of .ts, .js, .mts, or .mjs modules" },
schema: [],
messages: {
extension: "Only .ts, .js, .mts and .mjs modules may be imported.",
attributes: "Import attributes are not allowed.",
},
},
create(context) {
function checkSource(source: TSESTree.StringLiteral): void {
if (isAssetSource(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 === AST_NODE_TYPES.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`: 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 },
],
// Rules outside the categories.
"no-array-constructor": "error",
"no-case-declarations": "error",
"no-empty": "error",
"no-fallthrough": "error",
"no-inner-declarations": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-var": "error",
"prefer-const": "error",
"require-await": "error",
"require-yield": "error",
"typescript/adjacent-overload-signatures": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-empty-interface": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error",
// On Node, these come from an explicit `node:` import instead of the global scope.
"no-restricted-globals": [
"error",
"window",
"process",
"Buffer",
"global",
"setImmediate",
"clearImmediate",
],
// The explicit-types template's extras.
"import/no-cycle": "error",
// `../` is banned in every file; anything above a file needs a `#` alias.
"import/no-relative-parent-imports": "error",
"typescript/consistent-type-imports": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-non-null-assertion": "error",
// No disable comments at all.
"project/no-disable-comments": "error",
"project/no-asset-imports": "error",
"unicorn/no-abusive-eslint-disable": "error",
// Size and complexity limits match Deno's: this repo's `deno lint` ran the
// `recommended` set with no config file, so no size or complexity rule
// applied and none applies here. Each Oxlint limit rule, from a category or
// not, is off:
// - `complexity`: Deno had no `max-complexity`.
// - `max-lines`: Deno had no `max-file-lines`.
// - `max-params`: Deno had no `max-params`.
// - `id-length`: Deno had no `max-identifier-length`.
// - `no-nested-ternary`: Deno had no `max-nest-ternaries`.
// - `max-lines-per-function`, `max-classes-per-file`, `max-depth`,
// `max-nested-callbacks`, `max-statements`, `import/max-dependencies`,
// `unicorn/max-nested-calls`: Deno had none of these.
complexity: "off",
"max-lines": "off",
"max-params": "off",
"id-length": "off",
"no-nested-ternary": "off",
"max-lines-per-function": "off",
"max-classes-per-file": "off",
"max-depth": "off",
"max-nested-callbacks": "off",
"max-statements": "off",
"import/max-dependencies": "off",
"unicorn/max-nested-calls": "off",
// Pedantic, but Node's and the web's own types (`Response`, `Stats`,
// `RequestInit`) are mutable, so no parameter of theirs can pass; the
// explicit-types template and the app repos turn it off too.
"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-asset-imports": noAssetImportsRule,
},
configs: { project: { rules } },
};
export default plugin;
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
mkdir "${path}/src"
cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/"
# package.json makes Node load the TypeScript sources as ES modules.
cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/"
chmod -R go-w "${path}"
printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}"
+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";
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 });
}
});
+135
View File
@@ -0,0 +1,135 @@
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";
import { isNotFound } from "./errors.ts";
import { isObject } from "./json.ts";
export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" };
export function codexAuthentication(token?: string): CodexAuthentication {
return token === undefined ? { kind: "persistent" } : { kind: "api-key", token };
}
// 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.
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)/imu;
export function isCodexAuthFailure(error: string): boolean {
return CODEX_AUTH_FAILURE.test(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
// access token is invalid." with api_error_status 401.
// The fields of Claude Code's stream-json `result` event that tell a sign-in
// failure apart, under their own names.
export type ClaudeFailureFields = Partial<Record<"api_error_status", number | null>> & {
result?: string;
};
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<T extends { status: { success: boolean }; error: string }>(
attempt: () => Promise<T>,
relogin: () => Promise<void>,
): Promise<T> {
let result = await attempt();
if (!result.status.success && isCodexAuthFailure(result.error)) {
await relogin();
result = await attempt();
}
return result;
}
async function readCodexAuth(path: string): Promise<string> {
const stat = await lstat(path);
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error("Codex login must be a regular file");
}
if ((stat.mode & 0o077) !== 0) {
throw new Error("Codex login must be private");
}
const text = await readFile(path, "utf8");
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error("invalid Codex login");
}
const tokens = isObject(value) ? value["tokens"] : undefined;
if (
!isObject(value) ||
typeof value["auth_mode"] !== "string" ||
value["auth_mode"] === "" ||
!isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
)
) {
throw new Error("invalid Codex login");
}
return text;
}
export async function stageCodexAuth(
canonicalPath: string,
profileHome: string,
uid: number,
gid: number,
): Promise<string> {
const codexHome = join(profileHome, ".codex");
await mkdir(codexHome, { recursive: true, mode: 0o700 });
await chmod(codexHome, 0o700);
await chown(codexHome, uid, gid);
const stagedPath = join(codexHome, "auth.json");
try {
await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 });
await chmod(stagedPath, 0o600);
await chown(stagedPath, uid, gid);
} catch (error) {
if (!isNotFound(error)) throw error;
}
return stagedPath;
}
// Creates an empty private file under a fresh name, as Deno.makeTempFile did.
async function makeTempFile(directory: string): Promise<string> {
const path = join(directory, `.tmp-${randomUUID()}`);
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 directory = dirname(canonicalPath);
await mkdir(directory, { recursive: true, mode: 0o700 });
const temporary = await makeTempFile(directory);
try {
await writeFile(temporary, text, { mode: 0o600 });
await chmod(temporary, 0o600);
await rename(temporary, canonicalPath);
} catch (error) {
await rm(temporary).catch(() => {});
throw error;
}
}
+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");
});
+120
View File
@@ -0,0 +1,120 @@
import { webcrypto } from "node:crypto";
import { isMapping } from "./json.ts";
import type { BotProfile } from "./profiles.ts";
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
claude: "claude-sonnet-5",
codex: "gpt-5.6-terra",
opencode: "openrouter/qwen/qwen3.8-27b",
};
export interface BotCredentials {
reviewerToken: string;
profiles: BotProfile[];
}
function decodeBase64Url(value: string): Uint8Array {
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (character) => character.codePointAt(0) ?? 0);
}
function encodeBase64Url(value: Uint8Array): string {
const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join("");
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}
const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer;
function importMasterKey(encoded: string, usage: webcrypto.KeyUsage): Promise<webcrypto.CryptoKey> {
const bytes = decodeBase64Url(encoded);
if (bytes.length !== 32) {
throw new Error("master-key must be 32 bytes encoded as base64url");
}
return webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]);
}
export async function decryptCredential(encrypted: string, masterKey: string): Promise<string> {
const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = encrypted.split(":");
if (version !== "v1" || encodedNonce === "" || encodedCiphertext === "" || extra !== "") {
throw new Error("credential must use v1:<nonce>:<ciphertext>");
}
const nonce = decodeBase64Url(encodedNonce);
if (nonce.length !== 12) throw new Error("credential nonce must be 12 bytes");
const plaintext = await webcrypto.subtle.decrypt(
{ name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "decrypt"),
buffer(decodeBase64Url(encodedCiphertext)),
);
return new TextDecoder().decode(plaintext);
}
export async function encryptCredential(
plaintext: string,
masterKey: string,
nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)),
): Promise<string> {
if (nonce.length !== 12) throw new Error("AES-GCM nonce must be 12 bytes");
const ciphertext = await webcrypto.subtle.encrypt(
{ name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "encrypt"),
new TextEncoder().encode(plaintext),
);
return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`;
}
async function parseProfile(entry: unknown, index: number, masterKey: string): Promise<BotProfile> {
const at = `profiles[${index}]`;
if (!isMapping(entry)) {
throw new TypeError(`${at}: expected a mapping`);
}
for (const key of Object.keys(entry)) {
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") {
throw new Error(`${key}: unknown field`);
}
}
const { "reviewer-token": reviewerToken, profiles } = value;
if (typeof reviewerToken !== "string") {
throw new TypeError("reviewer-token: expected an encrypted credential");
}
if (!Array.isArray(profiles) || profiles.length === 0) {
throw new Error("profiles must contain at least one profile");
}
const parsed = await Promise.all(
profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)),
);
return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed };
}
+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";
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);
});
+81
View File
@@ -0,0 +1,81 @@
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";
export type AgentCommandOptions = Omit<CommandOptions, "args" | "uid" | "gid">;
export interface AgentCommand {
output(): Promise<CommandOutput>;
spawn(): CommandChild;
}
export function agentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
options: AgentCommandOptions = {},
): AgentCommand {
const full: CommandOptions = { ...options, args, uid: profile.uid, gid: profile.gid };
return {
output: () => output(command, full),
spawn: () => spawn(command, full),
};
}
export function isRunnableProfileProcess(status: string, uid: number): boolean {
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
const state = /^State:\s+(\w)/mu.exec(status)?.[1];
return owner !== undefined && Number(owner) === uid && state !== "Z";
}
async function profileProcessIds(uid: number): Promise<number[]> {
const entries = await readdir("/proc", { withFileTypes: true });
const pids = entries
.filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name))
.map((entry) => entry.name);
const statuses = await Promise.all(
pids.map((pid) => readFile(`/proc/${pid}/status`, "utf8").catch(() => null)),
);
return pids
.filter((_pid, index) => {
const status = statuses[index];
return status !== null && status !== undefined && isRunnableProfileProcess(status, uid);
})
.map(Number);
}
// Kills the UID's processes every 10 ms until none is left, for up to 100 rounds.
export async function killProfileProcesses(uid: number, attempt = 0): Promise<void> {
if (attempt === 100) {
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
}
const ids = await profileProcessIds(uid);
if (ids.length === 0) return;
for (const pid of ids) {
try {
process.kill(pid, "SIGKILL");
} catch (error) {
if (!isNotFound(error)) throw error;
}
}
await sleep(10);
await killProfileProcesses(uid, attempt + 1);
}
export async function readProfileFile(profile: RuntimeProfile, path: string): Promise<string> {
const stat = await lstat(path);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== profile.uid ||
(stat.mode & 0o077) !== 0
) {
throw new Error(`${path}: expected a profile-owned regular private file`);
}
return readFile(path, "utf8");
}
+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);
});
+86
View File
@@ -0,0 +1,86 @@
import { isObject } from "./json.ts";
import { AgentAuthError } from "./profiles.ts";
export function openCodeArgs(model: string, prompt: string): string[] {
return ["run", "--format", "json", "--auto", "--model", model, prompt];
}
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
export async function collectOpenCodeOutput(
stdout: AsyncIterable<Uint8Array>,
stderr: AsyncIterable<Uint8Array>,
writeStdout: ChunkWriter,
writeStderr: ChunkWriter,
): Promise<{ stdout: string; stderr: string }> {
const collect = async (stream: AsyncIterable<Uint8Array>, write: ChunkWriter) => {
const chunks: Uint8Array[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
await write(chunk);
}
const length = chunks.reduce((total, chunk) => total + chunk.length, 0);
const joined = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
joined.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder().decode(joined);
};
const [output, error] = await Promise.all([
collect(stdout, writeStdout),
collect(stderr, writeStderr),
]);
return { stdout: output, stderr: error };
}
// OpenCode reports provider failures, including a rejected OpenRouter key
// (401 "User not found."), as a JSON error event on stdout and exits 1 with an
// empty stderr.
export function openCodeError(output: string): Error | undefined {
let failure: Error | undefined;
for (const line of output.split("\n")) {
if (line.trim() === "") continue;
let event: unknown;
try {
event = JSON.parse(line);
} catch {
continue;
}
if (!isObject(event)) continue;
const { type, error } = event;
if (type !== "error" || !isObject(error)) {
continue;
}
const { name, data } = error;
const { message, statusCode } = isObject(data) ? data : {};
const detail = `opencode ${typeof name === "string" ? name : "error"}${
typeof statusCode === "number" ? ` (API error ${statusCode})` : ""
}: ${typeof message === "string" ? message : "unknown error"}`;
failure =
statusCode === 401 || statusCode === 403
? new AgentAuthError("opencode sign-in failed", {
cause: new Error(detail),
})
: new Error(detail);
}
return failure;
}
export function parseOpenCodeEvents(output: string): string {
let result: string | undefined;
for (const line of output.split("\n")) {
if (line.trim() === "") continue;
const event: unknown = JSON.parse(line);
if (!isObject(event)) continue;
const { type, part } = event;
if (type !== "text" || !isObject(part)) continue;
const { text } = part;
if (typeof text === "string" && text.trim() !== "") result = text;
}
if (result === undefined) {
throw openCodeError(output) ?? new Error("OpenCode produced no text output");
}
return result;
}
+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";
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));
});
+240
View File
@@ -0,0 +1,240 @@
import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import { isNotFound } from "./errors.ts";
import { isObject } from "./json.ts";
import { failureFooter, withProfile } from "./profiles.ts";
import type { FailureReason, ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
export interface PostContext {
apiUrl: string;
repository: string;
issueIndex: string;
eventName: string;
}
interface Anchor {
path: string;
line: number;
side: "new" | "old";
body: string;
}
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
const stripAnsi = (text: string): string => text.replace(ANSI, "");
const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"],
};
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value);
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
const failureComment = (failures: ProfileFailure[]): string =>
`AI bot failed.\n\n${failureFooter(failures)}`;
class GiteaRequestError extends Error {}
function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) throw new TypeError(`${sourcePath}: not an array`);
return entries.map((entry: unknown, index) => {
const at = `${sourcePath}[${index}]`;
if (!isObject(entry)) {
throw new TypeError(`${at}: not an object`);
}
const { path, line, side = "new", body } = entry;
if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`);
}
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new TypeError(`${at}.line: expected a line number`);
}
if (side !== "new" && side !== "old") {
throw new Error(`${at}.side: expected "new" or "old"`);
}
if (typeof body !== "string" || body.trim() === "") {
throw new TypeError(`${at}.body: expected the finding`);
}
return { path, line, side, body };
});
}
async function readOutcome(path: string): Promise<RuntimeOutcome> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isObject(value) || !("ok" in value)) {
throw new Error("invalid agent outcome");
}
const outcome = value;
if (outcome["ok"] === false) {
// Only known labels reach the comment, never raw agent output.
const failures: unknown[] = Array.isArray(outcome["failures"]) ? outcome["failures"] : [];
return {
ok: false,
failures: failures.flatMap((entry: unknown) => {
const { type, model, reason } = isObject(entry) ? entry : {};
return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
? [{ type, model, reason }]
: [];
}),
};
}
if (
outcome["ok"] !== true ||
typeof outcome["body"] !== "string" ||
typeof outcome["reviewPath"] !== "string" ||
typeof outcome["anchorsPath"] !== "string" ||
!isObject(outcome["profile"])
) {
throw new Error("invalid agent outcome");
}
const profile = outcome["profile"];
if (!isProfileType(profile["type"]) || 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"],
model: profile["model"],
},
};
}
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;
}
export async function postRuntime(
manifestPath: string,
context: PostContext,
fetcher: typeof fetch = fetch,
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
try {
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).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): Promise<void> =>
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[]): Promise<void> =>
request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
body: stripAnsi(
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
),
event,
comments: entries.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(withProfile(body, outcome.profile)),
new_position: side === "new" ? line : 0,
old_position: side === "old" ? line : 0,
})),
});
await postReview(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) throw error;
console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
);
await postReview([]);
});
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
throw error;
}
} finally {
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
if (!isNotFound(error)) throw error;
});
}
}
function required(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
return value;
}
if (import.meta.main) {
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
apiUrl: required("GITEA_API_URL"),
repository: required("GITEA_REPOSITORY"),
issueIndex: required("ISSUE_INDEX"),
eventName: required("EVENT_NAME"),
});
}
+22
View File
@@ -0,0 +1,22 @@
import { appendFile, rm } from "node:fs/promises";
import process from "node:process";
import { prepareRuntime } from "./runtime.ts";
const env = (name: string): string => {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
return value;
};
const manifest = await prepareRuntime(
env("ACTION_PATH"),
env("RUNNER_TEMP"),
env("BOT_AGENTS_MASTER_KEY"),
);
try {
await appendFile(env("GITHUB_OUTPUT"), `manifest=${manifest.manifestPath}\n`);
} catch (error) {
await rm(manifest.runtimeDir, { recursive: true }).catch(() => {});
throw error;
}
+228
View File
@@ -0,0 +1,228 @@
import { expect, test } from "vite-plus/test";
import {
AgentAuthError,
agentEnvironment,
failureFooter,
type ProfileFailure,
profileFooter,
runProfiles,
sanitizedEnvironment,
} from "./profiles.ts";
test("formats profile attribution as a Markdown quote", () => {
expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
);
});
test("falls through only after usage exhaustion", async () => {
const visited: string[] = [];
const result = await runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
],
(profile) => {
visited.push(profile.type);
if (profile.type === "claude") {
throw new Error("You've hit your usage limit");
}
return Promise.resolve("done");
},
);
expect(result).toBe("done");
expect(visited).toStrictEqual(["claude", "codex"]);
});
test("recognizes Claude's weekly limit message", async () => {
const result = await runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
],
(profile) => {
if (profile.type === "claude") {
throw new Error("You've hit your weekly limit · resets 12am (UTC)");
}
return Promise.resolve("fallback");
},
);
expect(result).toBe("fallback");
});
test("recognizes provider API exhaustion status", async () => {
const results = await Promise.all(
["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) =>
runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
],
(profile) => {
if (profile.type === "claude") throw new Error(message);
return Promise.resolve("fallback");
},
),
),
);
expect(results).toStrictEqual(["fallback", "fallback"]);
});
test("falls through after a sign-in failure and records each try", async () => {
const failures: ProfileFailure[] = [];
const result = await runProfiles(
[
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" },
],
(profile) => {
if (profile.type === "codex") {
throw new AgentAuthError("codex sign-in failed");
}
if (profile.type === "claude") throw new Error("API Error: 429");
return Promise.resolve("fallback");
},
failures,
);
expect(result).toBe("fallback");
expect(failures).toStrictEqual([
{ type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" },
{ type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" },
]);
expect(failureFooter(failures)).toStrictEqual(
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " +
"`claude` · `claude-sonnet-5` (usage exhausted)",
);
});
test("throws the last sign-in failure when every profile fails", async () => {
const failures: ProfileFailure[] = [];
await expect(() =>
runProfiles(
[
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "claude", model: "claude-sonnet-5", token: "first" },
],
(profile) => {
throw new AgentAuthError(`${profile.type} sign-in failed`);
},
failures,
),
).rejects.toThrow(
expect.toSatisfy(
(e: unknown) => e instanceof AgentAuthError && e.message.includes("claude sign-in failed"),
),
);
expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]);
});
test("preserves an ordinary agent failure", async () => {
const visited: string[] = [];
await expect(() =>
runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
],
(profile) => {
visited.push(profile.type);
throw new Error("tests failed");
},
),
).rejects.toThrow("tests failed");
expect(visited).toStrictEqual(["claude"]);
});
test("does not mistake incidental quota text for exhaustion", async () => {
const visited: string[] = [];
await expect(() =>
runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
],
(profile) => {
visited.push(profile.type);
throw new Error("quota parser test failed at line 429");
},
),
).rejects.toThrow("quota parser test failed");
expect(visited).toStrictEqual(["claude"]);
});
test("exposes only the selected profile credential to the agent", () => {
const base = {
PATH: "/usr/bin",
HOME: "/home/bot",
UNRELATED_SECRET: "must-not-reach-agent",
BOT_AGENTS_MASTER_KEY: "master",
PROFILES: "contains every secret",
REVIEWER_TOKEN: "reviewer",
CLAUDE_CODE_OAUTH_TOKEN: "old-claude",
ANTHROPIC_API_KEY: "old-anthropic-key",
ANTHROPIC_AUTH_TOKEN: "old-anthropic-token",
OPENAI_API_KEY: "old-openai",
CODEX_API_KEY: "old-codex-key",
CODEX_ACCESS_TOKEN: "old-codex-token",
};
const profile = {
index: 0,
type: "claude" as const,
model: "claude-sonnet-5",
uid: 20_000,
gid: 20_000,
home: "/runtime/profile-0",
tokenFile: "/runtime/profile-0/token",
};
expect(agentEnvironment(base, profile, "selected")).toStrictEqual({
PATH: "/usr/bin",
HOME: "/runtime/profile-0",
CLAUDE_CODE_OAUTH_TOKEN: "selected",
});
expect(sanitizedEnvironment(base)).toStrictEqual({
PATH: "/usr/bin",
HOME: "/home/bot",
});
});
test("uses a private Codex home without inventing an API credential", () => {
const profile = {
index: 1,
type: "codex" as const,
model: "gpt-5.6-terra",
uid: 20_001,
gid: 20_001,
home: "/runtime/profile-1",
tokenFile: null,
};
expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({
PATH: "/bin",
HOME: "/runtime/profile-1",
CODEX_HOME: "/runtime/profile-1/.codex",
});
});
test("exposes an OpenRouter key only to the OpenCode profile", () => {
const profile = {
index: 2,
type: "opencode" as const,
model: "openrouter/qwen/qwen3.8-27b",
uid: 20_002,
gid: 20_002,
home: "/runtime/profile-2",
tokenFile: "/runtime/profile-2/token",
};
expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({
PATH: "/bin",
HOME: "/runtime/profile-2",
OPENROUTER_API_KEY: "selected",
});
});
+124
View File
@@ -0,0 +1,124 @@
export interface BotProfile {
type: "claude" | "codex" | "opencode";
model: string;
token?: string;
}
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
}
export function withProfile(body: string, profile: Pick<BotProfile, "type" | "model">): string {
return `${body}\n\n${profileFooter(profile)}`;
}
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
reason: FailureReason;
};
// Raised once an agent's credentials are rejected and any re-login is spent.
export class AgentAuthError extends Error {}
function isUsageExhausted(error: unknown): boolean {
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test(
String(error),
);
}
function failureReason(error: unknown): FailureReason {
if (error instanceof AgentAuthError) return "sign-in failed";
return isUsageExhausted(error) ? "usage exhausted" : "failed";
}
export function failureFooter(failures: ProfileFailure[]): string {
if (failures.length === 0) return "> Profile: none";
return `> Tried: ${failures
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
.join(", ")}`;
}
// Tries the profiles from `index` on, moving on only after a sign-in failure
// or exhausted usage, and records every profile it tried.
async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[],
index: number,
): Promise<T> {
const profile = profiles[index];
if (profile === undefined) {
throw new Error("profiles must contain at least one profile");
}
try {
return await attempt(profile);
} catch (error) {
const reason = failureReason(error);
failures.push({ type: profile.type, model: profile.model, reason });
if (reason === "failed" || index === profiles.length - 1) throw error;
console.error(`${profile.type} ${reason}; trying next profile`);
return attemptFrom(profiles, attempt, failures, index + 1);
}
}
export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [],
): Promise<T> {
return attemptFrom(profiles, attempt, failures, 0);
}
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
const result: Record<string, string> = {};
for (const name of [
"PATH",
"HOME",
"LANG",
"LANGUAGE",
"LC_ALL",
"TERM",
"COLORTERM",
"CI",
"TMPDIR",
"GITEA_TOKEN",
"GITEA_API_URL",
"GITEA_REPOSITORY",
"GITHUB_WORKSPACE",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS",
]) {
const value = environment[name];
if (value !== undefined) result[name] = value;
}
return result;
}
export function agentEnvironment(
environment: Record<string, string>,
profile: Pick<BotProfile, "type"> & { home: string },
token?: string,
): Record<string, string> {
const result = sanitizedEnvironment(environment);
result["HOME"] = profile.home;
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`;
if (token !== undefined && token !== "") {
const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN",
codex: "OPENAI_API_KEY",
opencode: "OPENROUTER_API_KEY",
}[profile.type];
result[tokenName] = token;
}
return result;
}
+503
View File
@@ -0,0 +1,503 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner.
import { chmod, chown, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import { createInterface } from "node:readline";
import type { Readable } from "node:stream";
import { text as readText } from "node:stream/consumers";
import { setTimeout as sleep } from "node:timers/promises";
import {
codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts";
import { chunks, endWith, output, writeChunk } from "./command.ts";
import type { CommandStatus } from "./command.ts";
import { agentCommand, killProfileProcesses, readProfileFile } from "./isolation.ts";
import { isObject } from "./json.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import {
AgentAuthError,
agentEnvironment,
runProfiles,
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
interface AttemptResult {
body: string;
reviewPath: string;
anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">;
}
// Claude Code's stream-json `result` event, in its own field names.
type ClaudeResult = ClaudeFailureFields &
Partial<Record<"is_error", boolean>> & {
subtype: string;
};
const env = (name: string): string => {
const value = process.env[name];
if (value === undefined) throw new Error(`${name} is not set`);
return value;
};
function definedEnvironment(): 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;
}
const API = env("GITEA_API_URL");
const REPO = env("GITEA_REPOSITORY");
const INDEX = env("ISSUE_INDEX");
const EVENT = env("EVENT_NAME");
const AUTHOR_TOKEN = env("GITEA_TOKEN");
const WORKSPACE = env("GITHUB_WORKSPACE");
const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
const PROCESS_ENV = sanitizedEnvironment(definedEnvironment());
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
const CODEX_AUTH = "/root/.codex/auth.json";
const SKILLS_INSTALL = "skills add obra/superpowers";
async function gitea(path: string, body?: unknown): Promise<Response> {
const response = await fetch(`${API}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response;
}
async function postAuthorComment(body: string): Promise<void> {
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
}
async function runRoot(command: string, args: string[]): Promise<void> {
const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true });
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
async function configureGitAuthor(): Promise<void> {
const user: unknown = await (await gitea("user")).json();
if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") {
throw new Error("user: unexpected Gitea response");
}
await runRoot("git", ["config", "user.name", user["login"]]);
await runRoot("git", ["config", "user.email", user["email"]]);
}
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8");
return token;
}
async function 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}`);
}
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
await runAgentCommand(profile, "git", [
"config",
"--global",
"--add",
"safe.directory",
WORKSPACE,
]);
await runAgentCommand(profile, "npx", [
"-y",
...SKILLS_INSTALL.split(" "),
"--global",
"--agent",
profile.type === "claude" ? "claude-code" : profile.type,
"--yes",
]);
}
// One issue comment as the prompt quotes it.
function formatComment(comment: unknown): string {
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {};
const login = isObject(user) ? user["login"] : undefined;
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
}
async function renderPrompt(
profile: RuntimeProfile,
): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> {
const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json");
const comments: unknown = await (
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
).json();
if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response");
}
const values: Record<string, string> = {
COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
CODE_RULES: await (await gitea(RULES_PATH)).text(),
EVENT_NAME: EVENT,
GITEA_API_URL: API,
GITEA_REPOSITORY: REPO,
ISSUE_INDEX: INDEX,
REVIEW_PATH: reviewPath,
ANCHORS_PATH: anchorsPath,
};
const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8");
return {
prompt: template.replaceAll(
/\$\{(\w+)\}/gu,
(match: string, name: string) => values[name] ?? match,
),
reviewPath,
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: ClaudeResult | undefined;
let error = "";
const copyError = (async (): Promise<void> => {
const decoder = new TextDecoder();
for await (const chunk of chunks(claude.stderr)) {
const part = decoder.decode(chunk, { stream: true });
error += part;
console.error(part);
}
error += decoder.decode();
})();
for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) {
if (line !== "") {
const event: unknown = JSON.parse(line);
if (isObject(event)) {
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);
}
}
}
if (event["type"] === "result") {
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event;
result = {
is_error: typeof isError === "boolean" ? isError : undefined,
result: typeof answer === "string" ? answer : undefined,
subtype: String(subtype),
api_error_status:
typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined,
};
}
}
}
}
const [status] = await Promise.all([claude.status, copyError]);
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"}`);
}
if (!status.success) {
throw new Error(error.trim() || `claude exited with ${status.code}`);
}
return result.result;
}
// 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 collect = async (stream: Readable): Promise<void> => {
const decoder = new TextDecoder();
for await (const chunk of chunks(stream)) {
shown += decoder.decode(chunk, { stream: true });
}
shown += decoder.decode();
};
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
const status = login.status;
await sleep(3000);
await pollForInstructions(() => shown, status);
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();
await endWith(login.stdin, `${token}\n`);
const [status, error] = await Promise.all([login.status, readText(login.stderr)]);
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 writeFile(path, "", { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, profile.uid, profile.gid);
return path;
}
async function runCodex(
profile: RuntimeProfile,
prompt: string,
authentication: CodexAuthentication,
): Promise<string> {
const token = authentication.kind === "api-key" ? authentication.token : undefined;
const environment = agentEnvironment(PROCESS_ENV, profile, token);
if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token);
} else {
const 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 (): Promise<{ status: CommandStatus; error: string }> => {
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 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, "") };
};
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 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);
}
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile);
const authentication = profile.type === "codex" ? codexAuthentication(token) : null;
let stagedCodexAuth: string | null = null;
let body: string | undefined;
let resultPath: string | null = null;
let reviewPath: string | undefined;
let anchorsPath: string | undefined;
try {
if (authentication?.kind === "persistent") {
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
}
await prepareProfile(profile);
const rendered = await renderPrompt(profile);
reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath;
if (profile.type === "claude") {
body = await runClaude(profile, token ?? "", rendered.prompt);
} else if (profile.type === "codex") {
resultPath = await runCodex(
profile,
rendered.prompt,
authentication ?? codexAuthentication(token),
);
} else {
body = await runOpenCode(profile, token ?? "", rendered.prompt);
}
} finally {
await killProfileProcesses(profile.uid);
if (stagedCodexAuth !== null) {
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
}
}
if (resultPath !== null) body = await readProfileFile(profile, resultPath);
if (body === undefined || reviewPath === undefined || anchorsPath === undefined) {
throw new Error("agent attempt produced no result");
}
return {
body,
reviewPath,
anchorsPath,
profile: { type: profile.type, model: profile.model },
};
}
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), {
mode: 0o600,
});
await chmod(MANIFEST.outcomeFile, 0o600);
}
const failures: ProfileFailure[] = [];
try {
await configureGitAuthor();
await writeOutcome({
ok: true,
...(await runProfiles(MANIFEST.profiles, runProfile, failures)),
});
} catch (error) {
console.error(error);
await writeOutcome({ ok: false, failures });
throw error;
}
+105
View File
@@ -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";
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 });
}
});
+118
View File
@@ -0,0 +1,118 @@
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { parseCredentials } from "./credentials.ts";
import { isMapping } from "./json.ts";
import type { BotProfile, ProfileFailure } from "./profiles.ts";
const FIRST_PROFILE_UID = 20_000;
export type RuntimeProfile = Pick<BotProfile, "type" | "model"> & {
index: number;
uid: number;
gid: number;
home: string;
tokenFile: string | null;
};
export interface RuntimeManifest {
runtimeDir: string;
manifestPath: string;
reviewerFile: string;
outcomeFile: string;
profiles: RuntimeProfile[];
}
export type RuntimeOutcome =
| {
ok: true;
body: string;
reviewPath: string;
anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">;
}
| { ok: false; failures: ProfileFailure[] };
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
await writeFile(path, value, { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, uid, gid);
}
export async function prepareRuntime(
actionPath: string,
runtimeRoot: string,
masterKey: string,
): Promise<RuntimeManifest> {
const credentials = await parseCredentials(
await readFile(join(actionPath, "profiles.json"), "utf8"),
masterKey,
);
const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-"));
try {
await chmod(runtimeDir, 0o711);
const reviewerFile = join(runtimeDir, "reviewer-token");
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
// One profile at a time, in order, as each claims the next UID.
const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>(
async (previous, profile, index) => {
const prepared = await previous;
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);
}
prepared.push({
index,
type: profile.type,
model: profile.model,
uid,
gid: uid,
home,
tokenFile,
});
return prepared;
},
Promise.resolve([]),
);
const manifestPath = join(runtimeDir, "manifest.json");
const manifest: RuntimeManifest = {
runtimeDir,
manifestPath,
reviewerFile,
outcomeFile: join(runtimeDir, "outcome.json"),
profiles,
};
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
return manifest;
} catch (error) {
await rm(runtimeDir, { recursive: true }).catch(() => {});
throw error;
}
}
function isRuntimeManifest(value: unknown): value is RuntimeManifest {
return (
isMapping(value) &&
typeof value["runtimeDir"] === "string" &&
typeof value["manifestPath"] === "string" &&
typeof value["reviewerFile"] === "string" &&
typeof value["outcomeFile"] === "string" &&
Array.isArray(value["profiles"])
);
}
export async function readRuntimeManifest(path: string): Promise<RuntimeManifest> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isRuntimeManifest(value)) {
throw new Error(`${path}: invalid runtime manifest`);
}
return value;
}
+47
View File
@@ -0,0 +1,47 @@
// Test support: the current user's IDs, a fresh temporary directory, and a
// plain command runner. It imports no source module, so the repository-level
// tests in tests/, which reach src/ only through `#testing`, never touch
// internals.
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { text } from "node:stream/consumers";
export function currentUid(): number {
return process.getuid?.() ?? -1;
}
export function currentGid(): number {
return process.getgid?.() ?? -1;
}
// The isolation tests switch UIDs, which only root may do.
export const IS_ROOT: boolean = currentUid() === 0;
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
export const IS_WINDOWS: boolean = process.platform === "win32";
export function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
}
export interface CommandResult {
success: boolean;
stderr: string;
}
// Runs a command with stdin closed and only the given environment, and
// collects its stderr.
export async function runCommand(
command: string,
args: string[],
env: Record<string, string>,
): Promise<CommandResult> {
const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] });
const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]);
const exitCode: unknown = closed[0];
return { success: exitCode === 0, stderr };
}
+22
View File
@@ -0,0 +1,22 @@
import process from "node:process";
import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test";
import { runCommand } from "#testing";
// Each phase runs as `node src/<phase>.ts` with no build step. Node loads the
// 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 runCommand(process.execPath, [path], { PATH: process.env["PATH"] ?? "" });
expect(result.success).toBe(false);
expect(result.stderr).toContain(`${firstVariable} is not set`);
});
+177
View File
@@ -0,0 +1,177 @@
import { readdir, readFile, rm, stat } from "node:fs/promises";
import process from "node:process";
import { assert, expect, test } from "vite-plus/test";
import { parse } from "yaml";
import { makeTempDir, runCommand } from "#testing";
type JsonObject = Record<string, unknown>;
const isMapping = (value: unknown): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value);
const mapping = (value: unknown, at: string): JsonObject => {
assert(isMapping(value), at);
return value;
};
const 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 runCommand("/bin/bash", ["scripts/protect-source.sh"], {
...process.env,
ACTION_PATH: process.cwd(),
GITHUB_OUTPUT: outputFile,
RUNNER_TEMP: temp,
});
assert(result.success, 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) => error instanceof Error && "code" in error && error.code === "ENOENT",
);
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,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false
}
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vite-plus";
import projectLint from "./scripts/project-lint.ts";
export default defineConfig({
lint: {
plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["./scripts/project-lint.ts"],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: projectLint.configs.project.rules,
options: {
typeAware: true,
typeCheck: true,
maxWarnings: 0,
reportUnusedDisableDirectives: "error",
},
},
});