7 Commits

Author SHA1 Message Date
Danny Kim 6c4e4bf67d Move the phase entry points into aliased module folders
Check / check (pull_request) Successful in 46s
2026-10-18 23:58:13 +09:00
Danny Kim 0a9f86a102 Group the sources into aliased modules and lint with the published plugins
Agents / agent (pull_request) Has been skipped
Check / check (pull_request) Successful in 38s
2026-10-18 23:50:27 +09:00
Danny Kim c8a150fee7 Drop Deno from the agents and spawn child processes directly (#22)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-18 14:40:48 +00:00
Danny Kim ccfb08f4d5 Document that package.json aliases may target only direct subfolders of src (#21)
Follow-up to #20, which was merged before this landed.

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

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

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

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

Reviewed-on: #21
Reviewed-by: bot <noreply@capsulizers.com>
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-18 14:37:49 +00:00
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
80 changed files with 7409 additions and 2497 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,39 @@
---
description: Child processes use plain node:child_process spawn (explicit stdio, uid/gid, whole env); src/processes/child.ts has exitCode (128+signo) and echo; no execFile maxBuffer
---
Every call site calls `spawn` from `node:child_process` itself, with
everything explicit:
- **`uid`/`gid`** of the profile for anything an agent profile runs (each
profile is its own UID, 20000+). Never drop them.
- **`env`** is the whole environment the child gets (a sanitized one for
agents); nothing from the coordinator is merged in.
- **`stdio`**: stdin is `"ignore"` so an agent CLI never waits on input
(only `codex login --with-api-key` pipes the token in). Output that is not
read is `"ignore"`; output that is read is `"pipe"` and streamed.
- No `execFile`/`exec`: their `maxBuffer` (1 MiB) kills a chatty child. Read
piped streams incrementally instead.
`src/processes/child.ts` holds what the call sites share:
- **`exitCode(child)`**: call it right after `spawn`. It settles on `exit`
(not `close`, which a daemonized grandchild can hold open), maps a signal to
`128 + signo` as a shell does, and rejects on a failed start. The rejection
is pre-caught so it is not reported as unhandled before the caller awaits it.
- **`echo(stream, chunk)`**: writes and waits for `drain` when the buffer is
full, so echoing never outruns a slow reader.
Tests use `spawnSync` for short commands.
Other port details:
- **`src/common/errors.ts`** `isNotFound` checks `ENOENT` (file) or `ESRCH` (process).
- Temporary files are an exclusive `wx` create with mode `0600`.
- `src/common/json.ts` holds type guards used instead of `as` casts on parsed JSON.
- `process` and `Buffer` must come from `node:` imports:
`no-restricted-globals` in `scripts/project-lint.ts` enforces it
([[lint-parity-never-shrinks]]).
Related: how each agent CLI reports bad credentials is in
[[agent-cli-auth-failures]].
@@ -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]]). `src/run/index.ts` and `src/post/index.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,35 @@
---
description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, lints with eslint-plugin-import-structure and explicit-types (concrete, absence)
---
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
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/index.ts` uses `import.meta.main`. There is no
`engines` (user decision 2026-10-18): the `node:24-bookworm` job image
decides the version ([[bot-agents-runtime-no-build]]).
- **Published lint plugins** (2026-10-18, after they reached npm):
`eslint-plugin-import-structure` (`modules` config) and
`eslint-plugin-explicit-types` (`concrete` and `absence` configs) are
devDependencies, enabled in `vite.config.ts`. explicit-types' `naming`
config is left off: its `upper-snake-string-unions` and
`no-upper-snake-object-keys` rules fire on wire tokens this repo does not
name (profile types such as `"claude"`, failure reasons posted to Gitea,
environment variable names), which cannot change without changing
behavior. Open question for the user. `eslint-plugin-lit-robust` does not apply
(no Lit).
- **Stricter input checks were accepted** in the migration: Gitea `/user` and
comment responses, the runtime manifest, and the outcome's profile type are
validated; some type errors became `TypeError` with the same messages.
- Merging is the user's call; the `bot` account's approval is required first
([[git-pr-workflow]]).
@@ -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 `src/post/index.ts`. That image is the only Node
pin (`package.json` has no `engines`); `check.yml` prints `node --version`
to confirm the cached image's minor.
- **Root-owned source copy:** `scripts/protect-source.sh` copies the action
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 66 run; verify isolation changes in node:24-bookworm as root
---
Some tests exercise real UID isolation, process cleanup, and the prepare phase's
credential cleanup. They need root and are written as
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show
as skipped instead of silently passing.
- As a normal user `vp test` passes 61 and skips 5. In CI (self-hosted
runner, root) all 66 run.
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare/index.ts`,
or `child.ts`, run the suite as root in `node:24-bookworm` (e.g. a
container with the repo mounted) before pushing; a green non-root run
proves nothing about those paths.
- The migration also ran an end-to-end smoke as root with fake
`codex`/`claude`/`npx` binaries and a fake Gitea through
`protect-source.sh` -> `prepare/index.ts` -> `run/index.ts` -> `post/index.ts`; repeat that
for changes to the phase flow ([[bot-agents-runtime-no-build]]).
- `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs,
`runCommand`) behind the `#testing` alias. Every test imports it as
`#testing`. It imports no source module, so `tests/` (which may not use
`../`) never reaches internals. Tests follow
[[tests-layout-vitest]] (unit tests next to their code in `src/`, 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.
+105
View File
@@ -0,0 +1,105 @@
---
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 aliases are `#prepare`, `#run`, `#post`, `#agents`, `#processes`, `#runtime`, `#profiles`,
`#common`, and `#testing`, in that layer order.
- Code outside `src/` (`scripts/`, `tests/`, `vite.config.ts`) gets no alias
of its own and imports its own files by `./` relative paths. It reaches
`src/` only through the aliases, since `../` is banned.
- No lint rule checks this; check `imports` by hand when adding an alias.
+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/index.ts` needs 24.2+ (`import.meta.main`).
- **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
`vp` brings its own Node, the way uv brings its own Python.
- **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.
+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.
+9 -8
View File
@@ -5,17 +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/index.ts needs Node 24.2+.
- run: node --version
- uses: denoland/setup-deno@v2
- run: npm ci
- run: >-
deno fmt --check . &&
deno lint . &&
deno check src/prepare.ts src/run.ts src/post.ts &&
deno test --allow-all
# 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/
+51 -7
View File
@@ -1,7 +1,7 @@
At the start of every new session, run `damem recall` and treat its output as
the repository context. If `damem` is unavailable, read and follow
[its installation instructions](https://github.com/cunarist/damem/blob/main/INSTALL.md)
before reasoning or responding.
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
@@ -10,6 +10,50 @@ 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.
Repository TypeScript and checks use Deno. The composite action uses npm only to
install the externally distributed Claude Code and Codex command-line tools; do
not treat those vendor executables as repository TypeScript dependencies.
# 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 `*.test.ts` in `src/`;
repository-level tests live in `tests/`.
A `#` alias in `package.json` `imports` may point anywhere and owns its
target: an alias to a folder's `index.ts` owns that folder and everything
below it; an alias to any other file owns only that file. No overlap: no file
may be owned by 2 aliases, so no alias inside another alias's area and no 2
aliases with the same target. Outside an area, only the exact alias reaches
it; a deeper alias specifier or a relative path into the area is a bypass.
Inside an area, relative imports of siblings and subfolders are free. A `../`
import is banned in every file, owned or not
(`import/no-relative-parent-imports`); runtime file URLs such as
`new URL("../prompt.md", import.meta.url)` are paths, not imports, and stay.
Files no alias owns need no `index.ts` and no alias, and import `./` siblings
and subfolders freely. Every top-level entry of `src/` is an aliased module
folder. The aliases, in layer order, are the three phases `#prepare`, `#run`,
and `#post`, then `#agents` (Codex,
Claude Code and OpenCode specifics), `#processes` (child processes and the
isolated profile users), `#runtime` (the prepared runtime and its
credentials), `#profiles`, `#common` (error and JSON checks), and `#testing`
(test helpers), each with a `src/<name>/index.ts` barrel. A phase's
`index.ts` is also its entry point, which `action.yml` runs as
`node src/<phase>/index.ts`. Tests that use the test
helpers import `#testing`, and `tests/` imports `src/` only through it. Local convention, stricter than the general rule above: in
this repo, aliases point only at a direct subfolder of `src/`
(`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses `./`
relative imports. `scripts/` is flat: one file per tool, no subfolders and no `index.ts`, imported directly by file path
([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)).
The only config files are `package.json`, `tsconfig.json`, and
`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; do not treat those
vendor executables as repository TypeScript dependencies.
+6 -5
View File
@@ -15,10 +15,11 @@ runs:
with:
fetch-depth: 0
token: ${{ gitea.token }}
# This step assumes this is `node:24-bookworm` container.
# This step assumes this is `node:24-bookworm` container. The action runs
# its TypeScript on that Node directly.
- name: Install dependencies
shell: bash
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai
# A local action lives inside the writable checkout. Execute a root-owned
# copy so an agent changing this repository cannot replace a later phase.
- name: Protect action source
@@ -34,7 +35,7 @@ runs:
- name: Prepare credentials
id: prepare
shell: bash
run: deno run -A "${{ steps.source.outputs.path }}/src/prepare.ts"
run: node "${{ steps.source.outputs.path }}/src/prepare/index.ts"
env:
ACTION_PATH: ${{ steps.source.outputs.path }}
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
@@ -42,7 +43,7 @@ runs:
- name: Run bot
id: agent
shell: bash
run: deno run -A "${{ steps.source.outputs.path }}/src/run.ts"
run: node "${{ steps.source.outputs.path }}/src/run/index.ts"
env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_TOKEN: ${{ gitea.token }}
@@ -55,7 +56,7 @@ runs:
id: post
if: always() && steps.prepare.outputs.manifest != ''
shell: bash
run: deno run -A "${{ steps.source.outputs.path }}/src/post.ts"
run: node "${{ steps.source.outputs.path }}/src/post/index.ts"
env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_API_URL: ${{ gitea.api_url }}
+3108
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"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": {
"#prepare": "./src/prepare/index.ts",
"#run": "./src/run/index.ts",
"#post": "./src/post/index.ts",
"#agents": "./src/agents/index.ts",
"#processes": "./src/processes/index.ts",
"#runtime": "./src/runtime/index.ts",
"#profiles": "./src/profiles/index.ts",
"#common": "./src/common/index.ts",
"#testing": "./src/testing/index.ts"
},
"devDependencies": {
"@eslint/core": "^0.17.0",
"@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.6.1",
"eslint-plugin-import-structure": "^0.10.2",
"vite-plus": "^1.2.0",
"yaml": "^2.9.1"
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY",
"profiles": [
{
"type": "codex",
"model": "gpt-5.6-terra"
},
{
"type": "claude",
"model": "claude-sonnet-5",
"token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg"
},
{
"type": "opencode",
"model": "openrouter/qwen/qwen3.8-27b",
"token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0"
}
]
}
-10
View File
@@ -1,10 +0,0 @@
reviewer-token: v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY
profiles:
- type: codex
model: gpt-5.6-terra
- type: claude
model: claude-sonnet-5
token: v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg
- type: opencode
model: openrouter/qwen/qwen3.8-27b
token: v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0
+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;
+4 -3
View File
@@ -2,8 +2,9 @@
set -euo pipefail
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
mkdir "${path}/src"
cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/"
cp -a "${ACTION_PATH}/profiles.yml" "${ACTION_PATH}/prompt.md" "${path}/"
cp -a "${ACTION_PATH}/src" "${path}/"
# package.json makes Node load the TypeScript sources as ES modules and
# resolves their `#` aliases.
cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/"
chmod -R go-w "${path}"
printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}"
+179
View File
@@ -0,0 +1,179 @@
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import { expect, test } from "vite-plus/test";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import {
codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
test("retries once after an invalid refresh token", async () => {
let attempts = 0;
let relogins = 0;
const result = await retryInvalidToken(
() =>
Promise.resolve({
code: 1,
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({
code: 1,
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({
code: attempts++ > 0 ? 0 : 1,
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}),
() => {
relogins++;
return Promise.resolve();
},
);
expect({ relogins, code: result.code }).toStrictEqual({
relogins: 1,
code: 0,
});
});
test("recognizes a rejected Claude OAuth token", () => {
expect(
isClaudeAuthFailure({
apiErrorStatus: 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({ apiErrorStatus: 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 });
}
});
+149
View File
@@ -0,0 +1,149 @@
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, isObject } from "#common";
interface CodexApiKey {
kind: "api-key";
token: string;
}
interface CodexPersistent {
kind: "persistent";
}
export type CodexAuthentication = CodexApiKey | CodexPersistent;
export function codexAuthentication(token?: string): CodexAuthentication {
return token === undefined ? { kind: "persistent" } : { kind: "api-key", token };
}
// 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, as `apiErrorStatus` and `result`.
export interface ClaudeFailureFields {
apiErrorStatus?: number;
result?: string;
}
export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
return (
result.apiErrorStatus === 401 ||
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test(
result.result ?? "",
)
);
}
export interface CommandResult {
code: number;
error: string;
}
export async function retryInvalidToken<T extends CommandResult>(
attempt: () => Promise<T>,
relogin: () => Promise<void>,
): Promise<T> {
let result = await attempt();
if (result.code !== 0 && 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.
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;
}
}
+17
View File
@@ -0,0 +1,17 @@
// Agent command-line specifics: Codex and Claude Code sign-in handling and
// OpenCode's arguments and output.
export {
codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
export type { ClaudeFailureFields, CodexAuthentication, CommandResult } from "./auth.ts";
export {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
+105
View File
@@ -0,0 +1,105 @@
import { assert, expect, test } from "vite-plus/test";
import { AgentAuthError } from "#profiles";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.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"}}')).toBeNull();
});
+94
View File
@@ -0,0 +1,94 @@
import { isObject } from "#common";
import { AgentAuthError } from "#profiles";
export function openCodeArgs(model: string, prompt: string): string[] {
return ["run", "--format", "json", "--auto", "--model", model, prompt];
}
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
interface OpenCodeOutput {
stdout: string;
stderr: string;
}
export async function collectOpenCodeOutput(
stdout: AsyncIterable<Uint8Array>,
stderr: AsyncIterable<Uint8Array>,
writeStdout: ChunkWriter,
writeStderr: ChunkWriter,
): Promise<OpenCodeOutput> {
const collect = async (
stream: AsyncIterable<Uint8Array>,
write: ChunkWriter,
): Promise<string> => {
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 | null {
let failure: Error | null = null;
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 | null = null;
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 === null) {
throw openCodeError(output) ?? new Error("OpenCode produced no text output");
}
return result;
}
-95
View File
@@ -1,95 +0,0 @@
import { dirname, join } from "jsr:@std/path@1";
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 };
}
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 && result.error.includes("invalid_refresh_token")
) {
await relogin();
result = await attempt();
}
return result;
}
async function readCodexAuth(path: string): Promise<string> {
const stat = await Deno.lstat(path);
if (!stat.isFile || stat.isSymlink) {
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 Deno.readTextFile(path);
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error("invalid Codex login");
}
const record = value as Record<string, unknown> | null;
const tokens = record?.tokens as Record<string, unknown> | null;
if (
record === null || typeof record !== "object" ||
typeof record.auth_mode !== "string" || record.auth_mode === "" ||
tokens === null || typeof tokens !== "object" ||
!["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 Deno.mkdir(codexHome, { recursive: true, mode: 0o700 });
await Deno.chmod(codexHome, 0o700);
await Deno.chown(codexHome, uid, gid);
const stagedPath = join(codexHome, "auth.json");
try {
await Deno.writeTextFile(stagedPath, await readCodexAuth(canonicalPath), {
mode: 0o600,
});
await Deno.chmod(stagedPath, 0o600);
await Deno.chown(stagedPath, uid, gid);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
return stagedPath;
}
export async function persistCodexAuth(
stagedPath: string,
canonicalPath: string,
): Promise<void> {
const text = await readCodexAuth(stagedPath);
const directory = dirname(canonicalPath);
await Deno.mkdir(directory, { recursive: true, mode: 0o700 });
const temporary = await Deno.makeTempFile({ dir: directory });
try {
await Deno.writeTextFile(temporary, text, { mode: 0o600 });
await Deno.chmod(temporary, 0o600);
await Deno.rename(temporary, canonicalPath);
} catch (error) {
await Deno.remove(temporary).catch(() => undefined);
throw error;
}
}
-125
View File
@@ -1,125 +0,0 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import {
codexAuthentication,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
Deno.test("retries once after an invalid refresh token", async () => {
let attempts = 0;
let relogins = 0;
const result = await retryInvalidToken(
() =>
Promise.resolve({
status: { success: false },
error: attempts++ === 0 ? "invalid_refresh_token" : "still failed",
}),
() => {
relogins++;
return Promise.resolve();
},
);
assertEquals({ attempts, relogins, error: result.error }, {
attempts: 2,
relogins: 1,
error: "still failed",
});
});
Deno.test("does not retry an unrelated failure", async () => {
let attempts = 0;
let relogins = 0;
await retryInvalidToken(
() => {
attempts++;
return Promise.resolve({
status: { success: false },
error: "rate limited",
});
},
() => {
relogins++;
return Promise.resolve();
},
);
assertEquals({ attempts, relogins }, { attempts: 1, relogins: 0 });
});
Deno.test("selects API-key login only for a configured Codex token", () => {
assertEquals(codexAuthentication("selected-key"), {
kind: "api-key",
token: "selected-key",
});
assertEquals(codexAuthentication(undefined), { kind: "persistent" });
});
const AUTH = JSON.stringify({
auth_mode: "chatgpt",
tokens: {
access_token: "access",
refresh_token: "refresh",
id_token: "id",
account_id: "account",
},
});
Deno.test("stages and persists only a regular private Codex login", async () => {
if (Deno.build.os === "windows") return;
const root = await Deno.makeTempDir();
try {
const canonical = `${root}/persistent/auth.json`;
await Deno.mkdir(`${root}/persistent`);
await Deno.writeTextFile(canonical, AUTH, { mode: 0o600 });
const home = `${root}/profile`;
await Deno.mkdir(home);
const staged = await stageCodexAuth(
canonical,
home,
Deno.uid()!,
Deno.gid()!,
);
assertEquals(await Deno.readTextFile(staged), AUTH);
assertEquals((await Deno.stat(staged)).mode! & 0o777, 0o600);
const refreshed = JSON.stringify({
...JSON.parse(AUTH),
tokens: { ...JSON.parse(AUTH).tokens, access_token: "refreshed" },
});
await Deno.writeTextFile(staged, refreshed);
await persistCodexAuth(staged, canonical);
assertEquals(await Deno.readTextFile(canonical), refreshed);
assertEquals((await Deno.stat(canonical)).mode! & 0o777, 0o600);
} finally {
await Deno.remove(root, { recursive: true });
}
});
Deno.test("rejects a symlink or malformed Codex login", async () => {
if (Deno.build.os === "windows") return;
const root = await Deno.makeTempDir();
try {
const canonical = `${root}/auth.json`;
const malformed = `${root}/malformed.json`;
const linked = `${root}/linked.json`;
await Deno.writeTextFile(canonical, AUTH, { mode: 0o600 });
await Deno.writeTextFile(malformed, '{"tokens":{}}', { mode: 0o600 });
await Deno.symlink(canonical, linked);
await assertRejects(
() => persistCodexAuth(linked, canonical),
Error,
"regular file",
);
await assertRejects(
() => persistCodexAuth(malformed, canonical),
Error,
"invalid Codex login",
);
} finally {
await Deno.remove(root, { recursive: true });
}
});
+7
View File
@@ -0,0 +1,7 @@
// A missing file or a missing process: Node reports them as ENOENT and ESRCH.
export function isNotFound(error: unknown): boolean {
if (!(error instanceof Error) || !("code" in error)) {
return false;
}
return error.code === "ENOENT" || error.code === "ESRCH";
}
+4
View File
@@ -0,0 +1,4 @@
// Helpers every phase shares: error and JSON value checks.
export { isNotFound } from "./errors.ts";
export { isMapping, isObject } from "./json.ts";
export type { JsonObject } from "./json.ts";
+12
View File
@@ -0,0 +1,12 @@
// Type guards for values read from JSON, which arrive as `unknown`.
export type JsonObject = NodeJS.Dict<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);
}
-144
View File
@@ -1,144 +0,0 @@
import { parse } from "jsr:@std/yaml@1";
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 type 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.charCodeAt(0));
}
function encodeBase64Url(value: Uint8Array): string {
const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join(
"",
);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_")
.replaceAll("=", "");
}
const buffer = (value: Uint8Array): ArrayBuffer =>
Uint8Array.from(value).buffer;
async function importMasterKey(
encoded: string,
usage: KeyUsage,
): Promise<CryptoKey> {
const bytes = decodeBase64Url(encoded);
if (bytes.length !== 32) {
throw new Error("master-key must be 32 bytes encoded as base64url");
}
return await crypto.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 crypto.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 = crypto.getRandomValues(new Uint8Array(12)),
): Promise<string> {
if (nonce.length !== 12) throw new Error("AES-GCM nonce must be 12 bytes");
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "encrypt"),
new TextEncoder().encode(plaintext),
);
return `v1:${encodeBase64Url(nonce)}:${
encodeBase64Url(new Uint8Array(ciphertext))
}`;
}
export async function parseCredentials(
text: string,
masterKey: string,
): Promise<BotCredentials> {
const value: unknown = parse(text);
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("credentials must be a mapping");
}
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (key !== "reviewer-token" && key !== "profiles") {
throw new Error(`${key}: unknown field`);
}
}
if (typeof record["reviewer-token"] !== "string") {
throw new Error("reviewer-token: expected an encrypted credential");
}
if (!Array.isArray(record.profiles) || record.profiles.length === 0) {
throw new Error("profiles must contain at least one profile");
}
const profiles = await Promise.all(
record.profiles.map(async (entry: unknown, index): Promise<BotProfile> => {
const at = `profiles[${index}]`;
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
throw new Error(`${at}: expected a mapping`);
}
const profile = entry as Record<string, unknown>;
for (const key of Object.keys(profile)) {
if (key !== "type" && key !== "model" && key !== "token") {
throw new Error(`${at}.${key}: unknown field`);
}
}
const { type, model, token } = profile;
if (type !== "claude" && type !== "codex" && type !== "opencode") {
throw new Error(
`${at}.type: expected \"claude\", \"codex\", or \"opencode\"`,
);
}
if (model !== undefined && (typeof model !== "string" || model === "")) {
throw new Error(`${at}.model: expected a model name`);
}
if (
(type === "claude" || type === "opencode") &&
typeof token !== "string"
) {
throw new Error(`${at}.token: expected an encrypted credential`);
}
if (token !== undefined && typeof token !== "string") {
throw new Error(`${at}.token: expected an encrypted credential`);
}
return {
type,
model: model ?? DEFAULT_MODELS[type],
...(typeof token === "string" && {
token: await decryptCredential(token, masterKey),
}),
};
}),
);
return {
reviewerToken: await decryptCredential(record["reviewer-token"], masterKey),
profiles,
};
}
-92
View File
@@ -1,92 +0,0 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import {
decryptCredential,
encryptCredential,
parseCredentials,
} from "./credentials.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32);
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
Deno.test("decrypts a known AES-256-GCM credential", async () => {
assertEquals(
await decryptCredential(ENCRYPTED, MASTER_KEY),
"secret-token",
);
});
Deno.test("encrypts a credential with a fresh supplied nonce", async () => {
assertEquals(
await encryptCredential("secret-token", MASTER_KEY, NONCE),
ENCRYPTED,
);
});
Deno.test("rejects a credential encrypted by another master key", async () => {
await assertRejects(() => decryptCredential(ENCRYPTED, OTHER_KEY));
});
Deno.test("rejects a credential with a nonstandard nonce", async () => {
await assertRejects(
() =>
decryptCredential(
"v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA",
MASTER_KEY,
),
Error,
"nonce must be 12 bytes",
);
});
Deno.test("decrypts the central reviewer token and ordered profiles", async () => {
assertEquals(
await parseCredentials(
`
reviewer-token: ${ENCRYPTED}
profiles:
- type: codex
model: gpt-custom
- type: claude
token: ${ENCRYPTED}
- type: opencode
token: ${ENCRYPTED}
`,
MASTER_KEY,
),
{
reviewerToken: "secret-token",
profiles: [
{ type: "codex", model: "gpt-custom" },
{
type: "claude",
model: "claude-sonnet-5",
token: "secret-token",
},
{
type: "opencode",
model: "openrouter/qwen/qwen3.8-27b",
token: "secret-token",
},
],
},
);
});
Deno.test("rejects malformed central credentials", async () => {
await assertRejects(
() => parseCredentials("profiles: []", MASTER_KEY),
Error,
"reviewer-token",
);
await assertRejects(
() =>
parseCredentials(
`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude`,
MASTER_KEY,
),
Error,
"profiles[0].token",
);
});
-68
View File
@@ -1,68 +0,0 @@
import type { RuntimeProfile } from "./runtime.ts";
export function agentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
options: Omit<Deno.CommandOptions, "args" | "uid" | "gid"> = {},
): Deno.Command {
return new Deno.Command(command, {
...options,
args,
uid: profile.uid,
gid: profile.gid,
});
}
export function isRunnableProfileProcess(
status: string,
uid: number,
): boolean {
const owner = status.match(/^Uid:\s+(\d+)/m)?.[1];
const state = status.match(/^State:\s+(\w)/m)?.[1];
return owner !== undefined && Number(owner) === uid && state !== "Z";
}
async function profileProcessIds(uid: number): Promise<number[]> {
const ids: number[] = [];
for await (const entry of Deno.readDir("/proc")) {
if (!entry.isDirectory || !/^\d+$/.test(entry.name)) continue;
const status = await Deno.readTextFile(`/proc/${entry.name}/status`).catch(
() => null,
);
if (status !== null && isRunnableProfileProcess(status, uid)) {
ids.push(Number(entry.name));
}
}
return ids;
}
export async function killProfileProcesses(uid: number): Promise<void> {
for (let attempt = 0; attempt < 100; attempt++) {
const ids = await profileProcessIds(uid);
if (ids.length === 0) return;
for (const pid of ids) {
try {
Deno.kill(pid, "SIGKILL");
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
}
export async function readProfileFile(
profile: RuntimeProfile,
path: string,
): Promise<string> {
const stat = await Deno.lstat(path);
if (
!stat.isFile || stat.isSymlink || stat.uid !== profile.uid ||
(stat.mode! & 0o077) !== 0
) {
throw new Error(`${path}: expected a profile-owned regular private file`);
}
return await Deno.readTextFile(path);
}
-134
View File
@@ -1,134 +0,0 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import {
agentCommand,
isRunnableProfileProcess,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts";
const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000,
type: "claude",
model: "test",
uid,
gid: uid,
home,
tokenFile: null,
});
Deno.test("zombie process entries are already quiescent", () => {
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
assertEquals(isRunnableProfileProcess(status, 20_010), false);
});
Deno.test({
name: "refuses to read a profile result symlink as root",
permissions: { read: true, write: true, sys: ["uid"] },
async fn() {
if (Deno.build.os === "windows") return;
const root = await Deno.makeTempDir();
try {
const secret = `${root}/reviewer-token`;
const result = `${root}/result.md`;
await Deno.writeTextFile(secret, "reviewer-secret", { mode: 0o600 });
await Deno.symlink(secret, result);
await assertRejects(
() => readProfileFile(profile(Deno.uid()!, root), result),
Error,
"regular private file",
);
} finally {
await Deno.remove(root, { recursive: true });
}
},
});
Deno.test({
name: "a profile process can read only its own private files",
permissions: { read: true, write: true, run: true, sys: ["uid"] },
async fn() {
if (Deno.uid() !== 0) return;
const root = await Deno.makeTempDir();
await Deno.chmod(root, 0o711);
try {
const homeA = `${root}/a`;
const homeB = `${root}/b`;
await Deno.mkdir(homeA, { mode: 0o700 });
await Deno.mkdir(homeB, { mode: 0o700 });
await Deno.chown(homeA, 20_000, 20_000);
await Deno.chown(homeB, 20_001, 20_001);
await Deno.writeTextFile(`${homeA}/token`, "own", { mode: 0o600 });
await Deno.chown(`${homeA}/token`, 20_000, 20_000);
await Deno.writeTextFile(`${homeB}/token`, "sibling", { mode: 0o600 });
await Deno.chown(`${homeB}/token`, 20_001, 20_001);
await Deno.writeTextFile(`${root}/reviewer`, "root", { mode: 0o600 });
const selected = profile(20_000, homeA);
const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], {
cwd: "/",
}).output();
const sibling = await agentCommand(selected, "/bin/cat", [
`${homeB}/token`,
], { cwd: "/" }).output();
const reviewer = await agentCommand(selected, "/bin/cat", [
`${root}/reviewer`,
], { cwd: "/" }).output();
assertEquals(new TextDecoder().decode(own.stdout), "own");
assertEquals(own.success, true);
assertEquals(sibling.success, false);
assertEquals(reviewer.success, false);
} finally {
await Deno.remove(root, { recursive: true });
}
},
});
Deno.test({
name: "cleanup leaves no runnable daemonized process",
permissions: "inherit",
async fn() {
if (Deno.uid() !== 0) return;
const selected = profile(20_010, "/tmp");
const launched = await agentCommand(selected, "/bin/sh", [
"-c",
"sleep 60 >/dev/null 2>&1 & echo $!",
], { cwd: "/" }).output();
const pid = Number(new TextDecoder().decode(launched.stdout).trim());
assertEquals(Number.isInteger(pid), true);
await killProfileProcesses(selected.uid);
const status = await Deno.readTextFile(`/proc/${pid}/status`).catch(
(error) => {
if (error instanceof Deno.errors.NotFound) return null;
throw error;
},
);
assertEquals(
status === null || !isRunnableProfileProcess(status, selected.uid),
true,
);
},
});
Deno.test({
name: "the agent process cannot read its coordinator environment",
permissions: { run: true, sys: ["uid"] },
async fn() {
if (Deno.uid() !== 0) return;
const selected = profile(20_020, "/tmp");
const result = await agentCommand(selected, "/usr/bin/cat", [
`/proc/${Deno.pid}/environ`,
], {
cwd: "/",
env: { PARENT_SECRET: "must-not-cross-boundary" },
clearEnv: true,
stderr: "piped",
}).output();
assertEquals(result.success, false);
},
});
-59
View File
@@ -1,59 +0,0 @@
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: ReadableStream<Uint8Array>,
stderr: ReadableStream<Uint8Array>,
writeStdout: ChunkWriter,
writeStderr: ChunkWriter,
): Promise<{ stdout: string; stderr: string }> {
const collect = async (
stream: ReadableStream<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 };
}
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 (typeof event !== "object" || event === null) continue;
const { type, part } = event as Record<string, unknown>;
if (type !== "text" || typeof part !== "object" || part === null) continue;
const text = (part as Record<string, unknown>).text;
if (typeof text === "string" && text.trim() !== "") result = text;
}
if (result === undefined) throw new Error("OpenCode produced no text output");
return result;
}
-76
View File
@@ -1,76 +0,0 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import {
collectOpenCodeOutput,
openCodeArgs,
parseOpenCodeEvents,
} from "./opencode.ts";
Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
assertEquals(
openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"),
[
"run",
"--format",
"json",
"--auto",
"--model",
"openrouter/qwen/qwen3.8-27b",
"Review this",
],
);
});
Deno.test("returns the final visible OpenCode text event", () => {
assertEquals(
parseOpenCodeEvents([
JSON.stringify({ type: "step_start", part: { type: "step-start" } }),
JSON.stringify({ type: "text", part: { type: "text", text: "draft" } }),
JSON.stringify({ type: "text", part: { type: "text", text: "done" } }),
JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }),
].join("\n")),
"done",
);
});
Deno.test("rejects a successful OpenCode run without visible text", async () => {
await assertRejects(
async () => {
parseOpenCodeEvents('{"type":"step_start"}');
await Promise.resolve();
},
Error,
"no text output",
);
});
Deno.test("streams OpenCode output while collecting it", async () => {
const encode = (value: string) => new TextEncoder().encode(value);
const stdout = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encode("first\n"));
controller.enqueue(encode("second\n"));
controller.close();
},
});
const stderr = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encode("warning\n"));
controller.close();
},
});
const shown: string[] = [];
const result = await collectOpenCodeOutput(
stdout,
stderr,
(chunk: Uint8Array) => {
shown.push(`out:${new TextDecoder().decode(chunk)}`);
},
(chunk: Uint8Array) => {
shown.push(`err:${new TextDecoder().decode(chunk)}`);
},
);
assertEquals(result, { stdout: "first\nsecond\n", stderr: "warning\n" });
assertEquals(shown, ["out:first\n", "err:warning\n", "out:second\n"]);
});
-235
View File
@@ -1,235 +0,0 @@
import { join } from "jsr:@std/path@1";
import {
readRuntimeManifest,
type RuntimeManifest,
type RuntimeOutcome,
} from "./runtime.ts";
import { withProfile } from "./profiles.ts";
export type PostContext = {
apiUrl: string;
repository: string;
issueIndex: string;
eventName: string;
};
type Anchor = {
path: string;
line: number;
side: "new" | "old";
body: string;
};
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
const stripAnsi = (text: string): string => text.replace(ANSI, "");
const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"],
};
const FAILURE = "AI bot failed.\n\n> Profile: none · all profiles failed";
class GiteaRequestError extends Error {}
function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) throw new Error(`${sourcePath}: not an array`);
return entries.map((entry: unknown, index) => {
const at = `${sourcePath}[${index}]`;
if (typeof entry !== "object" || entry === null) {
throw new Error(`${at}: not an object`);
}
const { path, line, side = "new", body } = entry as Record<string, unknown>;
if (typeof path !== "string" || path === "") {
throw new Error(`${at}.path: expected a repository path`);
}
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new Error(`${at}.line: expected a line number`);
}
if (side !== "new" && side !== "old") {
throw new Error(`${at}.side: expected \"new\" or \"old\"`);
}
if (typeof body !== "string" || body.trim() === "") {
throw new Error(`${at}.body: expected the finding`);
}
return { path, line, side, body };
});
}
async function readOutcome(path: string): Promise<RuntimeOutcome> {
const value: unknown = JSON.parse(await Deno.readTextFile(path));
if (typeof value !== "object" || value === null || !("ok" in value)) {
throw new Error("invalid agent outcome");
}
const outcome = value as Record<string, unknown>;
if (outcome.ok === false) return { ok: false };
if (
outcome.ok !== true || typeof outcome.body !== "string" ||
typeof outcome.reviewPath !== "string" ||
typeof outcome.anchorsPath !== "string" ||
typeof outcome.profile !== "object" || outcome.profile === null
) {
throw new Error("invalid agent outcome");
}
const profile = outcome.profile as Record<string, unknown>;
if (typeof profile.type !== "string" || typeof profile.model !== "string") {
throw new Error("invalid agent outcome");
}
return {
ok: true,
body: outcome.body,
reviewPath: outcome.reviewPath,
anchorsPath: outcome.anchorsPath,
profile: {
type: profile.type as "claude" | "codex" | "opencode",
model: profile.model,
},
};
}
async function readAgentArtifact(
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: Deno.FileInfo;
try {
stat = await Deno.lstat(path);
} catch (error) {
if (error instanceof Deno.errors.NotFound) return null;
throw error;
}
if (
!stat.isFile || stat.isSymlink || stat.uid !== profile.uid ||
(stat.mode! & 0o077) !== 0
) {
throw new Error(`${name}: expected a profile-owned regular private file`);
}
const text = await Deno.readTextFile(path);
return text === "" ? null : text;
}
export async function postRuntime(
manifestPath: string,
context: PostContext,
fetcher: typeof fetch = fetch,
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
try {
const reviewerToken = (await Deno.readTextFile(manifest.reviewerFile))
.trim();
const request = async (path: string, body: unknown): Promise<void> => {
let response: Response;
try {
response = await fetcher(`${context.apiUrl}/${path}`, {
method: "POST",
headers: {
Authorization: `token ${reviewerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
}
if (!response.ok) {
throw new GiteaRequestError(
`${path}: ${response.status} ${await response.text()}`,
);
}
};
const postComment = (body: string) =>
request(
`repos/${context.repository}/issues/${context.issueIndex}/comments`,
{ body: stripAnsi(body) },
);
let outcome: RuntimeOutcome;
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
await postComment(FAILURE);
throw new Error("agent phase failed", { cause: error });
}
if (!outcome.ok) {
await postComment(FAILURE);
throw new Error("agent phase failed");
}
try {
const review = await readAgentArtifact(
manifest,
outcome.reviewPath,
"review.md",
);
if (review === null) {
if (context.eventName === "pull_request") {
throw new Error("agent did not write a pull request review");
}
await postComment(withProfile(outcome.body, outcome.profile));
return;
}
const [verdict, ...rest] = review.split("\n");
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
const anchorText = await readAgentArtifact(
manifest,
outcome.anchorsPath,
"anchors.json",
);
const anchors = anchorText === null
? []
: parseAnchors(anchorText, outcome.anchorsPath);
const postReview = (entries: Anchor[]) =>
request(
`repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
{
body: stripAnsi(withProfile(
[`${mark} ${verdict.trim()}`, ...rest].join("\n"),
outcome.profile,
)),
event,
comments: entries.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(withProfile(body, outcome.profile)),
new_position: side === "new" ? line : 0,
old_position: side === "old" ? line : 0,
})),
},
);
await postReview(anchors).catch(async (error: Error) => {
if (anchors.length === 0) throw error;
console.error(`inline comments rejected: ${error.message}`);
await postReview([]);
});
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
await postComment(FAILURE);
throw error;
}
} finally {
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(
(error) => {
if (!(error instanceof Deno.errors.NotFound)) throw error;
},
);
}
}
if (import.meta.main) {
const required = (name: string): string => {
const value = Deno.env.get(name);
if (!value) throw new Error(`${name} is not set`);
return value;
};
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
apiUrl: required("GITEA_API_URL"),
repository: required("GITEA_REPOSITORY"),
issueIndex: required("ISSUE_INDEX"),
eventName: required("EVENT_NAME"),
});
}
+22
View File
@@ -0,0 +1,22 @@
// The post phase: posts the agent's result as the reviewer and removes the
// runtime credentials.
import process from "node:process";
import { postRuntime } from "./post.ts";
export { type Fetcher, type PostContext, postRuntime } from "./post.ts";
function required(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
return value;
}
if (import.meta.main) {
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
apiUrl: required("GITEA_API_URL"),
repository: required("GITEA_REPOSITORY"),
issueIndex: required("ISSUE_INDEX"),
eventName: required("EVENT_NAME"),
});
}
+306
View File
@@ -0,0 +1,306 @@
import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { assert, expect, test } from "vite-plus/test";
import { isNotFound } from "#common";
import type { RuntimeFailure, RuntimeManifest, RuntimeOutcome } from "#runtime";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import { type Fetcher, type PostContext, postRuntime } from "./post.ts";
const context: PostContext = {
apiUrl: "https://gitea.example/api/v1",
repository: "owner/repo",
issueIndex: "7",
eventName: "issue_comment",
};
interface Fixture {
manifestPath: string;
runtimeDir: string;
reviewerFile: string;
reviewPath: string;
outcomeFile: string;
}
interface FixtureSuccess {
ok: true;
body: string;
}
type FixtureSource = RuntimeFailure | FixtureSuccess;
// One request the fake Gitea received.
interface RecordedRequest {
url: string;
init: RequestInit;
}
async function writePrivate(path: string, text: string): Promise<void> {
await writeFile(path, text, { mode: 0o600 });
await chmod(path, 0o600);
}
async function writeArtifacts(
outcome: RuntimeOutcome,
review?: string,
anchors?: unknown,
): Promise<void> {
if (!outcome.ok) return;
if (review !== undefined) await writePrivate(outcome.reviewPath, review);
if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
}
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): Fetcher {
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: RecordedRequest[] = [];
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));
});
+224
View File
@@ -0,0 +1,224 @@
import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { isNotFound, isObject } from "#common";
import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles";
import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime";
export interface PostContext {
apiUrl: string;
repository: string;
issueIndex: string;
eventName: string;
}
interface Anchor {
path: string;
line: number;
side: "new" | "old";
body: string;
}
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
const stripAnsi = (text: string): string => text.replace(ANSI, "");
const VERDICTS: NodeJS.Dict<[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)}`;
// The `fetch` subset the reviewer posts through, which tests replace.
export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
class GiteaRequestError extends Error {}
function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) throw new TypeError(`${sourcePath}: not an array`);
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: Fetcher = 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;
});
}
}
-258
View File
@@ -1,258 +0,0 @@
import { assert, assertEquals, assertRejects } from "jsr:@std/assert@1";
import { type PostContext, postRuntime } from "./post.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
const context: PostContext = {
apiUrl: "https://gitea.example/api/v1",
repository: "owner/repo",
issueIndex: "7",
eventName: "issue_comment",
};
async function fixture(
source: { ok: false } | { ok: true; body: string },
review?: string,
anchors?: unknown,
): Promise<{
manifestPath: string;
runtimeDir: string;
reviewerFile: string;
reviewPath: string;
outcomeFile: string;
}> {
const runtimeDir = await Deno.makeTempDir();
const reviewerFile = `${runtimeDir}/reviewer-token`;
const outcomeFile = `${runtimeDir}/outcome.json`;
const profileHome = `${runtimeDir}/profile-0`;
await Deno.mkdir(profileHome);
await Deno.writeTextFile(reviewerFile, "reviewer-secret");
const outcome: RuntimeOutcome = source.ok
? {
...source,
profile: { type: "codex", model: "gpt-test" },
reviewPath: `${profileHome}/review.md`,
anchorsPath: `${profileHome}/anchors.json`,
}
: source;
await Deno.writeTextFile(outcomeFile, JSON.stringify(outcome));
if (outcome.ok) {
if (review !== undefined) {
await Deno.writeTextFile(outcome.reviewPath, review, { mode: 0o600 });
await Deno.chmod(outcome.reviewPath, 0o600);
}
if (anchors !== undefined) {
await Deno.writeTextFile(outcome.anchorsPath, JSON.stringify(anchors), {
mode: 0o600,
});
await Deno.chmod(outcome.anchorsPath, 0o600);
}
}
const manifestPath = `${runtimeDir}/manifest.json`;
const manifest: RuntimeManifest = {
runtimeDir,
manifestPath,
reviewerFile,
outcomeFile,
profiles: [{
index: 0,
type: "claude",
model: "test",
uid: Deno.uid()!,
gid: Deno.gid()!,
home: profileHome,
tokenFile: null,
}],
};
await Deno.writeTextFile(manifestPath, JSON.stringify(manifest));
return {
manifestPath,
runtimeDir,
reviewerFile,
reviewPath: `${profileHome}/review.md`,
outcomeFile,
};
}
const missing = async (path: string): Promise<boolean> => {
try {
await Deno.stat(path);
return false;
} catch (error) {
if (error instanceof Deno.errors.NotFound) return true;
throw error;
}
};
Deno.test("posts a successful issue result and removes runtime credentials", async () => {
const result = await fixture({ ok: true, body: "done" });
const requests: Array<{ url: string; init?: RequestInit }> = [];
await postRuntime(result.manifestPath, context, (url, init) => {
requests.push({ url: String(url), init });
return Promise.resolve(new Response(null, { status: 201 }));
});
assertEquals(requests.length, 1);
assertEquals(
requests[0].url,
`${context.apiUrl}/repos/owner/repo/issues/7/comments`,
);
assertEquals(JSON.parse(String(requests[0].init?.body)), {
body: "done\n\n> Profile: `codex` · `gpt-test`",
});
assertEquals(requests[0].init?.headers, {
Authorization: "token reviewer-secret",
"Content-Type": "application/json",
});
assert(await missing(result.runtimeDir));
});
Deno.test("retries a rejected inline review without anchors", async () => {
const result = await fixture(
{ ok: true, body: "ignored final response" },
"Approved\nReady",
[{ path: "run.ts", line: 1, side: "new", body: "Finding" }],
);
const bodies: unknown[] = [];
await postRuntime(result.manifestPath, {
...context,
eventName: "pull_request",
}, (_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return Promise.resolve(
new Response(null, { status: bodies.length === 1 ? 422 : 200 }),
);
});
assertEquals((bodies[0] as { comments: unknown[] }).comments.length, 1);
assertEquals(
(bodies[0] as { comments: Array<{ body: string }> }).comments[0].body,
"Finding\n\n> Profile: `codex` · `gpt-test`",
);
assertEquals((bodies[1] as { comments: unknown[] }).comments, []);
assertEquals(
(bodies[1] as { body: string }).body,
"✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
);
assert(await missing(result.runtimeDir));
});
Deno.test("reports a generic agent failure and keeps the action failed", async () => {
const result = await fixture({ ok: false });
const bodies: unknown[] = [];
await assertRejects(
() =>
postRuntime(result.manifestPath, context, (_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return Promise.resolve(new Response(null, { status: 201 }));
}),
Error,
"agent phase failed",
);
assertEquals(bodies, [{
body: "AI bot failed.\n\n> Profile: none · all profiles failed",
}]);
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
assert(await missing(result.runtimeDir));
});
Deno.test("reports a generic failure when the runner wrote no outcome", async () => {
const result = await fixture({ ok: false });
await Deno.remove(result.outcomeFile);
const bodies: unknown[] = [];
await assertRejects(
() =>
postRuntime(result.manifestPath, context, (_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return Promise.resolve(new Response(null, { status: 201 }));
}),
Error,
"agent phase failed",
);
assertEquals(bodies, [{
body: "AI bot failed.\n\n> Profile: none · all profiles failed",
}]);
assert(await missing(result.runtimeDir));
});
Deno.test("removes runtime credentials when posting fails", async () => {
const result = await fixture({ ok: true, body: "done" });
await assertRejects(
() =>
postRuntime(
result.manifestPath,
context,
() => Promise.resolve(new Response("failed", { status: 500 })),
),
Error,
"500",
);
assert(await missing(result.runtimeDir));
});
Deno.test("preserves a Gitea transport failure without retrying a comment", async () => {
const result = await fixture({ ok: true, body: "done" });
let requests = 0;
await assertRejects(
() =>
postRuntime(result.manifestPath, context, () => {
requests++;
return Promise.reject(new Error(`network failure ${requests}`));
}),
Error,
"network failure 1",
);
assertEquals(requests, 1);
assert(await missing(result.runtimeDir));
});
Deno.test("refuses to follow an agent artifact symlink", async () => {
if (Deno.build.os === "windows") return;
const result = await fixture({ ok: true, body: "done" });
await Deno.symlink(result.reviewerFile, result.reviewPath);
let requests = 0;
await assertRejects(
() =>
postRuntime(result.manifestPath, context, () => {
requests++;
return Promise.resolve(new Response(null, { status: 200 }));
}),
Error,
"regular private file",
);
assertEquals(requests, 1);
assert(await missing(result.runtimeDir));
});
Deno.test("reports an attributed failure when a pull request review is missing", async () => {
const result = await fixture({ ok: true, body: "done" });
const bodies: unknown[] = [];
await assertRejects(
() =>
postRuntime(result.manifestPath, {
...context,
eventName: "pull_request",
}, (_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return Promise.resolve(new Response(null, { status: 201 }));
}),
Error,
"did not write",
);
assertEquals(bodies, [{
body: "AI bot failed.\n\n> Profile: none · all profiles failed",
}]);
assert(await missing(result.runtimeDir));
});
-25
View File
@@ -1,25 +0,0 @@
import { prepareRuntime } from "./runtime.ts";
const env = (name: string): string => {
const value = Deno.env.get(name);
if (!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 Deno.writeTextFile(
env("GITHUB_OUTPUT"),
`manifest=${manifest.manifestPath}\n`,
{ append: true },
);
} catch (error) {
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(() =>
undefined
);
throw error;
}
+22
View File
@@ -0,0 +1,22 @@
import { appendFile, rm } from "node:fs/promises";
import process from "node:process";
import { prepareRuntime } from "#runtime";
const env = (name: string): string => {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
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;
}
+35
View File
@@ -0,0 +1,35 @@
import { spawn } from "node:child_process";
import { expect, test } from "vite-plus/test";
import { isNotFound } from "#common";
import { exitCode } from "./child.ts";
test("reports the exit code", async () => {
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }))).toBe(0);
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" }))).toBe(3);
});
test("reports a signal as 128 plus its number", async () => {
const child = spawn("/bin/sh", ["-c", "kill -KILL $$"], { stdio: "ignore" });
expect(await exitCode(child)).toBe(137);
});
test("resolves on exit while a grandchild still holds the output open", async () => {
const child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], { stdio: ["ignore", "pipe", "pipe"] });
const started = Date.now();
expect(await exitCode(child)).toBe(5);
expect(Date.now() - started).toBeLessThan(1500);
child.stdout.destroy();
child.stderr.destroy();
});
test("rejects when the command does not exist", async () => {
const child = spawn("/nonexistent/command", [], { stdio: "ignore" });
const code = exitCode(child);
// The rejection must stay unhandled-safe until it is awaited.
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
await expect(code).rejects.toSatisfy(isNotFound);
});
+30
View File
@@ -0,0 +1,30 @@
// What the child process call sites share: the exit status, read right after
// `spawn` so the exit event cannot pass unseen while the caller reads the
// streams, and echoing output without outrunning a slow reader.
import type { ChildProcess } from "node:child_process";
import { once } from "node:events";
import { constants } from "node:os";
import type { Writable } from "node:stream";
// Resolves once the process exits, before its streams close, with its exit
// code, or with 128 plus the signal number if a signal killed it, as a shell
// reports. A process that fails to start rejects with the spawn error.
export function exitCode(child: ChildProcess): Promise<number> {
const code = new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (exit, signal) => {
resolve(exit ?? (signal === null ? 1 : 128 + constants.signals[signal]));
});
});
// Callers await it after draining the streams; a failed start must not be
// reported as an unhandled rejection before then.
code.catch(() => {});
return code;
}
// Writes a chunk, waiting for the stream to drain when its buffer is full.
export async function echo(stream: Writable, chunk: string | Uint8Array): Promise<void> {
if (!stream.write(chunk)) {
await once(stream, "drain");
}
}
+4
View File
@@ -0,0 +1,4 @@
// Child processes: waiting on and echoing them, and the isolated profile
// user's processes and files.
export { echo, exitCode } from "./child.ts";
export { killProfileProcesses, readProfileFile } from "./isolation.ts";
+112
View File
@@ -0,0 +1,112 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import process from "node:process";
import { expect, test } from "vite-plus/test";
import { isNotFound } from "#common";
import type { RuntimeProfile } from "#runtime";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000,
type: "claude",
model: "test",
uid,
gid: uid,
home,
tokenFile: null,
});
// Runs a command as the profile's user from the root directory. Without `env`
// it inherits this process's environment.
function runAs(
selected: RuntimeProfile,
command: string,
args: string[],
env?: NodeJS.ProcessEnv,
): SpawnSyncReturns<string> {
return spawnSync(command, args, {
cwd: "/",
uid: selected.uid,
gid: selected.gid,
encoding: "utf8",
...(env !== undefined && { env }),
});
}
test("zombie process entries are already quiescent", () => {
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
});
test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", async () => {
const root = await makeTempDir();
try {
const secret = `${root}/reviewer-token`;
const result = `${root}/result.md`;
await writeFile(secret, "reviewer-secret", { mode: 0o600 });
await symlink(secret, result);
await expect(() => readProfileFile(profile(currentUid(), root), result)).rejects.toThrow(
"regular private file",
);
} finally {
await rm(root, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("a profile process can read only its own private files", async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
try {
const homeA = `${root}/a`;
const homeB = `${root}/b`;
await mkdir(homeA, { mode: 0o700 });
await mkdir(homeB, { mode: 0o700 });
await chown(homeA, 20_000, 20_000);
await chown(homeB, 20_001, 20_001);
await writeFile(`${homeA}/token`, "own", { mode: 0o600 });
await chown(`${homeA}/token`, 20_000, 20_000);
await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 });
await chown(`${homeB}/token`, 20_001, 20_001);
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
const selected = profile(20_000, homeA);
const own = runAs(selected, "/bin/cat", [`${homeA}/token`]);
const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]);
const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]);
expect(own.stdout).toBe("own");
expect(own.status).toBe(0);
expect(sibling.status).not.toBe(0);
expect(reviewer.status).not.toBe(0);
} finally {
await rm(root, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => {
const selected = profile(20_010, "/tmp");
const launched = runAs(selected, "/bin/sh", ["-c", "sleep 60 >/dev/null 2>&1 & echo $!"]);
const pid = Number(launched.stdout.trim());
expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid);
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
if (isNotFound(error)) return null;
throw error;
});
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
});
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", () => {
const selected = profile(20_020, "/tmp");
const result = runAs(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
PARENT_SECRET: "must-not-cross-boundary",
});
expect(result.status).not.toBe(0);
});
+59
View File
@@ -0,0 +1,59 @@
import { lstat, readdir, readFile } from "node:fs/promises";
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
import { isNotFound } from "#common";
import type { RuntimeProfile } from "#runtime";
export function isRunnableProfileProcess(status: string, uid: number): boolean {
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
const state = /^State:\s+(\w)/mu.exec(status)?.[1];
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");
}
-99
View File
@@ -1,99 +0,0 @@
export type 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)}`;
}
function isUsageExhausted(error: unknown): boolean {
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/i
.test(String(error));
}
export async function runProfiles<
T,
P extends Pick<BotProfile, "type" | "model">,
>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
): Promise<T> {
for (const [index, profile] of profiles.entries()) {
try {
return await attempt(profile);
} catch (error) {
if (!isUsageExhausted(error) || index === profiles.length - 1) {
throw error;
}
console.error(`${profile.type} usage exhausted; trying next profile`);
}
}
throw new Error("profiles must contain at least one profile");
}
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",
]
) {
if (environment[name] !== undefined) result[name] = environment[name];
}
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) {
const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN",
codex: "OPENAI_API_KEY",
opencode: "OPENROUTER_API_KEY",
}[profile.type];
result[tokenName] = token;
}
return result;
}
+12
View File
@@ -0,0 +1,12 @@
// Bot profiles: their footers, failure reports, ordered runs, and the
// environment each agent gets.
export {
AgentAuthError,
agentEnvironment,
failureFooter,
profileFooter,
runProfiles,
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
export type { BotProfile, FailureReason, ProfileFailure, ProfileIdentity } from "./profiles.ts";
+229
View File
@@ -0,0 +1,229 @@
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",
});
});
+134
View File
@@ -0,0 +1,134 @@
// The agent and model a profile runs, which every report names.
export interface ProfileIdentity {
type: "claude" | "codex" | "opencode";
model: string;
}
export interface BotProfile extends ProfileIdentity {
token?: string;
}
export function profileFooter(profile: ProfileIdentity): string {
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
}
export function withProfile(body: string, profile: ProfileIdentity): string {
return `${body}\n\n${profileFooter(profile)}`;
}
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export interface ProfileFailure extends ProfileIdentity {
reason: FailureReason;
}
// Where a profile's isolated user keeps its home.
interface ProfileHome {
type: ProfileIdentity["type"];
home: string;
}
// Raised once an agent's credentials are rejected and any re-login is spent.
export class AgentAuthError extends Error {}
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 ProfileIdentity>(
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 ProfileIdentity>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [],
): Promise<T> {
return attemptFrom(profiles, attempt, failures, 0);
}
export function sanitizedEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
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: NodeJS.ProcessEnv,
profile: ProfileHome,
token?: string,
): NodeJS.ProcessEnv {
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;
}
-186
View File
@@ -1,186 +0,0 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import {
agentEnvironment,
profileFooter,
runProfiles,
sanitizedEnvironment,
} from "./profiles.ts";
Deno.test("formats profile attribution as a Markdown quote", () => {
assertEquals(
profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }),
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
);
});
Deno.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");
},
);
assertEquals(result, "done");
assertEquals(visited, ["claude", "codex"]);
});
Deno.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");
},
);
assertEquals(result, "fallback");
});
Deno.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");
},
)
),
);
assertEquals(results, ["fallback", "fallback"]);
});
Deno.test("preserves an ordinary agent failure", async () => {
const visited: string[] = [];
await assertRejects(
() =>
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");
},
),
Error,
"tests failed",
);
assertEquals(visited, ["claude"]);
});
Deno.test("does not mistake incidental quota text for exhaustion", async () => {
const visited: string[] = [];
await assertRejects(
() =>
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");
},
),
Error,
"quota parser test failed",
);
assertEquals(visited, ["claude"]);
});
Deno.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",
};
assertEquals(
agentEnvironment(base, profile, "selected"),
{
PATH: "/usr/bin",
HOME: "/runtime/profile-0",
CLAUDE_CODE_OAUTH_TOKEN: "selected",
},
);
assertEquals(sanitizedEnvironment(base), {
PATH: "/usr/bin",
HOME: "/home/bot",
});
});
Deno.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,
};
assertEquals(agentEnvironment({ PATH: "/bin" }, profile), {
PATH: "/bin",
HOME: "/runtime/profile-1",
CODEX_HOME: "/runtime/profile-1/.codex",
});
});
Deno.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",
};
assertEquals(agentEnvironment({ PATH: "/bin" }, profile, "selected"), {
PATH: "/bin",
HOME: "/runtime/profile-2",
OPENROUTER_API_KEY: "selected",
});
});
-468
View File
@@ -1,468 +0,0 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner.
import { TextLineStream } from "jsr:@std/streams@1/text-line-stream";
import {
type CodexAuthentication,
codexAuthentication,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import {
agentCommand,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
import {
agentEnvironment,
runProfiles,
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
parseOpenCodeEvents,
} from "./opencode.ts";
import {
readRuntimeManifest,
type RuntimeOutcome,
type RuntimeProfile,
} from "./runtime.ts";
type GiteaUser = { login: string; email: string };
type GiteaComment = { user: GiteaUser; created_at: string; body: string };
type AttemptResult = {
body: string;
reviewPath: string;
anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">;
};
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 WORKSPACE = env("GITHUB_WORKSPACE");
const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
const PROCESS_ENV = sanitizedEnvironment(Deno.env.toObject());
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
const 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 new Deno.Command(command, {
args,
env: PROCESS_ENV,
clearEnv: true,
}).output();
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
async function configureGitAuthor(): Promise<void> {
const user: GiteaUser = await (await gitea("user")).json();
for (const [key, value] of [["name", user.login], ["email", user.email]]) {
await runRoot("git", ["config", `user.${key}`, value]);
}
}
async function readProfileToken(
profile: RuntimeProfile,
): Promise<string | undefined> {
return profile.tokenFile === null
? undefined
: await Deno.readTextFile(profile.tokenFile);
}
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",
]);
}
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: GiteaComment[] = await (await gitea(
`repos/${REPO}/issues/${INDEX}/comments?limit=100`,
)).json();
const values: Record<string, string> = {
COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments
.map((comment) =>
`## ${comment.user.login} at ${comment.created_at}\n\n${comment.body}\n`
)
.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 Deno.readTextFile(
new URL("../prompt.md", import.meta.url),
);
return {
prompt: template.replace(
/\$\{(\w+)\}/g,
(match, name) => 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:
| { is_error?: boolean; result?: string; subtype: string }
| undefined;
let error = "";
const copyError = claude.stderr.pipeThrough(new TextDecoderStream()).pipeTo(
new WritableStream({
write(chunk) {
error += chunk;
console.error(chunk);
},
}),
);
const lines = claude.stdout
.pipeThrough(new TextDecoderStream())
.pipeThrough(new TextLineStream());
for await (const line of lines) {
if (!line) continue;
const event = JSON.parse(line);
for (const part of event.message?.content ?? []) {
const text = part.thinking ?? part.text ?? part.name;
if (text) console.log(text);
}
if (event.type === "result") result = event;
}
const [status] = await Promise.all([claude.status, copyError]);
if (result?.is_error) {
throw new Error(result.result ?? `claude ended with ${result.subtype}`);
}
if (result?.result === undefined) {
throw new Error(
error.trim() || `claude ended with ${result?.subtype ?? "no result"}`,
);
}
if (!status.success) {
throw new Error(error.trim() || `claude exited with ${status.code}`);
}
return result.result;
}
async function codexDeviceLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
let shown = "";
const collect = (stream: ReadableStream<Uint8Array>): Promise<void> =>
stream.pipeThrough(new TextDecoderStream()).pipeTo(
new WritableStream({ write: (chunk) => void (shown += chunk) }),
);
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
const status = login.status;
await new Promise((resolve) => setTimeout(resolve, 3000));
while (shown.trim() === "") {
const exited = await Promise.race([
status.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 100)),
]);
if (exited) throw new Error("AI bot login produced no instructions");
}
await postAuthorComment(withProfile(shown, profile));
const [result] = await Promise.all([status, drained]);
if (!result.success) {
throw new Error(`codex login exited with ${result.code}`);
}
}
async function codexApiLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
token: string,
): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--with-api-key"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdin: "piped",
stderr: "piped",
}).spawn();
const writer = login.stdin.getWriter();
await writer.write(new TextEncoder().encode(`${token}\n`));
await writer.close();
const [status, error] = await Promise.all([
login.status,
new Response(login.stderr).text(),
]);
if (!status.success) {
throw new Error(error.trim() || `codex login exited with ${status.code}`);
}
}
async function makeProfileFile(
profile: RuntimeProfile,
name: string,
): Promise<string> {
const path = `${profile.home}/${name}`;
await Deno.writeTextFile(path, "", { mode: 0o600 });
await Deno.chmod(path, 0o600);
await Deno.chown(path, profile.uid, profile.gid);
return path;
}
async function runCodex(
profile: RuntimeProfile,
prompt: string,
authentication: CodexAuthentication,
): Promise<string> {
const token = authentication.kind === "api-key"
? authentication.token
: undefined;
const environment = agentEnvironment(PROCESS_ENV, profile, token);
if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token);
} else {
const loggedIn = await agentCommand(profile, "codex", [
"login",
"status",
], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
}).output();
if (!loggedIn.success) await codexDeviceLogin(profile, environment);
}
const outputPath = await makeProfileFile(profile, "result.md");
const attempt = async () => {
const codex = agentCommand(profile, "codex", [
"exec",
"--model",
profile.model,
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
outputPath,
prompt,
], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "inherit",
stderr: "piped",
}).spawn();
const decoder = new TextDecoder();
let error = "";
for await (const chunk of codex.stderr) {
await Deno.stderr.write(chunk);
error += decoder.decode(chunk, { stream: true });
}
error += decoder.decode();
return { status: await codex.status, error };
};
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) {
throw new Error(
result.error.trim() || `codex exited with ${result.status.code}`,
);
}
return outputPath;
}
async function runOpenCode(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const process = agentCommand(
profile,
"opencode",
openCodeArgs(profile.model, prompt),
{
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true,
stdout: "piped",
stderr: "piped",
},
).spawn();
const output = collectOpenCodeOutput(
process.stdout,
process.stderr,
async (chunk) => {
await Deno.stdout.write(chunk);
},
async (chunk) => {
await Deno.stderr.write(chunk);
},
);
const [status, streams] = await Promise.all([process.status, output]);
const error = streams.stderr.trim();
if (!status.success) {
throw 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!);
} 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 Deno.writeTextFile(MANIFEST.outcomeFile, JSON.stringify(outcome), {
mode: 0o600,
});
await Deno.chmod(MANIFEST.outcomeFile, 0o600);
}
try {
await configureGitAuthor();
await writeOutcome({
ok: true,
...await runProfiles(MANIFEST.profiles, runProfile),
});
} catch (error) {
console.error(error);
await writeOutcome({ ok: false });
throw error;
}
+506
View File
@@ -0,0 +1,506 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner.
import { spawn } from "node:child_process";
import { chmod, chown, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import { createInterface } from "node:readline";
import type { Readable } from "node:stream";
import { text as readText } from "node:stream/consumers";
import { finished } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises";
import {
type ClaudeFailureFields,
type CodexAuthentication,
codexAuthentication,
collectOpenCodeOutput,
type CommandResult,
isClaudeAuthFailure,
isCodexAuthFailure,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "#agents";
import { isObject } from "#common";
import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes";
import {
AgentAuthError,
agentEnvironment,
type ProfileFailure,
type ProfileIdentity,
runProfiles,
sanitizedEnvironment,
withProfile,
} from "#profiles";
import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "#runtime";
interface AttemptResult {
body: string;
reviewPath: string;
anchorsPath: string;
profile: ProfileIdentity;
}
// Claude Code's stream-json `result` event, with camelCase field names.
interface ClaudeResult extends ClaudeFailureFields {
isError?: boolean;
subtype: string;
}
interface RenderedPrompt {
prompt: string;
reviewPath: string;
anchorsPath: string;
}
const env = (name: string): string => {
const value = process.env[name];
if (value === undefined) throw new Error(`${name} is not set`);
return value;
};
function definedEnvironment(): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
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 code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" }));
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
async function configureGitAuthor(): Promise<void> {
const user: unknown = await (await gitea("user")).json();
if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") {
throw new Error("user: unexpected Gitea response");
}
await runRoot("git", ["config", "user.name", user["login"]]);
await runRoot("git", ["config", "user.email", user["email"]]);
}
async function readProfileToken(profile: RuntimeProfile): Promise<string | null> {
const token = profile.tokenFile === null ? null : await readFile(profile.tokenFile, "utf8");
return token;
}
async function runAgentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
token?: string,
): Promise<void> {
const child = spawn(command, args, {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
const code = await exitCode(child);
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
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<RenderedPrompt> {
const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json");
const comments: unknown = await (
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
).json();
if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response");
}
const values: NodeJS.ProcessEnv = {
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 = spawn(
"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",
},
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
},
);
const status = exitCode(claude);
let result: ClaudeResult | null = null;
let error = "";
const copyError = (async (): Promise<void> => {
claude.stderr.setEncoding("utf8");
for await (const chunk of claude.stderr) {
const part = String(chunk);
error += part;
console.error(part);
}
})();
for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) {
if (line !== "") {
const event: unknown = JSON.parse(line);
if (isObject(event)) {
const content = 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 = {
isError: typeof isError === "boolean" ? isError : undefined,
result: typeof answer === "string" ? answer : undefined,
subtype: String(subtype),
apiErrorStatus: typeof apiStatus === "number" ? apiStatus : undefined,
};
}
}
}
}
const [code] = await Promise.all([status, copyError]);
if (result?.isError === true) {
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure });
}
throw failure;
}
if (result?.result === undefined) {
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
}
if (code !== 0) {
throw new Error(error.trim() || `claude exited with ${code}`);
}
return result.result;
}
// Polls every 100 ms until the login prints something, failing if it exits first.
async function pollForInstructions(
shownText: () => string,
status: Promise<number>,
): 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: NodeJS.ProcessEnv,
): Promise<void> {
const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(login);
let shown = "";
const collect = async (stream: Readable): Promise<void> => {
stream.setEncoding("utf8");
for await (const chunk of stream) {
shown += String(chunk);
}
};
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
await sleep(3000);
await pollForInstructions(() => shown, status);
await postAuthorComment(withProfile(shown, profile));
const [code] = await Promise.all([status, drained]);
if (code !== 0) {
throw new AgentAuthError(`codex login exited with ${code}`);
}
}
async function codexApiLogin(
profile: RuntimeProfile,
environment: NodeJS.ProcessEnv,
token: string,
): Promise<void> {
const login = spawn("codex", ["login", "--with-api-key"], {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: ["pipe", "inherit", "pipe"],
});
const status = exitCode(login);
login.stdin.end(`${token}\n`);
await finished(login.stdin);
const [code, error] = await Promise.all([status, readText(login.stderr)]);
if (code !== 0) {
throw new Error(error.trim() || `codex login exited with ${code}`);
}
}
async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
const path = `${profile.home}/${name}`;
await writeFile(path, "", { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, profile.uid, profile.gid);
return path;
}
async function runCodex(
profile: RuntimeProfile,
prompt: string,
authentication: CodexAuthentication,
): Promise<string> {
const token = authentication.kind === "api-key" ? authentication.token : undefined;
const environment = agentEnvironment(PROCESS_ENV, profile, token);
if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token);
} else {
const check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
if ((await exitCode(check)) !== 0) await codexDeviceLogin(profile, environment);
}
const outputPath = await makeProfileFile(profile, "result.md");
const attempt = async (): Promise<CommandResult> => {
const codex = spawn(
"codex",
[
"exec",
"--model",
profile.model,
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
outputPath,
prompt,
],
{
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "inherit", "pipe"],
},
);
const status = exitCode(codex);
codex.stderr.setEncoding("utf8");
let error = "";
for await (const chunk of codex.stderr) {
const part = String(chunk);
await echo(process.stderr, part);
error += part;
}
// Codex echoes the prompt, whose comments may quote auth errors.
return { code: await status, error: error.replaceAll(prompt, "") };
};
const result =
authentication.kind === "api-key"
? await attempt()
: await retryInvalidToken(attempt, async () => {
await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment);
});
if (result.code !== 0) {
const failure = new Error(result.error.trim() || `codex exited with ${result.code}`);
if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure });
}
throw failure;
}
return outputPath;
}
async function runOpenCode(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(child);
const collected = collectOpenCodeOutput(
child.stdout,
child.stderr,
(chunk) => echo(process.stdout, chunk),
(chunk) => echo(process.stderr, chunk),
);
const [code, streams] = await Promise.all([status, collected]);
const error = streams.stderr.trim();
if (code !== 0) {
throw openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${code}`);
}
return parseOpenCodeEvents(streams.stdout);
}
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile);
const authentication = profile.type === "codex" ? codexAuthentication(token ?? undefined) : null;
let stagedCodexAuth: string | null = null;
let body: string | null = null;
let resultPath: string | null = null;
let reviewPath: string | null = null;
let anchorsPath: string | null = null;
try {
if (authentication?.kind === "persistent") {
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
}
await prepareProfile(profile);
const rendered = await renderPrompt(profile);
reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath;
if (profile.type === "claude") {
body = await runClaude(profile, token ?? "", rendered.prompt);
} else if (profile.type === "codex") {
resultPath = await runCodex(
profile,
rendered.prompt,
authentication ?? codexAuthentication(token ?? undefined),
);
} else {
body = await runOpenCode(profile, token ?? "", rendered.prompt);
}
} finally {
await killProfileProcesses(profile.uid);
if (stagedCodexAuth !== null) {
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
}
}
if (resultPath !== null) body = await readProfileFile(profile, resultPath);
if (body === null || reviewPath === null || anchorsPath === null) {
throw new Error("agent attempt produced no result");
}
return {
body,
reviewPath,
anchorsPath,
profile: { type: profile.type, model: profile.model },
};
}
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), {
mode: 0o600,
});
await chmod(MANIFEST.outcomeFile, 0o600);
}
const failures: ProfileFailure[] = [];
try {
await configureGitAuthor();
await writeOutcome({
ok: true,
...(await runProfiles(MANIFEST.profiles, runProfile, failures)),
});
} catch (error) {
console.error(error);
await writeOutcome({ ok: false, failures });
throw error;
}
-104
View File
@@ -1,104 +0,0 @@
import { join } from "jsr:@std/path@1";
import { parseCredentials } from "./credentials.ts";
import type { BotProfile } 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 type 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 };
async function writePrivate(
path: string,
value: string,
uid: number,
gid: number,
): Promise<void> {
await Deno.writeTextFile(path, value, { mode: 0o600 });
await Deno.chmod(path, 0o600);
await Deno.chown(path, uid, gid);
}
export async function prepareRuntime(
actionPath: string,
runtimeRoot: string,
masterKey: string,
): Promise<RuntimeManifest> {
const credentials = await parseCredentials(
await Deno.readTextFile(join(actionPath, "profiles.yml")),
masterKey,
);
const runtimeDir = await Deno.makeTempDir({
dir: runtimeRoot,
prefix: "bot-agents-",
});
try {
await Deno.chmod(runtimeDir, 0o711);
const reviewerFile = join(runtimeDir, "reviewer-token");
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
const profiles: RuntimeProfile[] = [];
for (const [index, profile] of credentials.profiles.entries()) {
const uid = FIRST_PROFILE_UID + index;
const home = join(runtimeDir, `profile-${index}`);
await Deno.mkdir(home, { mode: 0o700 });
await Deno.chmod(home, 0o700);
await Deno.chown(home, uid, uid);
const tokenFile = profile.token ? join(home, "token") : null;
if (tokenFile !== null) {
await writePrivate(tokenFile, profile.token!, uid, uid);
}
profiles.push({
index,
type: profile.type,
model: profile.model,
uid,
gid: uid,
home,
tokenFile,
});
}
const manifestPath = join(runtimeDir, "manifest.json");
const manifest: RuntimeManifest = {
runtimeDir,
manifestPath,
reviewerFile,
outcomeFile: join(runtimeDir, "outcome.json"),
profiles,
};
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
return manifest;
} catch (error) {
await Deno.remove(runtimeDir, { recursive: true }).catch(() => undefined);
throw error;
}
}
export async function readRuntimeManifest(
path: string,
): Promise<RuntimeManifest> {
return JSON.parse(await Deno.readTextFile(path));
}
+69
View File
@@ -0,0 +1,69 @@
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");
});
+126
View File
@@ -0,0 +1,126 @@
import { webcrypto } from "node:crypto";
import { isMapping } from "#common";
import type { BotProfile } from "#profiles";
interface DefaultModels {
claude: string;
codex: string;
opencode: string;
}
const DEFAULT_MODELS: DefaultModels = {
claude: "claude-sonnet-5",
codex: "gpt-5.6-terra",
opencode: "openrouter/qwen/qwen3.8-27b",
};
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 };
}
+10
View File
@@ -0,0 +1,10 @@
// The runtime the prepare phase writes for the later phases: decrypted
// credentials laid out per profile, described by a manifest.
export { prepareRuntime, readRuntimeManifest } from "./runtime.ts";
export type {
RuntimeFailure,
RuntimeManifest,
RuntimeOutcome,
RuntimeProfile,
RuntimeSuccess,
} from "./runtime.ts";
+101
View File
@@ -0,0 +1,101 @@
import { spawnSync } from "node:child_process";
import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { assert, expect, test } from "vite-plus/test";
import { IS_ROOT, makeTempDir } from "#testing";
import { prepareRuntime, type RuntimeProfile } from "./runtime.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
// Runs `command` as the profile's user, from the root directory.
function runAs(profile: RuntimeProfile, command: string, path: string): boolean {
const result = spawnSync(command, [path], { cwd: "/", uid: profile.uid, gid: profile.gid });
return result.status === 0;
}
async function expectPrivate(path: string, uid: number, mode: number): Promise<void> {
const info = await stat(path);
expect(info.uid).toStrictEqual(uid);
expect(info.mode & 0o777).toBe(mode);
}
test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credentials", async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
const action = await makeTempDir();
try {
await writeFile(
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }],
}),
);
const manifest = await prepareRuntime(action, root, MASTER_KEY);
const serialized = await readFile(manifest.manifestPath, "utf8");
assert(!serialized.includes("secret-token"));
expect(await readFile(manifest.reviewerFile, "utf8")).toBe("secret-token");
await expectPrivate(manifest.reviewerFile, 0, 0o600);
const [claude, codex] = manifest.profiles;
assert(claude !== undefined && codex !== undefined);
expect(claude.uid).not.toStrictEqual(codex.uid);
await expectPrivate(claude.home, claude.uid, 0o700);
assert(claude.tokenFile !== null);
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
await expectPrivate(claude.tokenFile, claude.uid, 0o600);
expect((await stat(codex.home)).uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null);
const ownToken = spawnSync("/bin/cat", [claude.tokenFile], {
cwd: "/",
uid: claude.uid,
gid: claude.gid,
encoding: "utf8",
});
expect(ownToken.status).toBe(0);
expect(ownToken.stdout).toBe("secret-token");
expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
expect(runAs(claude, "/bin/ls", codex.home)).toBe(false);
} finally {
await rm(root, { recursive: true });
await rm(action, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fails", async () => {
const root = await makeTempDir();
const action = await makeTempDir();
try {
await writeFile(
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "claude", token: ENCRYPTED }],
}),
);
const result = spawnSync(
process.execPath,
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
{
env: {
ACTION_PATH: action,
RUNNER_TEMP: root,
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
GITHUB_OUTPUT: action,
},
},
);
expect(result.status).not.toBe(0);
expect(await readdir(root)).toStrictEqual([]);
} finally {
await rm(root, { recursive: true });
await rm(action, { recursive: true });
}
});
+124
View File
@@ -0,0 +1,124 @@
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { isMapping } from "#common";
import type { ProfileFailure, ProfileIdentity } from "#profiles";
import { parseCredentials } from "./credentials.ts";
const FIRST_PROFILE_UID = 20_000;
export interface RuntimeProfile extends ProfileIdentity {
index: number;
uid: number;
gid: number;
home: string;
tokenFile: string | null;
}
export interface RuntimeManifest {
runtimeDir: string;
manifestPath: string;
reviewerFile: string;
outcomeFile: string;
profiles: RuntimeProfile[];
}
export interface RuntimeSuccess {
ok: true;
body: string;
reviewPath: string;
anchorsPath: string;
profile: ProfileIdentity;
}
export interface RuntimeFailure {
ok: false;
failures: ProfileFailure[];
}
export type RuntimeOutcome = RuntimeSuccess | RuntimeFailure;
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
await writeFile(path, value, { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, uid, gid);
}
export async function prepareRuntime(
actionPath: string,
runtimeRoot: string,
masterKey: string,
): Promise<RuntimeManifest> {
const credentials = await parseCredentials(
await readFile(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;
}
-108
View File
@@ -1,108 +0,0 @@
import { assert, assertEquals, assertNotEquals } from "jsr:@std/assert@1";
import { prepareRuntime } from "./runtime.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
Deno.test({
name: "prepares root-only reviewer and isolated profile credentials",
permissions: { read: true, write: true, run: true, sys: ["uid"] },
async fn() {
if (Deno.uid() !== 0) return;
const root = await Deno.makeTempDir();
await Deno.chmod(root, 0o711);
const action = await Deno.makeTempDir();
try {
await Deno.writeTextFile(
`${action}/profiles.yml`,
`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude\n token: ${ENCRYPTED}\n - type: codex\n`,
);
const manifest = await prepareRuntime(action, root, MASTER_KEY);
const serialized = await Deno.readTextFile(manifest.manifestPath);
assert(!serialized.includes("secret-token"));
assertEquals(
await Deno.readTextFile(manifest.reviewerFile),
"secret-token",
);
assertEquals(
(await Deno.stat(manifest.reviewerFile)).mode! & 0o777,
0o600,
);
assertEquals((await Deno.stat(manifest.reviewerFile)).uid, 0);
const [claude, codex] = manifest.profiles;
assertNotEquals(claude.uid, codex.uid);
assertEquals((await Deno.stat(claude.home)).uid, claude.uid);
assertEquals((await Deno.stat(claude.home)).mode! & 0o777, 0o700);
assert(claude.tokenFile !== null);
assertEquals(await Deno.readTextFile(claude.tokenFile), "secret-token");
assertEquals((await Deno.stat(claude.tokenFile)).uid, claude.uid);
assertEquals((await Deno.stat(claude.tokenFile)).mode! & 0o777, 0o600);
assertEquals((await Deno.stat(codex.home)).uid, codex.uid);
assertEquals(codex.tokenFile, null);
const ownToken = await new Deno.Command("/bin/cat", {
args: [claude.tokenFile],
cwd: "/",
uid: claude.uid,
gid: claude.gid,
}).output();
assertEquals(ownToken.success, true);
assertEquals(new TextDecoder().decode(ownToken.stdout), "secret-token");
const reviewerToken = await new Deno.Command("/bin/cat", {
args: [manifest.reviewerFile],
cwd: "/",
uid: claude.uid,
gid: claude.gid,
}).output();
assertEquals(reviewerToken.success, false);
const siblingHome = await new Deno.Command("/bin/ls", {
args: [codex.home],
cwd: "/",
uid: claude.uid,
gid: claude.gid,
}).output();
assertEquals(siblingHome.success, false);
} finally {
await Deno.remove(root, { recursive: true });
await Deno.remove(action, { recursive: true });
}
},
});
Deno.test({
name: "removes prepared credentials when output publication fails",
permissions: "inherit",
async fn() {
if (Deno.uid() !== 0) return;
const root = await Deno.makeTempDir();
const action = await Deno.makeTempDir();
try {
await Deno.writeTextFile(
`${action}/profiles.yml`,
`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude\n token: ${ENCRYPTED}\n`,
);
const result = await new Deno.Command(Deno.execPath(), {
args: ["run", "-A", new URL("./prepare.ts", import.meta.url).pathname],
env: {
ACTION_PATH: action,
RUNNER_TEMP: root,
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
GITHUB_OUTPUT: action,
},
clearEnv: true,
}).output();
assertEquals(result.success, false);
const entries = [];
for await (const entry of Deno.readDir(root)) entries.push(entry.name);
assertEquals(entries, []);
} finally {
await Deno.remove(root, { recursive: true });
await Deno.remove(action, { recursive: true });
}
},
});
+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: NodeJS.ProcessEnv,
): 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 };
}
-188
View File
@@ -1,188 +0,0 @@
import { assert, assertEquals } from "jsr:@std/assert@1";
import { parse } from "jsr:@std/yaml@1";
type Mapping = Record<string, unknown>;
const mapping = (value: unknown, at: string): Mapping => {
assert(
typeof value === "object" && value !== null && !Array.isArray(value),
at,
);
return value as Mapping;
};
const sequence = (value: unknown, at: string): Mapping[] => {
assert(Array.isArray(value), at);
return value.map((entry, index) => mapping(entry, `${at}[${index}]`));
};
async function yaml(path: string): Promise<Mapping> {
return mapping(parse(await Deno.readTextFile(path)), path);
}
async function yamlFiles(directory = "."): Promise<string[]> {
const result: string[] = [];
for await (const entry of Deno.readDir(directory)) {
if (entry.name === ".git" || entry.name === ".superpowers") continue;
const path = directory === "." ? entry.name : `${directory}/${entry.name}`;
if (entry.isDirectory) result.push(...await yamlFiles(path));
if (entry.isFile && /\.ya?ml$/.test(entry.name)) result.push(path);
}
return result;
}
Deno.test("all committed YAML parses", async () => {
const files = await yamlFiles();
assert(files.length > 0);
for (const file of files) await yaml(file);
});
Deno.test("the action exposes only the master key and scopes it to preparation", async () => {
const action = await yaml("action.yml");
assertEquals(Object.keys(mapping(action.inputs, "inputs")), ["master-key"]);
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps");
for (const step of steps) {
if (typeof step.run === "string") {
assertEquals(step.run.includes("\n"), false, String(step.name));
}
}
const checkout = steps.find((step) => step.uses === "actions/checkout@v4");
const prepare = steps.find((step) => step.id === "prepare");
const agent = steps.find((step) => step.id === "agent");
const post = steps.find((step) => step.id === "post");
const source = steps.find((step) => step.id === "source");
assert(checkout && source && prepare && agent && post);
assertEquals(
mapping(checkout.with, "checkout.with").token,
"${{ gitea.token }}",
);
assertEquals(
mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY,
"${{ inputs.master-key }}",
);
assertEquals(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY"), false);
assertEquals(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY"), false);
assertEquals(
mapping(agent.env, "agent.env").GITEA_TOKEN,
"${{ gitea.token }}",
);
assert(String(post.if).includes("always()"));
for (const step of [prepare, agent, post]) {
assert(String(step.run).includes("steps.source.outputs.path"));
}
});
Deno.test("the source protection script stages immutable action files", async () => {
const temp = await Deno.makeTempDir();
const outputFile = `${temp}/output`;
try {
const output = await new Deno.Command("/bin/bash", {
args: ["scripts/protect-source.sh"],
env: {
ACTION_PATH: Deno.cwd(),
GITHUB_OUTPUT: outputFile,
RUNNER_TEMP: temp,
},
stderr: "piped",
}).output();
assert(output.success, new TextDecoder().decode(output.stderr));
const path = (await Deno.readTextFile(outputFile)).trim().slice(5);
for (
const file of [
"src/prepare.ts",
"src/run.ts",
"src/post.ts",
"profiles.yml",
"prompt.md",
]
) {
const stat = await Deno.stat(`${path}/${file}`);
assert(stat.isFile, file);
assertEquals(stat.mode! & 0o022, 0, file);
}
} finally {
await Deno.remove(temp, { recursive: true });
}
});
Deno.test("module-relative runtime assets exist", async () => {
const module = new URL("run.ts", import.meta.url);
const source = await Deno.readTextFile(module);
for (
const match of source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/g)
) {
const asset = new URL(match[1], module);
const stat = await Deno.stat(asset);
assert(stat.isFile, asset.pathname);
}
});
Deno.test("the repository uses its trusted main action", async () => {
const workflow = await yaml(".gitea/workflows/agents.yml");
const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent");
const permissions = mapping(job.permissions, "jobs.agent.permissions");
assertEquals(permissions, {
code: "write",
issues: "write",
"pull-requests": "write",
});
const steps = sequence(job.steps, "jobs.agent.steps");
const local = steps.findIndex((step) => step.uses === "./");
assertEquals(local, -1);
const trusted = steps.findIndex((step) =>
step.uses === "https://git.capsulizers.com/commons/bot-agents@main"
);
assert(trusted >= 0);
assertEquals(
mapping(steps[trusted].with, "trusted.with")["master-key"],
"${{ secrets.BOT_AGENTS_MASTER_KEY }}",
);
assertEquals(JSON.stringify(workflow).includes("gitea.actor != 'bot'"), true);
});
Deno.test("CI checks every phase and the root isolation suite", async () => {
const workflow = await yaml(".gitea/workflows/check.yml");
const job = mapping(mapping(workflow.jobs, "jobs").deno, "jobs.deno");
const commands = sequence(job.steps, "jobs.deno.steps")
.map((step) => typeof step.run === "string" ? step.run : "")
.join("\n");
for (const entrypoint of ["src/prepare.ts", "src/run.ts", "src/post.ts"]) {
assert(commands.includes(entrypoint), entrypoint);
}
assert(commands.includes("deno test --allow-all"));
});
Deno.test("uses the standard Skills installer without custom discovery", async () => {
const runner = await Deno.readTextFile("src/run.ts");
assert(runner.includes("skills add obra/superpowers"));
try {
await Deno.stat("superpowers.ts");
assert(false, "superpowers.ts should be removed");
} catch (error) {
assert(error instanceof Deno.errors.NotFound);
}
});
Deno.test("installs the OpenCode CLI", async () => {
const action = await Deno.readTextFile("action.yml");
assert(action.includes("opencode-ai"));
});
Deno.test("profile preparation does not receive provider credentials", async () => {
const runner = await Deno.readTextFile("src/run.ts");
assert(runner.includes("await prepareProfile(profile);"));
assertEquals(runner.includes("prepareProfile(profile, token)"), false);
});
Deno.test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials = await yaml("profiles.yml");
const profiles = sequence(credentials.profiles, "profiles");
assertEquals(profiles.map((profile) => profile.type), [
"codex",
"claude",
"opencode",
]);
assertEquals(profiles[2].model, "openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2].token).startsWith("v1:"));
});
+21
View File
@@ -0,0 +1,21 @@
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>/index.ts` with no build step. Node loads the
// whole import graph, stripping types, before the phase reads its first
// variable, so failing on that variable proves every module loads.
const PHASES: [phase: string, firstVariable: string][] = [
["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}/index.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`);
});
+183
View File
@@ -0,0 +1,183 @@
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 = NodeJS.Dict<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/index.ts",
"src/run/index.ts",
"src/post/index.ts",
"src/runtime/index.ts",
"src/agents/auth.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/index.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/index.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("does not install Deno", async () => {
const action = await readFile("action.yml", "utf8");
expect(action).not.toMatch(/\bdeno\b/iu);
});
test("profile preparation does not receive provider credentials", async () => {
const runner = await readFile("src/run/index.ts", "utf8");
assert(runner.includes("await prepareProfile(profile);"));
expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
});
test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json")["profiles"], "profiles");
expect(profiles.map((profile) => profile["type"])).toStrictEqual(["codex", "claude", "opencode"]);
expect(profiles[2]?.["model"]).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.["token"]).startsWith("v1:"));
});
+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
}
}
+29
View File
@@ -0,0 +1,29 @@
import explicitTypes from "eslint-plugin-explicit-types";
import importStructure from "eslint-plugin-import-structure";
import { defineConfig } from "vite-plus";
import projectLint from "./scripts/project-lint.ts";
export default defineConfig({
lint: {
plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: [
"eslint-plugin-explicit-types",
"eslint-plugin-import-structure",
"./scripts/project-lint.ts",
],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: {
...explicitTypes.configs.concrete.rules,
...explicitTypes.configs.absence.rules,
...importStructure.configs.modules.rules,
...projectLint.configs.project.rules,
},
options: {
typeAware: true,
typeCheck: true,
maxWarnings: 0,
reportUnusedDisableDirectives: "error",
},
},
});