Compare commits
9 Commits
v0.2.0
..
50328caac5
| Author | SHA1 | Date | |
|---|---|---|---|
| 50328caac5 | |||
| 006bbe6b0c | |||
| f001a71c61 | |||
| ea20f6d629 | |||
| 4ecdf5adc0 | |||
| 3626db0876 | |||
| 535a7a486c | |||
| e56ea246bf | |||
| ba9e0c0787 |
@@ -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.
|
||||
@@ -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,36 @@
|
||||
---
|
||||
description: Containerfiles build the frontend in a node:24-alpine stage with npm ci and vp build; CI uses setup-vp and runs vp check, vp build, vp test (plus lit-check)
|
||||
---
|
||||
|
||||
## Containerfiles
|
||||
|
||||
- The frontend build stage is `docker.io/library/node:24-alpine`, replacing
|
||||
`denoland/deno:alpine`. Vite+ publishes `linux-x64-musl` binaries, so Alpine
|
||||
works. (`ghcr.io/voidzero-dev/vite-plus` is a glibc alternative that would
|
||||
need a pinned digest; not used.)
|
||||
- The stage copies `package.json` and `package-lock.json` first, runs
|
||||
`npm ci`, then copies the sources and configs and runs `npx vp build`.
|
||||
- The runtime stage copies the built `js/` and `css/` directories (not single
|
||||
files) from the node stage.
|
||||
- No Deno binary, `deno install`, or `deno task` remains in any stage.
|
||||
|
||||
```dockerfile
|
||||
FROM docker.io/library/node:24-alpine AS frontend
|
||||
WORKDIR /src/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npx vp build
|
||||
```
|
||||
|
||||
## CI
|
||||
|
||||
- GitHub repos (temeddix): `.github/workflows`. Capsulizers repos (Gitea):
|
||||
`.gitea/workflows`. Don't move a repo's CI between hosts.
|
||||
- Install with `voidzero-dev/setup-vp`, then run `vp check`, `vp build`, and
|
||||
`vp test`; Lit repos also run `npm run lit-check`
|
||||
([[lit-analyzer-ts6]]).
|
||||
- Repos that ran CI on a self-hosted runner keep it.
|
||||
- Until `@cunarist/eslint-plugin-*` are on npm, `npm ci` fails in CI. That is
|
||||
expected and stated in the PR; run `npm install` once they are published
|
||||
and commit the refreshed lockfile.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: Code that must ship as standalone JS (memona framed_find.js, brain stream-recovery.js) is written as .ts, compiled at build time, and its output gitignored
|
||||
---
|
||||
|
||||
Some apps ship plain JavaScript that runs outside the main bundle: memona's
|
||||
`framed_find.js` and brain's `stream-recovery.js`. Under the TS-only rule
|
||||
([[ts-only-files-and-imports]]) they are handled like this (2026-10-16):
|
||||
|
||||
- The source is a `.ts` file, linted and type-checked like everything else.
|
||||
- The `.js` is produced at build time by the existing build (a Vite input or
|
||||
a build step in `vite.config.ts` or `scripts/`), never committed.
|
||||
- The output path is gitignored and, if it lands in a linted folder, listed
|
||||
in `lint.ignorePatterns`; never in `overrides`, never with disable comments.
|
||||
- Whatever loads the file (Rust `include_str!`, a container `COPY`, an HTML
|
||||
`<script>`) keeps the same path and name as before, so behavior is
|
||||
unchanged. Builds that need the file must run the compile first; check
|
||||
that the Containerfile and CI order the steps that way.
|
||||
|
||||
Don't commit a hand-maintained `.js` copy "for convenience", and don't turn
|
||||
it into `.mjs` or `.cjs`.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description: Apps use the npm lint plugins' configs, port their old deno-lint-plugin.ts rules into scripts/project-lint.ts, never override source files, and refactor instead of raising limits
|
||||
---
|
||||
|
||||
## Plugins
|
||||
|
||||
Apps consume the published plugins as npm devDependencies, through `jsPlugins`
|
||||
and the configs equivalent to what their Deno setup enabled:
|
||||
|
||||
- `@cunarist/eslint-plugin-explicit-types`: `concrete`, `naming`, `absence`
|
||||
- `@cunarist/eslint-plugin-import-check`: `import-check`
|
||||
- `@cunarist/eslint-plugin-lit`: `core`, `strict`, `dom-ref`,
|
||||
`reactive-controller`, `naming`
|
||||
|
||||
## Repo-local rules
|
||||
|
||||
The local `scripts/deno-lint-plugin.ts` that home, eximium, stockvibe, brain,
|
||||
memona, and memona-home carried is ported into `scripts/project-lint.ts`
|
||||
([[project-lint-plugin]]) under the `project` namespace, same rule names and
|
||||
behavior. Depending on the repo: `no-self-sync`, `no-sync-in-render`,
|
||||
`app-module-conventions`, `always-wildcard-export`, `no-class-extends`,
|
||||
`no-lit-nothing`, `no-title-attribute`, `prefer-wa-components`,
|
||||
`no-satisfies`, `no-unknown`, `restricted-types`, `no-line-lint-ignore`,
|
||||
`no-app-disposables-in-component`, `no-crepe-react-ui`. `@hugoalh` rules map to
|
||||
Oxlint built-ins in `configs.project`.
|
||||
|
||||
memona's `deno-lint-plugin.ts` and its `coding-standards` memory are the house
|
||||
style the explicit-types rules came from; they win any conflict.
|
||||
|
||||
## No overrides, refactor instead
|
||||
|
||||
Apps use **no `overrides` for source files at all** (the rule-registry
|
||||
exception exists only in plugin repos). Oxlint's default limits stay, and
|
||||
violations are refactored without behavior change in the same PR: barrels for
|
||||
`max-dependencies`, splits for `max-lines`/`max-lines-per-function`/
|
||||
`max-classes-per-file`, an options object for `max-params`. Long Lit
|
||||
`render()` templates are split into smaller template functions or
|
||||
components. See [[lint-overrides-policy]].
|
||||
|
||||
If a hand-written exception still looks truly necessary, ask the user first;
|
||||
when they're away, leave it failing or refactor, and log the question.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, no @cunarist plugins needed
|
||||
---
|
||||
|
||||
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
|
||||
Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
|
||||
|
||||
- **Live action.** Six consumer repos use `commons/bot-agents@main`, so a merge
|
||||
switches all of them at once. It changes only through a PR and a careful
|
||||
review. Suggested before merging a risky change: tag the current `main`
|
||||
(e.g. `deno-final`) as a rollback target and canary the branch from one
|
||||
consumer (`uses: .../commons/bot-agents@<branch>`) on a throwaway PR.
|
||||
- **Profiles are JSON.** `profiles.yml` became `profiles.json` with the same
|
||||
content (encrypted `v1:` values unchanged), read with `JSON.parse`. That
|
||||
removed the only runtime dependency (`@std/yaml`); keep it dependency-free.
|
||||
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. `engines.node`
|
||||
is `>=24.2`, which the user accepted for this repo
|
||||
([[bot-agents-runtime-no-build]]).
|
||||
- **No `@cunarist/eslint-plugin-*`.** The Deno setup enabled no plugin, so
|
||||
this repo depends on none and CI passes without them being published.
|
||||
- **Stricter input checks were accepted** in the migration: Gitea `/user` and
|
||||
comment responses, the runtime manifest, and the outcome's profile type are
|
||||
validated; some type errors became `TypeError` with the same messages.
|
||||
- Merging is the user's call; the `bot` account's approval is required first
|
||||
([[git-pr-workflow]]).
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
description: Frontends build with vp build in Vite app mode, keeping the exact output paths the Rust backend and HTML expect; emptyOutDir false when outDir holds committed files
|
||||
---
|
||||
|
||||
`deno bundle` was replaced by `vp build` in **Vite app mode** (not
|
||||
`build.lib`). Behavior must not change, so the output keeps the file names and
|
||||
locations the backend serves and the HTML references.
|
||||
|
||||
Typical shape for the Rust-backed frontends (home, eximium, stockvibe,
|
||||
memona-home), where `frontend/` builds into `backend/static`:
|
||||
|
||||
```ts
|
||||
build: {
|
||||
outDir: "../backend/static",
|
||||
emptyOutDir: false, // the folder holds committed files (index.html, ...)
|
||||
rollupOptions: {
|
||||
input: { bundle: "src/index.ts" },
|
||||
output: { entryFileNames: "js/[name].js" }, // -> js/bundle.js
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- **Fixed names, no hashes:** set `entryFileNames` (and `assetFileNames`
|
||||
where assets are emitted) explicitly. Vite's hashed defaults would 404,
|
||||
since Rust and the templates request `js/bundle.js` and `css/theme.css`.
|
||||
- **`emptyOutDir: false` whenever `outDir` is outside the Vite root or holds
|
||||
committed files.** Never let it default to true there: it would wipe them.
|
||||
- Output stays gitignored, so `.gitignore`, `.containerignore`, and the Rust
|
||||
side need no edits; home's startup check for `js/bundle.js` and
|
||||
`css/theme.css` keeps passing.
|
||||
- **CSS is never imported from TS** (no `import "./theme.css"`); the HTML
|
||||
links the stylesheet. How the CSS file is produced is per repo; read the
|
||||
repo's `vite.config.ts` before changing it.
|
||||
- Copy whole `js/` and `css/` directories in Containerfiles, not single files,
|
||||
so code-split chunks ship too ([[app-containers-and-ci]]).
|
||||
- Where a repo serves an `index.html` entry (brain's dashboard), use plain app
|
||||
mode with `index.html` as input, `base` set to the mount path, and `outDir:
|
||||
"dist"` with the default `emptyOutDir`.
|
||||
- `vp dev` must work too, and the decorators plugin must cover build, dev, and
|
||||
test ([[decorators-standard-accessor]]).
|
||||
|
||||
Check the repo's own migration PR for its final values before editing; these
|
||||
are the conventions it followed, not a config to paste.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
description: Child processes use plain node:child_process spawn (explicit stdio, uid/gid, whole env); src/child.ts has exitCode (128+signo) and echo; no execFile maxBuffer
|
||||
---
|
||||
|
||||
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/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/errors.ts`** `isNotFound` checks `ENOENT` (file) or `ESRCH` (process).
|
||||
- Temporary files are an exclusive `wx` create with mode `0600`.
|
||||
- `src/json.ts` holds type guards used instead of `as` casts on parsed JSON.
|
||||
- `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,20 @@
|
||||
---
|
||||
description: bot-agents lint has prefer-readonly-parameter-types off (Node/web types can't satisfy it) and one scripts/** override for no-unsafe-enum-comparison; nothing else
|
||||
---
|
||||
|
||||
The repo follows [[lint-config-shape]] with two recorded exceptions, both
|
||||
present when the migration PR was approved:
|
||||
|
||||
- `typescript/prefer-readonly-parameter-types` is `"off"` in
|
||||
`scripts/project-lint.ts`, as in the explicit-types template, because
|
||||
Node/web types (`Response`, `Stats`, `RequestInit`) can never satisfy it.
|
||||
- `vite.config.ts` has one `overrides` entry, for `scripts/**` only:
|
||||
`typescript/no-unsafe-enum-comparison` off, because the lint plugin compares
|
||||
AST node `type`s, which the AST types declare as an enum. This is the
|
||||
tooling exception [[lint-overrides-policy]] allows.
|
||||
|
||||
Oxlint's default size limits apply to `src/`; the migration split `run.ts`
|
||||
into `run-context.ts`, `agents.ts`, and `agent-{claude,codex,opencode}.ts`,
|
||||
and `post.ts` into `post.ts` + `post-artifacts.ts`, rather than raising them.
|
||||
Sequential awaits in loops became recursion or a `reduce` chain to stay
|
||||
sequential. Add no other opt-out without the user.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
description: The action runs src/<phase>.ts directly with node on node:24-bookworm (no build); protect-source.sh copies only src/*.ts, package.json, profiles.json, prompt.md
|
||||
---
|
||||
|
||||
`action.yml` is a composite action running three phases as
|
||||
`node "<source>/src/{prepare,run,post}.ts"`. Node's built-in type stripping
|
||||
runs the TypeScript; there is **no build step, no bundle, and no runtime
|
||||
dependency**. Hence `tsconfig.json` has `erasableSyntaxOnly` and
|
||||
`allowImportingTsExtensions`: only erasable syntax (no `enum`, no parameter
|
||||
properties, no decorators) and relative `.ts` imports.
|
||||
|
||||
- **Image:** the steps assume the `node:24-bookworm` job container. Node must
|
||||
be 24.2+ for `import.meta.main` in `post.ts`; `check.yml` prints
|
||||
`node --version` to confirm the cached image's minor.
|
||||
- **Root-owned source copy:** `scripts/protect-source.sh` copies the action
|
||||
into `$RUNNER_TEMP` so an agent editing the checkout can't swap a later
|
||||
phase. It copies exactly `src/*.ts`, `package.json` (`"type": "module"`
|
||||
makes Node load the sources as ES modules), `profiles.json`, and
|
||||
`prompt.md`. **A new runtime file or subdirectory must be added there**, or
|
||||
it is missing at run time. `src/` is flat; a subfolder would not be copied.
|
||||
- `tests/entrypoints.test.ts` runs each phase under real Node with a cleared
|
||||
environment and expects its first "X is not set" error, which proves the
|
||||
whole import graph loads without a build. Keep it in sync with the phases.
|
||||
- **None of the app build conventions apply here:** no `vp build`, no
|
||||
frontend or Rust/Tauri output paths, no Containerfile, no decorators
|
||||
plugin, no lit-check, no generated JS ([[app-vite-build-output]],
|
||||
[[app-containers-and-ci]], [[app-generated-js-as-ts]] describe the other
|
||||
apps). Don't add any of them.
|
||||
- **CI** (`.gitea/workflows/check.yml`) runs on the self-hosted runner's
|
||||
default image as root: `npm ci`, `npx vp check`, `npx vp test`. It does not
|
||||
use `setup-vp`. YAML stays declarative ([[yaml-stays-declarative]]).
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 61 run; verify isolation changes in node:24-bookworm as root
|
||||
---
|
||||
|
||||
Some tests exercise real UID isolation, process cleanup, and `prepare.ts`
|
||||
credential cleanup. They need root and are written as
|
||||
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `src/testing.ts`), so they show
|
||||
as skipped instead of silently passing.
|
||||
|
||||
- As a normal user `vp test` passes 56 and skips 5. In CI (self-hosted
|
||||
runner, root) all 61 run.
|
||||
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`,
|
||||
or `command.ts`, run the suite as root in `node:24-bookworm` (e.g. a
|
||||
container with the repo mounted) before pushing; a green non-root run
|
||||
proves nothing about those paths.
|
||||
- The migration also ran an end-to-end smoke as root with fake
|
||||
`codex`/`claude`/`npx` binaries and a fake Gitea through
|
||||
`protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.ts`; repeat that
|
||||
for changes to the phase flow ([[bot-agents-runtime-no-build]]).
|
||||
- `src/testing.ts` holds the shared test helpers; tests follow
|
||||
[[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in
|
||||
`tests/`).
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
description: Keep TC39 standard decorators with accessor; Oxc can't lower them, so scripts/decorators.ts (esbuild Vite plugin) does; never legacy decorators
|
||||
---
|
||||
|
||||
Applies to every repo that uses decorators (the Lit frontends).
|
||||
|
||||
**Decision (2026-10-16):** keep standard (TC39) decorators with `accessor`,
|
||||
exactly as the Deno code had them. No legacy decorators.
|
||||
|
||||
```ts
|
||||
@customElement("app-card")
|
||||
export class AppCard extends LitElement {
|
||||
@property() accessor name = ""; // keep this form
|
||||
}
|
||||
```
|
||||
|
||||
**Gotcha, verified 2026-10-16:** Vite 8's Oxc transformer parses standard
|
||||
decorators but can **not** lower them, so `vp build`, `vp dev`, and `vp test`
|
||||
emit decorator syntax browsers and Node don't run. Check again on each Vite+
|
||||
upgrade; when Oxc can lower them, delete the plugin and the `esbuild`
|
||||
devDependency.
|
||||
|
||||
**Fix in use:** `scripts/decorators.ts`, a small Vite plugin backed by
|
||||
`esbuild` (devDependency), registered in `vite.config.ts` `plugins` so it
|
||||
covers build, dev server, and Vitest alike:
|
||||
|
||||
- `enforce: "pre"`, so it runs before Oxc;
|
||||
- transforms only `.ts` modules whose source contains `@`;
|
||||
- `esbuild.transform(code, { loader: "ts", target: "es2022", sourcemap,
|
||||
sourcefile: id })`, returning `{ code, map }`.
|
||||
|
||||
It is tooling under `scripts/`: no tests, and it must pass every lint rule.
|
||||
|
||||
**The options as presented to the user:**
|
||||
|
||||
| Option | What | Why not |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| A | Legacy decorators: two tsconfig flags (`experimentalDecorators`, ...) and a codemod removing `accessor` | the user ruled out legacy decorators |
|
||||
| B | Wait for Oxc to lower `accessor` decorators | not available yet; blocks the migration |
|
||||
| C | No decorators: `static properties = {...}` + `declare` fields | rewrites every component |
|
||||
| D | A transform step in the Vite build | chosen, with esbuild as the transformer (above) |
|
||||
|
||||
Don't add `experimentalDecorators` to `tsconfig.json`, and don't strip
|
||||
`accessor` from fields.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: Every repo moved from Deno to Node + Vite+ in Oct 2026 because Cloudflare bought Deno; nothing goes back to Deno
|
||||
---
|
||||
|
||||
On 2026-10-12 the user moved every project from Deno to Node.js + Vite+
|
||||
(`vp`). Cloudflare bought Deno on 2026-10-09; the runtime gets roughly 12
|
||||
months of maintenance and Deno Deploy shuts down around April 2027. None of the
|
||||
repos used Deploy or KV, so the move is about the toolchain, not hosting.
|
||||
|
||||
Do not reintroduce `deno.json`, `deno.lock`, `Deno.*` APIs, `jsr:` or `npm:`
|
||||
specifiers, `mod.ts` entries, or `deno task` anywhere, docs and scripts
|
||||
included.
|
||||
|
||||
## Order the migration followed
|
||||
|
||||
1. The custom Deno lint plugins were ported to npm first, as
|
||||
`@cunarist/eslint-plugin-explicit-types`, `-import-check`, and `-lit`.
|
||||
`cunarist/eslint-plugin-explicit-types` is the template every other repo
|
||||
copies (config shape, `scripts/project-lint.ts`, AGENTS.md, notes).
|
||||
2. Then `commons/bot-agents` (six repos use it as an action), then the apps:
|
||||
home, eximium, stockvibe, brain, memona, memona-home.
|
||||
3. deno-practice, interview-practice, web-landing, and vemium were left out
|
||||
(archivable). Old `cunarist/deno-*` repos are archived one at a time, and
|
||||
only after their `eslint-plugin-*` replacement is published to npm.
|
||||
|
||||
## What "migrated" means
|
||||
|
||||
- Same behavior. A migration PR changes tooling, not features.
|
||||
- Lint coverage is at least what Deno enforced: [[lint-parity-never-shrinks]].
|
||||
- Only three config files and only `.ts`: [[toolchain-node-vite-plus]],
|
||||
[[ts-only-files-and-imports]].
|
||||
- Deno.test suites became Vitest: [[tests-layout-vitest]].
|
||||
|
||||
The custom plugins' Deno names map like this:
|
||||
|
||||
| Deno | npm |
|
||||
| ----------------------------------------------- | ---------------------------------------- |
|
||||
| `jsr:@cunarist/deno-lint-plugin-explicit-types` | `@cunarist/eslint-plugin-explicit-types` |
|
||||
| `jsr:@cunarist/deno-import-check` (`./lint`) | `@cunarist/eslint-plugin-import-check` |
|
||||
| `jsr:@cunarist/deno-lint-plugin-lit` | `@cunarist/eslint-plugin-lit` |
|
||||
| `scripts/deno-lint-plugin.ts` (per app) | `scripts/project-lint.ts` (per app) |
|
||||
|
||||
Until those packages are on npm, a repo that depends on them fails `npm ci` in
|
||||
CI; that is expected and stated in the PR, never worked around by committing
|
||||
tarballs or `file:` dependencies.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
description: Work on a branch, plain imperative commits without AI trailers, one PR with the Claude Code footer, review until approved, never merge yourself; squash only
|
||||
---
|
||||
|
||||
## Branches and commits
|
||||
|
||||
- Never push to `main`. Every change goes through a branch and a PR, even a
|
||||
one-line fix.
|
||||
- Commit messages are plain imperative sentences ("Port the import-check
|
||||
rules to an ESLint plugin"). No conventional-commit prefixes, no AI
|
||||
trailers (`Co-Authored-By: Claude`, "Generated with ..."), no emoji.
|
||||
- A version bump is its own commit titled `Version X.Y.Z` ([[publishing-ci-only]]).
|
||||
- When several agents share a branch, each works in its own `git worktree`,
|
||||
runs `git pull --rebase` before pushing, and retries a rejected push after
|
||||
rebasing. Remove the worktree and temp branch afterwards.
|
||||
- `vp check`, `vp test`, and the build pass locally before every push.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- One PR per repo for a migration, everything included (size-rule refactors
|
||||
too).
|
||||
- The body covers: summary, what changed, deviations and risks, verification
|
||||
done, and open questions. It ends with exactly this line:
|
||||
|
||||
```
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
- Then run the review cycle (skill `review-cycle`): on Capsulizers Gitea repos
|
||||
that have `.gitea/workflows/agents.yml`, comment `@bot` and address the `bot`
|
||||
account's review; elsewhere, a subagent reviews the diff and posts the
|
||||
review as a PR comment. Repeat until fully approved, then ask the user to
|
||||
review.
|
||||
|
||||
## Merging
|
||||
|
||||
- **Never merge on your own.** The user merges, unless they explicitly allow
|
||||
it for one specific PR.
|
||||
- GitHub repos are squash-merge only, so a PR lands as one commit titled after
|
||||
the PR.
|
||||
- Also never, without the user: publish, archive a repo, or delete a branch
|
||||
the user may still use.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **cunarist** and **temeddix**: GitHub only, CI in `.github/workflows`, use
|
||||
the `gh` CLI.
|
||||
- **Capsulizers**: Gitea at git.capsulizers.com (Gitea 1.26), CI in
|
||||
`.gitea/workflows`, PRs through the Gitea API. Push and open PRs as the
|
||||
user's own account (credentials in the user's environment; source them in a
|
||||
subshell and never print them); `bot` is a separate review account. The
|
||||
org's codified rules live in the `commons/code-rules` README.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
description: Icons are .ts modules exporting a typed Lit svg template (memona's style), one per file behind an index.ts; never .svg imports as text
|
||||
---
|
||||
|
||||
memona's way of declaring icons is the standard (2026-10-14). An icon is a
|
||||
`.ts` module that exports a Lit `TemplateResult` holding the inline SVG:
|
||||
|
||||
```ts
|
||||
// src/icons/lock.ts
|
||||
import { html, type TemplateResult } from "lit";
|
||||
|
||||
/** Font Awesome Free's solid `lock`, as its SVG source. */
|
||||
export const lockIcon: TemplateResult = html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 384 512"
|
||||
>
|
||||
<path fill="currentColor" d="M128 96l0 64 ..." />
|
||||
</svg>`;
|
||||
```
|
||||
|
||||
```ts
|
||||
// usage
|
||||
import { lockIcon } from "#icons";
|
||||
render() { return html`<span class="icon">${lockIcon}</span>`; }
|
||||
```
|
||||
|
||||
- One icon per file, camelCase name with an `Icon` suffix, a doc comment
|
||||
saying what it depicts, exported through `src/icons/index.ts`.
|
||||
- Use `fill="currentColor"` so CSS controls the colour.
|
||||
- Keep a third-party licence comment that the SVG source carries (Font
|
||||
Awesome Free's `<!--! ... -->` notice) inside the template.
|
||||
|
||||
**Replaced pattern:** eximium, stockvibe, and memona-home imported Font
|
||||
Awesome `.svg` files as text in `icons.ts`:
|
||||
|
||||
```ts
|
||||
import lock from "@fortawesome/fontawesome-free/svgs/solid/lock.svg" with { type: "text" }; // no
|
||||
```
|
||||
|
||||
That breaks [[ts-only-files-and-imports]] (non-`.ts` import plus import
|
||||
attributes) and was converted to memona's style during the frontend
|
||||
migration. Before writing new icon code in any repo, read how that repo (or
|
||||
memona) already declares icons and match it.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: Shared code comes through # aliases in package.json imports, each directory's entry is index.ts, barrels keep imports few; never ../ out of a directory
|
||||
---
|
||||
|
||||
## `#` aliases
|
||||
|
||||
Shared code is imported through Node subpath imports declared in
|
||||
`package.json` `imports`, never by a relative path that climbs out of a
|
||||
directory:
|
||||
|
||||
```jsonc
|
||||
"imports": {
|
||||
"#helpers": "./src/helpers/index.ts",
|
||||
"#components/*": "./src/components/*"
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
import { typeNameTail } from "#helpers"; // yes
|
||||
import { typeNameTail } from "../helpers/ast.ts"; // no: import/no-relative-parent-imports
|
||||
```
|
||||
|
||||
Node, TypeScript, Vite, Vite+'s config bundler, and Oxlint's plugin loader
|
||||
all resolve them natively, so `.ts` source runs unbuilt. `vp pack` inlines them,
|
||||
so they never reach package consumers.
|
||||
|
||||
- Deno's trailing-slash mappings (`"#components/"`) don't exist in Node; they
|
||||
become patterns, `"#components/*": "./src/components/*"`.
|
||||
- Aliases point at `.ts` files only. There is no `#package` alias
|
||||
([[ts-only-files-and-imports]]).
|
||||
- In repos linted by import-check, the key order of `imports` is the layer
|
||||
order `enforce-layer-order` checks; add a new alias at the right layer.
|
||||
- Keep test-only helpers in their own alias (e.g. `#testing`) so they never
|
||||
land in a bundle.
|
||||
|
||||
## `index.ts` entries and barrels
|
||||
|
||||
- Every directory's entry file is `index.ts`, never `mod.ts` (decided
|
||||
2026-10-12; import-check's `enforce-mod-file` now requires `index.ts`).
|
||||
- A directory used from outside exposes its API through its `index.ts`
|
||||
barrel, and callers import the barrel (`#ui`), not files inside it
|
||||
(`no-barrel-bypass`).
|
||||
- Barrels are also the standard fix for `import/max-dependencies`: one barrel
|
||||
import replaces many direct ones ([[lint-overrides-policy]]).
|
||||
- `import/no-cycle` is on; a barrel must not create a cycle.
|
||||
|
||||
Relative imports inside one directory (`./sibling.ts`) are fine, with the
|
||||
`.ts` extension written out.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
description: vite.config.ts lint uses Oxlint categories at error with default limits, names no rule itself, and spreads configs from the plugins and scripts/project-lint.ts
|
||||
---
|
||||
|
||||
The lint config is short and category-driven (user decisions 2026-10-14/15):
|
||||
turn on whole categories, then list only what parity or a real need adds.
|
||||
|
||||
```ts
|
||||
lint: {
|
||||
plugins: ["typescript", "unicorn", "oxc", "import"],
|
||||
jsPlugins: ["@cunarist/eslint-plugin-explicit-types", "./scripts/project-lint.ts"],
|
||||
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
|
||||
rules: { ...explicitTypes.configs.concrete.rules, ...projectLint.configs.project.rules },
|
||||
ignorePatterns: ["src/generated/**"], // generated files only, see below
|
||||
options: { typeAware: true, typeCheck: true, maxWarnings: 0, reportUnusedDisableDirectives: "error" },
|
||||
},
|
||||
```
|
||||
|
||||
- **Categories:** `correctness`, `suspicious`, `pedantic`, `perf` at `error`.
|
||||
`style` and `restriction` are never turned on wholesale; pick single rules
|
||||
from them in `configs.project`. Extra rules that categories bring are fine.
|
||||
- **Default limits stay** (2026-10-16): pedantic's `max-lines` 300,
|
||||
`max-lines-per-function` 50, `max-classes-per-file` 1,
|
||||
`max-dependencies` 10, `max-params` 3, `complexity` 20. Don't raise them to
|
||||
make code pass; refactor (see [[lint-overrides-policy]]). Apps follow this
|
||||
strictly. The plugin template already turns off `max-lines`,
|
||||
`max-lines-per-function`, `typescript/prefer-readonly-parameter-types`, and
|
||||
`typescript/no-unsafe-enum-comparison` because AST visitors fight them; do
|
||||
not add new opt-outs anywhere without asking the user.
|
||||
- **`vite.config.ts` names no rule.** Every rule id, built-in ones included,
|
||||
lives in `configs.project` of `scripts/project-lint.ts`
|
||||
([[project-lint-plugin]]). `vite.config.ts` only spreads configs.
|
||||
- **Published plugins are consumed through their configs**: ESLint flat config
|
||||
uses `plugin.configs.<name>`; Oxlint lists the package in `jsPlugins` and
|
||||
spreads `plugin.configs.<name>.rules`, since Oxlint plugins carry no configs.
|
||||
Use the configs equivalent to what the repo enabled under Deno.
|
||||
- **`maxWarnings: 0`** keeps Deno's "every diagnostic fails" behaviour.
|
||||
- **`ignorePatterns`** is only for generated files (compiled output, codegen).
|
||||
Hand-written code is never ignored.
|
||||
- **No lint disable comments**: [[lint-no-disable-comments]].
|
||||
- **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]].
|
||||
- Don't add a second import sorter; import-check's `enforce-import-order` is
|
||||
the only one, and Oxfmt's `sortImports` stays off.
|
||||
|
||||
`perf` came last: in the template it flagged nine `.includes` on constant
|
||||
arrays, fixed as `ReadonlySet` + `.has()` rather than by turning a rule off.
|
||||
That is the expected response to any new rule: change the code.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: Every eslint-/oxlint-disable or -enable comment is banned in every form; three checks enforce it together, keep all three
|
||||
---
|
||||
|
||||
Banned (2026-10-16): `eslint-disable`, `eslint-disable-line`,
|
||||
`eslint-disable-next-line`, `eslint-enable`, and the same with `oxlint-`, as
|
||||
line or block comments, anywhere in the repo. Fix the code; if the code truly
|
||||
can't be fixed, ask the user, don't silence the rule. Old
|
||||
`deno-lint-ignore` lines are removed during the migration and their cause
|
||||
fixed.
|
||||
|
||||
Oxlint (1.87) has no `noInlineConfig` and no `eslint-comments` rules, and
|
||||
`respectEslintDisableDirectives: false` only ignores `eslint-*` directives. So
|
||||
three checks enforce the ban together, and none is redundant:
|
||||
|
||||
1. `project/no-disable-comments` (custom, in `scripts/project-lint.ts`) fails
|
||||
any directive.
|
||||
2. `unicorn/no-abusive-eslint-disable` fails a directive that names no rule,
|
||||
which would otherwise silence the custom rule on its own line.
|
||||
3. `options.reportUnusedDisableDirectives: "error"` fails a directive naming
|
||||
`project/no-disable-comments` (the custom rule skips that one, so the
|
||||
directive silences nothing and is reported as unused).
|
||||
|
||||
Every form was probed and fails `vp check`.
|
||||
|
||||
A rule that fires on legitimate code is a rule problem: change the rule or its
|
||||
options in `configs.project` with the user's agreement, not the call site.
|
||||
Known example: rule records are `Map`s keyed by id rather than object
|
||||
literals with kebab-case keys, which keeps naming rules happy without any
|
||||
directive.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: No lint overrides for source files; narrow ones only for scripts/**; generated files go in ignorePatterns; refactor (barrels, splits) instead of raising limits
|
||||
---
|
||||
|
||||
Final policy (2026-10-16). The user considers per-file `overrides` bad
|
||||
practice.
|
||||
|
||||
- **Source files: never.** No `overrides` entry may target `src/**` or any
|
||||
other hand-written production or test code.
|
||||
- **The one exception** is in lint-plugin repos: `import/max-dependencies`
|
||||
off for `src/rules/index.ts`, the rule registry, which must import every
|
||||
rule. It is not a precedent for apps.
|
||||
- **`scripts/**`:** a narrow override is allowed when a tooling script truly
|
||||
needs it, e.g. `no-console` in a CLI script.
|
||||
- **Generated files:** list them in `ignorePatterns`, never `overrides`.
|
||||
- **Anything else needs the user's approval first.** When the user is away,
|
||||
don't add it: refactor, or leave the violation failing and log the question.
|
||||
|
||||
## Fix the code instead
|
||||
|
||||
Oxlint's defaults stay; violations are refactored without behavior change,
|
||||
covered by the tests, inside the same migration PR.
|
||||
|
||||
| Rule | Usual fix |
|
||||
| ------------------------- | ------------------------------------------------------- |
|
||||
| `import/max-dependencies` | import a barrel (`#ui`) instead of many direct modules |
|
||||
| `max-lines` | split the file by responsibility |
|
||||
| `max-lines-per-function` | extract helpers; split a long Lit template into parts |
|
||||
| `max-classes-per-file` | one class per file |
|
||||
| `max-params` | pass one options object |
|
||||
| `prefer-set-has` | `const K: ReadonlySet<string> = new Set([...])`, `.has` |
|
||||
|
||||
```ts
|
||||
// before: 14 imports, over the limit of 10
|
||||
import { a } from "#ui/button";
|
||||
import { b } from "#ui/dialog";
|
||||
// after: one barrel
|
||||
import { a, b } from "#ui";
|
||||
```
|
||||
|
||||
Measured on memona before the move (Oxlint 1.87, default limits): 921
|
||||
pedantic hits in 274 files, of which `max-lines-per-function` 206,
|
||||
`max-lines` 145, `max-classes-per-file` 59, `max-dependencies` 45 (median 14
|
||||
imports, worst 29). The user chose refactoring over raising limits or
|
||||
matching memona's old hugoalh limits (2048 lines per file, no function limit).
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
description: Lint coverage never drops below what Deno enforced; every Deno rule maps to an Oxlint rule, a plugin rule, the compiler, or our own rule
|
||||
---
|
||||
|
||||
Hard rule (2026-10-12): after the move, a repo has at least the lint coverage
|
||||
it had under Deno: built-in rules (the `recommended` and `jsr` tags plus
|
||||
opt-ins like `camelcase`, `eqeqeq`, `guard-for-in`, `no-console`,
|
||||
`no-eval`, `no-throw-literal`, `no-non-null-assertion`,
|
||||
`verbatim-module-syntax`), `@hugoalh` rules, and the custom plugins. Rules may
|
||||
be added, never lost. Formatting may change. "Gap" is not an acceptable end
|
||||
state: a Deno rule Oxlint lacks is replaced by a plugin rule or our own rule in
|
||||
[[project-lint-plugin]].
|
||||
|
||||
Before removing any line from `configs.project`, find what else covers it.
|
||||
|
||||
## How the template mapped Deno's 97 rules
|
||||
|
||||
- **Same rule** in `configs.project` at `error`.
|
||||
- **Renamed equivalent:** `no-new-symbol` → `no-new-native-nonconstructor`;
|
||||
`no-window` → `no-restricted-globals: ["error", "window"]`;
|
||||
`ban-untagged-ignore` → `unicorn/no-abusive-eslint-disable`;
|
||||
`ban-unused-ignore`, `ban-unknown-rule-code` →
|
||||
`reportUnusedDisableDirectives`; `camelcase` → built-in `id-match`
|
||||
([[naming-id-match]]).
|
||||
- **Caught by the parser or `typeCheck`:** `no-dupe-args`, `no-octal`,
|
||||
`no-await-in-sync-fn`, `no-import-assertions` (TS2880), `no-sloppy-imports`
|
||||
(TS2835), `verbatim-module-syntax` (TS1484).
|
||||
- **`no-slow-types`:** `isolatedDeclarations`, enforced by `vp pack` (TS9010)
|
||||
in packages.
|
||||
- **Vacuous:** JSX rules (no `.tsx` exists) and Deno-only rules.
|
||||
- **`@hugoalh` rules** map to Oxlint built-ins in `configs.project`.
|
||||
- **import-check's CLI cycle check** → `import/no-cycle`.
|
||||
|
||||
The template's list in `configs.project` is the baseline to copy: `curly`,
|
||||
`guard-for-in`, `no-console`, `no-empty`, `no-regex-spaces`,
|
||||
`no-restricted-globals`, `no-var`, `prefer-const`, `import/no-cycle`,
|
||||
`import/no-relative-parent-imports`, `typescript/adjacent-overload-signatures`,
|
||||
`typescript/consistent-type-imports`, `typescript/no-empty-interface`,
|
||||
`typescript/no-explicit-any`, `typescript/no-namespace`,
|
||||
`typescript/no-non-null-assertion`, the two `project/*` rules,
|
||||
`unicorn/no-abusive-eslint-disable`, `id-match`, and
|
||||
`typescript/switch-exhaustiveness-check` with
|
||||
`considerDefaultExhaustiveForUnions`.
|
||||
|
||||
Each migration PR states its parity mapping (or links the template's table in
|
||||
cunarist/eslint-plugin-explicit-types#1) so the user can check it.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
description: lit-analyzer runs from scripts/lit-check.ts, which maps "typescript" to the aliased typescript-6 devDependency; npm run lit-check in CI
|
||||
---
|
||||
|
||||
Applies to Lit repos that ran `cunarist/deno-lit-analyzer` under Deno.
|
||||
|
||||
lit-analyzer is built on TypeScript's JavaScript compiler API and is run
|
||||
against TypeScript 6 (the open question was whether it works with TS 7 /
|
||||
tsgo). The repo installs TS 6 under another name and redirects lit-analyzer
|
||||
to it, so the rest of the toolchain is unaffected (decided 2026-10-16):
|
||||
|
||||
```jsonc
|
||||
// package.json
|
||||
"scripts": { "lit-check": "node scripts/lit-check.ts" },
|
||||
"devDependencies": { "typescript-6": "npm:typescript@^6", "lit-analyzer": "..." }
|
||||
```
|
||||
|
||||
```ts
|
||||
// scripts/lit-check.ts (shape)
|
||||
import { registerHooks } from "node:module";
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
// when lit-analyzer asks for "typescript", hand it TS 6
|
||||
return next(specifier === "typescript" ? "typescript-6" : specifier, context);
|
||||
},
|
||||
});
|
||||
// then run lit-analyzer
|
||||
```
|
||||
|
||||
- Node runs the `.ts` script directly (type stripping), so no build step.
|
||||
- **Gotcha:** the obvious `await import("lit-analyzer/cli.js")` fails
|
||||
`project/no-non-ts-imports` (a `.js` subpath). Import the package entry, or
|
||||
spawn the package's bin with `node:child_process`, instead.
|
||||
- CI runs `npm run lit-check` as its own step.
|
||||
- It is tooling under `scripts/`: no tests; a narrow `overrides` entry for it
|
||||
(e.g. `no-console`) is allowed if truly needed.
|
||||
|
||||
Don't replace this with a global TypeScript downgrade, a second
|
||||
`tsconfig`, or a `.js` wrapper script.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
description: package.json key order is metadata, scripts, exports, imports, devDependencies, dependencies; standard fields only; no peerDependencies or packageManager
|
||||
---
|
||||
|
||||
## Key order (2026-10-14)
|
||||
|
||||
Metadata first, then `scripts`, `exports`, `imports`, `devDependencies`,
|
||||
`dependencies`. Oxfmt would re-sort the file, so `vite.config.ts` sets
|
||||
`fmt: { sortPackageJson: false }`; keep it, and keep the order by hand.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "@cunarist/eslint-plugin-x",
|
||||
"version": "0.1.0",
|
||||
"description": "...",
|
||||
"keywords": ["eslint", "eslint-plugin", "eslintplugin", "oxlint"],
|
||||
"license": "MIT",
|
||||
"repository": { "type": "git", "url": "git+https://github.com/..." },
|
||||
"type": "module",
|
||||
"files": ["dist"],
|
||||
"engines": { "node": ">=22" },
|
||||
"eslint-doc-generator": {/* tool options that can only live here */},
|
||||
"scripts": { "docs": "eslint-doc-generator" },
|
||||
"exports": { ".": "./dist/index.js" },
|
||||
"imports": { "#helpers": "./src/helpers/index.ts" },
|
||||
"devDependencies": { "vite-plus": "^1.1.0" },
|
||||
"dependencies": {},
|
||||
}
|
||||
```
|
||||
|
||||
The `imports` key order can carry meaning: import-check's
|
||||
`enforce-layer-order` reads it as the layer order. Never reorder it casually.
|
||||
|
||||
## Fields
|
||||
|
||||
- **Standard fields only.** `engines` has only `node` (never `engines.bun`).
|
||||
A tool-owned key (`eslint-doc-generator`) is allowed only because the tool
|
||||
reads nothing else and no other config file is allowed.
|
||||
- **No `packageManager`.** npm is detected from `package-lock.json`.
|
||||
- **No `peerDependencies`, ever** (user preference, 2026-10-14). If something
|
||||
is needed at runtime, it goes in `dependencies`.
|
||||
- **`dependencies`** = what runs or is type-referenced at runtime by consumers
|
||||
(the lit plugin ships `parse5`, `oxc-parser`, `oxc-resolver`, and
|
||||
`@eslint/core` for its public types; they stay external, never bundled).
|
||||
- **`devDependencies`** = everything else: `vite-plus`, `eslint`,
|
||||
`@typescript-eslint/parser`, `eslint-doc-generator`, `esbuild`,
|
||||
`typescript-6`, `@types/node`. ESLint is a devDependency only; plugins need
|
||||
nothing from it at runtime.
|
||||
|
||||
Refresh the lockfile with `npm install` (or `npm install
|
||||
--package-lock-only` for a version bump) and commit it with the change.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
description: scripts/project-lint.ts is the repo-local plugin "project" holding the repo's rule list (typed DummyRuleMap) and repo-only rules; never published, no tests
|
||||
---
|
||||
|
||||
Every repo has `scripts/project-lint.ts`, copied from
|
||||
`cunarist/eslint-plugin-explicit-types` and extended. It is a repo-local
|
||||
Oxlint plugin with `meta.name` `"project"`, loaded through `jsPlugins` in
|
||||
`vite.config.ts`. It holds:
|
||||
|
||||
- **`configs.project.rules`**: the repo's whole rule list beyond the published
|
||||
plugin configs: Deno parity rules, extras, per-rule options, and opt-outs,
|
||||
built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...).
|
||||
- **Custom rules**, enabled as `project/<rule>`. Every repo has
|
||||
`no-disable-comments` and `no-non-ts-imports`. Apps also port the rules of
|
||||
their old `scripts/deno-lint-plugin.ts` here under the same names and
|
||||
behavior (`no-self-sync`, `app-module-conventions`, `restricted-types`, ...).
|
||||
|
||||
Rules and plugin stay in this one file; don't add `scripts/rules/`.
|
||||
|
||||
```ts
|
||||
import type { DummyRuleMap } from "vite-plus/lint";
|
||||
|
||||
const rules: DummyRuleMap = {
|
||||
"id-match": ["error", "^_*[^_]*_*$|^[^a-z]*$", {/* ... */}],
|
||||
curly: "error",
|
||||
"project/no-disable-comments": "error",
|
||||
};
|
||||
|
||||
const plugin = {
|
||||
meta: { name: "project" },
|
||||
rules: { "no-disable-comments": noDisableCommentsRule /* ... */ },
|
||||
configs: { project: { rules } },
|
||||
};
|
||||
export default plugin;
|
||||
```
|
||||
|
||||
## Why `DummyRuleMap` and an unannotated plugin (decided 2026-10-15)
|
||||
|
||||
- `DummyRuleMap` (re-exported by `vite-plus/lint`) is Oxlint's type for
|
||||
`OxlintConfig.rules`. Despite the name it lists every built-in rule with its
|
||||
options, so a typo in a rule id or option is a type error, not just a CI
|
||||
failure. Without it, TypeScript widens `"error"` to `string`; `as const`
|
||||
doesn't help because Oxlint wants mutable tuples.
|
||||
- `@eslint/core`'s `Plugin` is too loose to read back: `configs` is optional,
|
||||
any key may be missing, and a config may be an array, so
|
||||
`projectLint.configs.project.rules` fails in `vite.config.ts` (and `!` is
|
||||
banned). Optional chaining still leaves the array case.
|
||||
- Oxlint's own `Plugin` type (`vite-plus/lint/plugins`) has no `configs`.
|
||||
- Two hand-written interfaces worked but were dropped as noise; a plain
|
||||
config object was rejected because custom rules need the plugin form anyway
|
||||
and every repo should look the same.
|
||||
|
||||
`scripts/` is tooling, not production code: never packed or published, and
|
||||
no tests. Narrow `overrides` for `scripts/**` are allowed when a script truly
|
||||
needs one (e.g. `no-console`); see [[lint-overrides-policy]].
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: Packages publish only from CI when a GitHub release vX.Y.Z is published, via npm trusted publishing; never npm publish by hand; notes live only on the release page
|
||||
---
|
||||
|
||||
Standing convention (2026-10-12), applied to every package:
|
||||
|
||||
- **Only CI publishes.** Publishing a GitHub release `vX.Y.Z` triggers
|
||||
`.github/workflows/publish.yml`. Never run `npm publish` locally and never
|
||||
push a bare `v*` tag. Use the `release` skill, and only when the user asked
|
||||
for a release.
|
||||
- **The workflow** checks that the tag equals `v` + `package.json` `version`,
|
||||
runs the full CI as a reusable workflow (every Node version in the matrix),
|
||||
then `vp pack` and `npm publish --access public --provenance`. A release
|
||||
marked pre-release publishes under the `next` dist-tag, others `latest`.
|
||||
It runs on Node 24 (npm 11, which trusted publishing needs) with no cache.
|
||||
- **Auth is npm trusted publishing** (OIDC, `id-token: write`), which also
|
||||
attaches provenance. npm only allows a trusted publisher on a package that
|
||||
already exists, so the very first release bootstraps with a short-lived
|
||||
token in the `NPM_TOKEN` repo secret (the workflow uses it only when set).
|
||||
Afterwards the user configures the trusted publisher for `publish.yml`,
|
||||
deletes the secret, and sets the package to disallow tokens.
|
||||
- **Release notes live only on the GitHub release page**
|
||||
(`--generate-notes`, edited there). There is no `CHANGELOG.md`; never add
|
||||
one.
|
||||
- **The version lives in two places in plugin packages:** `package.json` and
|
||||
`meta.version` in `src/index.ts`; `tests/package-meta.test.ts` fails if they
|
||||
differ.
|
||||
- **Before 1.0**, a change that breaks users (renamed rule id, a rule that
|
||||
fires on more code, removed behavior) is a minor bump.
|
||||
- **Before a release**, pack the package and lint a known-bad and a known-good
|
||||
file from separate consumer projects, once through `vp lint` with
|
||||
`jsPlugins` and once through ESLint 9 with the configs. On the lit plugin
|
||||
this caught two bugs that 313 unit tests missed.
|
||||
|
||||
The cunarist lint plugins (explicit-types, import-check, lit) are published
|
||||
together, with a token the user provides; the old `deno-*` repos are archived
|
||||
only after that, one at a time.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
description: Vitest via vp test; unit tests sit next to code as *.test.ts, regression/integration tests in tests/ via the public entry; scripts/ has no tests
|
||||
---
|
||||
|
||||
Decided 2026-10-14 for all repos.
|
||||
|
||||
- **Runner:** Vitest through `vp test`. Import test APIs from
|
||||
`vite-plus/test` (`describe`, `expect`, `it`).
|
||||
- **Unit tests** sit next to the code: `src/foo/bar.ts` is tested in
|
||||
`src/foo/bar.test.ts`. Vitest's default include finds them; no `test`
|
||||
config needed. Packages publish only `dist/`, so colocated tests cost nothing
|
||||
in the tarball.
|
||||
- **Regression and integration tests** live in the top-level `tests/` folder
|
||||
and use only the public entry point (an alias like `#plugin`), never
|
||||
internals. `vp test tests` runs just these.
|
||||
- **No in-source tests** (`import.meta.vitest`).
|
||||
- **`scripts/` gets no tests** (2026-10-15): it is repo tooling, not
|
||||
production code (`project-lint.ts`, `decorators.ts`, `lit-check.ts`,
|
||||
release helpers).
|
||||
- Test files follow every lint rule; no relaxed overrides for tests.
|
||||
|
||||
## Migrating Deno tests
|
||||
|
||||
`Deno.test` suites were converted by a codemod
|
||||
(`~/.local/share/deno-migration/src/convert.ts` on the user's machine), then
|
||||
its TODOs finished by hand. Watch for:
|
||||
|
||||
- `@std/assert` → `expect` (`assertEquals` → `toStrictEqual` or `toBe`,
|
||||
`assertThrows` → `toThrow`, `assertRejects` → `rejects.toThrow`);
|
||||
- `Deno.*` file, env, and process APIs → `node:fs`, `process.env`,
|
||||
`node:child_process`;
|
||||
- `t.step` → nested `describe`/`it`;
|
||||
- sanitizer options, which Vitest doesn't have.
|
||||
|
||||
A migration PR must keep every test case; a dropped case is lost coverage.
|
||||
Tests must pass locally before any push.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
description: Node.js 22+ with Vite+ (vp) and npm; only package.json, tsconfig.json, vite.config.ts plus a committed package-lock.json
|
||||
---
|
||||
|
||||
## The stack
|
||||
|
||||
- **Runtime:** Node.js 22 or newer. `engines` is `{ "node": ">=22" }` and
|
||||
nothing else. Containers and the publish job use Node 24 (LTS); a repo may
|
||||
need more where a feature demands it (bot-agents uses Node 24.2+).
|
||||
- **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or
|
||||
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
|
||||
`vp` brings its own Node, the way uv brings its own Python.
|
||||
- **Toolchain:** Vite+ (`vite-plus` devDependency, command `vp`): Vite,
|
||||
Vitest, Oxlint, Oxfmt, type checking, tsdown.
|
||||
- **Package manager:** npm everywhere. `package-lock.json` is committed in
|
||||
apps _and_ libraries (user agreed 2026-10-14: `npm ci` needs it). There is no
|
||||
`packageManager` field; Vite+ detects npm from the lockfile.
|
||||
|
||||
## Config files
|
||||
|
||||
Exactly three, plus the lockfile: `package.json`, `tsconfig.json`,
|
||||
`vite.config.ts`. No `.oxlintrc.json`, `.prettierrc`, `eslint.config.*`,
|
||||
`vitest.config.*`, `.npmrc`, or `tsdown.config.*`. Tool options go into
|
||||
`vite.config.ts` keys (`lint`, `fmt`, `test`, `pack`) or, when a tool only
|
||||
reads `package.json` (eslint-doc-generator), into a `package.json` key.
|
||||
The target is a short `vite.config.ts`, around 30 lines (not strict).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Does |
|
||||
| ------------------------ | ---------------------------------------------------------- |
|
||||
| `vp check` | format check, Oxlint (type-aware), and type check together |
|
||||
| `vp check --fix` | the same, applying formatting and safe fixes |
|
||||
| `vp test` | Vitest; `vp test tests` runs only the top-level `tests/` |
|
||||
| `vp build` | Vite production build (apps) |
|
||||
| `vp dev` | Vite dev server (apps) |
|
||||
| `vp pack` | tsdown library build into `dist/` (packages) |
|
||||
| `npm ci` / `npm install` | install; never another package manager |
|
||||
|
||||
`vp check`, `vp test`, and the build (`vp build` or `vp pack`) must all pass
|
||||
before any push. CI installs the toolchain with `voidzero-dev/setup-vp`.
|
||||
|
||||
## Gotchas found during the migration
|
||||
|
||||
- `vp check` does **not** report `isolatedDeclarations` errors (TS9010); only
|
||||
`vp pack` does, so packages run `vp pack` in CI on every PR.
|
||||
- Oxfmt sorts `package.json` keys by default, which destroys the required key
|
||||
order. Set `fmt: { sortPackageJson: false }` ([[package-json-shape]]).
|
||||
- Oxlint and Oxfmt skip what `.gitignore` lists; that is the only reason
|
||||
`dist/` and `node_modules/` are not linted. Keep build output gitignored.
|
||||
- Vite+ bundles `vite.config.ts` itself and resolves `#` imports in it.
|
||||
- Oxlint loads a `.ts` plugin file directly on Node 24 (`jsPlugins`), so
|
||||
repo-local lint plugins need no build step.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: Every file is .ts (no js/mjs/cjs/jsx/tsx, configs included) and source imports only .ts; CSS comes from HTML, JSON/SVG become .ts modules
|
||||
---
|
||||
|
||||
The user's rule, from the Deno days and restated for Node (2026-10-12): "No
|
||||
JS, not even JSX/TSX, only TS." Source, scripts, tests, and configs are all
|
||||
plain `.ts`. Build output in a gitignored folder is the only JS.
|
||||
|
||||
`tsconfig.json`: no `allowJs`, no `jsx`, and the template's strict flags
|
||||
(`verbatimModuleSyntax`, `erasableSyntaxOnly`, `allowImportingTsExtensions`,
|
||||
`isolatedDeclarations` in packages, `exactOptionalPropertyTypes`,
|
||||
`noUncheckedIndexedAccess`). If a tool or framework seems to need a JS or TSX
|
||||
file, stop and flag it instead of adding one.
|
||||
|
||||
## Only `.ts` imports (2026-10-14)
|
||||
|
||||
`project/no-non-ts-imports` (in `scripts/project-lint.ts`) fails:
|
||||
|
||||
- a relative, absolute, `#`, or package-subpath specifier whose last segment
|
||||
has a non-`.ts` extension: `.css`, `.json`, `.svg`, `.js`, `.mjs`, `.tsx`;
|
||||
- any `?query` suffix (`?raw`, `?url`, `?inline`);
|
||||
- any import attributes (`with { type: "json" }`, `with { type: "text" }`).
|
||||
|
||||
Bare packages (`lit`, `@types/json-schema`, `lodash.debounce`), `#` aliases,
|
||||
and `node:` builtins pass. `@fortawesome/x/svgs/a.svg` fails.
|
||||
|
||||
```ts
|
||||
import "./theme.css"; // no
|
||||
import data from "./scenes.json" with { type: "json" }; // no
|
||||
import icon from "./lock.svg?raw"; // no
|
||||
import { scenes } from "#data/scenes"; // yes: scenes.ts
|
||||
```
|
||||
|
||||
## What to do instead
|
||||
|
||||
- **CSS:** load it from HTML with `<link rel="stylesheet" href="...">`. Never
|
||||
import CSS from TS, not even for Vite. (This also removes most of the
|
||||
`import/no-unassigned-import` hits the `suspicious` category reports.)
|
||||
- **JSON data:** turn it into a `.ts` module exporting a typed constant.
|
||||
- **SVG:** a `.ts` module exporting a Lit template; see [[icons-memona-style]].
|
||||
- **`package.json` fields at runtime:** never `import pkg from
|
||||
"./package.json"` and there is no `#package` alias. Write the value out in
|
||||
`.ts` and add a test in `tests/` that reads `package.json` with `node:fs`
|
||||
and fails on drift (the plugin repos do this for `meta.name`/`meta.version`).
|
||||
- **Reading a file is not importing it.** `readFileSync` + `JSON.parse` is
|
||||
fine in tooling and tests.
|
||||
- **A tool's `.js` entry:** don't `import("pkg/cli.js")`; use the package
|
||||
entry or spawn the package's bin ([[lit-analyzer-ts6]]).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Workflow and container YAML stays declarative; operational shell belongs in separate scripts
|
||||
---
|
||||
|
||||
Do not embed multiline operational scripts in workflow, Compose, or container
|
||||
YAML. Keep YAML declarative and move shell logic into a small, named `.sh` file
|
||||
that can be tested directly.
|
||||
@@ -0,0 +1 @@
|
||||
*
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: migrate-deno-repo
|
||||
description: Checklist for moving a Deno repo (or a leftover Deno part of one) to Node 22+ + Vite+ with npm, same behavior, full lint parity, one PR
|
||||
---
|
||||
|
||||
Template: `cunarist/eslint-plugin-explicit-types` on `main`. Read its
|
||||
`AGENTS.md`, `vite.config.ts`, `scripts/project-lint.ts`, `package.json`, and
|
||||
CI before starting, and copy their shape. Never ask the user mid-way when
|
||||
they're away: decide by the memories, record the choice in the PR.
|
||||
|
||||
Branch `node-migration` (apps) or `initial-port` (new plugin repos); one PR
|
||||
with everything.
|
||||
|
||||
**Inventory first**
|
||||
|
||||
- [ ] List every `deno.json` task, import-map entry, lint rule (tags,
|
||||
includes, `@hugoalh`, custom plugins), test, `Deno.*` call, JSON/CSS/SVG
|
||||
import, and every place the output is consumed (Rust, HTML,
|
||||
Containerfile, CI).
|
||||
|
||||
**Toolchain**
|
||||
|
||||
- [ ] `package.json` in the required key order, `engines.node ">=22"`, no
|
||||
`packageManager`, no `peerDependencies`; `npm install`; commit
|
||||
`package-lock.json`.
|
||||
- [ ] `tsconfig.json` from the template; `vite.config.ts` with `fmt:
|
||||
{ sortPackageJson: false }` and the category-based `lint` block.
|
||||
- [ ] Delete `deno.json`, `deno.lock`, and Deno-only files. `mod.ts` →
|
||||
`index.ts`; `deno.json` imports → `package.json` `imports` (`#x/*`
|
||||
patterns for trailing-slash mappings); `jsr:`/`npm:` → npm packages.
|
||||
|
||||
**Code**
|
||||
|
||||
- [ ] Only `.ts` files; no non-`.ts` imports: CSS via `<link>`, JSON and SVG
|
||||
as `.ts` modules, icons in memona's style.
|
||||
- [ ] `Deno.*` → `node:*` / `process`. Generated JS written as `.ts`, built,
|
||||
gitignored.
|
||||
- [ ] Decorators stay standard with `accessor`; add `scripts/decorators.ts`
|
||||
if the repo has any. Lit repos: `scripts/lit-check.ts` + `npm run
|
||||
lit-check`.
|
||||
|
||||
**Lint**
|
||||
|
||||
- [ ] `scripts/project-lint.ts` copied; old local Deno lint rules ported into
|
||||
it; parity mapping for every former rule; `id-match`; no disable
|
||||
comments; no source `overrides`; refactor size-rule hits.
|
||||
|
||||
**Tests**
|
||||
|
||||
- [ ] `Deno.test` → Vitest (codemod, then finish TODOs); unit tests next to
|
||||
code, integration in `tests/`; no case dropped.
|
||||
|
||||
**Build, ship, CI**
|
||||
|
||||
- [ ] `vp build` in app mode with the same output paths, or `vp pack` for
|
||||
packages. Containerfile node stage on `node:24-alpine`. CI with
|
||||
`setup-vp`: `vp check`, build, `vp test` (+ lit-check). Packages:
|
||||
release-triggered `publish.yml`.
|
||||
- [ ] `AGENTS.md` with the damem header, `.agents/` notes from the damem kit,
|
||||
README updated (Node.js 22+, no Deno instructions). `damem doctor`
|
||||
clean.
|
||||
|
||||
**Finish**
|
||||
|
||||
- [ ] `vp check`, `vp test`, build all pass locally; the app runs.
|
||||
- [ ] Commit in plain imperative sentences; open the PR (summary, changes,
|
||||
parity mapping, deviations/risks, "CI fails until @cunarist/eslint-plugin-*
|
||||
are published" if relevant, verification, footer line).
|
||||
- [ ] Run `review-cycle`. Never merge, publish, or archive.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Agents
|
||||
run-name: >-
|
||||
AI bot at
|
||||
#${{ gitea.event.issue.number || gitea.event.pull_request.number }}
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
types: [opened, review_requested]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: >-
|
||||
agents-${{ gitea.event.sender.login == 'bot' && gitea.run_id ||
|
||||
gitea.event.issue.number || gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
agent:
|
||||
if: >-
|
||||
gitea.actor != 'bot' && gitea.event.sender.login != 'bot' &&
|
||||
((gitea.event_name == 'issues' &&
|
||||
contains(gitea.event.issue.body, '@bot')) ||
|
||||
(gitea.event_name == 'pull_request' &&
|
||||
((gitea.event.action == 'opened' &&
|
||||
contains(gitea.event.pull_request.body, '@bot')) ||
|
||||
gitea.event.requested_reviewer.login == 'bot')) ||
|
||||
(gitea.event_name == 'issue_comment' &&
|
||||
contains(gitea.event.comment.body, '@bot')) ||
|
||||
(gitea.event_name == 'pull_request_review_comment' &&
|
||||
contains(gitea.event.review.content, '@bot')))
|
||||
permissions:
|
||||
code: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
volumes:
|
||||
- bot-home:/root
|
||||
steps:
|
||||
- name: Run AI bot
|
||||
uses: https://git.capsulizers.com/commons/bot-agents@main
|
||||
with:
|
||||
master-key: ${{ secrets.BOT_AGENTS_MASTER_KEY }}
|
||||
@@ -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.ts needs Node 24.2+.
|
||||
- run: node --version
|
||||
|
||||
- uses: denoland/setup-deno@v2
|
||||
- run: npm ci
|
||||
|
||||
- run: >-
|
||||
deno fmt --check . &&
|
||||
deno lint . &&
|
||||
deno check run.ts &&
|
||||
deno test
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
@@ -0,0 +1,29 @@
|
||||
At the start of every new session, run `damem recall` in the terminal and treat
|
||||
its output as the context for this repository. If the `damem` command is not
|
||||
found, read [INSTALL.md](https://github.com/cunarist/damem/blob/main/INSTALL.md)
|
||||
and follow it before reasoning or responding.
|
||||
|
||||
# Pull requests
|
||||
|
||||
Land every change through a pull request. Immediately after creating one,
|
||||
comment `@bot` to request an AI-bot review. Wait for green CI and the bot's
|
||||
approval on the current head before merging. Never merge without the user's
|
||||
explicit permission.
|
||||
|
||||
# Runtime and checks
|
||||
|
||||
The action's TypeScript runs directly on the job image's Node.js (24.2 or
|
||||
newer), which strips the types itself: there is no build step and no runtime
|
||||
dependency. Keep the sources to erasable syntax and `.ts` imports, which
|
||||
`tsconfig.json` enforces. Checks use Vite+: `vp check` formats, lints, and
|
||||
type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as
|
||||
`src/*.test.ts`; repository-level tests live in `tests/`.
|
||||
|
||||
The only config files are `package.json`, `tsconfig.json`, and
|
||||
`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule
|
||||
list live in `scripts/project-lint.ts`. Lint disable comments are not allowed;
|
||||
fix the source instead.
|
||||
|
||||
The composite action uses npm only to install the externally distributed
|
||||
Claude Code, Codex, and OpenCode command-line tools; do not treat those
|
||||
vendor executables as repository TypeScript dependencies.
|
||||
+42
-32
@@ -2,30 +2,11 @@ name: Bot agents
|
||||
description: Run a coding agent for approved Gitea issue/PR automation
|
||||
|
||||
inputs:
|
||||
bot-type:
|
||||
description: Which bot to run, either `codex` or `claude`
|
||||
required: true
|
||||
author-token:
|
||||
master-key:
|
||||
description: >-
|
||||
Gitea token of the account that commits, pushes, and opens pull requests,
|
||||
with the `read:user`, `write:issue`, and `write:repository` scopes. The
|
||||
agent sees it as `GITEA_TOKEN`.
|
||||
Base64url-encoded 256-bit key used to decrypt the central bot profiles
|
||||
and reviewer credential.
|
||||
required: true
|
||||
reviewer-token:
|
||||
description: >-
|
||||
Gitea token of the bot account that posts comments and pull request
|
||||
reviews, with the `write:issue` and `write:repository` scopes. Never
|
||||
exposed to the agent, so it must differ from the author.
|
||||
required: true
|
||||
bot-token:
|
||||
description: API key or token for the selected bot
|
||||
required: false
|
||||
model:
|
||||
description: >-
|
||||
Model for the selected bot. Defaults to `claude-sonnet-5` or
|
||||
`gpt-5.6-terra`, the mid tiers, which cover reviews and fixes.
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -33,23 +14,52 @@ runs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ inputs.author-token }}
|
||||
# This step assumes this is `node:24-bookworm` container.
|
||||
token: ${{ gitea.token }}
|
||||
# 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 deno
|
||||
- name: Run bot
|
||||
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
|
||||
id: source
|
||||
shell: bash
|
||||
run: deno run -A "${ACTION_PATH}/run.ts"
|
||||
env:
|
||||
ACTION_PATH: ${{ gitea.action_path }}
|
||||
BOT_TYPE: ${{ inputs.bot-type }}
|
||||
BOT_TOKEN: ${{ inputs.bot-token }}
|
||||
MODEL: ${{ inputs.model }}
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
run: bash "${{ gitea.action_path }}/scripts/protect-source.sh"
|
||||
- name: Allow isolated profiles to edit the checkout
|
||||
shell: bash
|
||||
run: chmod -R a+rwX "${GITHUB_WORKSPACE}"
|
||||
- name: Prepare credentials
|
||||
id: prepare
|
||||
shell: bash
|
||||
run: node "${{ steps.source.outputs.path }}/src/prepare.ts"
|
||||
env:
|
||||
ACTION_PATH: ${{ steps.source.outputs.path }}
|
||||
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
- name: Run bot
|
||||
id: agent
|
||||
shell: bash
|
||||
run: node "${{ steps.source.outputs.path }}/src/run.ts"
|
||||
env:
|
||||
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
GITEA_API_URL: ${{ gitea.api_url }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_TOKEN: ${{ inputs.author-token }}
|
||||
REVIEWER_TOKEN: ${{ inputs.reviewer-token }}
|
||||
EVENT_NAME: ${{ gitea.event_name }}
|
||||
ISSUE_INDEX: ${{ gitea.event.issue.number || gitea.event.pull_request.number }}
|
||||
COMMENT: ${{ toJSON(gitea.event.comment || gitea.event.review) }}
|
||||
- name: Post result and remove credentials
|
||||
id: post
|
||||
if: always() && steps.prepare.outputs.manifest != ''
|
||||
shell: bash
|
||||
run: node "${{ steps.source.outputs.path }}/src/post.ts"
|
||||
env:
|
||||
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
|
||||
GITEA_API_URL: ${{ gitea.api_url }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
EVENT_NAME: ${{ gitea.event_name }}
|
||||
ISSUE_INDEX: ${{ gitea.event.issue.number || gitea.event.pull_request.number }}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { assertEquals } from "jsr:@std/assert@1";
|
||||
import { retryInvalidToken } 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 });
|
||||
});
|
||||
Generated
+3083
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "bot-agents",
|
||||
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@types/node": "^24.19.2",
|
||||
"@typescript-eslint/types": "^8.71.2",
|
||||
"vite-plus": "^1.2.0",
|
||||
"yaml": "^2.9.1"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,31 +19,31 @@ author's push credentials. Read the code there, run its checks and tests when
|
||||
they bear on the task, and push from there.
|
||||
|
||||
For a `pull_request` event, and for a comment on a pull request that asks you to
|
||||
review it, invoke the installed `superpowers:requesting-code-review` skill
|
||||
before inspecting the PR. You are the reviewer that has already been dispatched,
|
||||
so run the skill's code reviewer template yourself instead of dispatching
|
||||
another reviewer. Use the pull request and triggering instruction as its
|
||||
description and requirements, and review the exact base and head SHAs without
|
||||
changing code. Run the project's required checks on the head and treat a real
|
||||
failure as at least Important. The bot automation workflow itself is not a
|
||||
project check: ignore its skipped or canceled duplicate/automatic runs, and
|
||||
never reject a PR because the current review run is unfinished. Only a failed
|
||||
required check for the reviewed head blocks approval. Check the whole repository
|
||||
against the code rules at the end of this prompt, not only the diff; a violation
|
||||
is at least Important even when the diff did not cause it. Write the complete
|
||||
review, and nothing else, to the file `${REVIEW_PATH}`: it is posted verbatim as
|
||||
a pull request review from the bot account, and your final response is not
|
||||
posted at all. The file's first line must be exactly the verdict and nothing
|
||||
else: `Approved` when the head is ready to merge, `Changes requested` otherwise.
|
||||
The mark in front of it is added when posting, so write the words alone; any
|
||||
other first line is posted as a plain comment, which wastes the run. Minor
|
||||
issues alone never block, and neither does a finding the author has answered in
|
||||
the comment history below as intended or a false alarm, once the code or docs
|
||||
make that clear. When the verdict is `Changes requested`, the second line names
|
||||
what must change in one line, addressed to the author; the author's own agent
|
||||
picks the fixes up, so never ask `@bot` to make them. For UI changes, check that
|
||||
the result is aligned, clean, and pixel-perfect, and that included screenshots
|
||||
prove the intended result was achieved.
|
||||
review it, use the installed `requesting-code-review` skill before inspecting
|
||||
the PR. You are the reviewer that has already been dispatched, so run the
|
||||
skill's code reviewer template yourself instead of dispatching another reviewer.
|
||||
Use the pull request and triggering instruction as its description and
|
||||
requirements, and review the exact base and head SHAs without changing code. Run
|
||||
the project's required checks on the head and treat a real failure as at least
|
||||
Important. The bot automation workflow itself is not a project check: ignore its
|
||||
skipped or canceled duplicate/automatic runs, and never reject a PR because the
|
||||
current review run is unfinished. Only a failed required check for the reviewed
|
||||
head blocks approval. Check the whole repository against the code rules at the
|
||||
end of this prompt, not only the diff; a violation is at least Important even
|
||||
when the diff did not cause it. Write the complete review, and nothing else, to
|
||||
the file `${REVIEW_PATH}`: it is posted verbatim as a pull request review from
|
||||
the bot account, and your final response is not posted at all. The file's first
|
||||
line must be exactly the verdict and nothing else: `Approved` when the head is
|
||||
ready to merge, `Changes requested` otherwise. The mark in front of it is added
|
||||
when posting, so write the words alone; any other first line is posted as a
|
||||
plain comment, which wastes the run. Minor issues alone never block, and neither
|
||||
does a finding the author has answered in the comment history below as intended
|
||||
or a false alarm, once the code or docs make that clear. When the verdict is
|
||||
`Changes requested`, the second line names what must change in one line,
|
||||
addressed to the author; the author's own agent picks the fixes up, so never ask
|
||||
`@bot` to make them. For UI changes, check that the result is aligned, clean,
|
||||
and pixel-perfect, and that included screenshots prove the intended result was
|
||||
achieved.
|
||||
|
||||
Every finding that belongs to one line of the diff goes on that line instead of
|
||||
into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
// Runs a coding agent for one Gitea issue or pull request event and posts its
|
||||
// final response back. The agent works as the author account through
|
||||
// GITEA_TOKEN; everything this script posts goes through the reviewer token,
|
||||
// so it appears as the bot account.
|
||||
import { TextLineStream } from "jsr:@std/streams@1/text-line-stream";
|
||||
import { retryInvalidToken } from "./auth.ts";
|
||||
import {
|
||||
type BotType,
|
||||
parseBotType,
|
||||
superpowersInstallCommands,
|
||||
} from "./superpowers.ts";
|
||||
|
||||
type GiteaUser = { login: string; email: string };
|
||||
type GiteaComment = { user: GiteaUser; created_at: string; body: string };
|
||||
|
||||
const env = (name: string): string => {
|
||||
const value = Deno.env.get(name);
|
||||
if (value === undefined) throw new Error(`${name} is not set`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const API = env("GITEA_API_URL");
|
||||
const REPO = env("GITEA_REPOSITORY");
|
||||
const INDEX = env("ISSUE_INDEX");
|
||||
const EVENT = env("EVENT_NAME");
|
||||
const BOT = parseBotType(env("BOT_TYPE"));
|
||||
const AUTHOR_TOKEN = env("GITEA_TOKEN");
|
||||
const REVIEWER_TOKEN = env("REVIEWER_TOKEN");
|
||||
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
|
||||
// The mid tiers: a run follows a fixed template and the project's checks.
|
||||
const DEFAULT_MODELS: Record<string, string> = {
|
||||
claude: "claude-sonnet-5",
|
||||
codex: "gpt-5.6-terra",
|
||||
};
|
||||
const model = (bot: string): string =>
|
||||
Deno.env.get("MODEL") || DEFAULT_MODELS[bot];
|
||||
|
||||
// The reviewer token is withheld so the agent cannot approve as the bot.
|
||||
const { REVIEWER_TOKEN: _, ...agentEnv } = Deno.env.toObject();
|
||||
|
||||
async function gitea(
|
||||
token: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<Response> {
|
||||
const response = await fetch(`${API}/${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
|
||||
const stripAnsi = (text: string): string => text.replace(ANSI, "");
|
||||
|
||||
async function postComment(body: string): Promise<void> {
|
||||
await gitea(REVIEWER_TOKEN, `repos/${REPO}/issues/${INDEX}/comments`, {
|
||||
body: stripAnsi(body),
|
||||
});
|
||||
}
|
||||
|
||||
// A review is posted whenever the agent wrote one, whether a review request or
|
||||
// a comment asked for it. It comes through a file, because a final chat message
|
||||
// picks up narration while a file's first line is written on purpose. That line
|
||||
// is the verdict, matched whole; anything unexpected only comments, never
|
||||
// approves. The mark in front is added here, so it is never part of the match.
|
||||
const REVIEW_DIR = await Deno.makeTempDir();
|
||||
const REVIEW_PATH = `${REVIEW_DIR}/review.md`;
|
||||
const VERDICTS: Record<string, [event: string, mark: string]> = {
|
||||
Approved: ["APPROVED", "✅"],
|
||||
"Changes requested": ["REQUEST_CHANGES", "🛑"],
|
||||
};
|
||||
|
||||
// A finding about one line is posted on that line of the diff rather than as
|
||||
// `file:line` prose in the body. Those anchors come as JSON, so the file and
|
||||
// line are structured instead of parsed back out of English; a malformed entry
|
||||
// fails the run, because a silently dropped finding is worse than a red run.
|
||||
const ANCHORS_PATH = `${REVIEW_DIR}/anchors.json`;
|
||||
type Anchor = { path: string; line: number; side: "new" | "old"; body: string };
|
||||
|
||||
function parseAnchors(text: string): Anchor[] {
|
||||
const entries: unknown = JSON.parse(text);
|
||||
if (!Array.isArray(entries)) throw new Error(`${ANCHORS_PATH}: not an array`);
|
||||
return entries.map((entry: unknown, index) => {
|
||||
const at = `${ANCHORS_PATH}[${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 readAnchors(): Promise<Anchor[]> {
|
||||
const written = await Deno.readTextFile(ANCHORS_PATH).catch(() => null);
|
||||
return written === null ? [] : parseAnchors(written);
|
||||
}
|
||||
|
||||
async function postResult(body: string): Promise<void> {
|
||||
const review = await Deno.readTextFile(REVIEW_PATH).catch(() => null);
|
||||
if (review === null) {
|
||||
if (EVENT === "pull_request") {
|
||||
throw new Error(`no review was written to ${REVIEW_PATH}`);
|
||||
}
|
||||
return postComment(body);
|
||||
}
|
||||
const [verdict, ...rest] = review.split("\n");
|
||||
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
|
||||
const post = (anchors: Anchor[]) =>
|
||||
gitea(REVIEWER_TOKEN, `repos/${REPO}/pulls/${INDEX}/reviews`, {
|
||||
body: stripAnsi([`${mark} ${verdict.trim()}`, ...rest].join("\n")),
|
||||
event,
|
||||
comments: anchors.map(({ path, line, side, body }) => ({
|
||||
path,
|
||||
body: stripAnsi(body),
|
||||
new_position: side === "new" ? line : 0,
|
||||
old_position: side === "old" ? line : 0,
|
||||
})),
|
||||
});
|
||||
const anchors = await readAnchors();
|
||||
// Gitea rejects the whole review when an anchor names a line outside the
|
||||
// diff, and a verdict that never lands blocks the pull request, so the body
|
||||
// goes up alone rather than not at all.
|
||||
await post(anchors).catch(async (error: Error) => {
|
||||
if (anchors.length === 0) throw error;
|
||||
console.error(`inline comments rejected: ${error.message}`);
|
||||
await post([]);
|
||||
});
|
||||
}
|
||||
|
||||
async function run(command: string, args: string[]): Promise<void> {
|
||||
const { success, code } = await new Deno.Command(command, { args }).output();
|
||||
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
|
||||
}
|
||||
|
||||
// The reviewer uses Superpowers' requesting-code-review template. Both plugin
|
||||
// installers are idempotent on the persisted bot home.
|
||||
async function installSuperpowers(bot: BotType): Promise<void> {
|
||||
for (const { command, args } of superpowersInstallCommands(bot)) {
|
||||
await run(command, args);
|
||||
}
|
||||
}
|
||||
|
||||
// Commits belong to the same account as the pull request they end up in.
|
||||
async function configureGitAuthor(): Promise<void> {
|
||||
const user: GiteaUser = await (await gitea(AUTHOR_TOKEN, "user")).json();
|
||||
for (const [key, value] of [["name", user.login], ["email", user.email]]) {
|
||||
await run("git", ["config", "--global", `user.${key}`, value]);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPrompt(): Promise<string> {
|
||||
const comments: GiteaComment[] = await (await gitea(
|
||||
REVIEWER_TOKEN,
|
||||
`repos/${REPO}/issues/${INDEX}/comments?limit=100`,
|
||||
)).json();
|
||||
const values: Record<string, string> = {
|
||||
COMMENT: env("COMMENT"),
|
||||
ISSUE_COMMENTS: comments
|
||||
.map((c) => `## ${c.user.login} at ${c.created_at}\n\n${c.body}\n`)
|
||||
.join("\n"),
|
||||
CODE_RULES: await (await gitea(REVIEWER_TOKEN, RULES_PATH)).text(),
|
||||
EVENT_NAME: EVENT,
|
||||
GITEA_API_URL: API,
|
||||
GITEA_REPOSITORY: REPO,
|
||||
ISSUE_INDEX: INDEX,
|
||||
REVIEW_PATH,
|
||||
ANCHORS_PATH,
|
||||
};
|
||||
const template = await Deno.readTextFile(
|
||||
new URL("prompt.md", import.meta.url),
|
||||
);
|
||||
return template.replace(
|
||||
/\$\{(\w+)\}/g,
|
||||
(match, name) => values[name] ?? match,
|
||||
);
|
||||
}
|
||||
|
||||
async function runClaude(prompt: string): Promise<string> {
|
||||
const token = Deno.env.get("BOT_TOKEN");
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Run `claude setup-token` locally and set the `bot-token` action input.",
|
||||
);
|
||||
}
|
||||
await installSuperpowers("claude");
|
||||
const claude = new Deno.Command("claude", {
|
||||
args: [
|
||||
"--print",
|
||||
"--dangerously-skip-permissions",
|
||||
"--model",
|
||||
model("claude"),
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
prompt,
|
||||
],
|
||||
// Claude refuses --dangerously-skip-permissions as root outside a sandbox.
|
||||
env: { ...agentEnv, CLAUDE_CODE_OAUTH_TOKEN: token, IS_SANDBOX: "1" },
|
||||
clearEnv: true,
|
||||
stdout: "piped",
|
||||
}).spawn();
|
||||
let result: { result?: string; subtype: string } | undefined;
|
||||
// Print events as they stream so the runner does not kill the job as a zombie.
|
||||
const lines = claude.stdout
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(new TextLineStream());
|
||||
for await (const line of lines) {
|
||||
if (!line) continue;
|
||||
const event = JSON.parse(line);
|
||||
for (const part of event.message?.content ?? []) {
|
||||
const text = part.thinking ?? part.text ?? part.name;
|
||||
if (text) console.log(text);
|
||||
}
|
||||
if (event.type === "result") result = event;
|
||||
}
|
||||
await claude.status;
|
||||
if (result?.result === undefined) {
|
||||
throw new Error(`claude ended with ${result?.subtype ?? "no result"}`);
|
||||
}
|
||||
return result.result;
|
||||
}
|
||||
|
||||
// Posts the device code so a human can finish the login on the persisted home.
|
||||
async function codexDeviceLogin(): Promise<void> {
|
||||
const login = new Deno.Command("codex", {
|
||||
args: ["login", "--device-auth"],
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
}).spawn();
|
||||
let shown = "";
|
||||
const collect = (stream: ReadableStream<Uint8Array>): Promise<void> =>
|
||||
stream.pipeThrough(new TextDecoderStream()).pipeTo(
|
||||
new WritableStream({ write: (chunk) => void (shown += chunk) }),
|
||||
);
|
||||
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
|
||||
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 postComment(shown);
|
||||
await Promise.all([status, drained]);
|
||||
}
|
||||
|
||||
async function runCodex(prompt: string): Promise<string> {
|
||||
const loggedIn = await new Deno.Command("codex", {
|
||||
args: ["login", "status"],
|
||||
})
|
||||
.output();
|
||||
if (!loggedIn.success) await codexDeviceLogin();
|
||||
await installSuperpowers("codex");
|
||||
const file = await Deno.makeTempFile();
|
||||
const attempt = async () => {
|
||||
const codex = new Deno.Command("codex", {
|
||||
args: [
|
||||
"exec",
|
||||
"--model",
|
||||
model("codex"),
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--output-last-message",
|
||||
file,
|
||||
prompt,
|
||||
],
|
||||
env: agentEnv,
|
||||
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 { status } = await retryInvalidToken(attempt, async () => {
|
||||
await run("codex", ["logout"]);
|
||||
await codexDeviceLogin();
|
||||
});
|
||||
if (!status.success) throw new Error(`codex exited with ${status.code}`);
|
||||
return await Deno.readTextFile(file);
|
||||
}
|
||||
|
||||
try {
|
||||
await configureGitAuthor();
|
||||
const prompt = await renderPrompt();
|
||||
const result = BOT === "claude"
|
||||
? await runClaude(prompt)
|
||||
: await runCodex(prompt);
|
||||
await postResult(result);
|
||||
} catch (error) {
|
||||
await postComment(`Bot failed: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* `project` — lint rules this repository holds its own source to. Deno lint
|
||||
* ran here with its `recommended` rules and no plugins; the Oxlint categories
|
||||
* in `vite.config.ts` plus the rule list below keep at least that coverage.
|
||||
* Its rules of its own are `no-disable-comments` and `no-non-ts-imports`.
|
||||
*
|
||||
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core";
|
||||
import type { TSESTree } from "@typescript-eslint/types";
|
||||
import type { DummyRuleMap } from "vite-plus/lint";
|
||||
|
||||
/** The source code both hosts hand a rule, with the comment list. */
|
||||
interface ProjectSourceCode extends TextSourceCode {
|
||||
getAllComments(): TSESTree.Comment[];
|
||||
}
|
||||
|
||||
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */
|
||||
interface ProjectRuleTypes {
|
||||
LangOptions: LanguageOptions;
|
||||
Code: ProjectSourceCode;
|
||||
RuleOptions: [];
|
||||
Visitor: RuleVisitor;
|
||||
Node: TSESTree.Node;
|
||||
MessageIds: string;
|
||||
ExtRuleDocs: unknown;
|
||||
}
|
||||
|
||||
/** A repo-local rule, in ESLint's standard `create` form. */
|
||||
type ProjectRule = RuleDefinition<ProjectRuleTypes>;
|
||||
|
||||
/** A comment that turns lint rules off or back on, in ESLint or Oxlint form. */
|
||||
const DIRECTIVE_PATTERN: RegExp = /^\s*(?:eslint|oxlint)-(?:disable|enable)/u;
|
||||
|
||||
/** This rule's own name, which a directive may not use to silence it. */
|
||||
const RULE_NAME: string = "no-disable-comments";
|
||||
|
||||
/**
|
||||
* `no-disable-comments` — rejects every `eslint-disable*`, `eslint-enable*`,
|
||||
* `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has
|
||||
* no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them
|
||||
* itself: when a rule fires, fix the source.
|
||||
*
|
||||
* A directive can silence the report on itself, so two built-in checks close
|
||||
* the gaps: `unicorn/no-abusive-eslint-disable` fails one that names no rule,
|
||||
* and this rule skips one that names it, which then silences nothing and fails
|
||||
* `reportUnusedDisableDirectives` in `vite.config.ts`.
|
||||
*/
|
||||
const noDisableCommentsRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: { description: "Disallow lint disable and enable comments" },
|
||||
schema: [],
|
||||
messages: { directive: "Lint disable comments are not allowed; fix the source." },
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Program(_node: TSESTree.Program): void {
|
||||
for (const comment of context.sourceCode.getAllComments()) {
|
||||
if (DIRECTIVE_PATTERN.test(comment.value) && !comment.value.includes(RULE_NAME)) {
|
||||
context.report({ loc: comment.loc, messageId: "directive" });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** A module specifier read as a path: relative, absolute, or a `#` alias. */
|
||||
const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
|
||||
|
||||
/**
|
||||
* Whether `source` names something other than a `.ts` module: a query string
|
||||
* such as `?raw`, or a final path segment with any other extension. Bare
|
||||
* package names are left alone, dots and all, as are extensionless paths.
|
||||
*/
|
||||
function isNonTsSource(source: string): boolean {
|
||||
if (source.includes("?")) {
|
||||
return true;
|
||||
}
|
||||
const segments: string[] = source.split("/");
|
||||
if (!PATH_SPECIFIER_PATTERN.test(source)) {
|
||||
const nameLength: number = source.startsWith("@") ? 2 : 1;
|
||||
if (segments.length <= nameLength) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const lastSegment: string = segments.at(-1) ?? "";
|
||||
if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) {
|
||||
return false;
|
||||
}
|
||||
return !lastSegment.endsWith(".ts");
|
||||
}
|
||||
|
||||
/**
|
||||
* `no-non-ts-imports` — rejects importing anything but a `.ts` module: a
|
||||
* source path, static or a dynamic `import()` with a string literal, ending in
|
||||
* another extension (`.css`, `.json`, `.svg`, `.js`, `.tsx`) or carrying a
|
||||
* query string (`?raw`, `?url`), and any import or export with import
|
||||
* attributes. Bare package names, `#` aliases, and `node:` builtins without an
|
||||
* extension pass; package subpaths are checked like paths.
|
||||
*/
|
||||
const noNonTsImportsRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: { description: "Disallow importing modules other than .ts files" },
|
||||
schema: [],
|
||||
messages: {
|
||||
extension: "Only .ts modules may be imported.",
|
||||
attributes: "Import attributes are not allowed.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
function checkSource(source: TSESTree.StringLiteral): void {
|
||||
if (isNonTsSource(source.value)) {
|
||||
context.report({ node: source, messageId: "extension" });
|
||||
}
|
||||
}
|
||||
function checkDeclaration(
|
||||
node:
|
||||
| TSESTree.ImportDeclaration
|
||||
| TSESTree.ExportNamedDeclaration
|
||||
| TSESTree.ExportAllDeclaration,
|
||||
): void {
|
||||
if (node.source === null) {
|
||||
return;
|
||||
}
|
||||
checkSource(node.source);
|
||||
if (node.attributes.length > 0) {
|
||||
context.report({ node, messageId: "attributes" });
|
||||
}
|
||||
}
|
||||
return {
|
||||
ImportDeclaration: checkDeclaration,
|
||||
ExportNamedDeclaration: checkDeclaration,
|
||||
ExportAllDeclaration: checkDeclaration,
|
||||
ImportExpression(node: TSESTree.ImportExpression): void {
|
||||
if (node.source.type === "Literal" && typeof node.source.value === "string") {
|
||||
checkSource(node.source);
|
||||
}
|
||||
if (node.options !== null) {
|
||||
context.report({ node, messageId: "attributes" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* This repository's own rule list, spread by `vite.config.ts`: Deno's
|
||||
* `recommended` rules that sit outside the Oxlint categories, plus the
|
||||
* template's extras. Built-in Oxlint rule ids sit here too, so
|
||||
* `vite.config.ts` lists no rule by name.
|
||||
*/
|
||||
const rules: DummyRuleMap = {
|
||||
// The explicit-types template's `camelcase` stand-in: no `_` inside a name
|
||||
// once leading and trailing ones are trimmed, unless it has no lowercase
|
||||
// ASCII letter (UPPER_SNAKE).
|
||||
"id-match": [
|
||||
"error",
|
||||
"^_*[^_]*_*$|^[^a-z]*$",
|
||||
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true },
|
||||
],
|
||||
// Deno's `recommended` rules outside the categories.
|
||||
"no-array-constructor": "error",
|
||||
"no-case-declarations": "error",
|
||||
"no-empty": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-inner-declarations": "error",
|
||||
"no-prototype-builtins": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-var": "error",
|
||||
"prefer-const": "error",
|
||||
"require-await": "error",
|
||||
"require-yield": "error",
|
||||
"typescript/adjacent-overload-signatures": "error",
|
||||
"typescript/ban-ts-comment": "error",
|
||||
"typescript/no-empty-interface": "error",
|
||||
"typescript/no-empty-object-type": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-namespace": "error",
|
||||
"typescript/no-non-null-asserted-optional-chain": "error",
|
||||
"typescript/no-unsafe-function-type": "error",
|
||||
"typescript/no-wrapper-object-types": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/prefer-namespace-keyword": "error",
|
||||
"typescript/triple-slash-reference": "error",
|
||||
// Deno's `no-window`, `no-process-global`, and `no-node-globals`: on Node,
|
||||
// these come from an explicit `node:` import instead of the global scope.
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
"window",
|
||||
"process",
|
||||
"Buffer",
|
||||
"global",
|
||||
"setImmediate",
|
||||
"clearImmediate",
|
||||
],
|
||||
// The explicit-types template's extras.
|
||||
"import/no-cycle": "error",
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
"typescript/no-non-null-assertion": "error",
|
||||
// Deno's `ban-untagged-ignore` and `ban-unused-ignore`, made stricter: no
|
||||
// disable comments at all.
|
||||
"project/no-disable-comments": "error",
|
||||
"project/no-non-ts-imports": "error",
|
||||
"unicorn/no-abusive-eslint-disable": "error",
|
||||
// Pedantic, but Node's and the web's own types (`Response`, `Stats`,
|
||||
// `RequestInit`) are mutable, so no parameter of theirs can pass; the
|
||||
// explicit-types template and the app repos turn it off too.
|
||||
"typescript/prefer-readonly-parameter-types": "off",
|
||||
};
|
||||
|
||||
/**
|
||||
* The repo-local plugin, namespace `project`: its own rules, enabled as
|
||||
* `project/<rule>`, and `configs.project`, the rule list above.
|
||||
*/
|
||||
const plugin = {
|
||||
meta: { name: "project" },
|
||||
rules: {
|
||||
"no-disable-comments": noDisableCommentsRule,
|
||||
"no-non-ts-imports": noNonTsImportsRule,
|
||||
},
|
||||
configs: { project: { rules } },
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
|
||||
mkdir "${path}/src"
|
||||
cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/"
|
||||
# package.json makes Node load the TypeScript sources as ES modules.
|
||||
cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/"
|
||||
chmod -R go-w "${path}"
|
||||
printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}"
|
||||
@@ -0,0 +1,119 @@
|
||||
// Runs Claude Code for one profile and returns its final result text.
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import { isClaudeAuthFailure } from "./auth.ts";
|
||||
import type { ClaudeFailureFields } from "./auth.ts";
|
||||
import { exitCode } from "./child.ts";
|
||||
import { isObject } from "./json.ts";
|
||||
import type { JsonObject } from "./json.ts";
|
||||
import { AgentAuthError, agentEnvironment } from "./profiles.ts";
|
||||
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
// Claude Code's stream-json `result` event, in its own field names.
|
||||
type ClaudeResult = ClaudeFailureFields &
|
||||
Partial<Record<"is_error", boolean>> & {
|
||||
subtype: string;
|
||||
};
|
||||
|
||||
// Echoes stderr as it arrives and resolves with all of it.
|
||||
async function copyError(stream: Readable): Promise<string> {
|
||||
stream.setEncoding("utf8");
|
||||
let error = "";
|
||||
for await (const text of stream) {
|
||||
error += String(text);
|
||||
console.error(text);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
// Prints the visible text of each streamed message part.
|
||||
function printParts(event: JsonObject): void {
|
||||
const content = isObject(event.message) ? event.message.content : undefined;
|
||||
for (const part of Array.isArray(content) ? content : []) {
|
||||
if (isObject(part)) {
|
||||
const text = part.thinking ?? part.text ?? part.name;
|
||||
if (typeof text === "string" && text !== "") {
|
||||
console.log(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toResult(event: JsonObject): ClaudeResult {
|
||||
const { is_error: isError, result, subtype, api_error_status: apiErrorStatus } = event;
|
||||
return {
|
||||
...(typeof isError === "boolean" && { is_error: isError }),
|
||||
...(typeof result === "string" && { result }),
|
||||
subtype: String(subtype),
|
||||
...((typeof apiErrorStatus === "number" || apiErrorStatus === null) && {
|
||||
api_error_status: apiErrorStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Prints each streamed message part and returns the final `result` event.
|
||||
async function readEvents(stream: Readable): Promise<ClaudeResult | undefined> {
|
||||
let result: ClaudeResult | undefined;
|
||||
for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) {
|
||||
if (line !== "") {
|
||||
const event: unknown = JSON.parse(line);
|
||||
if (isObject(event)) {
|
||||
printParts(event);
|
||||
if (event.type === "result") {
|
||||
result = toResult(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function checkResult(result: ClaudeResult | undefined, error: string): string {
|
||||
if (result?.is_error === true) {
|
||||
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
|
||||
if (isClaudeAuthFailure(result)) {
|
||||
throw new AgentAuthError("claude sign-in failed", { cause: failure });
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
if (result?.result === undefined) {
|
||||
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
|
||||
}
|
||||
return result.result;
|
||||
}
|
||||
|
||||
export async function runClaude(
|
||||
profile: RuntimeProfile,
|
||||
token: string,
|
||||
prompt: string,
|
||||
): Promise<string> {
|
||||
const args = [
|
||||
"--print",
|
||||
"--dangerously-skip-permissions",
|
||||
"--model",
|
||||
profile.model,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
prompt,
|
||||
];
|
||||
const claude = spawn("claude", args, {
|
||||
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);
|
||||
const errorText = copyError(claude.stderr);
|
||||
const result = await readEvents(claude.stdout);
|
||||
const [code, error] = await Promise.all([status, errorText]);
|
||||
const body = checkResult(result, error);
|
||||
if (code !== 0) {
|
||||
throw new Error(error.trim() || `claude exited with ${code}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Signs Codex in for one profile, runs it, and returns its result file path.
|
||||
import { spawn } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import type { Readable } from "node:stream";
|
||||
import { finished } from "node:stream/promises";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
import { isCodexAuthFailure, retryInvalidToken } from "./auth.ts";
|
||||
import type { CodexAuthentication } from "./auth.ts";
|
||||
import { echo, exitCode } from "./child.ts";
|
||||
import { AgentAuthError, agentEnvironment, withProfile } from "./profiles.ts";
|
||||
import {
|
||||
makeProfileFile,
|
||||
postAuthorComment,
|
||||
PROCESS_ENV,
|
||||
runAgentCommand,
|
||||
WORKSPACE,
|
||||
} from "./run-context.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
interface CodexAttempt {
|
||||
code: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
async function collectText(stream: Readable, append: (chunk: string) => void): Promise<void> {
|
||||
stream.setEncoding("utf8");
|
||||
for await (const chunk of stream) {
|
||||
append(String(chunk));
|
||||
}
|
||||
}
|
||||
|
||||
// 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: Record<string, string>,
|
||||
): 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 append = (chunk: string): void => {
|
||||
shown += chunk;
|
||||
};
|
||||
const drained = Promise.all([
|
||||
collectText(login.stdout, append),
|
||||
collectText(login.stderr, append),
|
||||
]);
|
||||
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: Record<string, string>,
|
||||
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);
|
||||
let error = "";
|
||||
const [code] = await Promise.all([
|
||||
status,
|
||||
collectText(login.stderr, (chunk) => {
|
||||
error += chunk;
|
||||
}),
|
||||
]);
|
||||
if (code !== 0) {
|
||||
throw new Error(error.trim() || `codex login exited with ${code}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function signIn(
|
||||
profile: RuntimeProfile,
|
||||
environment: Record<string, string>,
|
||||
authentication: CodexAuthentication,
|
||||
): Promise<void> {
|
||||
if (authentication.kind === "api-key") {
|
||||
await codexApiLogin(profile, environment, authentication.token);
|
||||
return;
|
||||
}
|
||||
const 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);
|
||||
}
|
||||
}
|
||||
|
||||
async function execCodex(
|
||||
profile: RuntimeProfile,
|
||||
environment: Record<string, string>,
|
||||
prompt: string,
|
||||
outputPath: string,
|
||||
): Promise<CodexAttempt> {
|
||||
const args = [
|
||||
"exec",
|
||||
"--model",
|
||||
profile.model,
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--output-last-message",
|
||||
outputPath,
|
||||
prompt,
|
||||
];
|
||||
const codex = spawn("codex", args, {
|
||||
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 text = String(chunk);
|
||||
await echo(process.stderr, text);
|
||||
error += text;
|
||||
}
|
||||
// Codex echoes the prompt, whose comments may quote auth errors.
|
||||
return { code: await status, error: error.replaceAll(prompt, "") };
|
||||
}
|
||||
|
||||
export async function runCodex(
|
||||
profile: RuntimeProfile,
|
||||
prompt: string,
|
||||
authentication: CodexAuthentication,
|
||||
): Promise<string> {
|
||||
const token = authentication.kind === "api-key" ? authentication.token : undefined;
|
||||
const environment = agentEnvironment(PROCESS_ENV, profile, token);
|
||||
await signIn(profile, environment, authentication);
|
||||
const outputPath = await makeProfileFile(profile, "result.md");
|
||||
const attempt = (): Promise<CodexAttempt> => execCodex(profile, environment, prompt, outputPath);
|
||||
const result =
|
||||
authentication.kind === "api-key"
|
||||
? await attempt()
|
||||
: await retryInvalidToken(attempt, async () => {
|
||||
await runAgentCommand(profile, "codex", ["logout"]);
|
||||
await codexDeviceLogin(profile, environment);
|
||||
});
|
||||
if (result.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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Runs OpenCode for one profile and returns its final visible text.
|
||||
import { spawn } from "node:child_process";
|
||||
import process from "node:process";
|
||||
|
||||
import { echo, exitCode } from "./child.ts";
|
||||
import {
|
||||
collectOpenCodeOutput,
|
||||
openCodeArgs,
|
||||
openCodeError,
|
||||
parseOpenCodeEvents,
|
||||
} from "./opencode.ts";
|
||||
import { agentEnvironment } from "./profiles.ts";
|
||||
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
export async function runOpenCode(
|
||||
profile: RuntimeProfile,
|
||||
token: string,
|
||||
prompt: string,
|
||||
): Promise<string> {
|
||||
const child = 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);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Dispatches one attempt to the agent CLI its profile names.
|
||||
import { runClaude } from "./agent-claude.ts";
|
||||
import { runCodex } from "./agent-codex.ts";
|
||||
import { runOpenCode } from "./agent-opencode.ts";
|
||||
import { codexAuthentication } from "./auth.ts";
|
||||
import type { CodexAuthentication } from "./auth.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
export interface AgentAnswer {
|
||||
body?: string;
|
||||
resultPath?: string;
|
||||
}
|
||||
|
||||
// Runs the selected agent; Codex leaves its answer in a file, the others return it.
|
||||
export async function runAgent(
|
||||
profile: RuntimeProfile,
|
||||
token: string | undefined,
|
||||
prompt: string,
|
||||
authentication: CodexAuthentication | null,
|
||||
): Promise<AgentAnswer> {
|
||||
if (profile.type === "claude") {
|
||||
return { body: await runClaude(profile, token ?? "", prompt) };
|
||||
}
|
||||
if (profile.type === "codex") {
|
||||
return {
|
||||
resultPath: await runCodex(profile, prompt, authentication ?? codexAuthentication(token)),
|
||||
};
|
||||
}
|
||||
return { body: await runOpenCode(profile, token ?? "", prompt) };
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
codexAuthentication,
|
||||
isClaudeAuthFailure,
|
||||
isCodexAuthFailure,
|
||||
persistCodexAuth,
|
||||
retryInvalidToken,
|
||||
stageCodexAuth,
|
||||
} from "./auth.ts";
|
||||
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
|
||||
|
||||
test("retries once after an invalid refresh token", async () => {
|
||||
let attempts = 0;
|
||||
let relogins = 0;
|
||||
const result = await retryInvalidToken(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
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({
|
||||
api_error_status: 401,
|
||||
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isClaudeAuthFailure({
|
||||
result: "OAuth token has expired. Please obtain a new token or refresh your existing token.",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" })).toBe(false);
|
||||
});
|
||||
|
||||
test("selects API-key login only for a configured Codex token", () => {
|
||||
expect(codexAuthentication("selected-key")).toStrictEqual({
|
||||
kind: "api-key",
|
||||
token: "selected-key",
|
||||
});
|
||||
expect(codexAuthentication()).toStrictEqual({ kind: "persistent" });
|
||||
});
|
||||
|
||||
const AUTH_TOKENS = {
|
||||
access_token: "access",
|
||||
refresh_token: "refresh",
|
||||
id_token: "id",
|
||||
account_id: "account",
|
||||
};
|
||||
const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS });
|
||||
|
||||
test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login", async () => {
|
||||
const root = await makeTempDir();
|
||||
try {
|
||||
const canonical = `${root}/persistent/auth.json`;
|
||||
await mkdir(`${root}/persistent`);
|
||||
await writeFile(canonical, AUTH, { mode: 0o600 });
|
||||
const home = `${root}/profile`;
|
||||
await mkdir(home);
|
||||
|
||||
const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid());
|
||||
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH);
|
||||
expect((await stat(staged)).mode & 0o777).toBe(0o600);
|
||||
|
||||
const refreshed = JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: { ...AUTH_TOKENS, access_token: "refreshed" },
|
||||
});
|
||||
await writeFile(staged, refreshed);
|
||||
await persistCodexAuth(staged, canonical);
|
||||
expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed);
|
||||
expect((await stat(canonical)).mode & 0o777).toBe(0o600);
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () => {
|
||||
const root = await makeTempDir();
|
||||
try {
|
||||
const canonical = `${root}/auth.json`;
|
||||
const malformed = `${root}/malformed.json`;
|
||||
const linked = `${root}/linked.json`;
|
||||
await writeFile(canonical, AUTH, { mode: 0o600 });
|
||||
await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 });
|
||||
await symlink(canonical, linked);
|
||||
|
||||
await expect(() => persistCodexAuth(linked, canonical)).rejects.toThrow("regular file");
|
||||
await expect(() => persistCodexAuth(malformed, canonical)).rejects.toThrow(
|
||||
"invalid Codex login",
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
chmod,
|
||||
chown,
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import { isObject } from "./json.ts";
|
||||
|
||||
export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" };
|
||||
|
||||
export function codexAuthentication(token?: string): CodexAuthentication {
|
||||
return token === undefined ? { kind: "persistent" } : { kind: "api-key", token };
|
||||
}
|
||||
|
||||
// Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys
|
||||
// on its own ERROR lines; tool output and the echoed prompt must not count.
|
||||
const CODEX_AUTH_FAILURE =
|
||||
/^(?:\S+\s+)?ERROR\b.*(?:\b401\b|unauthorized|invalid_refresh_token|refresh_token_(?:expired|reused|invalidated)|token_(?:revoked|expired)|invalid_api_key|(?:log|sign) in again)/imu;
|
||||
|
||||
export function isCodexAuthFailure(error: string): boolean {
|
||||
return CODEX_AUTH_FAILURE.test(error);
|
||||
}
|
||||
|
||||
// Claude Code reports a rejected CLAUDE_CODE_OAUTH_TOKEN as an is_error
|
||||
// stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth
|
||||
// access token is invalid." with api_error_status 401.
|
||||
// The fields of Claude Code's stream-json `result` event that tell a sign-in
|
||||
// failure apart, under their own names.
|
||||
export type ClaudeFailureFields = Partial<Record<"api_error_status", number | null>> & {
|
||||
result?: string;
|
||||
};
|
||||
|
||||
export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
|
||||
return (
|
||||
result.api_error_status === 401 ||
|
||||
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test(
|
||||
result.result ?? "",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function retryInvalidToken<T extends { code: number; error: string }>(
|
||||
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;
|
||||
}
|
||||
|
||||
function hasCodexTokens(tokens: unknown): boolean {
|
||||
return (
|
||||
isObject(tokens) &&
|
||||
["access_token", "refresh_token", "id_token", "account_id"].every(
|
||||
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function readCodexAuth(path: string): Promise<string> {
|
||||
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");
|
||||
}
|
||||
if (
|
||||
!isObject(value) ||
|
||||
typeof value.auth_mode !== "string" ||
|
||||
value.auth_mode === "" ||
|
||||
!hasCodexTokens(value.tokens)
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
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);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts";
|
||||
|
||||
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
||||
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
|
||||
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32);
|
||||
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
||||
|
||||
test("decrypts a known AES-256-GCM credential", async () => {
|
||||
expect(await decryptCredential(ENCRYPTED, MASTER_KEY)).toBe("secret-token");
|
||||
});
|
||||
|
||||
test("encrypts a credential with a fresh supplied nonce", async () => {
|
||||
expect(await encryptCredential("secret-token", MASTER_KEY, NONCE)).toStrictEqual(ENCRYPTED);
|
||||
});
|
||||
|
||||
test("rejects a credential encrypted by another master key", async () => {
|
||||
await expect(() => decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("rejects a credential with a nonstandard nonce", async () => {
|
||||
await expect(() =>
|
||||
decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY),
|
||||
).rejects.toThrow("nonce must be 12 bytes");
|
||||
});
|
||||
|
||||
test("decrypts the central reviewer token and ordered profiles", async () => {
|
||||
expect(
|
||||
await parseCredentials(
|
||||
JSON.stringify({
|
||||
"reviewer-token": ENCRYPTED,
|
||||
profiles: [
|
||||
{ type: "codex", model: "gpt-custom" },
|
||||
{ type: "claude", token: ENCRYPTED },
|
||||
{ type: "opencode", token: ENCRYPTED },
|
||||
],
|
||||
}),
|
||||
MASTER_KEY,
|
||||
),
|
||||
).toStrictEqual({
|
||||
reviewerToken: "secret-token",
|
||||
profiles: [
|
||||
{ type: "codex", model: "gpt-custom" },
|
||||
{
|
||||
type: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
token: "secret-token",
|
||||
},
|
||||
{
|
||||
type: "opencode",
|
||||
model: "openrouter/qwen/qwen3.8-27b",
|
||||
token: "secret-token",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects malformed central credentials", async () => {
|
||||
await expect(() => parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow(
|
||||
"reviewer-token",
|
||||
);
|
||||
await expect(() =>
|
||||
parseCredentials(
|
||||
JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }),
|
||||
MASTER_KEY,
|
||||
),
|
||||
).rejects.toThrow("profiles[0].token");
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
import { isMapping } from "./json.ts";
|
||||
import type { BotProfile } from "./profiles.ts";
|
||||
|
||||
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
|
||||
claude: "claude-sonnet-5",
|
||||
codex: "gpt-5.6-terra",
|
||||
opencode: "openrouter/qwen/qwen3.8-27b",
|
||||
};
|
||||
|
||||
export interface BotCredentials {
|
||||
reviewerToken: string;
|
||||
profiles: BotProfile[];
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): Uint8Array {
|
||||
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
|
||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
||||
return Uint8Array.from(atob(padded), (character) => character.codePointAt(0) ?? 0);
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: Uint8Array): string {
|
||||
const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join("");
|
||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||
}
|
||||
|
||||
const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer;
|
||||
|
||||
function importMasterKey(encoded: string, usage: webcrypto.KeyUsage): Promise<webcrypto.CryptoKey> {
|
||||
const bytes = decodeBase64Url(encoded);
|
||||
if (bytes.length !== 32) {
|
||||
throw new Error("master-key must be 32 bytes encoded as base64url");
|
||||
}
|
||||
return webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]);
|
||||
}
|
||||
|
||||
export async function decryptCredential(encrypted: string, masterKey: string): Promise<string> {
|
||||
const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = encrypted.split(":");
|
||||
if (version !== "v1" || encodedNonce === "" || encodedCiphertext === "" || extra !== "") {
|
||||
throw new Error("credential must use v1:<nonce>:<ciphertext>");
|
||||
}
|
||||
const nonce = decodeBase64Url(encodedNonce);
|
||||
if (nonce.length !== 12) throw new Error("credential nonce must be 12 bytes");
|
||||
const plaintext = await webcrypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: buffer(nonce) },
|
||||
await importMasterKey(masterKey, "decrypt"),
|
||||
buffer(decodeBase64Url(encodedCiphertext)),
|
||||
);
|
||||
return new TextDecoder().decode(plaintext);
|
||||
}
|
||||
|
||||
export async function encryptCredential(
|
||||
plaintext: string,
|
||||
masterKey: string,
|
||||
nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)),
|
||||
): Promise<string> {
|
||||
if (nonce.length !== 12) throw new Error("AES-GCM nonce must be 12 bytes");
|
||||
const ciphertext = await webcrypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: buffer(nonce) },
|
||||
await importMasterKey(masterKey, "encrypt"),
|
||||
new TextEncoder().encode(plaintext),
|
||||
);
|
||||
return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`;
|
||||
}
|
||||
|
||||
async function parseProfile(entry: unknown, index: number, masterKey: string): Promise<BotProfile> {
|
||||
const at = `profiles[${index}]`;
|
||||
if (!isMapping(entry)) {
|
||||
throw new TypeError(`${at}: expected a mapping`);
|
||||
}
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (key !== "type" && key !== "model" && key !== "token") {
|
||||
throw new Error(`${at}.${key}: unknown field`);
|
||||
}
|
||||
}
|
||||
const { type, model, token } = entry;
|
||||
if (type !== "claude" && type !== "codex" && type !== "opencode") {
|
||||
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`);
|
||||
}
|
||||
if (model !== undefined && (typeof model !== "string" || model === "")) {
|
||||
throw new Error(`${at}.model: expected a model name`);
|
||||
}
|
||||
if ((type === "claude" || type === "opencode") && typeof token !== "string") {
|
||||
throw new Error(`${at}.token: expected an encrypted credential`);
|
||||
}
|
||||
if (token !== undefined && typeof token !== "string") {
|
||||
throw new Error(`${at}.token: expected an encrypted credential`);
|
||||
}
|
||||
return {
|
||||
type,
|
||||
model: model ?? DEFAULT_MODELS[type],
|
||||
...(typeof token === "string" && {
|
||||
token: await decryptCredential(token, masterKey),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> {
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (!isMapping(value)) {
|
||||
throw new TypeError("credentials must be a mapping");
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (key !== "reviewer-token" && key !== "profiles") {
|
||||
throw new Error(`${key}: unknown field`);
|
||||
}
|
||||
}
|
||||
const { "reviewer-token": reviewerToken, profiles } = value;
|
||||
if (typeof reviewerToken !== "string") {
|
||||
throw new TypeError("reviewer-token: expected an encrypted credential");
|
||||
}
|
||||
if (!Array.isArray(profiles) || profiles.length === 0) {
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
}
|
||||
const parsed = await Promise.all(
|
||||
profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)),
|
||||
);
|
||||
return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed };
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { SpawnSyncReturns } from "node:child_process";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "./testing.ts";
|
||||
|
||||
const profile = (uid: number, home: string): RuntimeProfile => ({
|
||||
index: uid - 20_000,
|
||||
type: "claude",
|
||||
model: "test",
|
||||
uid,
|
||||
gid: uid,
|
||||
home,
|
||||
tokenFile: null,
|
||||
});
|
||||
|
||||
// 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?: Record<string, string>,
|
||||
): 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);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { lstat, readdir, readFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function killProcess(pid: number): void {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kills the UID's processes every 10 ms until none is left, for up to 100 rounds.
|
||||
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) {
|
||||
killProcess(pid);
|
||||
}
|
||||
await sleep(10);
|
||||
await killProfileProcesses(uid, attempt + 1);
|
||||
}
|
||||
|
||||
export async function readProfileFile(profile: RuntimeProfile, path: string): Promise<string> {
|
||||
const stat = await lstat(path);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== profile.uid ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new Error(`${path}: expected a profile-owned regular private file`);
|
||||
}
|
||||
return readFile(path, "utf8");
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// Type guards for values read from JSON, which arrive as `unknown`.
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
// Any non-null object, arrays included: reading a missing key yields undefined.
|
||||
export function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
// A plain mapping, which an array is not.
|
||||
export function isMapping(value: unknown): value is JsonObject {
|
||||
return isObject(value) && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { assert, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
collectOpenCodeOutput,
|
||||
openCodeArgs,
|
||||
openCodeError,
|
||||
parseOpenCodeEvents,
|
||||
} from "./opencode.ts";
|
||||
import { AgentAuthError } from "./profiles.ts";
|
||||
|
||||
test("builds a non-interactive auto-approved OpenCode command", () => {
|
||||
expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([
|
||||
"run",
|
||||
"--format",
|
||||
"json",
|
||||
"--auto",
|
||||
"--model",
|
||||
"openrouter/qwen/qwen3.8-27b",
|
||||
"Review this",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns the final visible OpenCode text event", () => {
|
||||
expect(
|
||||
parseOpenCodeEvents(
|
||||
[
|
||||
JSON.stringify({ type: "step_start", part: { type: "step-start" } }),
|
||||
JSON.stringify({ type: "text", part: { type: "text", text: "draft" } }),
|
||||
JSON.stringify({ type: "text", part: { type: "text", text: "done" } }),
|
||||
JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }),
|
||||
].join("\n"),
|
||||
),
|
||||
).toBe("done");
|
||||
});
|
||||
|
||||
test("rejects a successful OpenCode run without visible text", async () => {
|
||||
await expect(async () => {
|
||||
parseOpenCodeEvents('{"type":"step_start"}');
|
||||
await Promise.resolve();
|
||||
}).rejects.toThrow("no text output");
|
||||
});
|
||||
|
||||
const encode = (value: string): Uint8Array => new TextEncoder().encode(value);
|
||||
|
||||
test("streams OpenCode output while collecting it", async () => {
|
||||
const stdout = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encode("first\n"));
|
||||
controller.enqueue(encode("second\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const stderr = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encode("warning\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const shown: string[] = [];
|
||||
|
||||
const result = await collectOpenCodeOutput(
|
||||
stdout,
|
||||
stderr,
|
||||
(chunk: Uint8Array) => {
|
||||
shown.push(`out:${new TextDecoder().decode(chunk)}`);
|
||||
},
|
||||
(chunk: Uint8Array) => {
|
||||
shown.push(`err:${new TextDecoder().decode(chunk)}`);
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual({ stdout: "first\nsecond\n", stderr: "warning\n" });
|
||||
expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]);
|
||||
});
|
||||
|
||||
const openRouterError = (statusCode: number, message: string): string =>
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
sessionID: "ses_test",
|
||||
error: {
|
||||
name: "APIError",
|
||||
data: {
|
||||
message,
|
||||
statusCode,
|
||||
isRetryable: false,
|
||||
responseBody: JSON.stringify({ error: { message, code: statusCode } }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test("reports a rejected OpenRouter key as a sign-in failure", () => {
|
||||
const error = openCodeError(openRouterError(401, "User not found."));
|
||||
assert.instanceOf(error, AgentAuthError);
|
||||
expect(error.message).toBe("opencode sign-in failed");
|
||||
assert.instanceOf(error.cause, Error);
|
||||
expect(error.cause.message).toBe("opencode APIError (API error 401): User not found.");
|
||||
});
|
||||
|
||||
test("surfaces an OpenCode error event instead of empty output", () => {
|
||||
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow(
|
||||
"API error 429",
|
||||
);
|
||||
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBe(undefined);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { isObject } from "./json.ts";
|
||||
import { AgentAuthError } from "./profiles.ts";
|
||||
|
||||
export function openCodeArgs(model: string, prompt: string): string[] {
|
||||
return ["run", "--format", "json", "--auto", "--model", model, prompt];
|
||||
}
|
||||
|
||||
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
|
||||
|
||||
export async function collectOpenCodeOutput(
|
||||
stdout: AsyncIterable<Uint8Array>,
|
||||
stderr: AsyncIterable<Uint8Array>,
|
||||
writeStdout: ChunkWriter,
|
||||
writeStderr: ChunkWriter,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const collect = async (stream: AsyncIterable<Uint8Array>, write: ChunkWriter) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
await write(chunk);
|
||||
}
|
||||
const length = chunks.reduce((total, chunk) => total + chunk.length, 0);
|
||||
const joined = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
joined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return new TextDecoder().decode(joined);
|
||||
};
|
||||
const [output, error] = await Promise.all([
|
||||
collect(stdout, writeStdout),
|
||||
collect(stderr, writeStderr),
|
||||
]);
|
||||
return { stdout: output, stderr: error };
|
||||
}
|
||||
|
||||
// OpenCode reports provider failures, including a rejected OpenRouter key
|
||||
// (401 "User not found."), as a JSON error event on stdout and exits 1 with an
|
||||
// empty stderr.
|
||||
export function openCodeError(output: string): Error | undefined {
|
||||
let failure: Error | undefined;
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.trim() === "") continue;
|
||||
let event: unknown;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isObject(event)) continue;
|
||||
const { type, error } = event;
|
||||
if (type !== "error" || !isObject(error)) {
|
||||
continue;
|
||||
}
|
||||
const { name, data } = error;
|
||||
const { message, statusCode } = isObject(data) ? data : {};
|
||||
const detail = `opencode ${typeof name === "string" ? name : "error"}${
|
||||
typeof statusCode === "number" ? ` (API error ${statusCode})` : ""
|
||||
}: ${typeof message === "string" ? message : "unknown error"}`;
|
||||
failure =
|
||||
statusCode === 401 || statusCode === 403
|
||||
? new AgentAuthError("opencode sign-in failed", {
|
||||
cause: new Error(detail),
|
||||
})
|
||||
: new Error(detail);
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
|
||||
export function parseOpenCodeEvents(output: string): string {
|
||||
let result: string | undefined;
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.trim() === "") continue;
|
||||
const event: unknown = JSON.parse(line);
|
||||
if (!isObject(event)) continue;
|
||||
const { type, part } = event;
|
||||
if (type !== "text" || !isObject(part)) continue;
|
||||
const { text } = part;
|
||||
if (typeof text === "string" && text.trim() !== "") result = text;
|
||||
}
|
||||
if (result === undefined) {
|
||||
throw openCodeError(output) ?? new Error("OpenCode produced no text output");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Reads what the agent phase left behind: its outcome, and the review and
|
||||
// anchors files it wrote into a profile home.
|
||||
import type { Stats } from "node:fs";
|
||||
import { lstat, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import { isObject } from "./json.ts";
|
||||
import { isFailureReason, isProfileType } from "./profiles.ts";
|
||||
import type { ProfileFailure } from "./profiles.ts";
|
||||
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
||||
|
||||
export interface Anchor {
|
||||
path: string;
|
||||
line: number;
|
||||
side: "new" | "old";
|
||||
body: string;
|
||||
}
|
||||
|
||||
function parseAnchor(entry: unknown, at: string): Anchor {
|
||||
if (!isObject(entry)) {
|
||||
throw new TypeError(`${at}: not an object`);
|
||||
}
|
||||
const { path, line, side = "new", body } = entry;
|
||||
if (typeof path !== "string" || path === "") {
|
||||
throw new TypeError(`${at}.path: expected a repository path`);
|
||||
}
|
||||
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
|
||||
throw new TypeError(`${at}.line: expected a line number`);
|
||||
}
|
||||
if (side !== "new" && side !== "old") {
|
||||
throw new Error(`${at}.side: expected "new" or "old"`);
|
||||
}
|
||||
if (typeof body !== "string" || body.trim() === "") {
|
||||
throw new TypeError(`${at}.body: expected the finding`);
|
||||
}
|
||||
return { path, line, side, body };
|
||||
}
|
||||
|
||||
export function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||
const entries: unknown = JSON.parse(text);
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new TypeError(`${sourcePath}: not an array`);
|
||||
}
|
||||
return entries.map((entry: unknown, index) => parseAnchor(entry, `${sourcePath}[${index}]`));
|
||||
}
|
||||
|
||||
// Only known labels reach the comment, never raw agent output.
|
||||
function knownFailures(failures: unknown): ProfileFailure[] {
|
||||
return (Array.isArray(failures) ? failures : []).flatMap((entry: unknown) => {
|
||||
if (!isObject(entry)) {
|
||||
return [];
|
||||
}
|
||||
const { type, model, reason } = entry;
|
||||
return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
|
||||
? [{ type, model, reason }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function readOutcome(path: string): Promise<RuntimeOutcome> {
|
||||
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||
if (!isObject(value) || !("ok" in value)) {
|
||||
throw new Error("invalid agent outcome");
|
||||
}
|
||||
if (value.ok === false) {
|
||||
return { ok: false, failures: knownFailures(value.failures) };
|
||||
}
|
||||
const { ok, body, reviewPath, anchorsPath, profile } = value;
|
||||
if (
|
||||
ok !== true ||
|
||||
typeof body !== "string" ||
|
||||
typeof reviewPath !== "string" ||
|
||||
typeof anchorsPath !== "string" ||
|
||||
!isObject(profile) ||
|
||||
!isProfileType(profile.type) ||
|
||||
typeof profile.model !== "string"
|
||||
) {
|
||||
throw new Error("invalid agent outcome");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
body,
|
||||
reviewPath,
|
||||
anchorsPath,
|
||||
profile: { type: profile.type, model: profile.model },
|
||||
};
|
||||
}
|
||||
|
||||
export async function readAgentArtifact(
|
||||
manifest: RuntimeManifest,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
|
||||
if (profile === undefined) throw new Error(`${name}: outside profile home`);
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await lstat(path);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== profile.uid ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new Error(`${name}: expected a profile-owned regular private file`);
|
||||
}
|
||||
const text = await readFile(path, "utf8");
|
||||
return text === "" ? null : text;
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
|
||||
import { assert, expect, test } from "vite-plus/test";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import { postRuntime } from "./post.ts";
|
||||
import type { PostContext } from "./post.ts";
|
||||
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
||||
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
|
||||
|
||||
const context: PostContext = {
|
||||
apiUrl: "https://gitea.example/api/v1",
|
||||
repository: "owner/repo",
|
||||
issueIndex: "7",
|
||||
eventName: "issue_comment",
|
||||
};
|
||||
|
||||
interface Fixture {
|
||||
manifestPath: string;
|
||||
runtimeDir: string;
|
||||
reviewerFile: string;
|
||||
reviewPath: string;
|
||||
outcomeFile: string;
|
||||
}
|
||||
|
||||
type FixtureSource = Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string };
|
||||
|
||||
async function writePrivate(path: string, text: string): Promise<void> {
|
||||
await writeFile(path, text, { mode: 0o600 });
|
||||
await chmod(path, 0o600);
|
||||
}
|
||||
|
||||
async function writeArtifacts(
|
||||
outcome: RuntimeOutcome,
|
||||
review: string | undefined,
|
||||
anchors: unknown,
|
||||
): Promise<void> {
|
||||
if (!outcome.ok) return;
|
||||
if (review !== undefined) await writePrivate(outcome.reviewPath, review);
|
||||
if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
|
||||
}
|
||||
|
||||
function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
|
||||
return {
|
||||
runtimeDir,
|
||||
manifestPath: `${runtimeDir}/manifest.json`,
|
||||
reviewerFile: `${runtimeDir}/reviewer-token`,
|
||||
outcomeFile: `${runtimeDir}/outcome.json`,
|
||||
profiles: [
|
||||
{
|
||||
index: 0,
|
||||
type: "claude",
|
||||
model: "test",
|
||||
uid: currentUid(),
|
||||
gid: currentGid(),
|
||||
home: profileHome,
|
||||
tokenFile: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(
|
||||
source: FixtureSource,
|
||||
review?: string,
|
||||
anchors?: unknown,
|
||||
): Promise<Fixture> {
|
||||
const runtimeDir = await makeTempDir();
|
||||
const profileHome = `${runtimeDir}/profile-0`;
|
||||
const manifest = manifestFor(runtimeDir, profileHome);
|
||||
await mkdir(profileHome);
|
||||
await writeFile(manifest.reviewerFile, "reviewer-secret");
|
||||
const outcome: RuntimeOutcome = source.ok
|
||||
? {
|
||||
...source,
|
||||
profile: { type: "codex", model: "gpt-test" },
|
||||
reviewPath: `${profileHome}/review.md`,
|
||||
anchorsPath: `${profileHome}/anchors.json`,
|
||||
}
|
||||
: source;
|
||||
await writeFile(manifest.outcomeFile, JSON.stringify(outcome));
|
||||
await writeArtifacts(outcome, review, anchors);
|
||||
await writeFile(manifest.manifestPath, JSON.stringify(manifest));
|
||||
const { manifestPath, reviewerFile, outcomeFile } = manifest;
|
||||
return {
|
||||
manifestPath,
|
||||
runtimeDir,
|
||||
reviewerFile,
|
||||
reviewPath: `${profileHome}/review.md`,
|
||||
outcomeFile,
|
||||
};
|
||||
}
|
||||
|
||||
const missing = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await stat(path);
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return true;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// The JSON body of one request the fake Gitea received.
|
||||
function requestBody(init?: RequestInit): unknown {
|
||||
return typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
|
||||
}
|
||||
|
||||
// A fake Gitea that records each request body and answers with `status`.
|
||||
function recordingFetch(bodies: unknown[], status = 201): typeof fetch {
|
||||
return (_url, init) => {
|
||||
bodies.push(requestBody(init));
|
||||
return Promise.resolve(new Response(null, { status }));
|
||||
};
|
||||
}
|
||||
|
||||
test("posts a successful issue result and removes runtime credentials", async () => {
|
||||
const result = await fixture({ ok: true, body: "done" });
|
||||
const requests: { url: unknown; init: RequestInit | undefined }[] = [];
|
||||
|
||||
await postRuntime(result.manifestPath, context, (url, init) => {
|
||||
requests.push({ url, init });
|
||||
return Promise.resolve(new Response(null, { status: 201 }));
|
||||
});
|
||||
|
||||
expect(requests.length).toBe(1);
|
||||
const [request] = requests;
|
||||
expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`);
|
||||
expect(requestBody(request?.init)).toStrictEqual({
|
||||
body: "done\n\n> Profile: `codex` · `gpt-test`",
|
||||
});
|
||||
expect(request?.init?.headers).toStrictEqual({
|
||||
Authorization: "token reviewer-secret",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
test("retries a rejected inline review without anchors", async () => {
|
||||
const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [
|
||||
{ path: "run.ts", line: 1, side: "new", body: "Finding" },
|
||||
]);
|
||||
const bodies: unknown[] = [];
|
||||
|
||||
await postRuntime(
|
||||
result.manifestPath,
|
||||
{
|
||||
...context,
|
||||
eventName: "pull_request",
|
||||
},
|
||||
(_url, init) => {
|
||||
bodies.push(requestBody(init));
|
||||
return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 }));
|
||||
},
|
||||
);
|
||||
|
||||
expect(bodies[0]).toMatchObject({
|
||||
comments: [{ body: "Finding\n\n> Profile: `codex` · `gpt-test`" }],
|
||||
});
|
||||
expect(bodies[1]).toMatchObject({
|
||||
comments: [],
|
||||
body: "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
|
||||
});
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
test("reports each tried profile and keeps the action failed", async () => {
|
||||
const result = await fixture({
|
||||
ok: false,
|
||||
failures: [
|
||||
{ type: "codex", model: "gpt-test", reason: "sign-in failed" },
|
||||
{ type: "claude", model: "claude-test", reason: "usage exhausted" },
|
||||
{ type: "opencode", model: "openrouter/test", reason: "failed" },
|
||||
],
|
||||
});
|
||||
const bodies: unknown[] = [];
|
||||
|
||||
await expect(() =>
|
||||
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
|
||||
).rejects.toThrow("agent phase failed");
|
||||
|
||||
expect(bodies).toStrictEqual([
|
||||
{
|
||||
body:
|
||||
"AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " +
|
||||
"`claude` · `claude-test` (usage exhausted), " +
|
||||
"`opencode` · `openrouter/test` (failed)",
|
||||
},
|
||||
]);
|
||||
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
test("drops failure entries that are not known labels", async () => {
|
||||
const result = await fixture({ ok: false, failures: [] });
|
||||
await writeFile(
|
||||
result.outcomeFile,
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
failures: [
|
||||
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" },
|
||||
{ type: "shell", model: "x", reason: "failed" },
|
||||
{ type: "claude", model: "claude-test", reason: "sign-in failed" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const bodies: unknown[] = [];
|
||||
|
||||
await expect(() =>
|
||||
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
|
||||
).rejects.toThrow("agent phase failed");
|
||||
|
||||
expect(bodies).toStrictEqual([
|
||||
{
|
||||
body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports a generic failure when the runner wrote no outcome", async () => {
|
||||
const result = await fixture({ ok: false, failures: [] });
|
||||
await rm(result.outcomeFile);
|
||||
const bodies: unknown[] = [];
|
||||
|
||||
await expect(() =>
|
||||
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
|
||||
).rejects.toThrow("agent phase failed");
|
||||
|
||||
expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]);
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
test("removes runtime credentials when posting fails", async () => {
|
||||
const result = await fixture({ ok: true, body: "done" });
|
||||
|
||||
await expect(() =>
|
||||
postRuntime(result.manifestPath, context, () =>
|
||||
Promise.resolve(new Response("failed", { status: 500 })),
|
||||
),
|
||||
).rejects.toThrow("500");
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
test("preserves a Gitea transport failure without retrying a comment", async () => {
|
||||
const result = await fixture({ ok: true, body: "done" });
|
||||
let requests = 0;
|
||||
|
||||
await expect(() =>
|
||||
postRuntime(result.manifestPath, context, () => {
|
||||
requests += 1;
|
||||
return Promise.reject(new Error(`network failure ${requests}`));
|
||||
}),
|
||||
).rejects.toThrow("network failure 1");
|
||||
|
||||
expect(requests).toBe(1);
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () => {
|
||||
const result = await fixture({ ok: true, body: "done" });
|
||||
await symlink(result.reviewerFile, result.reviewPath);
|
||||
let requests = 0;
|
||||
|
||||
await expect(() =>
|
||||
postRuntime(result.manifestPath, context, () => {
|
||||
requests += 1;
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
}),
|
||||
).rejects.toThrow("regular private file");
|
||||
|
||||
expect(requests).toBe(1);
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
test("reports an attributed failure when a pull request review is missing", async () => {
|
||||
const result = await fixture({ ok: true, body: "done" });
|
||||
const bodies: unknown[] = [];
|
||||
|
||||
await expect(() =>
|
||||
postRuntime(
|
||||
result.manifestPath,
|
||||
{
|
||||
...context,
|
||||
eventName: "pull_request",
|
||||
},
|
||||
recordingFetch(bodies),
|
||||
),
|
||||
).rejects.toThrow("did not write");
|
||||
|
||||
expect(bodies).toStrictEqual([
|
||||
{
|
||||
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
|
||||
},
|
||||
]);
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import { parseAnchors, readAgentArtifact, readOutcome } from "./post-artifacts.ts";
|
||||
import type { Anchor } from "./post-artifacts.ts";
|
||||
import { failureFooter, withProfile } from "./profiles.ts";
|
||||
import type { ProfileFailure } from "./profiles.ts";
|
||||
import { readRuntimeManifest } from "./runtime.ts";
|
||||
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
||||
|
||||
export interface PostContext {
|
||||
apiUrl: string;
|
||||
repository: string;
|
||||
issueIndex: string;
|
||||
eventName: string;
|
||||
}
|
||||
|
||||
type SuccessOutcome = Extract<RuntimeOutcome, { ok: true }>;
|
||||
|
||||
type GiteaRequest = (path: string, body: unknown) => Promise<void>;
|
||||
|
||||
// The reviewer's view of one issue or pull request.
|
||||
interface Publisher {
|
||||
context: PostContext;
|
||||
request: GiteaRequest;
|
||||
postComment(body: string): Promise<void>;
|
||||
}
|
||||
|
||||
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
|
||||
const stripAnsi = (text: string): string => text.replace(ANSI, "");
|
||||
const VERDICTS: Record<string, [event: string, mark: string]> = {
|
||||
Approved: ["APPROVED", "✅"],
|
||||
"Changes requested": ["REQUEST_CHANGES", "🛑"],
|
||||
};
|
||||
const failureComment = (failures: ProfileFailure[]): string =>
|
||||
`AI bot failed.\n\n${failureFooter(failures)}`;
|
||||
|
||||
class GiteaRequestError extends Error {}
|
||||
|
||||
function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest {
|
||||
return async (path, body) => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(`${apiUrl}/${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${reviewerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function readSuccess(
|
||||
manifest: RuntimeManifest,
|
||||
publisher: Publisher,
|
||||
): Promise<SuccessOutcome> {
|
||||
let outcome: RuntimeOutcome;
|
||||
try {
|
||||
outcome = await readOutcome(manifest.outcomeFile);
|
||||
} catch (error) {
|
||||
await publisher.postComment(failureComment([]));
|
||||
throw new Error("agent phase failed", { cause: error });
|
||||
}
|
||||
if (!outcome.ok) {
|
||||
await publisher.postComment(failureComment(outcome.failures));
|
||||
throw new Error("agent phase failed");
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
async function postReview(
|
||||
publisher: Publisher,
|
||||
outcome: SuccessOutcome,
|
||||
review: string,
|
||||
anchors: Anchor[],
|
||||
): Promise<void> {
|
||||
const { context, request } = publisher;
|
||||
const [verdict = "", ...rest] = review.split("\n");
|
||||
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
|
||||
const send = async (entries: Anchor[]): Promise<void> => {
|
||||
await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
|
||||
body: stripAnsi(
|
||||
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
|
||||
),
|
||||
event,
|
||||
comments: entries.map(({ path, line, side, body }) => ({
|
||||
path,
|
||||
body: stripAnsi(withProfile(body, outcome.profile)),
|
||||
new_position: side === "new" ? line : 0,
|
||||
old_position: side === "old" ? line : 0,
|
||||
})),
|
||||
});
|
||||
};
|
||||
await send(anchors).catch(async (error: unknown) => {
|
||||
if (anchors.length === 0) throw error;
|
||||
console.error(
|
||||
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
await send([]);
|
||||
});
|
||||
}
|
||||
|
||||
async function publishResult(
|
||||
manifest: RuntimeManifest,
|
||||
outcome: SuccessOutcome,
|
||||
publisher: Publisher,
|
||||
): Promise<void> {
|
||||
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md");
|
||||
if (review === null) {
|
||||
if (publisher.context.eventName === "pull_request") {
|
||||
throw new Error("agent did not write a pull request review");
|
||||
}
|
||||
await publisher.postComment(withProfile(outcome.body, outcome.profile));
|
||||
return;
|
||||
}
|
||||
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
|
||||
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
|
||||
await postReview(publisher, outcome, review, anchors);
|
||||
}
|
||||
|
||||
async function publish(
|
||||
manifest: RuntimeManifest,
|
||||
context: PostContext,
|
||||
fetcher: typeof fetch,
|
||||
): Promise<void> {
|
||||
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
|
||||
const request = giteaRequest(context.apiUrl, reviewerToken, fetcher);
|
||||
const publisher: Publisher = {
|
||||
context,
|
||||
request,
|
||||
postComment: async (body) => {
|
||||
await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
|
||||
body: stripAnsi(body),
|
||||
});
|
||||
},
|
||||
};
|
||||
const outcome = await readSuccess(manifest, publisher);
|
||||
try {
|
||||
await publishResult(manifest, outcome, publisher);
|
||||
} catch (error) {
|
||||
if (error instanceof GiteaRequestError) throw error;
|
||||
await publisher.postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function postRuntime(
|
||||
manifestPath: string,
|
||||
context: PostContext,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const manifest = await readRuntimeManifest(manifestPath);
|
||||
try {
|
||||
await publish(manifest, context, fetcher);
|
||||
} finally {
|
||||
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
|
||||
if (!isNotFound(error)) throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") throw new Error(`${name} is not set`);
|
||||
return value;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
|
||||
apiUrl: required("GITEA_API_URL"),
|
||||
repository: required("GITEA_REPOSITORY"),
|
||||
issueIndex: required("ISSUE_INDEX"),
|
||||
eventName: required("EVENT_NAME"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { appendFile, rm } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
|
||||
import { prepareRuntime } from "./runtime.ts";
|
||||
|
||||
const env = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") {
|
||||
throw new Error(`${name} is not set`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const manifest = await prepareRuntime(
|
||||
env("ACTION_PATH"),
|
||||
env("RUNNER_TEMP"),
|
||||
env("BOT_AGENTS_MASTER_KEY"),
|
||||
);
|
||||
try {
|
||||
await appendFile(env("GITHUB_OUTPUT"), `manifest=${manifest.manifestPath}\n`);
|
||||
} catch (error) {
|
||||
await rm(manifest.runtimeDir, { recursive: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
AgentAuthError,
|
||||
agentEnvironment,
|
||||
failureFooter,
|
||||
type ProfileFailure,
|
||||
profileFooter,
|
||||
runProfiles,
|
||||
sanitizedEnvironment,
|
||||
} from "./profiles.ts";
|
||||
|
||||
test("formats profile attribution as a Markdown quote", () => {
|
||||
expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
|
||||
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
|
||||
);
|
||||
});
|
||||
|
||||
test("falls through only after usage exhaustion", async () => {
|
||||
const visited: string[] = [];
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
visited.push(profile.type);
|
||||
if (profile.type === "claude") {
|
||||
throw new Error("You've hit your usage limit");
|
||||
}
|
||||
return Promise.resolve("done");
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("done");
|
||||
expect(visited).toStrictEqual(["claude", "codex"]);
|
||||
});
|
||||
|
||||
test("recognizes Claude's weekly limit message", async () => {
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
if (profile.type === "claude") {
|
||||
throw new Error("You've hit your weekly limit · resets 12am (UTC)");
|
||||
}
|
||||
return Promise.resolve("fallback");
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("fallback");
|
||||
});
|
||||
|
||||
test("recognizes provider API exhaustion status", async () => {
|
||||
const results = await Promise.all(
|
||||
["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) =>
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
if (profile.type === "claude") throw new Error(message);
|
||||
return Promise.resolve("fallback");
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(results).toStrictEqual(["fallback", "fallback"]);
|
||||
});
|
||||
|
||||
test("falls through after a sign-in failure and records each try", async () => {
|
||||
const failures: ProfileFailure[] = [];
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" },
|
||||
],
|
||||
(profile) => {
|
||||
if (profile.type === "codex") {
|
||||
throw new AgentAuthError("codex sign-in failed");
|
||||
}
|
||||
if (profile.type === "claude") throw new Error("API Error: 429");
|
||||
return Promise.resolve("fallback");
|
||||
},
|
||||
failures,
|
||||
);
|
||||
|
||||
expect(result).toBe("fallback");
|
||||
expect(failures).toStrictEqual([
|
||||
{ type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" },
|
||||
{ type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" },
|
||||
]);
|
||||
expect(failureFooter(failures)).toStrictEqual(
|
||||
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " +
|
||||
"`claude` · `claude-sonnet-5` (usage exhausted)",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws the last sign-in failure when every profile fails", async () => {
|
||||
const failures: ProfileFailure[] = [];
|
||||
await expect(() =>
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
],
|
||||
(profile) => {
|
||||
throw new AgentAuthError(`${profile.type} sign-in failed`);
|
||||
},
|
||||
failures,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
expect.toSatisfy(
|
||||
(e: unknown) => e instanceof AgentAuthError && e.message.includes("claude sign-in failed"),
|
||||
),
|
||||
);
|
||||
expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]);
|
||||
});
|
||||
|
||||
test("preserves an ordinary agent failure", async () => {
|
||||
const visited: string[] = [];
|
||||
await expect(() =>
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
visited.push(profile.type);
|
||||
throw new Error("tests failed");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("tests failed");
|
||||
expect(visited).toStrictEqual(["claude"]);
|
||||
});
|
||||
|
||||
test("does not mistake incidental quota text for exhaustion", async () => {
|
||||
const visited: string[] = [];
|
||||
await expect(() =>
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
visited.push(profile.type);
|
||||
throw new Error("quota parser test failed at line 429");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("quota parser test failed");
|
||||
expect(visited).toStrictEqual(["claude"]);
|
||||
});
|
||||
|
||||
test("exposes only the selected profile credential to the agent", () => {
|
||||
const base = {
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/home/bot",
|
||||
UNRELATED_SECRET: "must-not-reach-agent",
|
||||
BOT_AGENTS_MASTER_KEY: "master",
|
||||
PROFILES: "contains every secret",
|
||||
REVIEWER_TOKEN: "reviewer",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "old-claude",
|
||||
ANTHROPIC_API_KEY: "old-anthropic-key",
|
||||
ANTHROPIC_AUTH_TOKEN: "old-anthropic-token",
|
||||
OPENAI_API_KEY: "old-openai",
|
||||
CODEX_API_KEY: "old-codex-key",
|
||||
CODEX_ACCESS_TOKEN: "old-codex-token",
|
||||
};
|
||||
const profile = {
|
||||
index: 0,
|
||||
type: "claude" as const,
|
||||
model: "claude-sonnet-5",
|
||||
uid: 20_000,
|
||||
gid: 20_000,
|
||||
home: "/runtime/profile-0",
|
||||
tokenFile: "/runtime/profile-0/token",
|
||||
};
|
||||
|
||||
expect(agentEnvironment(base, profile, "selected")).toStrictEqual({
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/runtime/profile-0",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "selected",
|
||||
});
|
||||
expect(sanitizedEnvironment(base)).toStrictEqual({
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/home/bot",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses a private Codex home without inventing an API credential", () => {
|
||||
const profile = {
|
||||
index: 1,
|
||||
type: "codex" as const,
|
||||
model: "gpt-5.6-terra",
|
||||
uid: 20_001,
|
||||
gid: 20_001,
|
||||
home: "/runtime/profile-1",
|
||||
tokenFile: null,
|
||||
};
|
||||
|
||||
expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({
|
||||
PATH: "/bin",
|
||||
HOME: "/runtime/profile-1",
|
||||
CODEX_HOME: "/runtime/profile-1/.codex",
|
||||
});
|
||||
});
|
||||
|
||||
test("exposes an OpenRouter key only to the OpenCode profile", () => {
|
||||
const profile = {
|
||||
index: 2,
|
||||
type: "opencode" as const,
|
||||
model: "openrouter/qwen/qwen3.8-27b",
|
||||
uid: 20_002,
|
||||
gid: 20_002,
|
||||
home: "/runtime/profile-2",
|
||||
tokenFile: "/runtime/profile-2/token",
|
||||
};
|
||||
|
||||
expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({
|
||||
PATH: "/bin",
|
||||
HOME: "/runtime/profile-2",
|
||||
OPENROUTER_API_KEY: "selected",
|
||||
});
|
||||
});
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
export type ProfileType = "claude" | "codex" | "opencode";
|
||||
|
||||
export interface BotProfile {
|
||||
type: ProfileType;
|
||||
model: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
|
||||
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
|
||||
|
||||
export function isProfileType(value: unknown): value is ProfileType {
|
||||
return PROFILE_TYPES.has(value);
|
||||
}
|
||||
|
||||
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
|
||||
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
|
||||
}
|
||||
|
||||
export function withProfile(body: string, profile: Pick<BotProfile, "type" | "model">): string {
|
||||
return `${body}\n\n${profileFooter(profile)}`;
|
||||
}
|
||||
|
||||
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
|
||||
|
||||
export function isFailureReason(value: unknown): value is FailureReason {
|
||||
return FAILURE_REASONS.has(value);
|
||||
}
|
||||
|
||||
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
|
||||
reason: FailureReason;
|
||||
};
|
||||
|
||||
// Raised once an agent's credentials are rejected and any re-login is spent.
|
||||
export class AgentAuthError extends Error {}
|
||||
|
||||
function isUsageExhausted(error: unknown): boolean {
|
||||
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test(
|
||||
String(error),
|
||||
);
|
||||
}
|
||||
|
||||
function failureReason(error: unknown): FailureReason {
|
||||
if (error instanceof AgentAuthError) return "sign-in failed";
|
||||
return isUsageExhausted(error) ? "usage exhausted" : "failed";
|
||||
}
|
||||
|
||||
export function failureFooter(failures: ProfileFailure[]): string {
|
||||
if (failures.length === 0) return "> Profile: none";
|
||||
return `> Tried: ${failures
|
||||
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
|
||||
.join(", ")}`;
|
||||
}
|
||||
|
||||
// Tries the profiles from `index` on, moving on only after a sign-in failure
|
||||
// or exhausted usage, and records every profile it tried.
|
||||
async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
failures: ProfileFailure[],
|
||||
index: number,
|
||||
): Promise<T> {
|
||||
const profile = profiles[index];
|
||||
if (profile === undefined) {
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
}
|
||||
try {
|
||||
return await attempt(profile);
|
||||
} catch (error) {
|
||||
const reason = failureReason(error);
|
||||
failures.push({ type: profile.type, model: profile.model, reason });
|
||||
if (reason === "failed" || index === profiles.length - 1) throw error;
|
||||
console.error(`${profile.type} ${reason}; trying next profile`);
|
||||
return attemptFrom(profiles, attempt, failures, index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
failures: ProfileFailure[] = [],
|
||||
): Promise<T> {
|
||||
return attemptFrom(profiles, attempt, failures, 0);
|
||||
}
|
||||
|
||||
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const name of [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"LC_ALL",
|
||||
"TERM",
|
||||
"COLORTERM",
|
||||
"CI",
|
||||
"TMPDIR",
|
||||
"GITEA_TOKEN",
|
||||
"GITEA_API_URL",
|
||||
"GITEA_REPOSITORY",
|
||||
"GITHUB_WORKSPACE",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
]) {
|
||||
const value = environment[name];
|
||||
if (value !== undefined) result[name] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function agentEnvironment(
|
||||
environment: Record<string, string>,
|
||||
profile: Pick<BotProfile, "type"> & { home: string },
|
||||
token?: string,
|
||||
): Record<string, string> {
|
||||
const result = sanitizedEnvironment(environment);
|
||||
result.HOME = profile.home;
|
||||
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
|
||||
if (token !== undefined && token !== "") {
|
||||
const tokenName = {
|
||||
claude: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
codex: "OPENAI_API_KEY",
|
||||
opencode: "OPENROUTER_API_KEY",
|
||||
}[profile.type];
|
||||
result[tokenName] = token;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// The agent phase's environment and the helpers every agent runner shares. It
|
||||
// is read once, when the runner starts, as the single-file runner did.
|
||||
import { spawn } from "node:child_process";
|
||||
import { chmod, chown, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
|
||||
import { exitCode } from "./child.ts";
|
||||
import { agentEnvironment, sanitizedEnvironment } from "./profiles.ts";
|
||||
import { readRuntimeManifest } from "./runtime.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
export const env = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) {
|
||||
throw new Error(`${name} is not set`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
function definedEnvironment(): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
result[name] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const API = env("GITEA_API_URL");
|
||||
export const REPO = env("GITEA_REPOSITORY");
|
||||
export const INDEX = env("ISSUE_INDEX");
|
||||
export const EVENT = env("EVENT_NAME");
|
||||
const AUTHOR_TOKEN = env("GITEA_TOKEN");
|
||||
export const WORKSPACE = env("GITHUB_WORKSPACE");
|
||||
export const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
|
||||
export const PROCESS_ENV = sanitizedEnvironment(definedEnvironment());
|
||||
|
||||
export async function gitea(path: string, body?: unknown): Promise<Response> {
|
||||
const response = await fetch(`${API}/${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: {
|
||||
Authorization: `token ${AUTHOR_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body === undefined ? null : JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function postAuthorComment(body: string): Promise<void> {
|
||||
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
|
||||
}
|
||||
|
||||
export async function runAgentCommand(
|
||||
profile: RuntimeProfile,
|
||||
command: string,
|
||||
args: string[],
|
||||
token?: string,
|
||||
): Promise<void> {
|
||||
const 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
|
||||
const path = `${profile.home}/${name}`;
|
||||
await writeFile(path, "", { mode: 0o600 });
|
||||
await chmod(path, 0o600);
|
||||
await chown(path, profile.uid, profile.gid);
|
||||
return path;
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
// 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, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { codexAuthentication, persistCodexAuth, stageCodexAuth } from "./auth.ts";
|
||||
import { runAgent } from "./agents.ts";
|
||||
import type { AgentAnswer } from "./agents.ts";
|
||||
import { exitCode } from "./child.ts";
|
||||
import { killProfileProcesses, readProfileFile } from "./isolation.ts";
|
||||
import { isObject } from "./json.ts";
|
||||
import { runProfiles } from "./profiles.ts";
|
||||
import type { ProfileFailure } from "./profiles.ts";
|
||||
import {
|
||||
API,
|
||||
env,
|
||||
EVENT,
|
||||
gitea,
|
||||
INDEX,
|
||||
makeProfileFile,
|
||||
MANIFEST,
|
||||
PROCESS_ENV,
|
||||
REPO,
|
||||
runAgentCommand,
|
||||
WORKSPACE,
|
||||
} from "./run-context.ts";
|
||||
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
interface AttemptResult {
|
||||
body: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
profile: Pick<RuntimeProfile, "type" | "model">;
|
||||
}
|
||||
|
||||
interface RenderedPrompt {
|
||||
prompt: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
}
|
||||
|
||||
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
|
||||
const CODEX_AUTH = "/root/.codex/auth.json";
|
||||
const SKILLS_INSTALL = "skills add obra/superpowers";
|
||||
|
||||
async function runRoot(command: string, args: string[]): Promise<void> {
|
||||
const 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 | undefined> {
|
||||
const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8");
|
||||
return token;
|
||||
}
|
||||
|
||||
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
|
||||
await runAgentCommand(profile, "git", [
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
"safe.directory",
|
||||
WORKSPACE,
|
||||
]);
|
||||
await runAgentCommand(profile, "npx", [
|
||||
"-y",
|
||||
...SKILLS_INSTALL.split(" "),
|
||||
"--global",
|
||||
"--agent",
|
||||
profile.type === "claude" ? "claude-code" : profile.type,
|
||||
"--yes",
|
||||
]);
|
||||
}
|
||||
|
||||
// One issue comment as the prompt quotes it.
|
||||
function formatComment(comment: unknown): string {
|
||||
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {};
|
||||
const login = isObject(user) ? user.login : undefined;
|
||||
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
|
||||
}
|
||||
|
||||
async function promptValues(
|
||||
reviewPath: string,
|
||||
anchorsPath: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const comments: unknown = await (
|
||||
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
|
||||
).json();
|
||||
if (!Array.isArray(comments)) {
|
||||
throw new TypeError("comments: unexpected Gitea response");
|
||||
}
|
||||
return {
|
||||
COMMENT: env("COMMENT"),
|
||||
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
|
||||
CODE_RULES: await (await gitea(RULES_PATH)).text(),
|
||||
EVENT_NAME: EVENT,
|
||||
GITEA_API_URL: API,
|
||||
GITEA_REPOSITORY: REPO,
|
||||
ISSUE_INDEX: INDEX,
|
||||
REVIEW_PATH: reviewPath,
|
||||
ANCHORS_PATH: anchorsPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
|
||||
const reviewPath = await makeProfileFile(profile, "review.md");
|
||||
const anchorsPath = await makeProfileFile(profile, "anchors.json");
|
||||
const values = await promptValues(reviewPath, anchorsPath);
|
||||
const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8");
|
||||
return {
|
||||
prompt: template.replaceAll(
|
||||
/\$\{(\w+)\}/gu,
|
||||
(match: string, name: string) => values[name] ?? match,
|
||||
),
|
||||
reviewPath,
|
||||
anchorsPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
||||
const token = await readProfileToken(profile);
|
||||
const authentication = profile.type === "codex" ? codexAuthentication(token) : null;
|
||||
let stagedCodexAuth: string | null = null;
|
||||
let answer: AgentAnswer = {};
|
||||
let rendered: RenderedPrompt | undefined;
|
||||
try {
|
||||
if (authentication?.kind === "persistent") {
|
||||
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
|
||||
}
|
||||
await prepareProfile(profile);
|
||||
rendered = await renderPrompt(profile);
|
||||
answer = await runAgent(profile, token, rendered.prompt, authentication);
|
||||
} finally {
|
||||
await killProfileProcesses(profile.uid);
|
||||
if (stagedCodexAuth !== null) {
|
||||
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
|
||||
}
|
||||
}
|
||||
const body =
|
||||
answer.resultPath === undefined
|
||||
? answer.body
|
||||
: await readProfileFile(profile, answer.resultPath);
|
||||
if (body === undefined || rendered === undefined) {
|
||||
throw new Error("agent attempt produced no result");
|
||||
}
|
||||
const { reviewPath, anchorsPath } = rendered;
|
||||
return { body, reviewPath, anchorsPath, profile: { type: profile.type, model: profile.model } };
|
||||
}
|
||||
|
||||
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
|
||||
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { mode: 0o600 });
|
||||
await chmod(MANIFEST.outcomeFile, 0o600);
|
||||
}
|
||||
|
||||
const failures: ProfileFailure[] = [];
|
||||
try {
|
||||
await configureGitAuthor();
|
||||
await writeOutcome({ ok: true, ...(await runProfiles(MANIFEST.profiles, runProfile, failures)) });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await writeOutcome({ ok: false, failures });
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { assert, expect, test } from "vite-plus/test";
|
||||
|
||||
import { prepareRuntime } from "./runtime.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
import { IS_ROOT, makeTempDir } from "./testing.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.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 });
|
||||
}
|
||||
});
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { parseCredentials } from "./credentials.ts";
|
||||
import { isMapping } from "./json.ts";
|
||||
import type { BotProfile, ProfileFailure } from "./profiles.ts";
|
||||
|
||||
const FIRST_PROFILE_UID = 20_000;
|
||||
|
||||
export type RuntimeProfile = Pick<BotProfile, "type" | "model"> & {
|
||||
index: number;
|
||||
uid: number;
|
||||
gid: number;
|
||||
home: string;
|
||||
tokenFile: string | null;
|
||||
};
|
||||
|
||||
export interface RuntimeManifest {
|
||||
runtimeDir: string;
|
||||
manifestPath: string;
|
||||
reviewerFile: string;
|
||||
outcomeFile: string;
|
||||
profiles: RuntimeProfile[];
|
||||
}
|
||||
|
||||
export type RuntimeOutcome =
|
||||
| {
|
||||
ok: true;
|
||||
body: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
profile: Pick<RuntimeProfile, "type" | "model">;
|
||||
}
|
||||
| { ok: false; failures: ProfileFailure[] };
|
||||
|
||||
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
|
||||
await writeFile(path, value, { mode: 0o600 });
|
||||
await chmod(path, 0o600);
|
||||
await chown(path, uid, gid);
|
||||
}
|
||||
|
||||
async function prepareProfile(
|
||||
runtimeDir: string,
|
||||
profile: BotProfile,
|
||||
index: number,
|
||||
): Promise<RuntimeProfile> {
|
||||
const uid = FIRST_PROFILE_UID + index;
|
||||
const home = join(runtimeDir, `profile-${index}`);
|
||||
await mkdir(home, { mode: 0o700 });
|
||||
await chmod(home, 0o700);
|
||||
await chown(home, uid, uid);
|
||||
const tokenFile =
|
||||
profile.token === undefined || profile.token === "" ? null : join(home, "token");
|
||||
if (tokenFile !== null && profile.token !== undefined) {
|
||||
await writePrivate(tokenFile, profile.token, uid, uid);
|
||||
}
|
||||
return { index, type: profile.type, model: profile.model, uid, gid: uid, home, tokenFile };
|
||||
}
|
||||
|
||||
export async function prepareRuntime(
|
||||
actionPath: string,
|
||||
runtimeRoot: string,
|
||||
masterKey: string,
|
||||
): Promise<RuntimeManifest> {
|
||||
const credentials = await parseCredentials(
|
||||
await readFile(join(actionPath, "profiles.json"), "utf8"),
|
||||
masterKey,
|
||||
);
|
||||
const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-"));
|
||||
try {
|
||||
await chmod(runtimeDir, 0o711);
|
||||
const reviewerFile = join(runtimeDir, "reviewer-token");
|
||||
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
|
||||
|
||||
// One profile at a time, in order, as each claims the next UID.
|
||||
const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>(
|
||||
async (previous, profile, index) => [
|
||||
...(await previous),
|
||||
await prepareProfile(runtimeDir, profile, index),
|
||||
],
|
||||
Promise.resolve([]),
|
||||
);
|
||||
|
||||
const manifestPath = join(runtimeDir, "manifest.json");
|
||||
const manifest: RuntimeManifest = {
|
||||
runtimeDir,
|
||||
manifestPath,
|
||||
reviewerFile,
|
||||
outcomeFile: join(runtimeDir, "outcome.json"),
|
||||
profiles,
|
||||
};
|
||||
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
|
||||
return manifest;
|
||||
} catch (error) {
|
||||
await rm(runtimeDir, { recursive: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isRuntimeManifest(value: unknown): value is RuntimeManifest {
|
||||
return (
|
||||
isMapping(value) &&
|
||||
typeof value.runtimeDir === "string" &&
|
||||
typeof value.manifestPath === "string" &&
|
||||
typeof value.reviewerFile === "string" &&
|
||||
typeof value.outcomeFile === "string" &&
|
||||
Array.isArray(value.profiles)
|
||||
);
|
||||
}
|
||||
|
||||
export async function readRuntimeManifest(path: string): Promise<RuntimeManifest> {
|
||||
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||
if (!isRuntimeManifest(value)) {
|
||||
throw new Error(`${path}: invalid runtime manifest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Test support: the current user's IDs and a fresh temporary directory.
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
export function currentUid(): number {
|
||||
return process.getuid?.() ?? -1;
|
||||
}
|
||||
|
||||
export function currentGid(): number {
|
||||
return process.getgid?.() ?? -1;
|
||||
}
|
||||
|
||||
// The isolation tests switch UIDs, which only root may do.
|
||||
export const IS_ROOT: boolean = currentUid() === 0;
|
||||
|
||||
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
|
||||
export const IS_WINDOWS: boolean = process.platform === "win32";
|
||||
|
||||
export function makeTempDir(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
export type BotType = "claude" | "codex";
|
||||
|
||||
export type InstallCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
export function parseBotType(value: string): BotType {
|
||||
if (value === "claude" || value === "codex") return value;
|
||||
throw new Error(`unsupported bot type: ${value}`);
|
||||
}
|
||||
|
||||
export function superpowersInstallCommands(
|
||||
bot: BotType,
|
||||
): InstallCommand[] {
|
||||
if (bot === "claude") {
|
||||
return [
|
||||
{
|
||||
command: "claude",
|
||||
args: [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"obra/superpowers-marketplace",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "claude",
|
||||
args: [
|
||||
"plugin",
|
||||
"install",
|
||||
"-y",
|
||||
"superpowers@superpowers-marketplace",
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
return [{
|
||||
command: "codex",
|
||||
args: ["plugin", "add", "superpowers@openai-curated-remote"],
|
||||
}];
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { assertEquals, assertThrows } from "jsr:@std/assert@1";
|
||||
import { parseBotType, superpowersInstallCommands } from "./superpowers.ts";
|
||||
|
||||
Deno.test("installs Superpowers from the Codex marketplace", () => {
|
||||
assertEquals(superpowersInstallCommands("codex"), [{
|
||||
command: "codex",
|
||||
args: ["plugin", "add", "superpowers@openai-curated-remote"],
|
||||
}]);
|
||||
});
|
||||
|
||||
Deno.test("installs Superpowers from the Claude marketplace", () => {
|
||||
assertEquals(superpowersInstallCommands("claude"), [
|
||||
{
|
||||
command: "claude",
|
||||
args: [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"obra/superpowers-marketplace",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "claude",
|
||||
args: [
|
||||
"plugin",
|
||||
"install",
|
||||
"-y",
|
||||
"superpowers@superpowers-marketplace",
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("rejects an unsupported bot type", () => {
|
||||
assertThrows(
|
||||
() => parseBotType("other"),
|
||||
Error,
|
||||
"unsupported bot type: other",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
// Each phase runs as `node src/<phase>.ts` with no build step. Node loads the
|
||||
// whole import graph, stripping types, before the phase reads its first
|
||||
// variable, so failing on that variable proves every module loads.
|
||||
const PHASES: [phase: string, firstVariable: string][] = [
|
||||
["prepare", "ACTION_PATH"],
|
||||
["run", "GITEA_API_URL"],
|
||||
["post", "BOT_AGENTS_MANIFEST"],
|
||||
];
|
||||
|
||||
test.each(PHASES)("Node loads the %s phase", (phase, firstVariable) => {
|
||||
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url));
|
||||
const result = spawnSync(process.execPath, [path], {
|
||||
env: { PATH: process.env.PATH ?? "" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain(`${firstVariable} is not set`);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
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 { isNotFound } from "../src/errors.ts";
|
||||
import { isMapping } from "../src/json.ts";
|
||||
import type { JsonObject } from "../src/json.ts";
|
||||
import { makeTempDir } from "../src/testing.ts";
|
||||
|
||||
const mapping = (value: unknown, at: string): JsonObject => {
|
||||
assert(isMapping(value), at);
|
||||
return value;
|
||||
};
|
||||
|
||||
const sequence = (value: unknown, at: string): JsonObject[] => {
|
||||
assert(Array.isArray(value), at);
|
||||
return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`));
|
||||
};
|
||||
|
||||
async function yaml(path: string): Promise<JsonObject> {
|
||||
const value: unknown = parse(await readFile(path, "utf8"));
|
||||
return mapping(value, path);
|
||||
}
|
||||
|
||||
async function yamlFiles(): Promise<string[]> {
|
||||
const entries = await readdir(".", { recursive: true, withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /\.ya?ml$/u.test(entry.name))
|
||||
.map((entry) => `${entry.parentPath}/${entry.name}`.replace(/^\.\//u, ""))
|
||||
.filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path));
|
||||
}
|
||||
|
||||
const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => {
|
||||
const step = steps.find((entry) => entry[key] === value);
|
||||
assert(step !== undefined, `${key}: ${value}`);
|
||||
return step;
|
||||
};
|
||||
|
||||
test("all committed YAML parses", async () => {
|
||||
const files = await yamlFiles();
|
||||
assert(files.length > 0);
|
||||
await Promise.all(files.map((file) => yaml(file)));
|
||||
});
|
||||
|
||||
test("the action exposes only the master key and scopes it to preparation", async () => {
|
||||
const action = await yaml("action.yml");
|
||||
expect(Object.keys(mapping(action.inputs, "inputs"))).toStrictEqual(["master-key"]);
|
||||
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps");
|
||||
for (const step of steps) {
|
||||
if (typeof step.run === "string") {
|
||||
expect(step.run.includes("\n"), String(step.name)).toBe(false);
|
||||
}
|
||||
}
|
||||
const checkout = findStep(steps, "uses", "actions/checkout@v4");
|
||||
const prepare = findStep(steps, "id", "prepare");
|
||||
const agent = findStep(steps, "id", "agent");
|
||||
const post = findStep(steps, "id", "post");
|
||||
findStep(steps, "id", "source");
|
||||
expect(mapping(checkout.with, "checkout.with").token).toBe("${{ gitea.token }}");
|
||||
expect(mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY).toBe(
|
||||
"${{ inputs.master-key }}",
|
||||
);
|
||||
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
|
||||
expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
|
||||
expect(mapping(agent.env, "agent.env").GITEA_TOKEN).toBe("${{ gitea.token }}");
|
||||
assert(String(post.if).includes("always()"));
|
||||
for (const step of [prepare, agent, post]) {
|
||||
assert(String(step.run).includes("steps.source.outputs.path"));
|
||||
}
|
||||
});
|
||||
|
||||
test("the source protection script stages immutable action files", async () => {
|
||||
const temp = await makeTempDir();
|
||||
const outputFile = `${temp}/output`;
|
||||
try {
|
||||
const result = spawnSync("/bin/bash", ["scripts/protect-source.sh"], {
|
||||
env: {
|
||||
...process.env,
|
||||
ACTION_PATH: process.cwd(),
|
||||
GITHUB_OUTPUT: outputFile,
|
||||
RUNNER_TEMP: temp,
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert(result.status === 0, result.stderr);
|
||||
|
||||
const path = (await readFile(outputFile, "utf8")).trim().slice(5);
|
||||
const files = [
|
||||
"src/prepare.ts",
|
||||
"src/run.ts",
|
||||
"src/post.ts",
|
||||
"package.json",
|
||||
"profiles.json",
|
||||
"prompt.md",
|
||||
];
|
||||
const stats = await Promise.all(files.map((file) => stat(`${path}/${file}`)));
|
||||
for (const [index, info] of stats.entries()) {
|
||||
assert(info.isFile(), files[index]);
|
||||
expect(info.mode & 0o022, files[index]).toStrictEqual(0);
|
||||
}
|
||||
} finally {
|
||||
await rm(temp, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("module-relative runtime assets exist", async () => {
|
||||
const module = new URL("../src/run.ts", import.meta.url);
|
||||
const source = await readFile(module, "utf8");
|
||||
const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map(
|
||||
([, asset = ""]) => new URL(asset, module),
|
||||
);
|
||||
const stats = await Promise.all(assets.map((asset) => stat(asset)));
|
||||
for (const [index, info] of stats.entries()) {
|
||||
assert(info.isFile(), assets[index]?.pathname);
|
||||
}
|
||||
});
|
||||
|
||||
test("the repository uses its trusted main action", async () => {
|
||||
const workflow = await yaml(".gitea/workflows/agents.yml");
|
||||
const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent");
|
||||
const permissions = mapping(job.permissions, "jobs.agent.permissions");
|
||||
expect(permissions).toStrictEqual({
|
||||
code: "write",
|
||||
issues: "write",
|
||||
"pull-requests": "write",
|
||||
});
|
||||
const steps = sequence(job.steps, "jobs.agent.steps");
|
||||
expect(steps.findIndex((step) => step.uses === "./")).toStrictEqual(-1);
|
||||
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main");
|
||||
expect(mapping(trusted.with, "trusted.with")["master-key"]).toBe(
|
||||
"${{ secrets.BOT_AGENTS_MASTER_KEY }}",
|
||||
);
|
||||
expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true);
|
||||
});
|
||||
|
||||
test("CI checks every phase and the root isolation suite", async () => {
|
||||
const workflow = await yaml(".gitea/workflows/check.yml");
|
||||
const job = mapping(mapping(workflow.jobs, "jobs").check, "jobs.check");
|
||||
const commands = sequence(job.steps, "jobs.check.steps")
|
||||
.map((step) => (typeof step.run === "string" ? step.run : ""))
|
||||
.join("\n");
|
||||
// `vp check` type-checks every phase, and `vp test` runs each of them under
|
||||
// Node as well as the root-only isolation tests.
|
||||
assert(commands.includes("npm ci"));
|
||||
assert(commands.includes("vp check"));
|
||||
assert(commands.includes("vp test"));
|
||||
});
|
||||
|
||||
test("uses the standard Skills installer without custom discovery", async () => {
|
||||
const runner = await readFile("src/run.ts", "utf8");
|
||||
assert(runner.includes("skills add obra/superpowers"));
|
||||
const removed = await stat("superpowers.ts").then(
|
||||
() => false,
|
||||
(error: unknown) => isNotFound(error),
|
||||
);
|
||||
assert(removed, "superpowers.ts should be removed");
|
||||
});
|
||||
|
||||
test("installs the OpenCode CLI", async () => {
|
||||
const action = await readFile("action.yml", "utf8");
|
||||
assert(action.includes("opencode-ai"));
|
||||
});
|
||||
|
||||
test("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.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:"));
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2024",
|
||||
"module": "nodenext",
|
||||
"lib": ["es2024"],
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"allowUnreachableCode": false,
|
||||
"allowUnusedLabels": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "vite-plus";
|
||||
|
||||
import projectLint from "./scripts/project-lint.ts";
|
||||
|
||||
export default defineConfig({
|
||||
// Keep package.json in its hand-written order.
|
||||
fmt: { sortPackageJson: false },
|
||||
lint: {
|
||||
plugins: ["typescript", "unicorn", "oxc", "import"],
|
||||
jsPlugins: ["./scripts/project-lint.ts"],
|
||||
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
|
||||
rules: projectLint.configs.project.rules,
|
||||
// Repo tooling, not app code: the lint plugin compares AST node `type`s,
|
||||
// which the AST types declare as an enum.
|
||||
overrides: [
|
||||
{ files: ["scripts/**"], rules: { "typescript/no-unsafe-enum-comparison": "off" } },
|
||||
],
|
||||
options: {
|
||||
typeAware: true,
|
||||
typeCheck: true,
|
||||
maxWarnings: 0,
|
||||
reportUnusedDisableDirectives: "error",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user