Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0920a4e91b | |||
| 627458fb5f | |||
| 50df193a8c | |||
| 5b568f9ea6 | |||
| 60c63dee98 | |||
| 0a31dbb820 | |||
| 6ad859be62 | |||
| 8b5c249248 | |||
| 5af53b0882 | |||
| eba9b9c57c | |||
| 3cbe4171ea | |||
| cd1864e1a9 | |||
| 0c5059e3b4 | |||
| c6fc183ad6 | |||
| 1f54518320 | |||
| 999a089374 | |||
| 166476dee9 | |||
| c778c00068 | |||
| 6b87ff66b9 | |||
| d2d3cbffdb | |||
| bfa8269801 | |||
| 15fe481181 | |||
| 662e946fad | |||
| ffb7cb9a75 | |||
| 2e09945a6a | |||
| 934a3cfd03 | |||
| b170f25c7d |
@@ -1,39 +0,0 @@
|
||||
---
|
||||
description: Child processes use plain node:child_process spawn (explicit stdio, uid/gid, whole env); src/processes/child.ts has exitCode (128+signo) and echo; no execFile maxBuffer
|
||||
---
|
||||
|
||||
Every call site calls `spawn` from `node:child_process` itself, with
|
||||
everything explicit:
|
||||
|
||||
- **`uid`/`gid`** of the profile for anything an agent profile runs (each
|
||||
profile is its own UID, 20000+). Never drop them.
|
||||
- **`env`** is the whole environment the child gets (a sanitized one for
|
||||
agents); nothing from the coordinator is merged in.
|
||||
- **`stdio`**: stdin is `"ignore"` so an agent CLI never waits on input
|
||||
(only `codex login --with-api-key` pipes the token in). Output that is not
|
||||
read is `"ignore"`; output that is read is `"pipe"` and streamed.
|
||||
- No `execFile`/`exec`: their `maxBuffer` (1 MiB) kills a chatty child. Read
|
||||
piped streams incrementally instead.
|
||||
|
||||
`src/processes/child.ts` holds what the call sites share:
|
||||
|
||||
- **`exitCode(child)`**: call it right after `spawn`. It settles on `exit`
|
||||
(not `close`, which a daemonized grandchild can hold open), maps a signal to
|
||||
`128 + signo` as a shell does, and rejects on a failed start. The rejection
|
||||
is pre-caught so it is not reported as unhandled before the caller awaits it.
|
||||
- **`echo(stream, chunk)`**: writes and waits for `drain` when the buffer is
|
||||
full, so echoing never outruns a slow reader.
|
||||
|
||||
Tests use `spawnSync` for short commands.
|
||||
|
||||
Other port details:
|
||||
|
||||
- **`src/common/errors.ts`** `isNotFound` checks `ENOENT` (file) or `ESRCH` (process).
|
||||
- Temporary files are an exclusive `wx` create with mode `0600`.
|
||||
- `src/common/json.ts` holds type guards used instead of `as` casts on parsed JSON.
|
||||
- `process` and `Buffer` must come from `node:` imports:
|
||||
`no-restricted-globals` in `scripts/project-lint.ts` enforces it
|
||||
([[lint-parity-never-shrinks]]).
|
||||
|
||||
Related: how each agent CLI reports bad credentials is in
|
||||
[[agent-cli-auth-failures]].
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
description: bot-agents lint has prefer-readonly-parameter-types and the size/complexity rules off (Deno had no limits) and no overrides; nothing else
|
||||
---
|
||||
|
||||
The repo follows [[lint-config-shape]] with one recorded exception, present
|
||||
when the migration PR was approved:
|
||||
|
||||
- `typescript/prefer-readonly-parameter-types` is `"off"` in
|
||||
`scripts/project-lint.ts`, as in the explicit-types template, because
|
||||
Node/web types (`Response`, `Stats`, `RequestInit`) can never satisfy it.
|
||||
- `vite.config.ts` has no `overrides`. The `scripts/**` override for
|
||||
`typescript/no-unsafe-enum-comparison` was removed (2026-10-16):
|
||||
`scripts/project-lint.ts` compares node types with `AST_NODE_TYPES` members
|
||||
from `@typescript-eslint/types` instead of string literals
|
||||
([[lint-overrides-policy]]).
|
||||
|
||||
Size and complexity limits match Deno, which had none here, so every Oxlint
|
||||
limit rule is `"off"` ([[lint-config-shape]]). `run.ts` and `post.ts` keep
|
||||
main's single-file layout (2026-10-17 revert of the size splits). Sequential
|
||||
awaits in loops became recursion or a `reduce` chain (`no-await-in-loop`) to
|
||||
stay sequential. Add no other opt-out without the user.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, lints with eslint-plugin-import-structure and explicit-types (concrete, absence)
|
||||
description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, Deno kept only for agents, no eslint-plugin-* packages needed
|
||||
---
|
||||
|
||||
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
|
||||
@@ -15,17 +15,20 @@ Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
|
||||
removed the only runtime dependency (`@std/yaml`); keep it free of runtime
|
||||
dependencies (`@eslint/core` and the other devDependencies serve only the
|
||||
tooling).
|
||||
- **Node 24.2+**, not 22: `src/post/index.ts` uses `import.meta.main`. There is no
|
||||
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. There is no
|
||||
`engines` (user decision 2026-10-18): the `node:24-bookworm` job image
|
||||
decides the version ([[bot-agents-runtime-no-build]]).
|
||||
- **Published lint plugins** (2026-10-18, after they reached npm):
|
||||
`eslint-plugin-import-structure` (`modules` config) and
|
||||
`eslint-plugin-explicit-types` (`concrete` and `absence` configs) are
|
||||
devDependencies, enabled in `vite.config.ts` through their `all` configs.
|
||||
`upper-snake-string-unions` was off while the user decided; since
|
||||
2026-10-19 it is on and profile types, failure reasons, and anchor sides
|
||||
are `UPPER_SNAKE` ([[enum-values-upper-snake]]). `eslint-plugin-lit-robust`
|
||||
does not apply (no Lit).
|
||||
- **Deno stays in the action's global npm install** (`action.yml`), only
|
||||
because the agents still check Deno-based consumer repos (memona,
|
||||
memona-home). Remove it once those repos have moved; it is not a
|
||||
dependency of this repo's TypeScript.
|
||||
- **No `eslint-plugin-*` packages** (`eslint-plugin-explicit-types`,
|
||||
`eslint-plugin-import-structure`, `eslint-plugin-lit-robust`; unscoped
|
||||
since 2026-10-18, formerly `@cunarist/...`). The Deno setup enabled no
|
||||
plugin, so this repo depends on none and CI passes without them being
|
||||
published. Planned (2026-10-17): once the plugins are on npm, add
|
||||
`eslint-plugin-import-structure` (rule prefix `import-structure/`) here for
|
||||
its import checks.
|
||||
- **Stricter input checks were accepted** in the migration: Gitea `/user` and
|
||||
comment responses, the runtime manifest, and the outcome's profile type are
|
||||
validated; some type errors became `TypeError` with the same messages.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: src/command.ts reproduces Deno.Command on child_process.spawn (stdio defaults, clearEnv, uid/gid, 128+signo); src/errors.ts maps NotFound to ENOENT/ESRCH
|
||||
---
|
||||
|
||||
The Deno code was ported one for one; two small modules keep the old shapes
|
||||
so callers barely changed:
|
||||
|
||||
- **`src/command.ts`** wraps `child_process.spawn` with `Deno.Command`'s API:
|
||||
`output()` and `spawn()`, `clearEnv`, `uid`/`gid` (the agent isolation runs
|
||||
each profile as its own UID, 20000+), and Deno's stdio defaults: stdin
|
||||
inherited; stdout/stderr piped for `output()`, inherited for `spawn()`. A
|
||||
signal exit maps to status `128 + signo`. Status settles on process exit,
|
||||
not on stream close. Use it instead of calling `node:child_process`
|
||||
directly so isolation and status handling stay uniform.
|
||||
- **`src/errors.ts`** replaces `Deno.errors.NotFound` checks with an
|
||||
`ENOENT`/`ESRCH` code check.
|
||||
- `makeTempFile` became an exclusive `wx` create with mode `0600`.
|
||||
- `src/json.ts` holds type guards used instead of `as` casts on parsed JSON.
|
||||
- `process` and `Buffer` must come from `node:` imports:
|
||||
`no-restricted-globals` in `scripts/project-lint.ts` maps Deno's
|
||||
`no-window`/`no-process-global`/`no-node-globals` there
|
||||
([[lint-parity-never-shrinks]]).
|
||||
|
||||
Related: how each agent CLI reports bad credentials is in
|
||||
[[agent-cli-auth-failures]].
|
||||
@@ -10,7 +10,7 @@ dependency**. Hence `tsconfig.json` has `erasableSyntaxOnly` and
|
||||
properties, no decorators) and relative `.ts` imports.
|
||||
|
||||
- **Image:** the steps assume the `node:24-bookworm` job container. Node must
|
||||
be 24.2+ for `import.meta.main` in `src/post/index.ts`. That image is the only Node
|
||||
be 24.2+ for `import.meta.main` in `post.ts`. That image is the only Node
|
||||
pin (`package.json` has no `engines`); `check.yml` prints `node --version`
|
||||
to confirm the cached image's minor.
|
||||
- **Root-owned source copy:** `scripts/protect-source.sh` copies the action
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
---
|
||||
description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 66 run; verify isolation changes in node:24-bookworm as root
|
||||
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 the prepare phase's
|
||||
Some tests exercise real UID isolation, process cleanup, and `prepare.ts`
|
||||
credential cleanup. They need root and are written as
|
||||
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show
|
||||
as skipped instead of silently passing.
|
||||
|
||||
- As a normal user `vp test` passes 61 and skips 5. In CI (self-hosted
|
||||
runner, root) all 66 run.
|
||||
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare/index.ts`,
|
||||
or `child.ts`, run the suite as root in `node:24-bookworm` (e.g. a
|
||||
- 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/index.ts` -> `run/index.ts` -> `post/index.ts`; repeat that
|
||||
`protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.ts`; repeat that
|
||||
for changes to the phase flow ([[bot-agents-runtime-no-build]]).
|
||||
- `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs,
|
||||
`runCommand`) behind the `#testing` alias. Every test imports it as
|
||||
`#testing`. It imports no source module, so `tests/` (which may not use
|
||||
`../`) never reaches internals. Tests follow
|
||||
[[tests-layout-vitest]] (unit tests next to their code in `src/`, repo-level ones in
|
||||
[[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in
|
||||
`tests/`).
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
description: Enum-like string values are UPPER_SNAKE everywhere this repo owns the format, JSON included; external standards are converted at the boundary with explicit switch functions
|
||||
---
|
||||
|
||||
Decided 2026-10-19 (all repos). An enum-like string value, a member of a
|
||||
string-literal union, is `UPPER_SNAKE` wherever this repo owns the format:
|
||||
TypeScript unions, `profiles.json`, the runtime outcome file, and the
|
||||
`anchors.json` the agent writes per `prompt.md`. explicit-types
|
||||
`upper-snake-string-unions` enforces it and stays on.
|
||||
|
||||
A value an outside party defines (a CLI flag, an environment variable name,
|
||||
an HTTP API field, a human-readable message) is converted at the boundary by
|
||||
an explicit `switch` function, never by renaming our own values to match it:
|
||||
|
||||
```ts
|
||||
export interface ProfileIdentity {
|
||||
type: "CLAUDE" | "CODEX" | "OPENCODE";
|
||||
model: string;
|
||||
}
|
||||
|
||||
// The agent name the skills installer knows each profile type by.
|
||||
function skillsAgent(type: RuntimeProfile["type"]): string {
|
||||
switch (type) {
|
||||
case "CLAUDE":
|
||||
return "claude-code";
|
||||
case "CODEX":
|
||||
return "codex";
|
||||
case "OPENCODE":
|
||||
return "opencode";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this repo:
|
||||
|
||||
- Profile `type`: `CLAUDE`, `CODEX`, `OPENCODE` (also in `profiles.json`).
|
||||
`skillsAgent()` (run phase) and `tokenVariable()` (profiles) map them to the
|
||||
installer's agent name and the credential environment variable;
|
||||
`defaultModel()` (credentials) picks a model.
|
||||
- `FailureReason`: `SIGN_IN_FAILED`, `USAGE_EXHAUSTED`, `FAILED`; `toMessage()`
|
||||
gives the words that logs and posted comments show.
|
||||
- Anchor `side`: `NEW` or `OLD` (`prompt.md` tells the agent so); the Gitea
|
||||
review call turns it into `new_position` / `old_position`.
|
||||
|
||||
A lookup object keyed by enum values would need `UPPER_SNAKE` keys, which
|
||||
explicit-types `no-upper-snake-object-keys` bans, so use a `switch`. TypeScript
|
||||
checks such a switch for exhaustiveness through the declared return type; no
|
||||
`default:` branch ([[lint-config]]).
|
||||
@@ -72,7 +72,7 @@ import { x } from "../x.ts"; // no: no ../ anywhere
|
||||
- An area used from outside exposes its API through its alias target, and
|
||||
callers import the alias (`#ui`), not files inside it.
|
||||
- Barrels are also the standard fix for `import/max-dependencies`: one barrel
|
||||
import replaces many direct ones ([[lint-config]]).
|
||||
import replaces many direct ones ([[lint-overrides-policy]]).
|
||||
- `import/no-cycle` is on; a barrel must not create a cycle.
|
||||
|
||||
Relative imports are written with the `.ts` extension.
|
||||
@@ -97,8 +97,7 @@ It is not a plugin rule.
|
||||
- Every `package.json` `imports` alias points only at the `index.ts` of a
|
||||
direct subfolder of `src/`, e.g. `"#helpers": "./src/helpers/index.ts"`.
|
||||
No alias to a single file, a deeper folder, or anything outside `src/`.
|
||||
Here the aliases are `#prepare`, `#run`, `#post`, `#agents`, `#processes`, `#runtime`, `#profiles`,
|
||||
`#common`, and `#testing`, in that layer order.
|
||||
Here the one alias is `"#testing": "./src/testing/index.ts"`.
|
||||
- Code outside `src/` (`scripts/`, `tests/`, `vite.config.ts`) gets no alias
|
||||
of its own and imports its own files by `./` relative paths. It reaches
|
||||
`src/` only through the aliases, since `../` is banned.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: vite.config.ts lint uses Oxlint categories at error with Deno size limits, names no rule itself, and spreads configs from the plugins and scripts/project-lint.ts
|
||||
---
|
||||
|
||||
The lint config is short and category-driven (user decisions 2026-10-14/15):
|
||||
turn on whole categories, then list only what parity or a real need adds.
|
||||
|
||||
```ts
|
||||
lint: {
|
||||
plugins: ["typescript", "unicorn", "oxc", "import"],
|
||||
jsPlugins: ["eslint-plugin-explicit-types", "./scripts/project-lint.ts"],
|
||||
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
|
||||
rules: { ...explicitTypes.configs.concrete.rules, ...projectLint.configs.project.rules },
|
||||
ignorePatterns: ["src/generated/**"], // generated files only, see below
|
||||
options: { typeAware: true, typeCheck: true, maxWarnings: 0, reportUnusedDisableDirectives: "error" },
|
||||
},
|
||||
```
|
||||
|
||||
- **Categories:** `correctness`, `suspicious`, `pedantic`, `perf` at `error`.
|
||||
`style` and `restriction` are never turned on wholesale; pick single rules
|
||||
from them in `configs.project`. Extra rules that categories bring are fine.
|
||||
- **Size limits match Deno** (2026-10-17, replaces the 2026-10-16 "default
|
||||
limits stay"): repos behave as they did on Deno. bot-agents ran `deno lint`
|
||||
with no config, so it had no size or complexity limit, and every Oxlint
|
||||
limit rule (`complexity`, `max-lines`, `max-params`, `id-length`,
|
||||
`no-nested-ternary`, `max-lines-per-function`, `max-classes-per-file`,
|
||||
`max-depth`, `max-nested-callbacks`, `max-statements`,
|
||||
`import/max-dependencies`, `unicorn/max-nested-calls`) is `"off"` in
|
||||
`configs.project`. No file is split for size. Do not add new opt-outs
|
||||
anywhere without asking the user.
|
||||
- **`vite.config.ts` names no rule.** Every rule id, built-in ones included,
|
||||
lives in `configs.project` of `scripts/project-lint.ts`
|
||||
([[project-lint-plugin]]). `vite.config.ts` only spreads configs.
|
||||
- **Published plugins are consumed through their configs**: ESLint flat config
|
||||
uses `plugin.configs.<name>`; Oxlint lists the package in `jsPlugins` and
|
||||
spreads `plugin.configs.<name>.rules`, since Oxlint plugins carry no configs.
|
||||
Use the configs equivalent to what the repo enabled under Deno.
|
||||
- **`maxWarnings: 0`** keeps Deno's "every diagnostic fails" behaviour.
|
||||
- **`ignorePatterns`** is only for generated files (compiled output, codegen).
|
||||
Hand-written code is never ignored.
|
||||
- **No lint disable comments**: [[lint-no-disable-comments]].
|
||||
- **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]].
|
||||
- Don't add a second import sorter; the import-structure plugin's
|
||||
`enforce-import-order` is the only one, and Oxfmt's `sortImports` stays off.
|
||||
|
||||
`perf` came last: in the template it flagged nine `.includes` on constant
|
||||
arrays, fixed as `ReadonlySet` + `.has()` rather than by turning a rule off.
|
||||
That is the expected response to any new rule: change the code.
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
description: Lint config shape: every non-nursery Oxlint category on, plugins via `all`, a blacklist of offs in configs.project, per-file exceptions only in projectOverrides, ignorePatterns only for generated output
|
||||
---
|
||||
|
||||
Policy (2026-10-19): a blacklist, not a whitelist. Replaces the older
|
||||
category-subset and "no overrides" notes.
|
||||
|
||||
- **Categories:** `correctness`, `suspicious`, `pedantic`, `perf`, `style`,
|
||||
`restriction` at `error`. `nursery` stays off (no line for it).
|
||||
- **Plugins** are enabled whole through their `all` config
|
||||
(`explicitTypes.configs.all.rules`, `importStructure.configs.all.rules`).
|
||||
- **Blacklist:** repo-wide offs and option tweaks live in
|
||||
`projectLint.configs.project.rules` in `scripts/project-lint.ts`, spread
|
||||
last in `rules`, each with a one-line reason. Never re-list a rule a
|
||||
category already turns on. bot-agents runs on Node everywhere, so
|
||||
`import/no-nodejs-modules`, `unicorn/no-process-exit`, and `no-console` are
|
||||
off repo-wide.
|
||||
- **Per-file exceptions** only via `projectOverrides`, the whole `overrides`
|
||||
array exported from `scripts/project-lint.ts`: the Node code entry
|
||||
(`vite.config.ts`, `scripts/**`, `nodeRules`) first, then one entry per
|
||||
exception with a why-comment. `vite.config.ts` has exactly
|
||||
`overrides: projectOverrides`.
|
||||
- **No `allowFiles`** options in custom rules, and no disable comments
|
||||
([[lint-no-disable-comments]]).
|
||||
- **`ignorePatterns`** only for generated or build output, never hand-written
|
||||
code. A hand-written file is linted, with override entries for only what it
|
||||
legitimately breaks. bot-agents has no generated code, so none.
|
||||
- **Precedence:** categories < `rules` (a later spread wins) < `overrides` (a
|
||||
later entry wins).
|
||||
- Size and complexity limits (`complexity`, `max-lines`, ...) are set to the
|
||||
smallest value the current code passes.
|
||||
|
||||
```ts
|
||||
// scripts/project-lint.ts
|
||||
export const projectOverrides: OxlintOverride[] = [
|
||||
{ files: ["vite.config.ts", "scripts/**"], rules: nodeRules },
|
||||
// Why: src/example.ts must ... (a per-file exception, if one is ever needed)
|
||||
// { files: ["src/example.ts"], rules: { "some-rule": "off" } },
|
||||
];
|
||||
|
||||
// vite.config.ts
|
||||
rules: {
|
||||
...explicitTypes.configs.all.rules,
|
||||
...importStructure.configs.all.rules,
|
||||
...projectLint.configs.project.rules,
|
||||
},
|
||||
overrides: projectOverrides,
|
||||
```
|
||||
|
||||
## Rule decisions (2026-10-19, all repos)
|
||||
|
||||
- `typescript/promise-function-async` is on with `require-await` and
|
||||
`typescript/return-await`. A function that returns a promise is `async`
|
||||
and awaits it: `async f() { const x = await g(); return x; }`. Test
|
||||
doubles do the same (`const resolved = await Promise.resolve(v); return
|
||||
resolved;`), and `expect(promise).rejects` takes the promise itself.
|
||||
`src/processes/child.ts` is exempt in `projectOverrides`: `exitCode` must
|
||||
return the very promise it marks handled, or a failed spawn would surface
|
||||
as an unhandled rejection before callers await it.
|
||||
- `init-declarations` is on: an uninitialized `let` becomes
|
||||
`let x: T | null = null;` (`unknown` already includes `null`); a `try` that
|
||||
assigns and a `catch` that exits still narrow it.
|
||||
- `no-use-before-define` is on with only `{ functions: false }`.
|
||||
- `no-await-in-loop` is off (sequential awaits are intentional) and
|
||||
`unicorn/no-array-reduce` is on: sequential steps are plain `for` loops,
|
||||
never `reduce` chains or recursion.
|
||||
- `consistent-return`, `typescript/switch-exhaustiveness-check`, and
|
||||
`default-case` are off: TypeScript's checks cover them, and no `default:`
|
||||
branch is added to an exhaustive switch.
|
||||
- `unknown` is allowed; `any` is banned and the `typescript/no-unsafe-*`
|
||||
rules are on. Untrusted data is `unknown` plus a type guard.
|
||||
- `unicorn/no-array-for-each` (use `for...of`),
|
||||
`unicorn/prefer-structured-clone`, and `typescript/prefer-nullish-coalescing`
|
||||
(defaults) stay on.
|
||||
- explicit-types `upper-snake-string-unions` is on
|
||||
([[enum-values-upper-snake]]); `unicorn/custom-error-definition` is on, so a
|
||||
custom error has a constructor that sets `this.name`.
|
||||
- `src/post/` and `src/profiles/` (code and tests) are exempt from
|
||||
`project/prefer-ascii` in `projectOverrides`: posted Gitea text shows emoji
|
||||
verdict marks and a middle dot.
|
||||
|
||||
## Rule decisions (2026-10-20, all repos)
|
||||
|
||||
- `unicorn/number-literal-case` is off: Oxfmt lowercases hex digits back.
|
||||
- `typescript/consistent-return` is off too: it misfires on complete
|
||||
switches, like `consistent-return`.
|
||||
- On with defaults, no offs: `prefer-object-spread`, `unicorn/prefer-spread`,
|
||||
`unicorn/no-useless-spread`, `typescript/no-misused-spread`, `no-plusplus`
|
||||
(`x += 1`), `unicorn/no-array-method-this-argument`,
|
||||
`typescript/method-signature-style` ("property"),
|
||||
`typescript/unified-signatures`.
|
||||
- `unicorn/import-style` uses its default: `import nodePath from "node:path"`
|
||||
and `nodePath.join(...)` (`nodePath`, since `path` is a common variable).
|
||||
- `no-template-curly-in-string` is on; only `tests/workflow.test.ts`, which
|
||||
checks literal Gitea `${{ }}` expressions, is exempt in `projectOverrides`.
|
||||
- Rule defaults are preferred over option tweaks unless a tweak is needed.
|
||||
- `fmt` matches `deno fmt`: `printWidth: 80`, `quoteProps: "preserve"`,
|
||||
`embeddedLanguageFormatting: "off"`. `max-lines-per-function` is 124 after
|
||||
the narrower width.
|
||||
|
||||
A new category hit is fixed in the code; an off or exception is added only
|
||||
for a real collision or churn, with its reason. Rules: [[project-lint-plugin]].
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
description: No lint overrides for source files; narrow ones only for scripts/**; generated files go in ignorePatterns; size limits match Deno, so nothing is split for size
|
||||
---
|
||||
|
||||
Final policy (2026-10-16). The user considers per-file `overrides` bad
|
||||
practice.
|
||||
|
||||
- **Source files: never.** No `overrides` entry may target `src/**` or any
|
||||
other hand-written production or test code.
|
||||
- **The one exception** is in lint-plugin repos: `import/max-dependencies`
|
||||
off for `src/rules/index.ts`, the rule registry, which must import every
|
||||
rule. It is not a precedent for apps.
|
||||
- **`scripts/**`:** a narrow override is allowed when a tooling script truly
|
||||
needs it, e.g. `no-console` in a CLI script.
|
||||
- **Generated files:** list them in `ignorePatterns`, never `overrides`.
|
||||
- **Anything else needs the user's approval first.** When the user is away,
|
||||
don't add it: refactor, or leave the violation failing and log the question.
|
||||
|
||||
## Fix the code instead
|
||||
|
||||
Category rule violations are fixed in the code without behavior change,
|
||||
covered by the tests, inside the same migration PR; e.g. `prefer-set-has`
|
||||
becomes `const K: ReadonlySet<string> = new Set([...])` with `.has`.
|
||||
|
||||
Size and complexity limits are not refactored for: they match what Deno
|
||||
enforced (2026-10-17, replacing the earlier "refactor to Oxlint's defaults"),
|
||||
which for bot-agents is none, so those rules are `"off"`
|
||||
([[lint-config-shape]]).
|
||||
|
||||
Alias rule: an alias may point anywhere and owns its target (a folder's
|
||||
`index.ts` owns the folder and below, any other file owns only itself); no
|
||||
two aliases may own the same file, and `../` is banned everywhere
|
||||
([[imports-aliases-barrels]]).
|
||||
@@ -11,19 +11,12 @@ Oxlint plugin with `meta.name` `"project"`, loaded through `jsPlugins` in
|
||||
plugin configs: Deno parity rules, extras, per-rule options, and opt-outs,
|
||||
built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...).
|
||||
- **Custom rules**, enabled as `project/<rule>`. Every repo has
|
||||
`no-disable-comments` and `no-asset-imports`. Since 2026-10-19 every repo
|
||||
also has `prefer-ascii`, `no-nan` (a "no number" value is `null`, never
|
||||
`NaN`), and the hugoalh `recommended` ports from
|
||||
eslint-plugin-import-structure's own lint: `no-duplicate-typeofs`,
|
||||
`no-empty-yield`, `no-unsafe-number`, `no-useless-continue`,
|
||||
`no-duplicate-interfaces`, `no-duplicate-types`, `no-useless-type`, and
|
||||
`no-modifier-private`. `no-duplicate-awaits` and `no-duplicate-voids` are
|
||||
covered by `unicorn/no-unnecessary-await` and
|
||||
`typescript/no-meaningless-void-operator`, and `no-enum` by explicit-types.
|
||||
`no-disable-comments` and `no-asset-imports`; bot-agents has no others.
|
||||
|
||||
`scripts/` stays flat; don't add `scripts/rules/`. All rules live in the one
|
||||
`scripts/project-lint.ts` (no file splitting), next to the plugin object and
|
||||
the rule list.
|
||||
`scripts/` stays flat; don't add `scripts/rules/`. If the plugin ever needs
|
||||
more files, they sit flat beside it and share the prefix
|
||||
(`project-lint-modules.ts`, ...); `scripts/project-lint.ts` keeps the plugin
|
||||
object and the rule list.
|
||||
|
||||
```ts
|
||||
import type { DummyRuleMap } from "vite-plus/lint";
|
||||
@@ -59,5 +52,5 @@ export default plugin;
|
||||
and every repo should look the same.
|
||||
|
||||
`scripts/` is tooling, not production code: never packed or published, and
|
||||
no tests. Its offs sit in the Node code entry of `projectOverrides`; see
|
||||
[[lint-config]].
|
||||
no tests. Narrow `overrides` for `scripts/**` are allowed when a script truly
|
||||
needs one (e.g. `no-console`); see [[lint-overrides-policy]].
|
||||
|
||||
@@ -14,7 +14,7 @@ description: Node.js (no engines in apps; images pin 24) with Vite+ (vp) and npm
|
||||
`engines` or a `.node-version`, local `vp` uses its default Node
|
||||
(`vp env default`). bot-agents has no Containerfile or setup step: the
|
||||
self-hosted runner's `node:24-bookworm` job image runs the action and its
|
||||
checks, and `src/post/index.ts` needs 24.2+ (`import.meta.main`).
|
||||
checks, and `src/post.ts` needs 24.2+ (`import.meta.main`).
|
||||
- **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or
|
||||
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
|
||||
`vp` brings its own Node, the way uv brings its own Python.
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The action runs on this same image; src/post/index.ts needs Node 24.2+.
|
||||
# The action runs on this same image; src/post.ts needs Node 24.2+.
|
||||
- run: node --version
|
||||
|
||||
- run: npm ci
|
||||
|
||||
@@ -20,7 +20,7 @@ dependency. Keep the sources to erasable syntax and `.ts` imports, which
|
||||
`exactOptionalPropertyTypes` off
|
||||
([.agents/memory/ts-only-files-and-imports.md](.agents/memory/ts-only-files-and-imports.md)).
|
||||
Checks use Vite+: `vp check` formats, lints, and type-checks, and `vp test`
|
||||
runs Vitest. Unit tests sit next to the code as `*.test.ts` in `src/`;
|
||||
runs Vitest. Unit tests sit next to the code as `src/*.test.ts`;
|
||||
repository-level tests live in `tests/`.
|
||||
|
||||
A `#` alias in `package.json` `imports` may point anywhere and owns its
|
||||
@@ -34,28 +34,20 @@ import is banned in every file, owned or not
|
||||
(`import/no-relative-parent-imports`); runtime file URLs such as
|
||||
`new URL("../prompt.md", import.meta.url)` are paths, not imports, and stay.
|
||||
Files no alias owns need no `index.ts` and no alias, and import `./` siblings
|
||||
and subfolders freely. Every top-level entry of `src/` is an aliased module
|
||||
folder. The aliases, in layer order, are the three phases `#prepare`, `#run`,
|
||||
and `#post`, then `#agents` (Codex,
|
||||
Claude Code and OpenCode specifics), `#processes` (child processes and the
|
||||
isolated profile users), `#runtime` (the prepared runtime and its
|
||||
credentials), `#profiles`, `#common` (error and JSON checks), and `#testing`
|
||||
(test helpers), each with a `src/<name>/index.ts` barrel. A phase's
|
||||
`index.ts` is also its entry point, which `action.yml` runs as
|
||||
`node src/<phase>/index.ts`. Tests that use the test
|
||||
helpers import `#testing`, and `tests/` imports `src/` only through it. Local convention, stricter than the general rule above: in
|
||||
and subfolders freely. The one alias is `#testing` (`./src/testing/index.ts`):
|
||||
tests that use its helpers import it by alias, and `tests/` imports `src/`
|
||||
only through it. Local convention, stricter than the general rule above: in
|
||||
this repo, aliases point only at a direct subfolder of `src/`
|
||||
(`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses `./`
|
||||
relative imports. `scripts/` is flat: one file per tool, no subfolders and no `index.ts`, imported directly by file path
|
||||
([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)).
|
||||
|
||||
The only config files are `package.json`, `tsconfig.json`, and
|
||||
`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules, the rule
|
||||
list, and the `overrides` array (`projectOverrides`) live in
|
||||
`scripts/project-lint.ts`
|
||||
([.agents/memory/lint-config.md](.agents/memory/lint-config.md)). Lint disable
|
||||
comments are not allowed; fix the source instead.
|
||||
`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.
|
||||
Claude Code, Codex, and OpenCode command-line tools, plus Deno for agents that
|
||||
check Deno-based repositories; do not treat those vendor executables as
|
||||
repository TypeScript dependencies.
|
||||
|
||||
+6
-5
@@ -16,10 +16,11 @@ runs:
|
||||
fetch-depth: 0
|
||||
token: ${{ gitea.token }}
|
||||
# This step assumes this is `node:24-bookworm` container. The action runs
|
||||
# its TypeScript on that Node directly.
|
||||
# its TypeScript on that Node directly; Deno stays for the agents, which
|
||||
# still check Deno-based repositories with it.
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai
|
||||
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno
|
||||
# A local action lives inside the writable checkout. Execute a root-owned
|
||||
# copy so an agent changing this repository cannot replace a later phase.
|
||||
- name: Protect action source
|
||||
@@ -35,7 +36,7 @@ runs:
|
||||
- name: Prepare credentials
|
||||
id: prepare
|
||||
shell: bash
|
||||
run: node "${{ steps.source.outputs.path }}/src/prepare/index.ts"
|
||||
run: node "${{ steps.source.outputs.path }}/src/prepare.ts"
|
||||
env:
|
||||
ACTION_PATH: ${{ steps.source.outputs.path }}
|
||||
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
|
||||
@@ -43,7 +44,7 @@ runs:
|
||||
- name: Run bot
|
||||
id: agent
|
||||
shell: bash
|
||||
run: node "${{ steps.source.outputs.path }}/src/run/index.ts"
|
||||
run: node "${{ steps.source.outputs.path }}/src/run.ts"
|
||||
env:
|
||||
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
@@ -56,7 +57,7 @@ runs:
|
||||
id: post
|
||||
if: always() && steps.prepare.outputs.manifest != ''
|
||||
shell: bash
|
||||
run: node "${{ steps.source.outputs.path }}/src/post/index.ts"
|
||||
run: node "${{ steps.source.outputs.path }}/src/post.ts"
|
||||
env:
|
||||
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
|
||||
GITEA_API_URL: ${{ gitea.api_url }}
|
||||
|
||||
Generated
-36
@@ -9,8 +9,6 @@
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@types/node": "^24.19.2",
|
||||
"@typescript-eslint/types": "^8.71.2",
|
||||
"eslint-plugin-explicit-types": "^0.7.2",
|
||||
"eslint-plugin-import-structure": "^0.12.0",
|
||||
"vite-plus": "^1.2.0",
|
||||
"yaml": "^2.9.1"
|
||||
}
|
||||
@@ -1889,40 +1887,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-explicit-types": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.7.2.tgz",
|
||||
"integrity": "sha512-ffHVVuyBH2esZwwRItcz2sDnL5xvSRDAoLjSqmQbevT6ETcjtfThtLoWoc+cv5pvtxa6YiXEK1P2D+x/yUBc1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.18"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import-structure": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import-structure/-/eslint-plugin-import-structure-0.12.0.tgz",
|
||||
"integrity": "sha512-axo8TH0mik9NmsnW+J5rNwNXlOxxqOhULwN7zE4EJaNRdk/Ci/aRac3BAeeIi/SDUyPgN6Ig34l+mFlLQYWkfw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"eslint": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
|
||||
@@ -4,22 +4,12 @@
|
||||
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#prepare": "./src/prepare/index.ts",
|
||||
"#run": "./src/run/index.ts",
|
||||
"#post": "./src/post/index.ts",
|
||||
"#agents": "./src/agents/index.ts",
|
||||
"#processes": "./src/processes/index.ts",
|
||||
"#runtime": "./src/runtime/index.ts",
|
||||
"#profiles": "./src/profiles/index.ts",
|
||||
"#common": "./src/common/index.ts",
|
||||
"#testing": "./src/testing/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@types/node": "^24.19.2",
|
||||
"@typescript-eslint/types": "^8.71.2",
|
||||
"eslint-plugin-explicit-types": "^0.7.2",
|
||||
"eslint-plugin-import-structure": "^0.12.0",
|
||||
"vite-plus": "^1.2.0",
|
||||
"yaml": "^2.9.1"
|
||||
}
|
||||
|
||||
+6
-6
@@ -2,16 +2,16 @@
|
||||
"reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY",
|
||||
"profiles": [
|
||||
{
|
||||
"type": "CLAUDE",
|
||||
"type": "codex",
|
||||
"model": "gpt-5.6-terra"
|
||||
},
|
||||
{
|
||||
"type": "claude",
|
||||
"model": "claude-sonnet-5",
|
||||
"token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg"
|
||||
},
|
||||
{
|
||||
"type": "CODEX",
|
||||
"model": "gpt-5.6-terra"
|
||||
},
|
||||
{
|
||||
"type": "OPENCODE",
|
||||
"type": "opencode",
|
||||
"model": "openrouter/qwen/qwen3.8-27b",
|
||||
"token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0"
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ achieved.
|
||||
|
||||
Every finding that belongs to one line of the diff goes on that line instead of
|
||||
into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry
|
||||
`{"path": "<path from the repository root>", "line": <number>, "side": "NEW" |
|
||||
"OLD", "body": "<the finding>"}`.
|
||||
`side` is `NEW` for a line in the head file and `OLD` for one only in the base
|
||||
`{"path": "<path from the repository root>", "line": <number>, "side": "new" |
|
||||
"old", "body": "<the finding>"}`.
|
||||
`side` is `new` for a line in the head file and `old` for one only in the base
|
||||
file; `line` is that file's own line number, and it must be a line the diff
|
||||
touches, or Gitea refuses the anchor. Write the file only when there is
|
||||
something to anchor, and keep each body to the what, the why, and the how, with
|
||||
|
||||
+80
-773
@@ -1,29 +1,20 @@
|
||||
/**
|
||||
* `project` - this repository's changes to the Oxlint categories in
|
||||
* `vite.config.ts`. Its rules of its own are `no-disable-comments`,
|
||||
* `no-asset-imports`, `prefer-ascii`, and ports of the hugoalh `recommended`
|
||||
* rules Oxlint lacks, as in eslint-plugin-import-structure's own lint.
|
||||
* `project` — lint rules this repository holds its own source to, on top of
|
||||
* the Oxlint categories in `vite.config.ts`. Its rules of its own are
|
||||
* `no-disable-comments` and `no-asset-imports`.
|
||||
*
|
||||
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type {
|
||||
LanguageOptions,
|
||||
Position,
|
||||
RuleDefinition,
|
||||
RuleTextEdit,
|
||||
RuleVisitor,
|
||||
TextSourceCode,
|
||||
} from "@eslint/core";
|
||||
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core";
|
||||
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types";
|
||||
import type { DummyRuleMap, OxlintOverride } from "vite-plus/lint";
|
||||
import type { DummyRuleMap } from "vite-plus/lint";
|
||||
|
||||
/** The source code both hosts hand a rule, with the comment list and index lookup. */
|
||||
/** The source code both hosts hand a rule, with the comment list. */
|
||||
interface ProjectSourceCode extends TextSourceCode {
|
||||
getAllComments: () => TSESTree.Comment[];
|
||||
getLocFromIndex: (index: number) => Position;
|
||||
getAllComments(): TSESTree.Comment[];
|
||||
}
|
||||
|
||||
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */
|
||||
@@ -47,7 +38,7 @@ const DIRECTIVE_PATTERN: RegExp = /^\s*(?:eslint|oxlint)-(?:disable|enable)/u;
|
||||
const RULE_NAME: string = "no-disable-comments";
|
||||
|
||||
/**
|
||||
* `no-disable-comments` - rejects every `eslint-disable*`, `eslint-enable*`,
|
||||
* `no-disable-comments` — rejects every `eslint-disable*`, `eslint-enable*`,
|
||||
* `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has
|
||||
* no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them
|
||||
* itself: when a rule fires, fix the source.
|
||||
@@ -62,18 +53,13 @@ const noDisableCommentsRule: ProjectRule = {
|
||||
type: "problem",
|
||||
docs: { description: "Disallow lint disable and enable comments" },
|
||||
schema: [],
|
||||
messages: {
|
||||
directive: "Lint disable comments are not allowed; fix the source.",
|
||||
},
|
||||
messages: { directive: "Lint disable comments are not allowed; fix the source." },
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Program(_node: TSESTree.Program): void {
|
||||
for (const comment of context.sourceCode.getAllComments()) {
|
||||
if (
|
||||
DIRECTIVE_PATTERN.test(comment.value) &&
|
||||
!comment.value.includes(RULE_NAME)
|
||||
) {
|
||||
if (DIRECTIVE_PATTERN.test(comment.value) && !comment.value.includes(RULE_NAME)) {
|
||||
context.report({ loc: comment.loc, messageId: "directive" });
|
||||
}
|
||||
}
|
||||
@@ -82,565 +68,6 @@ const noDisableCommentsRule: ProjectRule = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The highest code point `prefer-ascii` lets through. */
|
||||
const MAX_ASCII_CODE_POINT: number = 127;
|
||||
|
||||
/**
|
||||
* `prefer-ascii` - every character of the source is ASCII. A file is exempted
|
||||
* only through an entry in `projectOverrides`.
|
||||
*/
|
||||
const preferAsciiRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: { description: "Require the source to be ASCII" },
|
||||
schema: [],
|
||||
messages: {
|
||||
nonAscii:
|
||||
"Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Program(_node: TSESTree.Program): void {
|
||||
const { sourceCode } = context;
|
||||
let index: number = 0;
|
||||
for (const character of sourceCode.text) {
|
||||
const codePoint: number = character.codePointAt(0) ?? 0;
|
||||
if (codePoint > MAX_ASCII_CODE_POINT) {
|
||||
context.report({
|
||||
loc: {
|
||||
start: sourceCode.getLocFromIndex(index),
|
||||
end: sourceCode.getLocFromIndex(index + character.length),
|
||||
},
|
||||
messageId: "nonAscii",
|
||||
data: {
|
||||
character,
|
||||
codePoint: codePoint
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
.padStart(4, "0"),
|
||||
},
|
||||
});
|
||||
}
|
||||
index += character.length;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** `no-duplicate-typeofs` - `typeof typeof x` is always `"string"`. */
|
||||
const noDuplicateTypeofsRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: { description: "Disallow `typeof` applied twice in a row" },
|
||||
schema: [],
|
||||
messages: {
|
||||
doubled:
|
||||
'Multiple `typeof`s always return `"string"`, possibly not intended.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
UnaryExpression(node: TSESTree.UnaryExpression): void {
|
||||
if (
|
||||
node.operator === "typeof" &&
|
||||
node.argument.type === AST_NODE_TYPES.UnaryExpression &&
|
||||
node.argument.operator === "typeof"
|
||||
) {
|
||||
context.report({ node, messageId: "doubled" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** `no-empty-yield` - a `yield` with no expression, likely missing one. */
|
||||
const noEmptyYieldRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: { description: "Disallow `yield` without an expression" },
|
||||
schema: [],
|
||||
messages: {
|
||||
empty: "Empty `yield` is not allowed, possibly missing the expression.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
YieldExpression(node: TSESTree.YieldExpression): void {
|
||||
if (node.argument === null) {
|
||||
context.report({ node, messageId: "empty" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** The globals `no-nan` sees through, as in `globalThis.Number.NaN`. */
|
||||
const GLOBAL_NAMES: ReadonlySet<string> = new Set<string>([
|
||||
"globalThis",
|
||||
"self",
|
||||
"window",
|
||||
]);
|
||||
|
||||
/** The name a member expression's property spells, computed or not. */
|
||||
function propertyName(node: TSESTree.MemberExpression): string | null {
|
||||
if (node.property.type === AST_NODE_TYPES.Identifier && !node.computed) {
|
||||
return node.property.name;
|
||||
}
|
||||
if (
|
||||
node.property.type === AST_NODE_TYPES.Literal &&
|
||||
typeof node.property.value === "string"
|
||||
) {
|
||||
return node.property.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether `node` is a global object, such as `globalThis` or `globalThis.self`. */
|
||||
function isGlobalChain(node: TSESTree.Expression): boolean {
|
||||
if (node.type === AST_NODE_TYPES.Identifier) {
|
||||
return GLOBAL_NAMES.has(node.name);
|
||||
}
|
||||
return (
|
||||
node.type === AST_NODE_TYPES.MemberExpression &&
|
||||
GLOBAL_NAMES.has(propertyName(node) ?? "") &&
|
||||
isGlobalChain(node.object)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a member expression reads the global `NaN`: `Number.NaN`,
|
||||
* `globalThis.NaN`, or `globalThis.Number.NaN`.
|
||||
*/
|
||||
function isNanMember(node: TSESTree.MemberExpression): boolean {
|
||||
if (propertyName(node) !== "NaN") {
|
||||
return false;
|
||||
}
|
||||
const { object } = node;
|
||||
if (object.type === AST_NODE_TYPES.Identifier && object.name === "Number") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
isGlobalChain(object) ||
|
||||
(object.type === AST_NODE_TYPES.MemberExpression &&
|
||||
propertyName(object) === "Number" &&
|
||||
isGlobalChain(object.object))
|
||||
);
|
||||
}
|
||||
|
||||
/** `no-nan` - a `NaN` value is usually an error; use `null` for "no number". */
|
||||
const noNanRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: { description: "Disallow `NaN` as a value" },
|
||||
schema: [],
|
||||
messages: {
|
||||
nan: "Using `NaN` as a value is usually an error and not intended.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Identifier(node: TSESTree.Identifier): void {
|
||||
if (
|
||||
node.name === "NaN" &&
|
||||
node.parent.type !== AST_NODE_TYPES.MemberExpression
|
||||
) {
|
||||
context.report({ node, messageId: "nan" });
|
||||
}
|
||||
},
|
||||
MemberExpression(node: TSESTree.MemberExpression): void {
|
||||
if (isNanMember(node)) {
|
||||
context.report({ node, messageId: "nan" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** `no-unsafe-number` - a number literal outside the safe integer range. */
|
||||
const noUnsafeNumberRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Disallow number literals outside the safe integer range",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
unsafe:
|
||||
"Number literals with values equal to 2^53 or greater are too large to be represented accurately.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Literal(node: TSESTree.Literal): void {
|
||||
if (
|
||||
typeof node.value === "number" &&
|
||||
(node.value < Number.MIN_SAFE_INTEGER ||
|
||||
node.value > Number.MAX_SAFE_INTEGER)
|
||||
) {
|
||||
context.report({ node, messageId: "unsafe" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** A loop statement, whose last `continue` `no-useless-continue` checks. */
|
||||
type LoopStatement =
|
||||
| TSESTree.DoWhileStatement
|
||||
| TSESTree.ForInStatement
|
||||
| TSESTree.ForOfStatement
|
||||
| TSESTree.ForStatement
|
||||
| TSESTree.WhileStatement;
|
||||
|
||||
/** A loop's trailing unlabeled `continue`, and whether removing it is a safe fix. */
|
||||
interface TrailingContinue {
|
||||
statement: TSESTree.ContinueStatement;
|
||||
removable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unlabeled `continue` a loop ends with, and whether removing it is a
|
||||
* safe fix: it is in a block, rather than the loop's whole body.
|
||||
*/
|
||||
function trailingContinue(node: LoopStatement): TrailingContinue | null {
|
||||
if (node.body.type === AST_NODE_TYPES.BlockStatement) {
|
||||
const last = node.body.body.at(-1);
|
||||
return last?.type === AST_NODE_TYPES.ContinueStatement &&
|
||||
last.label === null
|
||||
? { statement: last, removable: true }
|
||||
: null;
|
||||
}
|
||||
if (
|
||||
node.body.type === AST_NODE_TYPES.ContinueStatement &&
|
||||
node.body.label === null
|
||||
) {
|
||||
return { statement: node.body, removable: false };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `no-useless-continue` - an unlabeled `continue` that ends a loop body. A
|
||||
* trailing `continue;` in a block is removed by the fix; a loop whose whole
|
||||
* body is `continue;` is only reported.
|
||||
*/
|
||||
const noUselessContinueRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description: "Disallow an unlabeled `continue` at the end of a loop",
|
||||
},
|
||||
fixable: "code",
|
||||
schema: [],
|
||||
messages: {
|
||||
useless:
|
||||
"The statement `continue` at the end of the loop and without a label is useless.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
function check(node: LoopStatement): void {
|
||||
const found: TrailingContinue | null = trailingContinue(node);
|
||||
if (found === null) {
|
||||
return;
|
||||
}
|
||||
if (found.removable) {
|
||||
context.report({
|
||||
node: found.statement,
|
||||
messageId: "useless",
|
||||
fix: (fixer): RuleTextEdit => fixer.remove(found.statement),
|
||||
});
|
||||
} else {
|
||||
context.report({ node: found.statement, messageId: "useless" });
|
||||
}
|
||||
}
|
||||
return {
|
||||
DoWhileStatement: check,
|
||||
ForInStatement: check,
|
||||
ForOfStatement: check,
|
||||
ForStatement: check,
|
||||
WhileStatement: check,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Node keys that say where a node is, not what it is, in ESLint and Oxlint. */
|
||||
const POSITION_KEYS: ReadonlySet<string> = new Set<string>([
|
||||
"parent",
|
||||
"range",
|
||||
"loc",
|
||||
"start",
|
||||
"end",
|
||||
]);
|
||||
|
||||
/** Node types whose member lists mean the same in any order. */
|
||||
const UNORDERED_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
"TSInterfaceBody",
|
||||
"TSTypeLiteral",
|
||||
"TSUnionType",
|
||||
"TSIntersectionType",
|
||||
]);
|
||||
|
||||
/** Type annotations too simple for an alias of their own (`no-useless-type`). */
|
||||
const SIMPLE_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
"TSAnyKeyword",
|
||||
"TSBigIntKeyword",
|
||||
"TSBooleanKeyword",
|
||||
"TSIntrinsicKeyword",
|
||||
"TSNeverKeyword",
|
||||
"TSNullKeyword",
|
||||
"TSNumberKeyword",
|
||||
"TSObjectKeyword",
|
||||
"TSStringKeyword",
|
||||
"TSSymbolKeyword",
|
||||
"TSUndefinedKeyword",
|
||||
"TSUnknownKeyword",
|
||||
"TSVoidKeyword",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A node's shape as text, without positions, so two declarations compare
|
||||
* alike wherever they sit. Members of a type literal, interface body,
|
||||
* union, or intersection are sorted, since their order means nothing.
|
||||
*/
|
||||
function serialize(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item: unknown) => serialize(item)).join(",")}]`;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
}
|
||||
const unordered: boolean =
|
||||
"type" in value && UNORDERED_TYPES.has(String(value.type));
|
||||
const parts: string[] = Object.entries(value)
|
||||
.filter(([key]: [string, unknown]) => !POSITION_KEYS.has(key))
|
||||
.map(([key, child]: [string, unknown]) => {
|
||||
const text: string =
|
||||
unordered && Array.isArray(child)
|
||||
? `[${child
|
||||
.map((item: unknown) => serialize(item))
|
||||
.toSorted()
|
||||
.join(",")}]`
|
||||
: serialize(child);
|
||||
return `${key}:${text}`;
|
||||
});
|
||||
return `{${parts.join(",")}}`;
|
||||
}
|
||||
|
||||
/** The top-level declarations in a block, exported or not, that `matches`. */
|
||||
function declarationsOf<T extends TSESTree.Node>(
|
||||
statements: TSESTree.Node[],
|
||||
matches: (node: TSESTree.Node) => node is T,
|
||||
): T[] {
|
||||
const found: T[] = [];
|
||||
for (const statement of statements) {
|
||||
const declaration: TSESTree.Node | null =
|
||||
statement.type === AST_NODE_TYPES.ExportNamedDeclaration
|
||||
? statement.declaration
|
||||
: statement;
|
||||
if (declaration !== null && matches(declaration)) {
|
||||
found.push(declaration);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Whether a node is an interface declaration. */
|
||||
function isInterface(
|
||||
node: TSESTree.Node,
|
||||
): node is TSESTree.TSInterfaceDeclaration {
|
||||
return node.type === AST_NODE_TYPES.TSInterfaceDeclaration;
|
||||
}
|
||||
|
||||
/** Whether a node is a type alias declaration. */
|
||||
function isTypeAlias(
|
||||
node: TSESTree.Node,
|
||||
): node is TSESTree.TSTypeAliasDeclaration {
|
||||
return node.type === AST_NODE_TYPES.TSTypeAliasDeclaration;
|
||||
}
|
||||
|
||||
/** Groups nodes by a key and returns the groups with more than one node. */
|
||||
function duplicateGroups<T>(nodes: T[], keyOf: (node: T) => string): T[][] {
|
||||
const groups: Map<string, T[]> = Map.groupBy(nodes, keyOf);
|
||||
return [...groups.values()].filter((group: T[]) => group.length > 1);
|
||||
}
|
||||
|
||||
/** What an interface declares, its `extends` list included. */
|
||||
function interfaceShape(node: TSESTree.TSInterfaceDeclaration): string {
|
||||
return `${serialize(node.extends)}&${serialize(node.body)}`;
|
||||
}
|
||||
|
||||
/** An interface's name. */
|
||||
function interfaceName(node: TSESTree.TSInterfaceDeclaration): string {
|
||||
return node.id.name;
|
||||
}
|
||||
|
||||
/** What a type alias stands for. */
|
||||
function typeAliasShape(node: TSESTree.TSTypeAliasDeclaration): string {
|
||||
return serialize(node.typeAnnotation);
|
||||
}
|
||||
|
||||
/**
|
||||
* `no-duplicate-interfaces` - two interfaces in one block with the same
|
||||
* name, which merge silently, or with the same shape, which could be one.
|
||||
*/
|
||||
const noDuplicateInterfacesRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow interfaces with the same name or shape in one block",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
sameShape:
|
||||
"Found multiple interfaces with the same shape, possibly mergeable.",
|
||||
sameName:
|
||||
"Found multiple interfaces named '{{name}}', possibly not intended.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
function check(statements: TSESTree.Node[]): void {
|
||||
const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf(
|
||||
statements,
|
||||
isInterface,
|
||||
);
|
||||
for (const node of duplicateGroups(nodes, interfaceShape).flat()) {
|
||||
context.report({ node, messageId: "sameShape" });
|
||||
}
|
||||
for (const node of duplicateGroups(nodes, interfaceName).flat()) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "sameName",
|
||||
data: { name: node.id.name },
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
BlockStatement(node: TSESTree.BlockStatement): void {
|
||||
check(node.body);
|
||||
},
|
||||
Program(node: TSESTree.Program): void {
|
||||
check(node.body);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** `no-duplicate-types` - two type aliases in one block with the same type. */
|
||||
const noDuplicateTypesRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description: "Disallow type aliases with the same type in one block",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
sameShape:
|
||||
"Found multiple types with the same shape, possibly mergeable.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
function check(statements: TSESTree.Node[]): void {
|
||||
const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf(
|
||||
statements,
|
||||
isTypeAlias,
|
||||
);
|
||||
for (const node of duplicateGroups(nodes, typeAliasShape).flat()) {
|
||||
context.report({ node, messageId: "sameShape" });
|
||||
}
|
||||
}
|
||||
return {
|
||||
BlockStatement(node: TSESTree.BlockStatement): void {
|
||||
check(node.body);
|
||||
},
|
||||
Program(node: TSESTree.Program): void {
|
||||
check(node.body);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** A class member that can carry the `private` keyword. */
|
||||
type AccessibilityCarrier =
|
||||
| TSESTree.AccessorProperty
|
||||
| TSESTree.MethodDefinition
|
||||
| TSESTree.PropertyDefinition
|
||||
| TSESTree.TSAbstractAccessorProperty
|
||||
| TSESTree.TSAbstractMethodDefinition
|
||||
| TSESTree.TSAbstractPropertyDefinition
|
||||
| TSESTree.TSParameterProperty;
|
||||
|
||||
/**
|
||||
* `no-modifier-private` - class members are made private with `#`, never the
|
||||
* `private` keyword, which only TypeScript enforces. A `private constructor`
|
||||
* has no `#` form and stays allowed.
|
||||
*/
|
||||
const noModifierPrivateRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: { description: "Disallow the private modifier" },
|
||||
schema: [],
|
||||
messages: {
|
||||
private: "Use a # private name instead of the private modifier.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
function check(node: AccessibilityCarrier): void {
|
||||
const isConstructor: boolean =
|
||||
node.type === AST_NODE_TYPES.MethodDefinition &&
|
||||
node.kind === "constructor";
|
||||
if (node.accessibility === "private" && !isConstructor) {
|
||||
context.report({ node, messageId: "private" });
|
||||
}
|
||||
}
|
||||
return {
|
||||
AccessorProperty: check,
|
||||
MethodDefinition: check,
|
||||
PropertyDefinition: check,
|
||||
TSAbstractAccessorProperty: check,
|
||||
TSAbstractMethodDefinition: check,
|
||||
TSAbstractPropertyDefinition: check,
|
||||
TSParameterProperty: check,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Whether a type is too simple to need an alias: a keyword, literal, or bare name. */
|
||||
function isSimpleType(node: TSESTree.TypeNode): boolean {
|
||||
if (SIMPLE_TYPES.has(node.type)) {
|
||||
return true;
|
||||
}
|
||||
if (node.type === AST_NODE_TYPES.TSLiteralType) {
|
||||
return node.literal.type === AST_NODE_TYPES.Literal;
|
||||
}
|
||||
return (
|
||||
node.type === AST_NODE_TYPES.TSTypeReference &&
|
||||
node.typeName.type === AST_NODE_TYPES.Identifier &&
|
||||
node.typeArguments === undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** `no-useless-type` - a type alias for a keyword, literal, or bare type name. */
|
||||
const noUselessTypeRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: { description: "Disallow type aliases too simple to need a name" },
|
||||
schema: [],
|
||||
messages: { simple: "The type is too simple to need an alias." },
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
TSTypeAliasDeclaration(node: TSESTree.TSTypeAliasDeclaration): void {
|
||||
if (isSimpleType(node.typeAnnotation)) {
|
||||
context.report({ node, messageId: "simple" });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** A module specifier read as a path: relative, absolute, or a `#` alias. */
|
||||
const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
|
||||
|
||||
@@ -649,12 +76,7 @@ const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
|
||||
* `.mts`, or `.mjs` package subpaths. `.cts`, `.cjs`, `.tsx`, and `.jsx` are
|
||||
* not among them.
|
||||
*/
|
||||
const SCRIPT_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
".ts",
|
||||
".js",
|
||||
".mts",
|
||||
".mjs",
|
||||
]);
|
||||
const SCRIPT_EXTENSIONS: ReadonlySet<string> = new Set([".ts", ".js", ".mts", ".mjs"]);
|
||||
|
||||
/**
|
||||
* Whether `source` names an asset rather than a script module: a query string
|
||||
@@ -674,11 +96,7 @@ function isAssetSource(source: string): boolean {
|
||||
}
|
||||
}
|
||||
const lastSegment: string = segments.at(-1) ?? "";
|
||||
if (
|
||||
lastSegment === "." ||
|
||||
lastSegment === ".." ||
|
||||
!lastSegment.includes(".")
|
||||
) {
|
||||
if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) {
|
||||
return false;
|
||||
}
|
||||
const extension: string = lastSegment.slice(lastSegment.lastIndexOf("."));
|
||||
@@ -686,7 +104,7 @@ function isAssetSource(source: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* `no-asset-imports` - rejects importing anything but a script module: a
|
||||
* `no-asset-imports` — rejects importing anything but a script module: a
|
||||
* source path, static or a dynamic `import()` with a string literal, ending in
|
||||
* an extension other than `.ts`, `.js`, `.mts`, or `.mjs` (`.css`, `.json`,
|
||||
* `.svg`, `.jsx`, `.tsx`, `.cjs`) or carrying a query string (`?raw`, `?url`),
|
||||
@@ -699,10 +117,7 @@ function isAssetSource(source: string): boolean {
|
||||
const noAssetImportsRule: ProjectRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow importing assets instead of .ts, .js, .mts, or .mjs modules",
|
||||
},
|
||||
docs: { description: "Disallow importing assets instead of .ts, .js, .mts, or .mjs modules" },
|
||||
schema: [],
|
||||
messages: {
|
||||
extension: "Only .ts, .js, .mts and .mjs modules may be imported.",
|
||||
@@ -734,10 +149,7 @@ const noAssetImportsRule: ProjectRule = {
|
||||
ExportNamedDeclaration: checkDeclaration,
|
||||
ExportAllDeclaration: checkDeclaration,
|
||||
ImportExpression(node: TSESTree.ImportExpression): void {
|
||||
if (
|
||||
node.source.type === AST_NODE_TYPES.Literal &&
|
||||
typeof node.source.value === "string"
|
||||
) {
|
||||
if (node.source.type === AST_NODE_TYPES.Literal && typeof node.source.value === "string") {
|
||||
checkSource(node.source);
|
||||
}
|
||||
if (node.options !== null) {
|
||||
@@ -749,9 +161,9 @@ const noAssetImportsRule: ProjectRule = {
|
||||
};
|
||||
|
||||
/**
|
||||
* This repository's changes to the Oxlint categories in `vite.config.ts`,
|
||||
* which turn every non-nursery category on: its own rules, the category
|
||||
* rules it turns off, and options that differ from a rule's defaults.
|
||||
* This repository's own rule list, spread by `vite.config.ts`: rules that
|
||||
* sit outside the Oxlint categories, plus the template's extras. Built-in
|
||||
* Oxlint rule ids sit here too, so `vite.config.ts` lists no rule by name.
|
||||
*/
|
||||
const rules: DummyRuleMap = {
|
||||
// The explicit-types template's `camelcase` stand-in: no `_` inside a name
|
||||
@@ -760,13 +172,33 @@ const rules: DummyRuleMap = {
|
||||
"id-match": [
|
||||
"error",
|
||||
"^_*[^_]*_*$|^[^a-z]*$",
|
||||
{
|
||||
properties: false,
|
||||
classFields: false,
|
||||
onlyDeclarations: false,
|
||||
ignoreDestructuring: true,
|
||||
},
|
||||
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true },
|
||||
],
|
||||
// Rules outside the categories.
|
||||
"no-array-constructor": "error",
|
||||
"no-case-declarations": "error",
|
||||
"no-empty": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-inner-declarations": "error",
|
||||
"no-prototype-builtins": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-var": "error",
|
||||
"prefer-const": "error",
|
||||
"require-await": "error",
|
||||
"require-yield": "error",
|
||||
"typescript/adjacent-overload-signatures": "error",
|
||||
"typescript/ban-ts-comment": "error",
|
||||
"typescript/no-empty-interface": "error",
|
||||
"typescript/no-empty-object-type": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-namespace": "error",
|
||||
"typescript/no-non-null-asserted-optional-chain": "error",
|
||||
"typescript/no-unsafe-function-type": "error",
|
||||
"typescript/no-wrapper-object-types": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/prefer-namespace-keyword": "error",
|
||||
"typescript/triple-slash-reference": "error",
|
||||
// On Node, these come from an explicit `node:` import instead of the global scope.
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
@@ -777,162 +209,47 @@ const rules: DummyRuleMap = {
|
||||
"setImmediate",
|
||||
"clearImmediate",
|
||||
],
|
||||
// Single capital type parameters, which explicit-types requires, may be one letter.
|
||||
"id-length": ["error", { exceptionPatterns: ["^[A-Z]$"] }],
|
||||
// Option tweaks.
|
||||
"one-var": ["error", "never"],
|
||||
"no-use-before-define": ["error", { functions: false }],
|
||||
"func-style": ["error", "declaration", { allowArrowFunctions: true }],
|
||||
// Each limit is the smallest value the current code passes.
|
||||
complexity: ["error", 26],
|
||||
"max-depth": ["error", 6],
|
||||
"max-lines": [
|
||||
"error",
|
||||
{ max: 2048, skipBlankLines: false, skipComments: false },
|
||||
],
|
||||
"max-lines-per-function": ["error", 124],
|
||||
"max-params": ["error", 4],
|
||||
"max-statements": ["error", 32],
|
||||
"import/max-dependencies": ["error", { max: 13 }],
|
||||
"unicorn/max-nested-calls": ["error", { max: 4 }],
|
||||
// No lint suppression and only `.ts` imports, as in the plugin template.
|
||||
// The explicit-types template's extras.
|
||||
"import/no-cycle": "error",
|
||||
// `../` is banned in every file; anything above a file needs a `#` alias.
|
||||
"import/no-relative-parent-imports": "error",
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
"typescript/no-non-null-assertion": "error",
|
||||
// No disable comments at all.
|
||||
"project/no-disable-comments": "error",
|
||||
"project/no-asset-imports": "error",
|
||||
// Ports of the hugoalh `recommended` rules Oxlint lacks, and ASCII-only source.
|
||||
"project/prefer-ascii": "error",
|
||||
"project/no-duplicate-typeofs": "error",
|
||||
"project/no-empty-yield": "error",
|
||||
"project/no-nan": "error",
|
||||
"project/no-unsafe-number": "error",
|
||||
"project/no-useless-continue": "error",
|
||||
"project/no-duplicate-interfaces": "error",
|
||||
"project/no-duplicate-types": "error",
|
||||
"project/no-modifier-private": "error",
|
||||
"project/no-useless-type": "error",
|
||||
// Off, collides with a repo or plugin rule, Oxfmt, or another category rule:
|
||||
// Collides with `#private`: members are public unless marked so.
|
||||
"typescript/explicit-member-accessibility": "off",
|
||||
// Collides with `typescript/prefer-readonly-parameter-types` being off.
|
||||
"typescript/prefer-readonly": "off",
|
||||
// Collides with Node's and the web's mutable types (`Response`, `Stats`).
|
||||
"unicorn/no-abusive-eslint-disable": "error",
|
||||
// Size and complexity limits match Deno's: this repo's `deno lint` ran the
|
||||
// `recommended` set with no config file, so no size or complexity rule
|
||||
// applied and none applies here. Each Oxlint limit rule, from a category or
|
||||
// not, is off:
|
||||
// - `complexity`: Deno had no `max-complexity`.
|
||||
// - `max-lines`: Deno had no `max-file-lines`.
|
||||
// - `max-params`: Deno had no `max-params`.
|
||||
// - `id-length`: Deno had no `max-identifier-length`.
|
||||
// - `no-nested-ternary`: Deno had no `max-nest-ternaries`.
|
||||
// - `max-lines-per-function`, `max-classes-per-file`, `max-depth`,
|
||||
// `max-nested-callbacks`, `max-statements`, `import/max-dependencies`,
|
||||
// `unicorn/max-nested-calls`: Deno had none of these.
|
||||
complexity: "off",
|
||||
"max-lines": "off",
|
||||
"max-params": "off",
|
||||
"id-length": "off",
|
||||
"no-nested-ternary": "off",
|
||||
"max-lines-per-function": "off",
|
||||
"max-classes-per-file": "off",
|
||||
"max-depth": "off",
|
||||
"max-nested-callbacks": "off",
|
||||
"max-statements": "off",
|
||||
"import/max-dependencies": "off",
|
||||
"unicorn/max-nested-calls": "off",
|
||||
// Pedantic, but Node's and the web's own types (`Response`, `Stats`,
|
||||
// `RequestInit`) are mutable, so no parameter of theirs can pass; the
|
||||
// explicit-types template and the app repos turn it off too.
|
||||
"typescript/prefer-readonly-parameter-types": "off",
|
||||
// Collides with explicit-types `no-utility-types`, which bans `Record`.
|
||||
"typescript/consistent-indexed-object-style": "off",
|
||||
// Collides with explicitly annotated declarations.
|
||||
"typescript/no-inferrable-types": "off",
|
||||
// Collides with import-structure `enforce-import-order`.
|
||||
"sort-imports": "off",
|
||||
// Collides with import-structure `enforce-mod-file` barrels.
|
||||
"oxc/no-barrel-file": "off",
|
||||
// Collides with exporting each declaration where it stands.
|
||||
"import/group-exports": "off",
|
||||
// Collides with exporting each declaration where it stands.
|
||||
"import/exports-last": "off",
|
||||
// Collides with `import/no-default-export`.
|
||||
"import/prefer-default-export": "off",
|
||||
// Collides with `unicorn/prefer-ternary`.
|
||||
"no-ternary": "off",
|
||||
// Collides with `require-await` and `promise-function-async`.
|
||||
"oxc/no-async-await": "off",
|
||||
// Collides with `typescript/prefer-optional-chain`.
|
||||
"oxc/no-optional-chaining": "off",
|
||||
// Collides with file mode checks such as `mode & 0o077`.
|
||||
"no-bitwise": "off",
|
||||
// Collides with explicit-types `no-optional-null`, which makes `undefined` the absent value.
|
||||
"no-undefined": "off",
|
||||
// Collides with `typescript/no-floating-promises`, which `void` satisfies.
|
||||
"no-void": "off",
|
||||
// Collides with JSON and Node APIs, which return `null`.
|
||||
"unicorn/no-null": "off",
|
||||
// Collides with `import/no-default-export`.
|
||||
"import/no-named-export": "off",
|
||||
// Collides with `typescript/consistent-type-imports`.
|
||||
"import/consistent-type-specifier-style": "off",
|
||||
// Collides with Oxfmt, which lowercases hex digits back.
|
||||
"unicorn/number-literal-case": "off",
|
||||
// Node code, everywhere: this action runs on Node.
|
||||
// Node built-ins are the action's runtime.
|
||||
"import/no-nodejs-modules": "off",
|
||||
// Command-line entry points set their exit status.
|
||||
"unicorn/no-process-exit": "off",
|
||||
// The action reports to the job log through the console.
|
||||
"no-console": "off",
|
||||
// Sequential awaits are intentional; workarounds cost more than they save.
|
||||
"no-await-in-loop": "off",
|
||||
// Not type-aware; TypeScript's `noImplicitReturns` covers it.
|
||||
"consistent-return": "off",
|
||||
// Misfires on complete switches, like `consistent-return`; TypeScript covers it.
|
||||
"typescript/consistent-return": "off",
|
||||
// TypeScript's type checking covers exhaustive switches.
|
||||
"typescript/switch-exhaustiveness-check": "off",
|
||||
// TypeScript checks completeness; a forced `default:` hides missing cases.
|
||||
"default-case": "off",
|
||||
// Off, too much churn:
|
||||
// Literal numbers read better than a constant per use.
|
||||
"no-magic-numbers": "off",
|
||||
// Keys follow meaning, not the alphabet.
|
||||
"sort-keys": "off",
|
||||
// Comments may start with code or lowercase identifiers.
|
||||
"capitalized-comments": "off",
|
||||
// explicit-types `require-return-type` covers return types.
|
||||
"typescript/explicit-function-return-type": "off",
|
||||
// explicit-types `require-return-type` covers return types.
|
||||
"typescript/explicit-module-boundary-types": "off",
|
||||
// Empty callbacks are legitimate no-ops.
|
||||
"no-empty-function": "off",
|
||||
// Methods may not need `this`.
|
||||
"class-methods-use-this": "off",
|
||||
// Braces only where a case declares.
|
||||
"unicorn/switch-case-braces": "off",
|
||||
// Plain property reads are fine.
|
||||
"prefer-destructuring": "off",
|
||||
// Object rest and spread are idiomatic.
|
||||
"oxc/no-rest-spread-properties": "off",
|
||||
// Short numbers need no separators.
|
||||
"unicorn/numeric-separators-style": "off",
|
||||
// `continue` keeps loop bodies flat.
|
||||
"no-continue": "off",
|
||||
};
|
||||
|
||||
/** Node code (`vite.config.ts` and `scripts/`): the first `projectOverrides` entry. */
|
||||
const nodeRules: DummyRuleMap = {
|
||||
// Vite and the Oxlint plugin host read a default export.
|
||||
"import/no-default-export": "off",
|
||||
};
|
||||
|
||||
/**
|
||||
* The whole `overrides` array of `vite.config.ts`: the Node code entry first,
|
||||
* then each per-file exception, each with a comment saying why. Later entries
|
||||
* win over earlier ones, and all of them over `rules`.
|
||||
*/
|
||||
export const projectOverrides: OxlintOverride[] = [
|
||||
{
|
||||
files: ["vite.config.ts", "scripts/**"],
|
||||
rules: nodeRules,
|
||||
},
|
||||
// exitCode must return the promise it marks handled; an async wrapper's
|
||||
// promise would reject unhandled before callers await it.
|
||||
{
|
||||
files: ["src/processes/child.ts"],
|
||||
rules: { "typescript/promise-function-async": "off" },
|
||||
},
|
||||
// Posted Gitea text shows emoji verdict marks and a middle dot.
|
||||
{
|
||||
files: [
|
||||
"src/post/post.ts",
|
||||
"src/post/post.test.ts",
|
||||
"src/profiles/profiles.ts",
|
||||
"src/profiles/profiles.test.ts",
|
||||
],
|
||||
rules: { "project/prefer-ascii": "off" },
|
||||
},
|
||||
// The workflow test checks literal Gitea Actions `${{ }}` expressions.
|
||||
{
|
||||
files: ["tests/workflow.test.ts"],
|
||||
rules: { "no-template-curly-in-string": "off" },
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The repo-local plugin, namespace `project`: its own rules, enabled as
|
||||
* `project/<rule>`, and `configs.project`, the rule list above.
|
||||
@@ -942,16 +259,6 @@ const plugin = {
|
||||
rules: {
|
||||
"no-disable-comments": noDisableCommentsRule,
|
||||
"no-asset-imports": noAssetImportsRule,
|
||||
"prefer-ascii": preferAsciiRule,
|
||||
"no-duplicate-typeofs": noDuplicateTypeofsRule,
|
||||
"no-empty-yield": noEmptyYieldRule,
|
||||
"no-nan": noNanRule,
|
||||
"no-unsafe-number": noUnsafeNumberRule,
|
||||
"no-useless-continue": noUselessContinueRule,
|
||||
"no-duplicate-interfaces": noDuplicateInterfacesRule,
|
||||
"no-duplicate-types": noDuplicateTypesRule,
|
||||
"no-modifier-private": noModifierPrivateRule,
|
||||
"no-useless-type": noUselessTypeRule,
|
||||
},
|
||||
configs: { project: { rules } },
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
set -euo pipefail
|
||||
|
||||
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
|
||||
cp -a "${ACTION_PATH}/src" "${path}/"
|
||||
# package.json makes Node load the TypeScript sources as ES modules and
|
||||
# resolves their `#` aliases.
|
||||
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}"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Agent command-line specifics: Codex and Claude Code sign-in handling and
|
||||
// OpenCode's arguments and output.
|
||||
export {
|
||||
codexAuthentication,
|
||||
isClaudeAuthFailure,
|
||||
isCodexAuthFailure,
|
||||
persistCodexAuth,
|
||||
retryInvalidToken,
|
||||
stageCodexAuth,
|
||||
} from "./auth.ts";
|
||||
export type {
|
||||
ClaudeFailureFields,
|
||||
CodexAuthentication,
|
||||
CommandResult,
|
||||
} from "./auth.ts";
|
||||
export {
|
||||
collectOpenCodeOutput,
|
||||
openCodeArgs,
|
||||
openCodeError,
|
||||
parseOpenCodeEvents,
|
||||
} from "./opencode.ts";
|
||||
@@ -1,14 +1,5 @@
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
|
||||
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
codexAuthentication,
|
||||
@@ -18,24 +9,20 @@ import {
|
||||
retryInvalidToken,
|
||||
stageCodexAuth,
|
||||
} from "./auth.ts";
|
||||
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
|
||||
|
||||
test("retries once after an invalid refresh token", async () => {
|
||||
let attempts = 0;
|
||||
let relogins = 0;
|
||||
const result = await retryInvalidToken(
|
||||
async () => {
|
||||
const resolved = await Promise.resolve({
|
||||
code: 1,
|
||||
error:
|
||||
(attempts += 1) === 1
|
||||
? "ERROR: invalid_refresh_token"
|
||||
: "still failed",
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
async () => {
|
||||
relogins += 1;
|
||||
await Promise.resolve();
|
||||
() =>
|
||||
Promise.resolve({
|
||||
status: { success: false },
|
||||
error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed",
|
||||
}),
|
||||
() => {
|
||||
relogins++;
|
||||
return Promise.resolve();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -50,17 +37,16 @@ test("does not retry an unrelated failure", async () => {
|
||||
let attempts = 0;
|
||||
let relogins = 0;
|
||||
await retryInvalidToken(
|
||||
async () => {
|
||||
attempts += 1;
|
||||
const resolved = await Promise.resolve({
|
||||
code: 1,
|
||||
() => {
|
||||
attempts++;
|
||||
return Promise.resolve({
|
||||
status: { success: false },
|
||||
error: "rate limited",
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
async () => {
|
||||
relogins += 1;
|
||||
await Promise.resolve();
|
||||
() => {
|
||||
relogins++;
|
||||
return Promise.resolve();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -98,42 +84,36 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
|
||||
let relogins = 0;
|
||||
let attempts = 0;
|
||||
const result = await retryInvalidToken(
|
||||
async () => {
|
||||
const resolved = await Promise.resolve({
|
||||
code: (attempts += 1) > 1 ? 0 : 1,
|
||||
() =>
|
||||
Promise.resolve({
|
||||
status: { success: attempts++ > 0 },
|
||||
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
async () => {
|
||||
relogins += 1;
|
||||
await Promise.resolve();
|
||||
}),
|
||||
() => {
|
||||
relogins++;
|
||||
return Promise.resolve();
|
||||
},
|
||||
);
|
||||
|
||||
expect({ relogins, code: result.code }).toStrictEqual({
|
||||
expect({ relogins, success: result.status.success }).toStrictEqual({
|
||||
relogins: 1,
|
||||
code: 0,
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("recognizes a rejected Claude OAuth token", () => {
|
||||
expect(
|
||||
isClaudeAuthFailure({
|
||||
apiErrorStatus: 401,
|
||||
result:
|
||||
"Failed to authenticate. API Error: 401 OAuth access token is invalid.",
|
||||
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.",
|
||||
result: "OAuth token has expired. Please obtain a new token or refresh your existing token.",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isClaudeAuthFailure({ apiErrorStatus: 429, result: "API Error: 429" }),
|
||||
).toBe(false);
|
||||
expect(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" })).toBe(false);
|
||||
});
|
||||
|
||||
test("selects API-key login only for a configured Codex token", () => {
|
||||
@@ -152,62 +132,47 @@ const AUTH_TOKENS = {
|
||||
};
|
||||
const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS });
|
||||
|
||||
test.skipIf(IS_WINDOWS)(
|
||||
"stages and persists only a regular private Codex login",
|
||||
async () => {
|
||||
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);
|
||||
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);
|
||||
const stagedStat = await stat(staged);
|
||||
expect(stagedStat.mode & 0o777).toBe(0o600);
|
||||
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);
|
||||
const canonicalStat = await stat(canonical);
|
||||
expect(canonicalStat.mode & 0o777).toBe(0o600);
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
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);
|
||||
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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -10,25 +10,15 @@ import {
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import nodePath from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { field, isNotFound, isObject } from "#common";
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import { isObject } from "./json.ts";
|
||||
|
||||
interface CodexApiKey {
|
||||
kind: "api-key";
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface CodexPersistent {
|
||||
kind: "persistent";
|
||||
}
|
||||
|
||||
export type CodexAuthentication = CodexApiKey | CodexPersistent;
|
||||
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 };
|
||||
return token === undefined ? { kind: "persistent" } : { kind: "api-key", token };
|
||||
}
|
||||
|
||||
// Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys
|
||||
@@ -44,32 +34,26 @@ export function isCodexAuthFailure(error: string): boolean {
|
||||
// stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth
|
||||
// access token is invalid." with api_error_status 401.
|
||||
// The fields of Claude Code's stream-json `result` event that tell a sign-in
|
||||
// failure apart, as `apiErrorStatus` and `result`.
|
||||
export interface ClaudeFailureFields {
|
||||
apiErrorStatus?: number;
|
||||
// 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.apiErrorStatus === 401 ||
|
||||
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 interface CommandResult {
|
||||
code: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export async function retryInvalidToken<T extends CommandResult>(
|
||||
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.code !== 0 && isCodexAuthFailure(result.error)) {
|
||||
if (!result.status.success && isCodexAuthFailure(result.error)) {
|
||||
await relogin();
|
||||
result = await attempt();
|
||||
}
|
||||
@@ -85,23 +69,20 @@ async function readCodexAuth(path: string): Promise<string> {
|
||||
throw new Error("Codex login must be private");
|
||||
}
|
||||
const text = await readFile(path, "utf8");
|
||||
let value: unknown = null;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("invalid Codex login");
|
||||
}
|
||||
const authMode = field(value, "auth_mode");
|
||||
const tokens = field(value, "tokens");
|
||||
const tokens = isObject(value) ? value["tokens"] : undefined;
|
||||
if (
|
||||
typeof authMode !== "string" ||
|
||||
authMode === "" ||
|
||||
!isObject(value) ||
|
||||
typeof value["auth_mode"] !== "string" ||
|
||||
value["auth_mode"] === "" ||
|
||||
!isObject(tokens) ||
|
||||
!["access_token", "refresh_token", "id_token", "account_id"].every(
|
||||
(name) => {
|
||||
const token = field(tokens, name);
|
||||
return typeof token === "string" && token !== "";
|
||||
},
|
||||
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
|
||||
)
|
||||
) {
|
||||
throw new Error("invalid Codex login");
|
||||
@@ -115,39 +96,32 @@ export async function stageCodexAuth(
|
||||
uid: number,
|
||||
gid: number,
|
||||
): Promise<string> {
|
||||
const codexHome = nodePath.join(profileHome, ".codex");
|
||||
const codexHome = join(profileHome, ".codex");
|
||||
await mkdir(codexHome, { recursive: true, mode: 0o700 });
|
||||
await chmod(codexHome, 0o700);
|
||||
await chown(codexHome, uid, gid);
|
||||
const stagedPath = nodePath.join(codexHome, "auth.json");
|
||||
const stagedPath = join(codexHome, "auth.json");
|
||||
try {
|
||||
await writeFile(stagedPath, await readCodexAuth(canonicalPath), {
|
||||
mode: 0o600,
|
||||
});
|
||||
await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 });
|
||||
await chmod(stagedPath, 0o600);
|
||||
await chown(stagedPath, uid, gid);
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (!isNotFound(error)) throw error;
|
||||
}
|
||||
return stagedPath;
|
||||
}
|
||||
|
||||
// Creates an empty private file under a fresh name.
|
||||
// Creates an empty private file under a fresh name, as Deno.makeTempFile did.
|
||||
async function makeTempFile(directory: string): Promise<string> {
|
||||
const path = nodePath.join(directory, `.tmp-${randomUUID()}`);
|
||||
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> {
|
||||
export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise<void> {
|
||||
const text = await readCodexAuth(stagedPath);
|
||||
const directory = nodePath.dirname(canonicalPath);
|
||||
const directory = dirname(canonicalPath);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const temporary = await makeTempFile(directory);
|
||||
try {
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// A child process with the call shapes the runner was reviewed with: `output()`
|
||||
// collects both streams, `spawn()` hands them out, and `clearEnv` passes only
|
||||
// the given environment. Streams default as Deno's did: stdin is inherited,
|
||||
// stdout and stderr are piped for `output()` and inherited for `spawn()`.
|
||||
import { spawn as spawnProcess } from "node:child_process";
|
||||
import type { ChildProcess, StdioOptions } from "node:child_process";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { constants } from "node:os";
|
||||
import process from "node:process";
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
import { buffer } from "node:stream/consumers";
|
||||
import { finished } from "node:stream/promises";
|
||||
|
||||
export type StdioMode = "inherit" | "piped" | "null";
|
||||
|
||||
export interface CommandOptions {
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
clearEnv?: boolean;
|
||||
uid?: number;
|
||||
gid?: number;
|
||||
stdin?: StdioMode;
|
||||
stdout?: StdioMode;
|
||||
stderr?: StdioMode;
|
||||
}
|
||||
|
||||
export interface CommandStatus {
|
||||
success: boolean;
|
||||
code: number;
|
||||
}
|
||||
|
||||
export interface CommandOutput extends CommandStatus {
|
||||
stdout: Uint8Array;
|
||||
stderr: Uint8Array;
|
||||
}
|
||||
|
||||
export interface CommandChild {
|
||||
status: Promise<CommandStatus>;
|
||||
stdin: Writable;
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
}
|
||||
|
||||
const STDIO: Record<StdioMode, "inherit" | "pipe" | "ignore"> = {
|
||||
inherit: "inherit",
|
||||
piped: "pipe",
|
||||
null: "ignore",
|
||||
};
|
||||
|
||||
function signalNumber(signal: NodeJS.Signals): number {
|
||||
return constants.signals[signal];
|
||||
}
|
||||
|
||||
function startProcess(
|
||||
command: string,
|
||||
options: CommandOptions,
|
||||
outputDefault: StdioMode,
|
||||
): ChildProcess {
|
||||
const stdio: StdioOptions = [
|
||||
STDIO[options.stdin ?? "inherit"],
|
||||
STDIO[options.stdout ?? outputDefault],
|
||||
STDIO[options.stderr ?? outputDefault],
|
||||
];
|
||||
const environment = options.clearEnv === true ? options.env : { ...process.env, ...options.env };
|
||||
return spawnProcess(command, options.args ?? [], {
|
||||
stdio,
|
||||
...(options.cwd !== undefined && { cwd: options.cwd }),
|
||||
...(environment !== undefined && { env: environment }),
|
||||
...(options.uid !== undefined && { uid: options.uid }),
|
||||
...(options.gid !== undefined && { gid: options.gid }),
|
||||
});
|
||||
}
|
||||
|
||||
// Settles when the process exits, before its streams close, as Deno's did. A
|
||||
// signal-killed process reports 128 plus the signal number, as a shell does.
|
||||
function waitForStatus(child: ChildProcess): Promise<CommandStatus> {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => {
|
||||
const exitCode = code ?? (signal === null ? 1 : 128 + signalNumber(signal));
|
||||
resolve({ success: exitCode === 0, code: exitCode });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function piped<T>(stream: T | null, name: string): T {
|
||||
if (stream === null) {
|
||||
throw new TypeError(`${name} is not piped`);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
function collect(stream: Readable | null): Promise<Uint8Array> {
|
||||
return stream === null ? Promise.resolve(new Uint8Array()) : buffer(stream);
|
||||
}
|
||||
|
||||
// A stream's chunks as bytes, whether or not it has an encoding set.
|
||||
export async function* chunks(stream: Readable): AsyncGenerator<Uint8Array> {
|
||||
for await (const chunk of stream) {
|
||||
const value: unknown = chunk;
|
||||
if (value instanceof Uint8Array) {
|
||||
yield value;
|
||||
} else if (typeof value === "string") {
|
||||
yield Buffer.from(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function spawn(command: string, options: CommandOptions = {}): CommandChild {
|
||||
const child = startProcess(command, options, "inherit");
|
||||
const status = waitForStatus(child);
|
||||
// A failed start rejects `status`; the caller sees it once it awaits that.
|
||||
status.catch(() => {});
|
||||
return {
|
||||
status,
|
||||
get stdin(): Writable {
|
||||
return piped(child.stdin, "stdin");
|
||||
},
|
||||
get stdout(): Readable {
|
||||
return piped(child.stdout, "stdout");
|
||||
},
|
||||
get stderr(): Readable {
|
||||
return piped(child.stderr, "stderr");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function output(
|
||||
command: string,
|
||||
options: CommandOptions = {},
|
||||
): Promise<CommandOutput> {
|
||||
const child = startProcess(command, options, "piped");
|
||||
const [status, stdout, stderr] = await Promise.all([
|
||||
waitForStatus(child),
|
||||
collect(child.stdout),
|
||||
collect(child.stderr),
|
||||
]);
|
||||
return { ...status, stdout, stderr };
|
||||
}
|
||||
|
||||
// Writes one chunk and settles once the stream has taken it, as Deno's
|
||||
// `Deno.stdout.write` did.
|
||||
export async function writeChunk(stream: Writable, chunk: Uint8Array): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.write(chunk, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Writes the last chunk and settles once the stream has flushed and closed.
|
||||
export async function endWith(stream: Writable, chunk: string): Promise<void> {
|
||||
stream.end(chunk);
|
||||
await finished(stream);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Helpers every phase shares: error and JSON value checks.
|
||||
export { isNotFound } from "./errors.ts";
|
||||
export { field, isMapping, isObject } from "./json.ts";
|
||||
@@ -1,25 +0,0 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import { field, isMapping, isObject } from "./json.ts";
|
||||
|
||||
test("reads a named field of a parsed object", () => {
|
||||
const value: unknown = JSON.parse(
|
||||
'{"login":"bot","user":{"login":"nested"}}',
|
||||
);
|
||||
expect(field(value, "login")).toBe("bot");
|
||||
expect(field(field(value, "user"), "login")).toBe("nested");
|
||||
expect(field(value, "missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reads no field of a value that is not an object", () => {
|
||||
for (const value of [null, undefined, "text", 1, true]) {
|
||||
expect(field(value, "login")).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("counts arrays as objects but not as mappings", () => {
|
||||
expect(isObject([])).toBe(true);
|
||||
expect(isMapping([])).toBe(false);
|
||||
expect(isMapping({})).toBe(true);
|
||||
expect(field(["first"], "0")).toBe("first");
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
// Checks and field reads for values parsed from JSON, which arrive as
|
||||
// `unknown`. Callers read the fields they know by name into their own
|
||||
// declared interfaces.
|
||||
|
||||
// Any non-null object, arrays included.
|
||||
export function isObject(value: unknown): value is object {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
// A plain mapping, which an array is not.
|
||||
export function isMapping(value: unknown): value is object {
|
||||
return isObject(value) && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// The named field of an object, or undefined for a missing field or a value
|
||||
// that is not an object.
|
||||
export function field(value: unknown, name: string): unknown {
|
||||
return isObject(value) ? Reflect.get(value, name) : undefined;
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import {
|
||||
decryptCredential,
|
||||
encryptCredential,
|
||||
parseCredentials,
|
||||
} from "./credentials.ts";
|
||||
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts";
|
||||
|
||||
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
||||
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
|
||||
const NONCE = Uint8Array.from({ length: 12 }, (_value, index) => index + 32);
|
||||
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32);
|
||||
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
||||
|
||||
test("decrypts a known AES-256-GCM credential", async () => {
|
||||
@@ -16,21 +11,16 @@ test("decrypts a known AES-256-GCM credential", async () => {
|
||||
});
|
||||
|
||||
test("encrypts a credential with a fresh supplied nonce", async () => {
|
||||
expect(
|
||||
await encryptCredential("secret-token", MASTER_KEY, NONCE),
|
||||
).toStrictEqual(ENCRYPTED);
|
||||
expect(await encryptCredential("secret-token", MASTER_KEY, NONCE)).toStrictEqual(ENCRYPTED);
|
||||
});
|
||||
|
||||
test("rejects a credential encrypted by another master key", async () => {
|
||||
await expect(decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow();
|
||||
await expect(() => decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("rejects a credential with a nonstandard nonce", async () => {
|
||||
await expect(
|
||||
decryptCredential(
|
||||
"v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA",
|
||||
MASTER_KEY,
|
||||
),
|
||||
await expect(() =>
|
||||
decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY),
|
||||
).rejects.toThrow("nonce must be 12 bytes");
|
||||
});
|
||||
|
||||
@@ -40,9 +30,9 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
|
||||
JSON.stringify({
|
||||
"reviewer-token": ENCRYPTED,
|
||||
profiles: [
|
||||
{ type: "CODEX", model: "gpt-custom" },
|
||||
{ type: "CLAUDE", token: ENCRYPTED },
|
||||
{ type: "OPENCODE", token: ENCRYPTED },
|
||||
{ type: "codex", model: "gpt-custom" },
|
||||
{ type: "claude", token: ENCRYPTED },
|
||||
{ type: "opencode", token: ENCRYPTED },
|
||||
],
|
||||
}),
|
||||
MASTER_KEY,
|
||||
@@ -50,14 +40,14 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
|
||||
).toStrictEqual({
|
||||
reviewerToken: "secret-token",
|
||||
profiles: [
|
||||
{ type: "CODEX", model: "gpt-custom" },
|
||||
{ type: "codex", model: "gpt-custom" },
|
||||
{
|
||||
type: "CLAUDE",
|
||||
type: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
token: "secret-token",
|
||||
},
|
||||
{
|
||||
type: "OPENCODE",
|
||||
type: "opencode",
|
||||
model: "openrouter/qwen/qwen3.8-27b",
|
||||
token: "secret-token",
|
||||
},
|
||||
@@ -66,30 +56,13 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
|
||||
});
|
||||
|
||||
test("rejects malformed central credentials", async () => {
|
||||
await expect(
|
||||
parseCredentials('{"profiles": []}', MASTER_KEY),
|
||||
).rejects.toThrow("reviewer-token");
|
||||
await expect(
|
||||
await expect(() => parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow(
|
||||
"reviewer-token",
|
||||
);
|
||||
await expect(() =>
|
||||
parseCredentials(
|
||||
JSON.stringify({
|
||||
"reviewer-token": ENCRYPTED,
|
||||
profiles: [{ type: "CLAUDE" }],
|
||||
}),
|
||||
JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }),
|
||||
MASTER_KEY,
|
||||
),
|
||||
).rejects.toThrow("profiles[0].token");
|
||||
});
|
||||
|
||||
test("reads lowercase profile types from older credentials", async () => {
|
||||
const credentials = await parseCredentials(
|
||||
JSON.stringify({
|
||||
"reviewer-token": ENCRYPTED,
|
||||
profiles: [{ type: "codex" }, { type: "claude", token: ENCRYPTED }],
|
||||
}),
|
||||
MASTER_KEY,
|
||||
);
|
||||
expect(credentials.profiles.map(({ type }) => type)).toStrictEqual([
|
||||
"CODEX",
|
||||
"CLAUDE",
|
||||
]);
|
||||
});
|
||||
@@ -1,19 +1,13 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
import { field, isMapping } from "#common";
|
||||
import type { BotProfile } from "#profiles";
|
||||
import { isMapping } from "./json.ts";
|
||||
import type { BotProfile } from "./profiles.ts";
|
||||
|
||||
// The model a profile runs when profiles.json names none.
|
||||
function defaultModel(type: BotProfile["type"]): string {
|
||||
switch (type) {
|
||||
case "CLAUDE":
|
||||
return "claude-sonnet-5";
|
||||
case "CODEX":
|
||||
return "gpt-5.6-terra";
|
||||
case "OPENCODE":
|
||||
return "openrouter/qwen/qwen3.8-27b";
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -23,61 +17,31 @@ export interface BotCredentials {
|
||||
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,
|
||||
);
|
||||
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 binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join("");
|
||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||
}
|
||||
|
||||
const buffer = (value: Uint8Array): ArrayBuffer =>
|
||||
Uint8Array.from(value).buffer;
|
||||
const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer;
|
||||
|
||||
async function importMasterKey(
|
||||
encoded: string,
|
||||
usage: webcrypto.KeyUsage,
|
||||
): Promise<webcrypto.CryptoKey> {
|
||||
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");
|
||||
}
|
||||
const key = await webcrypto.subtle.importKey(
|
||||
"raw",
|
||||
buffer(bytes),
|
||||
"AES-GCM",
|
||||
false,
|
||||
[usage],
|
||||
);
|
||||
return key;
|
||||
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 !== ""
|
||||
) {
|
||||
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");
|
||||
}
|
||||
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"),
|
||||
@@ -91,9 +55,7 @@ export async function encryptCredential(
|
||||
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");
|
||||
}
|
||||
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"),
|
||||
@@ -102,22 +64,7 @@ export async function encryptCredential(
|
||||
return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`;
|
||||
}
|
||||
|
||||
// profiles.json files written before the UPPER_SNAKE switch spell types in lowercase.
|
||||
const OLD_PROFILE_TYPES = new Map<unknown, BotProfile["type"]>([
|
||||
["claude", "CLAUDE"],
|
||||
["codex", "CODEX"],
|
||||
["opencode", "OPENCODE"],
|
||||
]);
|
||||
|
||||
function currentSpelling(type: unknown): unknown {
|
||||
return OLD_PROFILE_TYPES.get(type) ?? type;
|
||||
}
|
||||
|
||||
async function parseProfile(
|
||||
entry: unknown,
|
||||
index: number,
|
||||
masterKey: string,
|
||||
): Promise<BotProfile> {
|
||||
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`);
|
||||
@@ -127,16 +74,14 @@ async function parseProfile(
|
||||
throw new Error(`${at}.${key}: unknown field`);
|
||||
}
|
||||
}
|
||||
const type = currentSpelling(field(entry, "type"));
|
||||
const model = field(entry, "model");
|
||||
const token = field(entry, "token");
|
||||
if (type !== "CLAUDE" && type !== "CODEX" && type !== "OPENCODE") {
|
||||
throw new Error(`${at}.type: expected "CLAUDE", "CODEX", or "OPENCODE"`);
|
||||
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") {
|
||||
if ((type === "claude" || type === "opencode") && typeof token !== "string") {
|
||||
throw new Error(`${at}.token: expected an encrypted credential`);
|
||||
}
|
||||
if (token !== undefined && typeof token !== "string") {
|
||||
@@ -144,17 +89,14 @@ async function parseProfile(
|
||||
}
|
||||
return {
|
||||
type,
|
||||
model: model ?? defaultModel(type),
|
||||
model: model ?? DEFAULT_MODELS[type],
|
||||
...(typeof token === "string" && {
|
||||
token: await decryptCredential(token, masterKey),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseCredentials(
|
||||
text: string,
|
||||
masterKey: string,
|
||||
): Promise<BotCredentials> {
|
||||
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> {
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (!isMapping(value)) {
|
||||
throw new TypeError("credentials must be a mapping");
|
||||
@@ -164,8 +106,7 @@ export async function parseCredentials(
|
||||
throw new Error(`${key}: unknown field`);
|
||||
}
|
||||
}
|
||||
const reviewerToken = field(value, "reviewer-token");
|
||||
const profiles = field(value, "profiles");
|
||||
const { "reviewer-token": reviewerToken, profiles } = value;
|
||||
if (typeof reviewerToken !== "string") {
|
||||
throw new TypeError("reviewer-token: expected an encrypted credential");
|
||||
}
|
||||
@@ -173,13 +114,7 @@ export async function parseCredentials(
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
}
|
||||
const parsed = await Promise.all(
|
||||
profiles.map(async (entry: unknown, index) => {
|
||||
const profile = await parseProfile(entry, index, masterKey);
|
||||
return profile;
|
||||
}),
|
||||
profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)),
|
||||
);
|
||||
return {
|
||||
reviewerToken: await decryptCredential(reviewerToken, masterKey),
|
||||
profiles: parsed,
|
||||
};
|
||||
return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// A missing file or a missing process: Node reports them as ENOENT and ESRCH.
|
||||
// Node reports a missing file as ENOENT and a missing process as ESRCH, where
|
||||
// Deno threw `Deno.errors.NotFound` for both.
|
||||
export function isNotFound(error: unknown): boolean {
|
||||
if (!(error instanceof Error) || !("code" in error)) {
|
||||
return false;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import {
|
||||
agentCommand,
|
||||
isRunnableProfileProcess,
|
||||
killProfileProcesses,
|
||||
readProfileFile,
|
||||
} from "./isolation.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
|
||||
|
||||
const profile = (uid: number, home: string): RuntimeProfile => ({
|
||||
index: uid - 20_000,
|
||||
type: "claude",
|
||||
model: "test",
|
||||
uid,
|
||||
gid: uid,
|
||||
home,
|
||||
tokenFile: null,
|
||||
});
|
||||
|
||||
test("zombie process entries are already quiescent", () => {
|
||||
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
|
||||
expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
|
||||
});
|
||||
|
||||
test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", async () => {
|
||||
const root = await makeTempDir();
|
||||
try {
|
||||
const secret = `${root}/reviewer-token`;
|
||||
const result = `${root}/result.md`;
|
||||
await writeFile(secret, "reviewer-secret", { mode: 0o600 });
|
||||
await symlink(secret, result);
|
||||
|
||||
await expect(() => readProfileFile(profile(currentUid(), root), result)).rejects.toThrow(
|
||||
"regular private file",
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(!IS_ROOT)("a profile process can read only its own private files", async () => {
|
||||
const root = await makeTempDir();
|
||||
await chmod(root, 0o711);
|
||||
try {
|
||||
const homeA = `${root}/a`;
|
||||
const homeB = `${root}/b`;
|
||||
await mkdir(homeA, { mode: 0o700 });
|
||||
await mkdir(homeB, { mode: 0o700 });
|
||||
await chown(homeA, 20_000, 20_000);
|
||||
await chown(homeB, 20_001, 20_001);
|
||||
await writeFile(`${homeA}/token`, "own", { mode: 0o600 });
|
||||
await chown(`${homeA}/token`, 20_000, 20_000);
|
||||
await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 });
|
||||
await chown(`${homeB}/token`, 20_001, 20_001);
|
||||
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
|
||||
|
||||
const selected = profile(20_000, homeA);
|
||||
const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], {
|
||||
cwd: "/",
|
||||
}).output();
|
||||
const sibling = await agentCommand(selected, "/bin/cat", [`${homeB}/token`], {
|
||||
cwd: "/",
|
||||
}).output();
|
||||
const reviewer = await agentCommand(selected, "/bin/cat", [`${root}/reviewer`], {
|
||||
cwd: "/",
|
||||
}).output();
|
||||
|
||||
expect(new TextDecoder().decode(own.stdout)).toBe("own");
|
||||
expect(own.success).toBe(true);
|
||||
expect(sibling.success).toBe(false);
|
||||
expect(reviewer.success).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => {
|
||||
const selected = profile(20_010, "/tmp");
|
||||
const launched = await agentCommand(
|
||||
selected,
|
||||
"/bin/sh",
|
||||
["-c", "sleep 60 >/dev/null 2>&1 & echo $!"],
|
||||
{ cwd: "/" },
|
||||
).output();
|
||||
const pid = Number(new TextDecoder().decode(launched.stdout).trim());
|
||||
expect(Number.isInteger(pid)).toBe(true);
|
||||
|
||||
await killProfileProcesses(selected.uid);
|
||||
|
||||
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
|
||||
if (isNotFound(error)) return null;
|
||||
throw error;
|
||||
});
|
||||
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
|
||||
});
|
||||
|
||||
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", async () => {
|
||||
const selected = profile(20_020, "/tmp");
|
||||
const result = await agentCommand(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
|
||||
cwd: "/",
|
||||
env: { PARENT_SECRET: "must-not-cross-boundary" },
|
||||
clearEnv: true,
|
||||
stderr: "piped",
|
||||
}).output();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
@@ -2,12 +2,34 @@ import { lstat, readdir, readFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
import { isNotFound } from "#common";
|
||||
import type { RuntimeProfile } from "#runtime";
|
||||
import { output, spawn } from "./command.ts";
|
||||
import type { CommandChild, CommandOptions, CommandOutput } from "./command.ts";
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
export type AgentCommandOptions = Omit<CommandOptions, "args" | "uid" | "gid">;
|
||||
|
||||
export interface AgentCommand {
|
||||
output(): Promise<CommandOutput>;
|
||||
spawn(): CommandChild;
|
||||
}
|
||||
|
||||
export function agentCommand(
|
||||
profile: RuntimeProfile,
|
||||
command: string,
|
||||
args: string[],
|
||||
options: AgentCommandOptions = {},
|
||||
): AgentCommand {
|
||||
const full: CommandOptions = { ...options, args, uid: profile.uid, gid: profile.gid };
|
||||
return {
|
||||
output: () => output(command, full),
|
||||
spawn: () => spawn(command, full),
|
||||
};
|
||||
}
|
||||
|
||||
export function isRunnableProfileProcess(status: string, uid: number): boolean {
|
||||
const owner = /^Uid:\s+(?<uid>\d+)/mu.exec(status)?.groups?.["uid"];
|
||||
const state = /^State:\s+(?<state>\w)/mu.exec(status)?.groups?.["state"];
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -17,54 +39,35 @@ async function profileProcessIds(uid: number): Promise<number[]> {
|
||||
.filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name))
|
||||
.map((entry) => entry.name);
|
||||
const statuses = await Promise.all(
|
||||
pids.map(async (pid) => {
|
||||
const status = await readFile(`/proc/${pid}/status`, "utf8").catch(
|
||||
() => null,
|
||||
);
|
||||
return status;
|
||||
}),
|
||||
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)
|
||||
);
|
||||
return status !== null && status !== undefined && isRunnableProfileProcess(status, uid);
|
||||
})
|
||||
.map(Number);
|
||||
}
|
||||
|
||||
// Kills the UID's processes every 10 ms until none is left, for up to 100 rounds.
|
||||
export async function killProfileProcesses(
|
||||
uid: number,
|
||||
attempt = 0,
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
if (ids.length === 0) return;
|
||||
for (const pid of ids) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (!isNotFound(error)) throw error;
|
||||
}
|
||||
}
|
||||
await sleep(10);
|
||||
await killProfileProcesses(uid, attempt + 1);
|
||||
}
|
||||
|
||||
export async function readProfileFile(
|
||||
profile: RuntimeProfile,
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
export async function readProfileFile(profile: RuntimeProfile, path: string): Promise<string> {
|
||||
const stat = await lstat(path);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
+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);
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
import { assert, expect, test } from "vite-plus/test";
|
||||
|
||||
import { AgentAuthError } from "#profiles";
|
||||
|
||||
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([
|
||||
expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([
|
||||
"run",
|
||||
"--format",
|
||||
"json",
|
||||
@@ -72,10 +68,7 @@ test("streams OpenCode output while collecting it", async () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
stdout: "first\nsecond\n",
|
||||
stderr: "warning\n",
|
||||
});
|
||||
expect(result).toStrictEqual({ stdout: "first\nsecond\n", stderr: "warning\n" });
|
||||
expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]);
|
||||
});
|
||||
|
||||
@@ -99,14 +92,12 @@ test("reports a rejected OpenRouter key as a sign-in failure", () => {
|
||||
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.",
|
||||
);
|
||||
expect(error.cause.message).toBe("opencode APIError (API error 401): User not found.");
|
||||
});
|
||||
|
||||
test("surfaces an OpenCode error event instead of empty output", () => {
|
||||
expect(() =>
|
||||
parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded")),
|
||||
).toThrow("API error 429");
|
||||
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBeNull();
|
||||
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow(
|
||||
"API error 429",
|
||||
);
|
||||
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBe(undefined);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { field, isObject } from "#common";
|
||||
import { AgentAuthError } from "#profiles";
|
||||
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];
|
||||
@@ -7,21 +7,13 @@ export function openCodeArgs(model: string, prompt: string): string[] {
|
||||
|
||||
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
|
||||
|
||||
interface OpenCodeOutput {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export async function collectOpenCodeOutput(
|
||||
stdout: AsyncIterable<Uint8Array>,
|
||||
stderr: AsyncIterable<Uint8Array>,
|
||||
writeStdout: ChunkWriter,
|
||||
writeStderr: ChunkWriter,
|
||||
): Promise<OpenCodeOutput> {
|
||||
const collect = async (
|
||||
stream: AsyncIterable<Uint8Array>,
|
||||
write: ChunkWriter,
|
||||
): Promise<string> => {
|
||||
): 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);
|
||||
@@ -46,26 +38,23 @@ export async function collectOpenCodeOutput(
|
||||
// OpenCode reports provider failures, including a rejected OpenRouter key
|
||||
// (401 "User not found."), as a JSON error event on stdout and exits 1 with an
|
||||
// empty stderr.
|
||||
export function openCodeError(output: string): Error | null {
|
||||
let failure: Error | null = null;
|
||||
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 = null;
|
||||
if (line.trim() === "") continue;
|
||||
let event: unknown;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const error = field(event, "error");
|
||||
if (field(event, "type") !== "error" || !isObject(error)) {
|
||||
if (!isObject(event)) continue;
|
||||
const { type, error } = event;
|
||||
if (type !== "error" || !isObject(error)) {
|
||||
continue;
|
||||
}
|
||||
const name = field(error, "name");
|
||||
const data = field(error, "data");
|
||||
const message = field(data, "message");
|
||||
const statusCode = field(data, "statusCode");
|
||||
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"}`;
|
||||
@@ -80,25 +69,18 @@ export function openCodeError(output: string): Error | null {
|
||||
}
|
||||
|
||||
export function parseOpenCodeEvents(output: string): string {
|
||||
let result: string | null = null;
|
||||
let result: string | undefined;
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
if (line.trim() === "") continue;
|
||||
const event: unknown = JSON.parse(line);
|
||||
const part = field(event, "part");
|
||||
if (field(event, "type") !== "text" || !isObject(part)) {
|
||||
continue;
|
||||
}
|
||||
const text = field(part, "text");
|
||||
if (typeof text === "string" && text.trim() !== "") {
|
||||
result = text;
|
||||
}
|
||||
if (!isObject(event)) continue;
|
||||
const { type, part } = event;
|
||||
if (type !== "text" || !isObject(part)) continue;
|
||||
const { text } = part;
|
||||
if (typeof text === "string" && text.trim() !== "") result = text;
|
||||
}
|
||||
if (result === null) {
|
||||
throw (
|
||||
openCodeError(output) ?? new Error("OpenCode produced no text output")
|
||||
);
|
||||
if (result === undefined) {
|
||||
throw openCodeError(output) ?? new Error("OpenCode produced no text output");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
|
||||
import { assert, expect, test } from "vite-plus/test";
|
||||
|
||||
import { isNotFound } from "#common";
|
||||
import type { RuntimeFailure, RuntimeManifest, RuntimeOutcome } from "#runtime";
|
||||
import { 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";
|
||||
|
||||
import { type Fetcher, type PostContext, postRuntime } from "./post.ts";
|
||||
|
||||
const context: PostContext = {
|
||||
apiUrl: "https://gitea.example/api/v1",
|
||||
repository: "owner/repo",
|
||||
@@ -22,18 +23,7 @@ interface Fixture {
|
||||
outcomeFile: string;
|
||||
}
|
||||
|
||||
interface FixtureSuccess {
|
||||
ok: true;
|
||||
body: string;
|
||||
}
|
||||
|
||||
type FixtureSource = RuntimeFailure | FixtureSuccess;
|
||||
|
||||
// One request the fake Gitea received.
|
||||
interface RecordedRequest {
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
}
|
||||
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 });
|
||||
@@ -42,18 +32,12 @@ async function writePrivate(path: string, text: string): Promise<void> {
|
||||
|
||||
async function writeArtifacts(
|
||||
outcome: RuntimeOutcome,
|
||||
review?: string,
|
||||
anchors?: unknown,
|
||||
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));
|
||||
}
|
||||
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 {
|
||||
@@ -65,7 +49,7 @@ function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
|
||||
profiles: [
|
||||
{
|
||||
index: 0,
|
||||
type: "CLAUDE",
|
||||
type: "claude",
|
||||
model: "test",
|
||||
uid: currentUid(),
|
||||
gid: currentGid(),
|
||||
@@ -89,7 +73,7 @@ async function fixture(
|
||||
const outcome: RuntimeOutcome = source.ok
|
||||
? {
|
||||
...source,
|
||||
profile: { type: "CODEX", model: "gpt-test" },
|
||||
profile: { type: "codex", model: "gpt-test" },
|
||||
reviewPath: `${profileHome}/review.md`,
|
||||
anchorsPath: `${profileHome}/anchors.json`,
|
||||
}
|
||||
@@ -112,9 +96,7 @@ const missing = async (path: string): Promise<boolean> => {
|
||||
await stat(path);
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) {
|
||||
return true;
|
||||
}
|
||||
if (isNotFound(error)) return true;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -125,29 +107,25 @@ function requestBody(init?: RequestInit): unknown {
|
||||
}
|
||||
|
||||
// A fake Gitea that records each request body and answers with `status`.
|
||||
function recordingFetch(bodies: unknown[], status = 201): Fetcher {
|
||||
return async (_url, init) => {
|
||||
function recordingFetch(bodies: unknown[], status = 201): typeof fetch {
|
||||
return (_url, init) => {
|
||||
bodies.push(requestBody(init));
|
||||
const resolved = await Promise.resolve(new Response(null, { status }));
|
||||
return resolved;
|
||||
return Promise.resolve(new Response(null, { status }));
|
||||
};
|
||||
}
|
||||
|
||||
test("posts a successful issue result and removes runtime credentials", async () => {
|
||||
const result = await fixture({ ok: true, body: "done" });
|
||||
const requests: RecordedRequest[] = [];
|
||||
const requests: { url: unknown; init: RequestInit | undefined }[] = [];
|
||||
|
||||
await postRuntime(result.manifestPath, context, async (url, init) => {
|
||||
await postRuntime(result.manifestPath, context, (url, init) => {
|
||||
requests.push({ url, init });
|
||||
const resolved = await Promise.resolve(new Response(null, { status: 201 }));
|
||||
return resolved;
|
||||
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(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`);
|
||||
expect(requestBody(request?.init)).toStrictEqual({
|
||||
body: "done\n\n> Profile: `codex` · `gpt-test`",
|
||||
});
|
||||
@@ -159,11 +137,9 @@ test("posts a successful issue result and removes runtime credentials", async ()
|
||||
});
|
||||
|
||||
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 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(
|
||||
@@ -172,12 +148,9 @@ test("retries a rejected inline review without anchors", async () => {
|
||||
...context,
|
||||
eventName: "pull_request",
|
||||
},
|
||||
async (_url, init) => {
|
||||
(_url, init) => {
|
||||
bodies.push(requestBody(init));
|
||||
const resolved = await Promise.resolve(
|
||||
new Response(null, { status: bodies.length === 1 ? 422 : 200 }),
|
||||
);
|
||||
return resolved;
|
||||
return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 }));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -195,14 +168,14 @@ 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" },
|
||||
{ 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(
|
||||
await expect(() =>
|
||||
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
|
||||
).rejects.toThrow("agent phase failed");
|
||||
|
||||
@@ -225,15 +198,15 @@ test("drops failure entries that are not known labels", async () => {
|
||||
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" },
|
||||
{ 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(
|
||||
await expect(() =>
|
||||
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
|
||||
).rejects.toThrow("agent phase failed");
|
||||
|
||||
@@ -249,7 +222,7 @@ test("reports a generic failure when the runner wrote no outcome", async () => {
|
||||
await rm(result.outcomeFile);
|
||||
const bodies: unknown[] = [];
|
||||
|
||||
await expect(
|
||||
await expect(() =>
|
||||
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
|
||||
).rejects.toThrow("agent phase failed");
|
||||
|
||||
@@ -260,13 +233,10 @@ test("reports a generic failure when the runner wrote no outcome", async () => {
|
||||
test("removes runtime credentials when posting fails", async () => {
|
||||
const result = await fixture({ ok: true, body: "done" });
|
||||
|
||||
await expect(
|
||||
postRuntime(result.manifestPath, context, async () => {
|
||||
const resolved = await Promise.resolve(
|
||||
new Response("failed", { status: 500 }),
|
||||
);
|
||||
return resolved;
|
||||
}),
|
||||
await expect(() =>
|
||||
postRuntime(result.manifestPath, context, () =>
|
||||
Promise.resolve(new Response("failed", { status: 500 })),
|
||||
),
|
||||
).rejects.toThrow("500");
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
@@ -275,11 +245,10 @@ 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, async () => {
|
||||
await expect(() =>
|
||||
postRuntime(result.manifestPath, context, () => {
|
||||
requests += 1;
|
||||
await Promise.resolve();
|
||||
throw new Error(`network failure ${requests}`);
|
||||
return Promise.reject(new Error(`network failure ${requests}`));
|
||||
}),
|
||||
).rejects.toThrow("network failure 1");
|
||||
|
||||
@@ -287,33 +256,27 @@ test("preserves a Gitea transport failure without retrying a comment", async ()
|
||||
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;
|
||||
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, async () => {
|
||||
requests += 1;
|
||||
const resolved = await Promise.resolve(
|
||||
new Response(null, { status: 200 }),
|
||||
);
|
||||
return resolved;
|
||||
}),
|
||||
).rejects.toThrow("regular private file");
|
||||
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));
|
||||
},
|
||||
);
|
||||
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(
|
||||
await expect(() =>
|
||||
postRuntime(
|
||||
result.manifestPath,
|
||||
{
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import type { Stats } from "node:fs";
|
||||
import { lstat, readFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import { isObject } from "./json.ts";
|
||||
import { failureFooter, withProfile } from "./profiles.ts";
|
||||
import type { FailureReason, ProfileFailure } from "./profiles.ts";
|
||||
import { readRuntimeManifest } from "./runtime.ts";
|
||||
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
||||
|
||||
export interface PostContext {
|
||||
apiUrl: string;
|
||||
repository: string;
|
||||
issueIndex: string;
|
||||
eventName: string;
|
||||
}
|
||||
|
||||
interface Anchor {
|
||||
path: string;
|
||||
line: number;
|
||||
side: "new" | "old";
|
||||
body: string;
|
||||
}
|
||||
|
||||
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
|
||||
const stripAnsi = (text: string): string => text.replace(ANSI, "");
|
||||
const VERDICTS: Record<string, [event: string, mark: string]> = {
|
||||
Approved: ["APPROVED", "✅"],
|
||||
"Changes requested": ["REQUEST_CHANGES", "🛑"],
|
||||
};
|
||||
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
|
||||
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
|
||||
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value);
|
||||
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
|
||||
const failureComment = (failures: ProfileFailure[]): string =>
|
||||
`AI bot failed.\n\n${failureFooter(failures)}`;
|
||||
|
||||
class GiteaRequestError extends Error {}
|
||||
|
||||
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||
const entries: unknown = JSON.parse(text);
|
||||
if (!Array.isArray(entries)) throw new TypeError(`${sourcePath}: not an array`);
|
||||
return entries.map((entry: unknown, index) => {
|
||||
const at = `${sourcePath}[${index}]`;
|
||||
if (!isObject(entry)) {
|
||||
throw new TypeError(`${at}: not an object`);
|
||||
}
|
||||
const { path, line, side = "new", body } = entry;
|
||||
if (typeof path !== "string" || path === "") {
|
||||
throw new TypeError(`${at}.path: expected a repository path`);
|
||||
}
|
||||
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
|
||||
throw new TypeError(`${at}.line: expected a line number`);
|
||||
}
|
||||
if (side !== "new" && side !== "old") {
|
||||
throw new Error(`${at}.side: expected "new" or "old"`);
|
||||
}
|
||||
if (typeof body !== "string" || body.trim() === "") {
|
||||
throw new TypeError(`${at}.body: expected the finding`);
|
||||
}
|
||||
return { path, line, side, body };
|
||||
});
|
||||
}
|
||||
|
||||
async function readOutcome(path: string): Promise<RuntimeOutcome> {
|
||||
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||
if (!isObject(value) || !("ok" in value)) {
|
||||
throw new Error("invalid agent outcome");
|
||||
}
|
||||
const outcome = value;
|
||||
if (outcome["ok"] === false) {
|
||||
// Only known labels reach the comment, never raw agent output.
|
||||
const failures: unknown[] = Array.isArray(outcome["failures"]) ? outcome["failures"] : [];
|
||||
return {
|
||||
ok: false,
|
||||
failures: failures.flatMap((entry: unknown) => {
|
||||
const { type, model, reason } = isObject(entry) ? entry : {};
|
||||
return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
|
||||
? [{ type, model, reason }]
|
||||
: [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (
|
||||
outcome["ok"] !== true ||
|
||||
typeof outcome["body"] !== "string" ||
|
||||
typeof outcome["reviewPath"] !== "string" ||
|
||||
typeof outcome["anchorsPath"] !== "string" ||
|
||||
!isObject(outcome["profile"])
|
||||
) {
|
||||
throw new Error("invalid agent outcome");
|
||||
}
|
||||
const profile = outcome["profile"];
|
||||
if (!isProfileType(profile["type"]) || typeof profile["model"] !== "string") {
|
||||
throw new Error("invalid agent outcome");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
body: outcome["body"],
|
||||
reviewPath: outcome["reviewPath"],
|
||||
anchorsPath: outcome["anchorsPath"],
|
||||
profile: {
|
||||
type: profile["type"],
|
||||
model: profile["model"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readAgentArtifact(
|
||||
manifest: RuntimeManifest,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
|
||||
if (profile === undefined) throw new Error(`${name}: outside profile home`);
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await lstat(path);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== profile.uid ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new Error(`${name}: expected a profile-owned regular private file`);
|
||||
}
|
||||
const text = await readFile(path, "utf8");
|
||||
return text === "" ? null : text;
|
||||
}
|
||||
|
||||
export async function postRuntime(
|
||||
manifestPath: string,
|
||||
context: PostContext,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const manifest = await readRuntimeManifest(manifestPath);
|
||||
try {
|
||||
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
|
||||
const request = async (path: string, body: unknown): Promise<void> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(`${context.apiUrl}/${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${reviewerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
};
|
||||
const postComment = (body: string): Promise<void> =>
|
||||
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
|
||||
body: stripAnsi(body),
|
||||
});
|
||||
|
||||
let outcome: RuntimeOutcome;
|
||||
try {
|
||||
outcome = await readOutcome(manifest.outcomeFile);
|
||||
} catch (error) {
|
||||
await postComment(failureComment([]));
|
||||
throw new Error("agent phase failed", { cause: error });
|
||||
}
|
||||
if (!outcome.ok) {
|
||||
await postComment(failureComment(outcome.failures));
|
||||
throw new Error("agent phase failed");
|
||||
}
|
||||
|
||||
try {
|
||||
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md");
|
||||
if (review === null) {
|
||||
if (context.eventName === "pull_request") {
|
||||
throw new Error("agent did not write a pull request review");
|
||||
}
|
||||
await postComment(withProfile(outcome.body, outcome.profile));
|
||||
return;
|
||||
}
|
||||
|
||||
const [verdict = "", ...rest] = review.split("\n");
|
||||
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
|
||||
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
|
||||
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
|
||||
const postReview = (entries: Anchor[]): Promise<void> =>
|
||||
request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
|
||||
body: stripAnsi(
|
||||
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
|
||||
),
|
||||
event,
|
||||
comments: entries.map(({ path, line, side, body }) => ({
|
||||
path,
|
||||
body: stripAnsi(withProfile(body, outcome.profile)),
|
||||
new_position: side === "new" ? line : 0,
|
||||
old_position: side === "old" ? line : 0,
|
||||
})),
|
||||
});
|
||||
await postReview(anchors).catch(async (error: unknown) => {
|
||||
if (anchors.length === 0) throw error;
|
||||
console.error(
|
||||
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
await postReview([]);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof GiteaRequestError) throw error;
|
||||
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
|
||||
if (!isNotFound(error)) throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") throw new Error(`${name} is not set`);
|
||||
return value;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
|
||||
apiUrl: required("GITEA_API_URL"),
|
||||
repository: required("GITEA_REPOSITORY"),
|
||||
issueIndex: required("ISSUE_INDEX"),
|
||||
eventName: required("EVENT_NAME"),
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// The post phase: posts the agent's result as the reviewer and removes the
|
||||
// runtime credentials.
|
||||
import process from "node:process";
|
||||
|
||||
import { postRuntime } from "./post.ts";
|
||||
|
||||
export { type Fetcher, type PostContext, postRuntime } from "./post.ts";
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") {
|
||||
throw new Error(`${name} is not set`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
|
||||
apiUrl: required("GITEA_API_URL"),
|
||||
repository: required("GITEA_REPOSITORY"),
|
||||
issueIndex: required("ISSUE_INDEX"),
|
||||
eventName: required("EVENT_NAME"),
|
||||
});
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
import type { Stats } from "node:fs";
|
||||
import { lstat, readFile, rm } from "node:fs/promises";
|
||||
import nodePath from "node:path";
|
||||
|
||||
import { field, isNotFound, isObject } from "#common";
|
||||
import {
|
||||
failureFooter,
|
||||
type FailureReason,
|
||||
type ProfileFailure,
|
||||
withProfile,
|
||||
} from "#profiles";
|
||||
import {
|
||||
readRuntimeManifest,
|
||||
type RuntimeManifest,
|
||||
type RuntimeOutcome,
|
||||
} from "#runtime";
|
||||
|
||||
export interface PostContext {
|
||||
apiUrl: string;
|
||||
repository: string;
|
||||
issueIndex: string;
|
||||
eventName: string;
|
||||
}
|
||||
|
||||
interface Anchor {
|
||||
path: string;
|
||||
line: number;
|
||||
side: "NEW" | "OLD";
|
||||
body: string;
|
||||
}
|
||||
|
||||
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
|
||||
const stripAnsi = (text: string): string => text.replace(ANSI, "");
|
||||
const VERDICTS = new Map<string, [event: string, mark: string]>([
|
||||
["Approved", ["APPROVED", "✅"]],
|
||||
["Changes requested", ["REQUEST_CHANGES", "🛑"]],
|
||||
]);
|
||||
const PROFILE_TYPES = new Set<unknown>(["CLAUDE", "CODEX", "OPENCODE"]);
|
||||
const FAILURE_REASONS = new Set<unknown>([
|
||||
"SIGN_IN_FAILED",
|
||||
"USAGE_EXHAUSTED",
|
||||
"FAILED",
|
||||
]);
|
||||
// Anchors written before the UPPER_SNAKE switch spell sides in lowercase.
|
||||
const OLD_SIDES = new Map<unknown, Anchor["side"]>([
|
||||
["new", "NEW"],
|
||||
["old", "OLD"],
|
||||
]);
|
||||
const isProfileType = (value: unknown): value is ProfileFailure["type"] =>
|
||||
PROFILE_TYPES.has(value);
|
||||
const isFailureReason = (value: unknown): value is FailureReason =>
|
||||
FAILURE_REASONS.has(value);
|
||||
const failureComment = (failures: ProfileFailure[]): string =>
|
||||
`AI bot failed.\n\n${failureFooter(failures)}`;
|
||||
|
||||
// The `fetch` subset the reviewer posts through, which tests replace.
|
||||
export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
class GiteaRequestError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "GiteaRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||
const entries: unknown = JSON.parse(text);
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new TypeError(`${sourcePath}: not an array`);
|
||||
}
|
||||
return entries.map((entry: unknown, index) => {
|
||||
const at = `${sourcePath}[${index}]`;
|
||||
if (!isObject(entry)) {
|
||||
throw new TypeError(`${at}: not an object`);
|
||||
}
|
||||
const path = field(entry, "path");
|
||||
const line = field(entry, "line");
|
||||
const givenSide = field(entry, "side");
|
||||
const side =
|
||||
givenSide === undefined ? "NEW" : (OLD_SIDES.get(givenSide) ?? givenSide);
|
||||
const body = field(entry, "body");
|
||||
if (typeof path !== "string" || path === "") {
|
||||
throw new TypeError(`${at}.path: expected a repository path`);
|
||||
}
|
||||
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
|
||||
throw new TypeError(`${at}.line: expected a line number`);
|
||||
}
|
||||
if (side !== "NEW" && side !== "OLD") {
|
||||
throw new Error(`${at}.side: expected "NEW" or "OLD"`);
|
||||
}
|
||||
if (typeof body !== "string" || body.trim() === "") {
|
||||
throw new TypeError(`${at}.body: expected the finding`);
|
||||
}
|
||||
return { path, line, side, body };
|
||||
});
|
||||
}
|
||||
|
||||
async function readOutcome(path: string): Promise<RuntimeOutcome> {
|
||||
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||
if (!isObject(value) || !("ok" in value)) {
|
||||
throw new Error("invalid agent outcome");
|
||||
}
|
||||
const ok = field(value, "ok");
|
||||
if (ok === false) {
|
||||
// Only known labels reach the comment, never raw agent output.
|
||||
const listed = field(value, "failures");
|
||||
const failures: unknown[] = Array.isArray(listed) ? listed : [];
|
||||
return {
|
||||
ok: false,
|
||||
failures: failures.flatMap((entry: unknown) => {
|
||||
const type = field(entry, "type");
|
||||
const model = field(entry, "model");
|
||||
const reason = field(entry, "reason");
|
||||
return isProfileType(type) &&
|
||||
typeof model === "string" &&
|
||||
isFailureReason(reason)
|
||||
? [{ type, model, reason }]
|
||||
: [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
const body = field(value, "body");
|
||||
const reviewPath = field(value, "reviewPath");
|
||||
const anchorsPath = field(value, "anchorsPath");
|
||||
const profile = field(value, "profile");
|
||||
if (
|
||||
ok !== true ||
|
||||
typeof body !== "string" ||
|
||||
typeof reviewPath !== "string" ||
|
||||
typeof anchorsPath !== "string" ||
|
||||
!isObject(profile)
|
||||
) {
|
||||
throw new Error("invalid agent outcome");
|
||||
}
|
||||
const type = field(profile, "type");
|
||||
const model = field(profile, "model");
|
||||
if (!isProfileType(type) || typeof model !== "string") {
|
||||
throw new Error("invalid agent outcome");
|
||||
}
|
||||
return { ok: true, body, reviewPath, anchorsPath, profile: { type, model } };
|
||||
}
|
||||
|
||||
async function readAgentArtifact(
|
||||
manifest: RuntimeManifest,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
const profile = manifest.profiles.find(
|
||||
(entry) => nodePath.join(entry.home, name) === path,
|
||||
);
|
||||
if (profile === undefined) {
|
||||
throw new Error(`${name}: outside profile home`);
|
||||
}
|
||||
let stat: Stats | null = null;
|
||||
try {
|
||||
stat = await lstat(path);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== profile.uid ||
|
||||
(stat.mode & 0o077) !== 0
|
||||
) {
|
||||
throw new Error(`${name}: expected a profile-owned regular private file`);
|
||||
}
|
||||
const text = await readFile(path, "utf8");
|
||||
return text === "" ? null : text;
|
||||
}
|
||||
|
||||
export async function postRuntime(
|
||||
manifestPath: string,
|
||||
context: PostContext,
|
||||
fetcher: Fetcher = fetch,
|
||||
): Promise<void> {
|
||||
const manifest = await readRuntimeManifest(manifestPath);
|
||||
try {
|
||||
const reviewerText = await readFile(manifest.reviewerFile, "utf8");
|
||||
const reviewerToken = reviewerText.trim();
|
||||
const request = async (path: string, body: unknown): Promise<void> => {
|
||||
let response: Response | null = null;
|
||||
try {
|
||||
response = await fetcher(`${context.apiUrl}/${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${reviewerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new GiteaRequestError(
|
||||
`${path}: ${response.status} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const postComment = async (body: string): Promise<void> => {
|
||||
await request(
|
||||
`repos/${context.repository}/issues/${context.issueIndex}/comments`,
|
||||
{
|
||||
body: stripAnsi(body),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
let outcome: RuntimeOutcome | null = null;
|
||||
try {
|
||||
outcome = await readOutcome(manifest.outcomeFile);
|
||||
} catch (error) {
|
||||
await postComment(failureComment([]));
|
||||
throw new Error("agent phase failed", { cause: error });
|
||||
}
|
||||
if (!outcome.ok) {
|
||||
await postComment(failureComment(outcome.failures));
|
||||
throw new Error("agent phase failed");
|
||||
}
|
||||
|
||||
try {
|
||||
const review = await readAgentArtifact(
|
||||
manifest,
|
||||
outcome.reviewPath,
|
||||
"review.md",
|
||||
);
|
||||
if (review === null) {
|
||||
if (context.eventName === "pull_request") {
|
||||
throw new Error("agent did not write a pull request review");
|
||||
}
|
||||
await postComment(withProfile(outcome.body, outcome.profile));
|
||||
return;
|
||||
}
|
||||
|
||||
const [verdict = "", ...rest] = review.split("\n");
|
||||
const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "💬"];
|
||||
const anchorText = await readAgentArtifact(
|
||||
manifest,
|
||||
outcome.anchorsPath,
|
||||
"anchors.json",
|
||||
);
|
||||
const anchors =
|
||||
anchorText === null
|
||||
? []
|
||||
: parseAnchors(anchorText, outcome.anchorsPath);
|
||||
const postReview = async (entries: Anchor[]): Promise<void> => {
|
||||
await request(
|
||||
`repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
|
||||
{
|
||||
body: stripAnsi(
|
||||
withProfile(
|
||||
[`${mark} ${verdict.trim()}`, ...rest].join("\n"),
|
||||
outcome.profile,
|
||||
),
|
||||
),
|
||||
event,
|
||||
comments: entries.map(({ path, line, side, body }) => ({
|
||||
path,
|
||||
body: stripAnsi(withProfile(body, outcome.profile)),
|
||||
new_position: side === "NEW" ? line : 0,
|
||||
old_position: side === "OLD" ? line : 0,
|
||||
})),
|
||||
},
|
||||
);
|
||||
};
|
||||
await postReview(anchors).catch(async (error: unknown) => {
|
||||
if (anchors.length === 0) {
|
||||
throw error;
|
||||
}
|
||||
console.error(
|
||||
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
await postReview([]);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof GiteaRequestError) {
|
||||
throw error;
|
||||
}
|
||||
await postComment(
|
||||
failureComment([{ ...outcome.profile, reason: "FAILED" }]),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await rm(manifest.runtimeDir, { recursive: true }).catch(
|
||||
(error: unknown) => {
|
||||
if (!isNotFound(error)) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { appendFile, rm } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
|
||||
import { prepareRuntime } from "#runtime";
|
||||
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`);
|
||||
}
|
||||
if (value === undefined || value === "") throw new Error(`${name} is not set`);
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import { isNotFound } from "#common";
|
||||
|
||||
import { exitCode } from "./child.ts";
|
||||
|
||||
test("reports the exit code", async () => {
|
||||
expect(
|
||||
await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" })),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" })),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
test("reports a signal as 128 plus its number", async () => {
|
||||
const child = spawn("/bin/sh", ["-c", "kill -KILL $$"], { stdio: "ignore" });
|
||||
expect(await exitCode(child)).toBe(137);
|
||||
});
|
||||
|
||||
test("resolves on exit while a grandchild still holds the output open", async () => {
|
||||
const child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const started = Date.now();
|
||||
expect(await exitCode(child)).toBe(5);
|
||||
expect(Date.now() - started).toBeLessThan(1500);
|
||||
child.stdout.destroy();
|
||||
child.stderr.destroy();
|
||||
});
|
||||
|
||||
test("rejects when the command does not exist", async () => {
|
||||
const child = spawn("/nonexistent/command", [], { stdio: "ignore" });
|
||||
const code = exitCode(child);
|
||||
// The rejection must stay unhandled-safe until it is awaited.
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
await expect(code).rejects.toSatisfy(isNotFound);
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Child processes: waiting on and echoing them, and the isolated profile
|
||||
// user's processes and files.
|
||||
export { echo, exitCode } from "./child.ts";
|
||||
export { killProfileProcesses, readProfileFile } from "./isolation.ts";
|
||||
@@ -1,148 +0,0 @@
|
||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import {
|
||||
chmod,
|
||||
chown,
|
||||
mkdir,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import { isNotFound } from "#common";
|
||||
import type { RuntimeProfile } from "#runtime";
|
||||
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
|
||||
|
||||
import {
|
||||
isRunnableProfileProcess,
|
||||
killProfileProcesses,
|
||||
readProfileFile,
|
||||
} from "./isolation.ts";
|
||||
|
||||
const profile = (uid: number, home: string): RuntimeProfile => ({
|
||||
index: uid - 20_000,
|
||||
type: "CLAUDE",
|
||||
model: "test",
|
||||
uid,
|
||||
gid: uid,
|
||||
home,
|
||||
tokenFile: null,
|
||||
});
|
||||
|
||||
// Runs a command as the profile's user from the root directory. Without `env`
|
||||
// it inherits this process's environment.
|
||||
function runAs(
|
||||
selected: RuntimeProfile,
|
||||
command: string,
|
||||
args: string[],
|
||||
env?: ReadonlyMap<string, string>,
|
||||
): SpawnSyncReturns<string> {
|
||||
return spawnSync(command, args, {
|
||||
cwd: "/",
|
||||
uid: selected.uid,
|
||||
gid: selected.gid,
|
||||
encoding: "utf8",
|
||||
...(env !== undefined && { env: Object.fromEntries(env) }),
|
||||
});
|
||||
}
|
||||
|
||||
test("zombie process entries are already quiescent", () => {
|
||||
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
|
||||
expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
|
||||
});
|
||||
|
||||
test.skipIf(IS_WINDOWS)(
|
||||
"refuses to read a profile result symlink as root",
|
||||
async () => {
|
||||
const root = await makeTempDir();
|
||||
try {
|
||||
const secret = `${root}/reviewer-token`;
|
||||
const result = `${root}/result.md`;
|
||||
await writeFile(secret, "reviewer-secret", { mode: 0o600 });
|
||||
await symlink(secret, result);
|
||||
|
||||
await expect(
|
||||
readProfileFile(profile(currentUid(), root), result),
|
||||
).rejects.toThrow("regular private file");
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(!IS_ROOT)(
|
||||
"a profile process can read only its own private files",
|
||||
async () => {
|
||||
const root = await makeTempDir();
|
||||
await chmod(root, 0o711);
|
||||
try {
|
||||
const homeA = `${root}/a`;
|
||||
const homeB = `${root}/b`;
|
||||
await mkdir(homeA, { mode: 0o700 });
|
||||
await mkdir(homeB, { mode: 0o700 });
|
||||
await chown(homeA, 20_000, 20_000);
|
||||
await chown(homeB, 20_001, 20_001);
|
||||
await writeFile(`${homeA}/token`, "own", { mode: 0o600 });
|
||||
await chown(`${homeA}/token`, 20_000, 20_000);
|
||||
await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 });
|
||||
await chown(`${homeB}/token`, 20_001, 20_001);
|
||||
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
|
||||
|
||||
const selected = profile(20_000, homeA);
|
||||
const own = runAs(selected, "/bin/cat", [`${homeA}/token`]);
|
||||
const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]);
|
||||
const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]);
|
||||
|
||||
expect(own.stdout).toBe("own");
|
||||
expect(own.status).toBe(0);
|
||||
expect(sibling.status).not.toBe(0);
|
||||
expect(reviewer.status).not.toBe(0);
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(!IS_ROOT)(
|
||||
"cleanup leaves no runnable daemonized process",
|
||||
async () => {
|
||||
const selected = profile(20_010, "/tmp");
|
||||
const launched = runAs(selected, "/bin/sh", [
|
||||
"-c",
|
||||
"sleep 60 >/dev/null 2>&1 & echo $!",
|
||||
]);
|
||||
const pid = Number(launched.stdout.trim());
|
||||
expect(Number.isInteger(pid)).toBe(true);
|
||||
|
||||
await killProfileProcesses(selected.uid);
|
||||
|
||||
const status = await readFile(`/proc/${pid}/status`, "utf8").catch(
|
||||
(error: unknown) => {
|
||||
if (isNotFound(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
expect(
|
||||
status === null || !isRunnableProfileProcess(status, selected.uid),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(!IS_ROOT)(
|
||||
"the agent process cannot read its coordinator environment",
|
||||
() => {
|
||||
const selected = profile(20_020, "/tmp");
|
||||
const result = runAs(
|
||||
selected,
|
||||
"/usr/bin/cat",
|
||||
[`/proc/${process.pid}/environ`],
|
||||
new Map([["PARENT_SECRET", "must-not-cross-boundary"]]),
|
||||
);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,228 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
AgentAuthError,
|
||||
agentEnvironment,
|
||||
failureFooter,
|
||||
type ProfileFailure,
|
||||
profileFooter,
|
||||
runProfiles,
|
||||
sanitizedEnvironment,
|
||||
} from "./profiles.ts";
|
||||
|
||||
test("formats profile attribution as a Markdown quote", () => {
|
||||
expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
|
||||
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
|
||||
);
|
||||
});
|
||||
|
||||
test("falls through only after usage exhaustion", async () => {
|
||||
const visited: string[] = [];
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
visited.push(profile.type);
|
||||
if (profile.type === "claude") {
|
||||
throw new Error("You've hit your usage limit");
|
||||
}
|
||||
return Promise.resolve("done");
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("done");
|
||||
expect(visited).toStrictEqual(["claude", "codex"]);
|
||||
});
|
||||
|
||||
test("recognizes Claude's weekly limit message", async () => {
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
if (profile.type === "claude") {
|
||||
throw new Error("You've hit your weekly limit · resets 12am (UTC)");
|
||||
}
|
||||
return Promise.resolve("fallback");
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("fallback");
|
||||
});
|
||||
|
||||
test("recognizes provider API exhaustion status", async () => {
|
||||
const results = await Promise.all(
|
||||
["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) =>
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
if (profile.type === "claude") throw new Error(message);
|
||||
return Promise.resolve("fallback");
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(results).toStrictEqual(["fallback", "fallback"]);
|
||||
});
|
||||
|
||||
test("falls through after a sign-in failure and records each try", async () => {
|
||||
const failures: ProfileFailure[] = [];
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" },
|
||||
],
|
||||
(profile) => {
|
||||
if (profile.type === "codex") {
|
||||
throw new AgentAuthError("codex sign-in failed");
|
||||
}
|
||||
if (profile.type === "claude") throw new Error("API Error: 429");
|
||||
return Promise.resolve("fallback");
|
||||
},
|
||||
failures,
|
||||
);
|
||||
|
||||
expect(result).toBe("fallback");
|
||||
expect(failures).toStrictEqual([
|
||||
{ type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" },
|
||||
{ type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" },
|
||||
]);
|
||||
expect(failureFooter(failures)).toStrictEqual(
|
||||
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " +
|
||||
"`claude` · `claude-sonnet-5` (usage exhausted)",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws the last sign-in failure when every profile fails", async () => {
|
||||
const failures: ProfileFailure[] = [];
|
||||
await expect(() =>
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
],
|
||||
(profile) => {
|
||||
throw new AgentAuthError(`${profile.type} sign-in failed`);
|
||||
},
|
||||
failures,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
expect.toSatisfy(
|
||||
(e: unknown) => e instanceof AgentAuthError && e.message.includes("claude sign-in failed"),
|
||||
),
|
||||
);
|
||||
expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]);
|
||||
});
|
||||
|
||||
test("preserves an ordinary agent failure", async () => {
|
||||
const visited: string[] = [];
|
||||
await expect(() =>
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
visited.push(profile.type);
|
||||
throw new Error("tests failed");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("tests failed");
|
||||
expect(visited).toStrictEqual(["claude"]);
|
||||
});
|
||||
|
||||
test("does not mistake incidental quota text for exhaustion", async () => {
|
||||
const visited: string[] = [];
|
||||
await expect(() =>
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "claude", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "codex", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
visited.push(profile.type);
|
||||
throw new Error("quota parser test failed at line 429");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("quota parser test failed");
|
||||
expect(visited).toStrictEqual(["claude"]);
|
||||
});
|
||||
|
||||
test("exposes only the selected profile credential to the agent", () => {
|
||||
const base = {
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/home/bot",
|
||||
UNRELATED_SECRET: "must-not-reach-agent",
|
||||
BOT_AGENTS_MASTER_KEY: "master",
|
||||
PROFILES: "contains every secret",
|
||||
REVIEWER_TOKEN: "reviewer",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "old-claude",
|
||||
ANTHROPIC_API_KEY: "old-anthropic-key",
|
||||
ANTHROPIC_AUTH_TOKEN: "old-anthropic-token",
|
||||
OPENAI_API_KEY: "old-openai",
|
||||
CODEX_API_KEY: "old-codex-key",
|
||||
CODEX_ACCESS_TOKEN: "old-codex-token",
|
||||
};
|
||||
const profile = {
|
||||
index: 0,
|
||||
type: "claude" as const,
|
||||
model: "claude-sonnet-5",
|
||||
uid: 20_000,
|
||||
gid: 20_000,
|
||||
home: "/runtime/profile-0",
|
||||
tokenFile: "/runtime/profile-0/token",
|
||||
};
|
||||
|
||||
expect(agentEnvironment(base, profile, "selected")).toStrictEqual({
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/runtime/profile-0",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "selected",
|
||||
});
|
||||
expect(sanitizedEnvironment(base)).toStrictEqual({
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/home/bot",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses a private Codex home without inventing an API credential", () => {
|
||||
const profile = {
|
||||
index: 1,
|
||||
type: "codex" as const,
|
||||
model: "gpt-5.6-terra",
|
||||
uid: 20_001,
|
||||
gid: 20_001,
|
||||
home: "/runtime/profile-1",
|
||||
tokenFile: null,
|
||||
};
|
||||
|
||||
expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({
|
||||
PATH: "/bin",
|
||||
HOME: "/runtime/profile-1",
|
||||
CODEX_HOME: "/runtime/profile-1/.codex",
|
||||
});
|
||||
});
|
||||
|
||||
test("exposes an OpenRouter key only to the OpenCode profile", () => {
|
||||
const profile = {
|
||||
index: 2,
|
||||
type: "opencode" as const,
|
||||
model: "openrouter/qwen/qwen3.8-27b",
|
||||
uid: 20_002,
|
||||
gid: 20_002,
|
||||
home: "/runtime/profile-2",
|
||||
tokenFile: "/runtime/profile-2/token",
|
||||
};
|
||||
|
||||
expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({
|
||||
PATH: "/bin",
|
||||
HOME: "/runtime/profile-2",
|
||||
OPENROUTER_API_KEY: "selected",
|
||||
});
|
||||
});
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
export interface BotProfile {
|
||||
type: "claude" | "codex" | "opencode";
|
||||
model: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
|
||||
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
|
||||
}
|
||||
|
||||
export function withProfile(body: string, profile: Pick<BotProfile, "type" | "model">): string {
|
||||
return `${body}\n\n${profileFooter(profile)}`;
|
||||
}
|
||||
|
||||
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
|
||||
|
||||
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
|
||||
reason: FailureReason;
|
||||
};
|
||||
|
||||
// Raised once an agent's credentials are rejected and any re-login is spent.
|
||||
export class AgentAuthError extends Error {}
|
||||
|
||||
function isUsageExhausted(error: unknown): boolean {
|
||||
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test(
|
||||
String(error),
|
||||
);
|
||||
}
|
||||
|
||||
function failureReason(error: unknown): FailureReason {
|
||||
if (error instanceof AgentAuthError) return "sign-in failed";
|
||||
return isUsageExhausted(error) ? "usage exhausted" : "failed";
|
||||
}
|
||||
|
||||
export function failureFooter(failures: ProfileFailure[]): string {
|
||||
if (failures.length === 0) return "> Profile: none";
|
||||
return `> Tried: ${failures
|
||||
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
|
||||
.join(", ")}`;
|
||||
}
|
||||
|
||||
// Tries the profiles from `index` on, moving on only after a sign-in failure
|
||||
// or exhausted usage, and records every profile it tried.
|
||||
async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
failures: ProfileFailure[],
|
||||
index: number,
|
||||
): Promise<T> {
|
||||
const profile = profiles[index];
|
||||
if (profile === undefined) {
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
}
|
||||
try {
|
||||
return await attempt(profile);
|
||||
} catch (error) {
|
||||
const reason = failureReason(error);
|
||||
failures.push({ type: profile.type, model: profile.model, reason });
|
||||
if (reason === "failed" || index === profiles.length - 1) throw error;
|
||||
console.error(`${profile.type} ${reason}; trying next profile`);
|
||||
return attemptFrom(profiles, attempt, failures, index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
failures: ProfileFailure[] = [],
|
||||
): Promise<T> {
|
||||
return attemptFrom(profiles, attempt, failures, 0);
|
||||
}
|
||||
|
||||
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const name of [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"LC_ALL",
|
||||
"TERM",
|
||||
"COLORTERM",
|
||||
"CI",
|
||||
"TMPDIR",
|
||||
"GITEA_TOKEN",
|
||||
"GITEA_API_URL",
|
||||
"GITEA_REPOSITORY",
|
||||
"GITHUB_WORKSPACE",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
]) {
|
||||
const value = environment[name];
|
||||
if (value !== undefined) result[name] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function agentEnvironment(
|
||||
environment: Record<string, string>,
|
||||
profile: Pick<BotProfile, "type"> & { home: string },
|
||||
token?: string,
|
||||
): Record<string, string> {
|
||||
const result = sanitizedEnvironment(environment);
|
||||
result["HOME"] = profile.home;
|
||||
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`;
|
||||
if (token !== undefined && token !== "") {
|
||||
const tokenName = {
|
||||
claude: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
codex: "OPENAI_API_KEY",
|
||||
opencode: "OPENROUTER_API_KEY",
|
||||
}[profile.type];
|
||||
result[tokenName] = token;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Bot profiles: their footers, failure reports, ordered runs, and the
|
||||
// environment each agent gets.
|
||||
export {
|
||||
AgentAuthError,
|
||||
agentEnvironment,
|
||||
failureFooter,
|
||||
profileFooter,
|
||||
runProfiles,
|
||||
sanitizedEnvironment,
|
||||
withProfile,
|
||||
} from "./profiles.ts";
|
||||
export type {
|
||||
BotProfile,
|
||||
FailureReason,
|
||||
ProfileFailure,
|
||||
ProfileIdentity,
|
||||
} from "./profiles.ts";
|
||||
@@ -1,255 +0,0 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import {
|
||||
AgentAuthError,
|
||||
agentEnvironment,
|
||||
failureFooter,
|
||||
type ProfileFailure,
|
||||
profileFooter,
|
||||
runProfiles,
|
||||
sanitizedEnvironment,
|
||||
} from "./profiles.ts";
|
||||
|
||||
test("formats profile attribution as a Markdown quote", () => {
|
||||
expect(
|
||||
profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" }),
|
||||
).toBe("> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`");
|
||||
});
|
||||
|
||||
test("falls through only after usage exhaustion", async () => {
|
||||
const visited: string[] = [];
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
],
|
||||
async (profile) => {
|
||||
visited.push(profile.type);
|
||||
if (profile.type === "CLAUDE") {
|
||||
throw new Error("You've hit your usage limit");
|
||||
}
|
||||
const resolved = await Promise.resolve("done");
|
||||
return resolved;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("done");
|
||||
expect(visited).toStrictEqual(["CLAUDE", "CODEX"]);
|
||||
});
|
||||
|
||||
test("recognizes Claude's weekly limit message", async () => {
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
],
|
||||
async (profile) => {
|
||||
if (profile.type === "CLAUDE") {
|
||||
throw new Error("You've hit your weekly limit · resets 12am (UTC)");
|
||||
}
|
||||
const resolved = await Promise.resolve("fallback");
|
||||
return resolved;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("fallback");
|
||||
});
|
||||
|
||||
test("recognizes provider API exhaustion status", async () => {
|
||||
const results = await Promise.all(
|
||||
["API Error: 402 Insufficient funds", "HTTP error: 429"].map(
|
||||
async (message) => {
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
],
|
||||
async (profile) => {
|
||||
if (profile.type === "CLAUDE") {
|
||||
throw new Error(message);
|
||||
}
|
||||
const resolved = await Promise.resolve("fallback");
|
||||
return resolved;
|
||||
},
|
||||
);
|
||||
return result;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(results).toStrictEqual(["fallback", "fallback"]);
|
||||
});
|
||||
|
||||
test("falls through after a sign-in failure and records each try", async () => {
|
||||
const failures: ProfileFailure[] = [];
|
||||
const result = await runProfiles(
|
||||
[
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" },
|
||||
],
|
||||
async (profile) => {
|
||||
if (profile.type === "CODEX") {
|
||||
throw new AgentAuthError("codex sign-in failed");
|
||||
}
|
||||
if (profile.type === "CLAUDE") {
|
||||
throw new Error("API Error: 429");
|
||||
}
|
||||
const resolved = await Promise.resolve("fallback");
|
||||
return resolved;
|
||||
},
|
||||
failures,
|
||||
);
|
||||
|
||||
expect(result).toBe("fallback");
|
||||
expect(failures).toStrictEqual([
|
||||
{ type: "CODEX", model: "gpt-5.6-terra", reason: "SIGN_IN_FAILED" },
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", reason: "USAGE_EXHAUSTED" },
|
||||
]);
|
||||
expect(failureFooter(failures)).toStrictEqual(
|
||||
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " +
|
||||
"`claude` · `claude-sonnet-5` (usage exhausted)",
|
||||
);
|
||||
});
|
||||
|
||||
test("throws the last sign-in failure when every profile fails", async () => {
|
||||
const failures: ProfileFailure[] = [];
|
||||
await expect(
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
],
|
||||
(profile) => {
|
||||
throw new AgentAuthError(`${profile.type} sign-in failed`);
|
||||
},
|
||||
failures,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
expect.toSatisfy(
|
||||
(error: unknown) =>
|
||||
error instanceof AgentAuthError &&
|
||||
error.message.includes("CLAUDE sign-in failed"),
|
||||
),
|
||||
);
|
||||
expect(failures.map(({ reason }) => reason)).toStrictEqual([
|
||||
"SIGN_IN_FAILED",
|
||||
"SIGN_IN_FAILED",
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves an ordinary agent failure", async () => {
|
||||
const visited: string[] = [];
|
||||
await expect(
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
visited.push(profile.type);
|
||||
throw new Error("tests failed");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("tests failed");
|
||||
expect(visited).toStrictEqual(["CLAUDE"]);
|
||||
});
|
||||
|
||||
test("does not mistake incidental quota text for exhaustion", async () => {
|
||||
const visited: string[] = [];
|
||||
await expect(
|
||||
runProfiles(
|
||||
[
|
||||
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
|
||||
{ type: "CODEX", model: "gpt-5.6-terra" },
|
||||
],
|
||||
(profile) => {
|
||||
visited.push(profile.type);
|
||||
throw new Error("quota parser test failed at line 429");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("quota parser test failed");
|
||||
expect(visited).toStrictEqual(["CLAUDE"]);
|
||||
});
|
||||
|
||||
test("exposes only the selected profile credential to the agent", () => {
|
||||
const base = new Map([
|
||||
["PATH", "/usr/bin"],
|
||||
["HOME", "/home/bot"],
|
||||
["UNRELATED_SECRET", "must-not-reach-agent"],
|
||||
["BOT_AGENTS_MASTER_KEY", "master"],
|
||||
["PROFILES", "contains every secret"],
|
||||
["REVIEWER_TOKEN", "reviewer"],
|
||||
["CLAUDE_CODE_OAUTH_TOKEN", "old-claude"],
|
||||
["ANTHROPIC_API_KEY", "old-anthropic-key"],
|
||||
["ANTHROPIC_AUTH_TOKEN", "old-anthropic-token"],
|
||||
["OPENAI_API_KEY", "old-openai"],
|
||||
["CODEX_API_KEY", "old-codex-key"],
|
||||
["CODEX_ACCESS_TOKEN", "old-codex-token"],
|
||||
]);
|
||||
const profile = {
|
||||
index: 0,
|
||||
type: "CLAUDE" as const,
|
||||
model: "claude-sonnet-5",
|
||||
uid: 20_000,
|
||||
gid: 20_000,
|
||||
home: "/runtime/profile-0",
|
||||
tokenFile: "/runtime/profile-0/token",
|
||||
};
|
||||
|
||||
expect(agentEnvironment(base, profile, "selected")).toStrictEqual(
|
||||
new Map([
|
||||
["PATH", "/usr/bin"],
|
||||
["HOME", "/runtime/profile-0"],
|
||||
["CLAUDE_CODE_OAUTH_TOKEN", "selected"],
|
||||
]),
|
||||
);
|
||||
expect(sanitizedEnvironment(base)).toStrictEqual(
|
||||
new Map([
|
||||
["PATH", "/usr/bin"],
|
||||
["HOME", "/home/bot"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("uses a private Codex home without inventing an API credential", () => {
|
||||
const profile = {
|
||||
index: 1,
|
||||
type: "CODEX" as const,
|
||||
model: "gpt-5.6-terra",
|
||||
uid: 20_001,
|
||||
gid: 20_001,
|
||||
home: "/runtime/profile-1",
|
||||
tokenFile: null,
|
||||
};
|
||||
|
||||
expect(agentEnvironment(new Map([["PATH", "/bin"]]), profile)).toStrictEqual(
|
||||
new Map([
|
||||
["PATH", "/bin"],
|
||||
["HOME", "/runtime/profile-1"],
|
||||
["CODEX_HOME", "/runtime/profile-1/.codex"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("exposes an OpenRouter key only to the OpenCode profile", () => {
|
||||
const profile = {
|
||||
index: 2,
|
||||
type: "OPENCODE" as const,
|
||||
model: "openrouter/qwen/qwen3.8-27b",
|
||||
uid: 20_002,
|
||||
gid: 20_002,
|
||||
home: "/runtime/profile-2",
|
||||
tokenFile: "/runtime/profile-2/token",
|
||||
};
|
||||
|
||||
expect(
|
||||
agentEnvironment(new Map([["PATH", "/bin"]]), profile, "selected"),
|
||||
).toStrictEqual(
|
||||
new Map([
|
||||
["PATH", "/bin"],
|
||||
["HOME", "/runtime/profile-2"],
|
||||
["OPENROUTER_API_KEY", "selected"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
// The agent and model a profile runs, which every report names.
|
||||
export interface ProfileIdentity {
|
||||
type: "CLAUDE" | "CODEX" | "OPENCODE";
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface BotProfile extends ProfileIdentity {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
// The lowercase name a profile type reads as in posted comments.
|
||||
function profileName(type: ProfileIdentity["type"]): string {
|
||||
switch (type) {
|
||||
case "CLAUDE":
|
||||
return "claude";
|
||||
case "CODEX":
|
||||
return "codex";
|
||||
case "OPENCODE":
|
||||
return "opencode";
|
||||
}
|
||||
}
|
||||
|
||||
export function profileFooter(profile: ProfileIdentity): string {
|
||||
return `> Profile: \`${profileName(profile.type)}\` · \`${profile.model}\``;
|
||||
}
|
||||
|
||||
export function withProfile(body: string, profile: ProfileIdentity): string {
|
||||
return `${body}\n\n${profileFooter(profile)}`;
|
||||
}
|
||||
|
||||
export type FailureReason = "SIGN_IN_FAILED" | "USAGE_EXHAUSTED" | "FAILED";
|
||||
|
||||
// The words a failure reason reads as in logs and posted comments.
|
||||
function toMessage(reason: FailureReason): string {
|
||||
switch (reason) {
|
||||
case "SIGN_IN_FAILED":
|
||||
return "sign-in failed";
|
||||
case "USAGE_EXHAUSTED":
|
||||
return "usage exhausted";
|
||||
case "FAILED":
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProfileFailure extends ProfileIdentity {
|
||||
reason: FailureReason;
|
||||
}
|
||||
|
||||
// Where a profile's isolated user keeps its home.
|
||||
interface ProfileHome {
|
||||
type: ProfileIdentity["type"];
|
||||
home: string;
|
||||
}
|
||||
|
||||
// Raised once an agent's credentials are rejected and any re-login is spent.
|
||||
export class AgentAuthError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "AgentAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
function isUsageExhausted(error: unknown): boolean {
|
||||
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test(
|
||||
String(error),
|
||||
);
|
||||
}
|
||||
|
||||
function failureReason(error: unknown): FailureReason {
|
||||
if (error instanceof AgentAuthError) {
|
||||
return "SIGN_IN_FAILED";
|
||||
}
|
||||
return isUsageExhausted(error) ? "USAGE_EXHAUSTED" : "FAILED";
|
||||
}
|
||||
|
||||
export function failureFooter(failures: ProfileFailure[]): string {
|
||||
if (failures.length === 0) {
|
||||
return "> Profile: none";
|
||||
}
|
||||
return `> Tried: ${failures
|
||||
.map(
|
||||
({ type, model, reason }) =>
|
||||
`\`${profileName(type)}\` · \`${model}\` (${toMessage(reason)})`,
|
||||
)
|
||||
.join(", ")}`;
|
||||
}
|
||||
|
||||
// Tries the profiles in order, moving on only after a sign-in failure or
|
||||
// exhausted usage, and records every profile it tried.
|
||||
export async function runProfiles<T, P extends ProfileIdentity>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
failures: ProfileFailure[] = [],
|
||||
): Promise<T> {
|
||||
for (const [index, profile] of profiles.entries()) {
|
||||
try {
|
||||
return await attempt(profile);
|
||||
} catch (error) {
|
||||
const reason = failureReason(error);
|
||||
failures.push({ type: profile.type, model: profile.model, reason });
|
||||
if (reason === "FAILED" || index === profiles.length - 1) {
|
||||
throw error;
|
||||
}
|
||||
console.error(
|
||||
`${profile.type} ${toMessage(reason)}; trying next profile`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
}
|
||||
|
||||
// Environments are maps from variable name to value, converted to the object
|
||||
// form only where a child process is spawned.
|
||||
export function sanitizedEnvironment(
|
||||
environment: ReadonlyMap<string, string>,
|
||||
): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const name of [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"LC_ALL",
|
||||
"TERM",
|
||||
"COLORTERM",
|
||||
"CI",
|
||||
"TMPDIR",
|
||||
"GITEA_TOKEN",
|
||||
"GITEA_API_URL",
|
||||
"GITEA_REPOSITORY",
|
||||
"GITHUB_WORKSPACE",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
]) {
|
||||
const value = environment.get(name);
|
||||
if (value !== undefined) {
|
||||
result.set(name, value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// The environment variable each agent CLI reads its credential from.
|
||||
function tokenVariable(type: ProfileIdentity["type"]): string {
|
||||
switch (type) {
|
||||
case "CLAUDE":
|
||||
return "CLAUDE_CODE_OAUTH_TOKEN";
|
||||
case "CODEX":
|
||||
return "OPENAI_API_KEY";
|
||||
case "OPENCODE":
|
||||
return "OPENROUTER_API_KEY";
|
||||
}
|
||||
}
|
||||
|
||||
export function agentEnvironment(
|
||||
environment: ReadonlyMap<string, string>,
|
||||
profile: ProfileHome,
|
||||
token?: string,
|
||||
): Map<string, string> {
|
||||
const result = sanitizedEnvironment(environment);
|
||||
result.set("HOME", profile.home);
|
||||
if (profile.type === "CODEX") {
|
||||
result.set("CODEX_HOME", `${profile.home}/.codex`);
|
||||
}
|
||||
if (token !== undefined && token !== "") {
|
||||
const tokenName = tokenVariable(profile.type);
|
||||
result.set(tokenName, token);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
// Runs one ordered agent profile. Credential decryption and reviewer posting
|
||||
// live in separate processes so neither privileged secret reaches this runner.
|
||||
import { chmod, chown, readFile, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { Readable } from "node:stream";
|
||||
import { text as readText } from "node:stream/consumers";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
import {
|
||||
codexAuthentication,
|
||||
isClaudeAuthFailure,
|
||||
isCodexAuthFailure,
|
||||
persistCodexAuth,
|
||||
retryInvalidToken,
|
||||
stageCodexAuth,
|
||||
} from "./auth.ts";
|
||||
import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts";
|
||||
import { chunks, endWith, output, writeChunk } from "./command.ts";
|
||||
import type { CommandStatus } from "./command.ts";
|
||||
import { agentCommand, killProfileProcesses, readProfileFile } from "./isolation.ts";
|
||||
import { isObject } from "./json.ts";
|
||||
import {
|
||||
collectOpenCodeOutput,
|
||||
openCodeArgs,
|
||||
openCodeError,
|
||||
parseOpenCodeEvents,
|
||||
} from "./opencode.ts";
|
||||
import {
|
||||
AgentAuthError,
|
||||
agentEnvironment,
|
||||
runProfiles,
|
||||
sanitizedEnvironment,
|
||||
withProfile,
|
||||
} from "./profiles.ts";
|
||||
import type { ProfileFailure } from "./profiles.ts";
|
||||
import { readRuntimeManifest } from "./runtime.ts";
|
||||
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
interface AttemptResult {
|
||||
body: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
profile: Pick<RuntimeProfile, "type" | "model">;
|
||||
}
|
||||
|
||||
// Claude Code's stream-json `result` event, in its own field names.
|
||||
type ClaudeResult = ClaudeFailureFields &
|
||||
Partial<Record<"is_error", boolean>> & {
|
||||
subtype: string;
|
||||
};
|
||||
|
||||
const env = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) throw new Error(`${name} is not set`);
|
||||
return value;
|
||||
};
|
||||
|
||||
function definedEnvironment(): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
result[name] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const API = env("GITEA_API_URL");
|
||||
const REPO = env("GITEA_REPOSITORY");
|
||||
const INDEX = env("ISSUE_INDEX");
|
||||
const EVENT = env("EVENT_NAME");
|
||||
const AUTHOR_TOKEN = env("GITEA_TOKEN");
|
||||
const WORKSPACE = env("GITHUB_WORKSPACE");
|
||||
const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
|
||||
const PROCESS_ENV = sanitizedEnvironment(definedEnvironment());
|
||||
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
|
||||
const CODEX_AUTH = "/root/.codex/auth.json";
|
||||
const SKILLS_INSTALL = "skills add obra/superpowers";
|
||||
|
||||
async function gitea(path: string, body?: unknown): Promise<Response> {
|
||||
const response = await fetch(`${API}/${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: {
|
||||
Authorization: `token ${AUTHOR_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function postAuthorComment(body: string): Promise<void> {
|
||||
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
|
||||
}
|
||||
|
||||
async function runRoot(command: string, args: string[]): Promise<void> {
|
||||
const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true });
|
||||
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
|
||||
}
|
||||
|
||||
async function configureGitAuthor(): Promise<void> {
|
||||
const user: unknown = await (await gitea("user")).json();
|
||||
if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") {
|
||||
throw new Error("user: unexpected Gitea response");
|
||||
}
|
||||
await runRoot("git", ["config", "user.name", user["login"]]);
|
||||
await runRoot("git", ["config", "user.email", user["email"]]);
|
||||
}
|
||||
|
||||
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
|
||||
const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8");
|
||||
return token;
|
||||
}
|
||||
|
||||
async function runAgentCommand(
|
||||
profile: RuntimeProfile,
|
||||
command: string,
|
||||
args: string[],
|
||||
token?: string,
|
||||
): Promise<void> {
|
||||
const { success, code } = await agentCommand(profile, command, args, {
|
||||
cwd: WORKSPACE,
|
||||
env: agentEnvironment(PROCESS_ENV, profile, token),
|
||||
clearEnv: true,
|
||||
}).output();
|
||||
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
|
||||
}
|
||||
|
||||
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
|
||||
await runAgentCommand(profile, "git", [
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
"safe.directory",
|
||||
WORKSPACE,
|
||||
]);
|
||||
await runAgentCommand(profile, "npx", [
|
||||
"-y",
|
||||
...SKILLS_INSTALL.split(" "),
|
||||
"--global",
|
||||
"--agent",
|
||||
profile.type === "claude" ? "claude-code" : profile.type,
|
||||
"--yes",
|
||||
]);
|
||||
}
|
||||
|
||||
// One issue comment as the prompt quotes it.
|
||||
function formatComment(comment: unknown): string {
|
||||
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {};
|
||||
const login = isObject(user) ? user["login"] : undefined;
|
||||
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
|
||||
}
|
||||
|
||||
async function renderPrompt(
|
||||
profile: RuntimeProfile,
|
||||
): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> {
|
||||
const reviewPath = await makeProfileFile(profile, "review.md");
|
||||
const anchorsPath = await makeProfileFile(profile, "anchors.json");
|
||||
const comments: unknown = await (
|
||||
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
|
||||
).json();
|
||||
if (!Array.isArray(comments)) {
|
||||
throw new TypeError("comments: unexpected Gitea response");
|
||||
}
|
||||
const values: Record<string, string> = {
|
||||
COMMENT: env("COMMENT"),
|
||||
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
|
||||
CODE_RULES: await (await gitea(RULES_PATH)).text(),
|
||||
EVENT_NAME: EVENT,
|
||||
GITEA_API_URL: API,
|
||||
GITEA_REPOSITORY: REPO,
|
||||
ISSUE_INDEX: INDEX,
|
||||
REVIEW_PATH: reviewPath,
|
||||
ANCHORS_PATH: anchorsPath,
|
||||
};
|
||||
const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8");
|
||||
return {
|
||||
prompt: template.replaceAll(
|
||||
/\$\{(\w+)\}/gu,
|
||||
(match: string, name: string) => values[name] ?? match,
|
||||
),
|
||||
reviewPath,
|
||||
anchorsPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise<string> {
|
||||
const claude = agentCommand(
|
||||
profile,
|
||||
"claude",
|
||||
[
|
||||
"--print",
|
||||
"--dangerously-skip-permissions",
|
||||
"--model",
|
||||
profile.model,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
prompt,
|
||||
],
|
||||
{
|
||||
cwd: WORKSPACE,
|
||||
env: {
|
||||
...agentEnvironment(PROCESS_ENV, profile, token),
|
||||
IS_SANDBOX: "1",
|
||||
},
|
||||
clearEnv: true,
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
},
|
||||
).spawn();
|
||||
let result: ClaudeResult | undefined;
|
||||
let error = "";
|
||||
const copyError = (async (): Promise<void> => {
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of chunks(claude.stderr)) {
|
||||
const part = decoder.decode(chunk, { stream: true });
|
||||
error += part;
|
||||
console.error(part);
|
||||
}
|
||||
error += decoder.decode();
|
||||
})();
|
||||
for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) {
|
||||
if (line !== "") {
|
||||
const event: unknown = JSON.parse(line);
|
||||
if (isObject(event)) {
|
||||
const content = isObject(event["message"]) ? event["message"]["content"] : undefined;
|
||||
for (const part of Array.isArray(content) ? content : []) {
|
||||
if (isObject(part)) {
|
||||
const text = part["thinking"] ?? part["text"] ?? part["name"];
|
||||
if (typeof text === "string" && text !== "") {
|
||||
console.log(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event["type"] === "result") {
|
||||
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event;
|
||||
result = {
|
||||
is_error: typeof isError === "boolean" ? isError : undefined,
|
||||
result: typeof answer === "string" ? answer : undefined,
|
||||
subtype: String(subtype),
|
||||
api_error_status:
|
||||
typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const [status] = await Promise.all([claude.status, copyError]);
|
||||
if (result?.is_error === true) {
|
||||
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
|
||||
if (isClaudeAuthFailure(result)) {
|
||||
throw new AgentAuthError("claude sign-in failed", { cause: failure });
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
if (result?.result === undefined) {
|
||||
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
|
||||
}
|
||||
if (!status.success) {
|
||||
throw new Error(error.trim() || `claude exited with ${status.code}`);
|
||||
}
|
||||
return result.result;
|
||||
}
|
||||
|
||||
// Polls every 100 ms until the login prints something, failing if it exits first.
|
||||
async function pollForInstructions(
|
||||
shownText: () => string,
|
||||
status: Promise<CommandStatus>,
|
||||
): Promise<void> {
|
||||
if (shownText().trim() !== "") {
|
||||
return;
|
||||
}
|
||||
const exited = await Promise.race([status.then(() => true), sleep(100, false)]);
|
||||
if (exited) {
|
||||
throw new AgentAuthError("AI bot login produced no instructions");
|
||||
}
|
||||
await pollForInstructions(shownText, status);
|
||||
}
|
||||
|
||||
async function codexDeviceLogin(
|
||||
profile: RuntimeProfile,
|
||||
environment: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const login = agentCommand(profile, "codex", ["login", "--device-auth"], {
|
||||
cwd: WORKSPACE,
|
||||
env: environment,
|
||||
clearEnv: true,
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
}).spawn();
|
||||
let shown = "";
|
||||
const collect = async (stream: Readable): Promise<void> => {
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of chunks(stream)) {
|
||||
shown += decoder.decode(chunk, { stream: true });
|
||||
}
|
||||
shown += decoder.decode();
|
||||
};
|
||||
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
|
||||
const status = login.status;
|
||||
await sleep(3000);
|
||||
await pollForInstructions(() => shown, status);
|
||||
await postAuthorComment(withProfile(shown, profile));
|
||||
const [result] = await Promise.all([status, drained]);
|
||||
if (!result.success) {
|
||||
throw new AgentAuthError(`codex login exited with ${result.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function codexApiLogin(
|
||||
profile: RuntimeProfile,
|
||||
environment: Record<string, string>,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const login = agentCommand(profile, "codex", ["login", "--with-api-key"], {
|
||||
cwd: WORKSPACE,
|
||||
env: environment,
|
||||
clearEnv: true,
|
||||
stdin: "piped",
|
||||
stderr: "piped",
|
||||
}).spawn();
|
||||
await endWith(login.stdin, `${token}\n`);
|
||||
const [status, error] = await Promise.all([login.status, readText(login.stderr)]);
|
||||
if (!status.success) {
|
||||
throw new Error(error.trim() || `codex login exited with ${status.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
|
||||
const path = `${profile.home}/${name}`;
|
||||
await writeFile(path, "", { mode: 0o600 });
|
||||
await chmod(path, 0o600);
|
||||
await chown(path, profile.uid, profile.gid);
|
||||
return path;
|
||||
}
|
||||
|
||||
async function runCodex(
|
||||
profile: RuntimeProfile,
|
||||
prompt: string,
|
||||
authentication: CodexAuthentication,
|
||||
): Promise<string> {
|
||||
const token = authentication.kind === "api-key" ? authentication.token : undefined;
|
||||
const environment = agentEnvironment(PROCESS_ENV, profile, token);
|
||||
if (authentication.kind === "api-key") {
|
||||
await codexApiLogin(profile, environment, authentication.token);
|
||||
} else {
|
||||
const loggedIn = await agentCommand(profile, "codex", ["login", "status"], {
|
||||
cwd: WORKSPACE,
|
||||
env: environment,
|
||||
clearEnv: true,
|
||||
}).output();
|
||||
if (!loggedIn.success) await codexDeviceLogin(profile, environment);
|
||||
}
|
||||
const outputPath = await makeProfileFile(profile, "result.md");
|
||||
const attempt = async (): Promise<{ status: CommandStatus; error: string }> => {
|
||||
const codex = agentCommand(
|
||||
profile,
|
||||
"codex",
|
||||
[
|
||||
"exec",
|
||||
"--model",
|
||||
profile.model,
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--output-last-message",
|
||||
outputPath,
|
||||
prompt,
|
||||
],
|
||||
{
|
||||
cwd: WORKSPACE,
|
||||
env: environment,
|
||||
clearEnv: true,
|
||||
stdout: "inherit",
|
||||
stderr: "piped",
|
||||
},
|
||||
).spawn();
|
||||
const decoder = new TextDecoder();
|
||||
let error = "";
|
||||
for await (const chunk of chunks(codex.stderr)) {
|
||||
await writeChunk(process.stderr, chunk);
|
||||
error += decoder.decode(chunk, { stream: true });
|
||||
}
|
||||
error += decoder.decode();
|
||||
// Codex echoes the prompt, whose comments may quote auth errors.
|
||||
return { status: await codex.status, error: error.replaceAll(prompt, "") };
|
||||
};
|
||||
const result =
|
||||
authentication.kind === "api-key"
|
||||
? await attempt()
|
||||
: await retryInvalidToken(attempt, async () => {
|
||||
await runAgentCommand(profile, "codex", ["logout"]);
|
||||
await codexDeviceLogin(profile, environment);
|
||||
});
|
||||
if (!result.status.success) {
|
||||
const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`);
|
||||
if (isCodexAuthFailure(result.error)) {
|
||||
throw new AgentAuthError("codex sign-in failed", { cause: failure });
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
async function runOpenCode(
|
||||
profile: RuntimeProfile,
|
||||
token: string,
|
||||
prompt: string,
|
||||
): Promise<string> {
|
||||
const child = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), {
|
||||
cwd: WORKSPACE,
|
||||
env: agentEnvironment(PROCESS_ENV, profile, token),
|
||||
clearEnv: true,
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
}).spawn();
|
||||
const collected = collectOpenCodeOutput(
|
||||
chunks(child.stdout),
|
||||
chunks(child.stderr),
|
||||
async (chunk) => {
|
||||
await writeChunk(process.stdout, chunk);
|
||||
},
|
||||
async (chunk) => {
|
||||
await writeChunk(process.stderr, chunk);
|
||||
},
|
||||
);
|
||||
const [status, streams] = await Promise.all([child.status, collected]);
|
||||
const error = streams.stderr.trim();
|
||||
if (!status.success) {
|
||||
throw (
|
||||
openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`)
|
||||
);
|
||||
}
|
||||
return parseOpenCodeEvents(streams.stdout);
|
||||
}
|
||||
|
||||
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
||||
const token = await readProfileToken(profile);
|
||||
const authentication = profile.type === "codex" ? codexAuthentication(token) : null;
|
||||
let stagedCodexAuth: string | null = null;
|
||||
let body: string | undefined;
|
||||
let resultPath: string | null = null;
|
||||
let reviewPath: string | undefined;
|
||||
let anchorsPath: string | undefined;
|
||||
try {
|
||||
if (authentication?.kind === "persistent") {
|
||||
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
|
||||
}
|
||||
await prepareProfile(profile);
|
||||
const rendered = await renderPrompt(profile);
|
||||
reviewPath = rendered.reviewPath;
|
||||
anchorsPath = rendered.anchorsPath;
|
||||
if (profile.type === "claude") {
|
||||
body = await runClaude(profile, token ?? "", rendered.prompt);
|
||||
} else if (profile.type === "codex") {
|
||||
resultPath = await runCodex(
|
||||
profile,
|
||||
rendered.prompt,
|
||||
authentication ?? codexAuthentication(token),
|
||||
);
|
||||
} else {
|
||||
body = await runOpenCode(profile, token ?? "", rendered.prompt);
|
||||
}
|
||||
} finally {
|
||||
await killProfileProcesses(profile.uid);
|
||||
if (stagedCodexAuth !== null) {
|
||||
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
|
||||
}
|
||||
}
|
||||
if (resultPath !== null) body = await readProfileFile(profile, resultPath);
|
||||
if (body === undefined || reviewPath === undefined || anchorsPath === undefined) {
|
||||
throw new Error("agent attempt produced no result");
|
||||
}
|
||||
return {
|
||||
body,
|
||||
reviewPath,
|
||||
anchorsPath,
|
||||
profile: { type: profile.type, model: profile.model },
|
||||
};
|
||||
}
|
||||
|
||||
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
|
||||
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), {
|
||||
mode: 0o600,
|
||||
});
|
||||
await chmod(MANIFEST.outcomeFile, 0o600);
|
||||
}
|
||||
|
||||
const failures: ProfileFailure[] = [];
|
||||
try {
|
||||
await configureGitAuthor();
|
||||
await writeOutcome({
|
||||
ok: true,
|
||||
...(await runProfiles(MANIFEST.profiles, runProfile, failures)),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await writeOutcome({ ok: false, failures });
|
||||
throw error;
|
||||
}
|
||||
@@ -1,593 +0,0 @@
|
||||
// Runs one ordered agent profile. Credential decryption and reviewer posting
|
||||
// live in separate processes so neither privileged secret reaches this runner.
|
||||
import { spawn } from "node:child_process";
|
||||
import { chmod, chown, readFile, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { Readable } from "node:stream";
|
||||
import { text as readText } from "node:stream/consumers";
|
||||
import { finished } from "node:stream/promises";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
import {
|
||||
type ClaudeFailureFields,
|
||||
type CodexAuthentication,
|
||||
codexAuthentication,
|
||||
collectOpenCodeOutput,
|
||||
type CommandResult,
|
||||
isClaudeAuthFailure,
|
||||
isCodexAuthFailure,
|
||||
openCodeArgs,
|
||||
openCodeError,
|
||||
parseOpenCodeEvents,
|
||||
persistCodexAuth,
|
||||
retryInvalidToken,
|
||||
stageCodexAuth,
|
||||
} from "#agents";
|
||||
import { field, isObject } from "#common";
|
||||
import {
|
||||
echo,
|
||||
exitCode,
|
||||
killProfileProcesses,
|
||||
readProfileFile,
|
||||
} from "#processes";
|
||||
import {
|
||||
AgentAuthError,
|
||||
agentEnvironment,
|
||||
type ProfileFailure,
|
||||
type ProfileIdentity,
|
||||
runProfiles,
|
||||
sanitizedEnvironment,
|
||||
withProfile,
|
||||
} from "#profiles";
|
||||
import {
|
||||
readRuntimeManifest,
|
||||
type RuntimeOutcome,
|
||||
type RuntimeProfile,
|
||||
} from "#runtime";
|
||||
|
||||
interface AttemptResult {
|
||||
body: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
profile: ProfileIdentity;
|
||||
}
|
||||
|
||||
// Claude Code's stream-json `result` event, with camelCase field names.
|
||||
interface ClaudeResult extends ClaudeFailureFields {
|
||||
isError?: boolean;
|
||||
subtype: string;
|
||||
}
|
||||
|
||||
interface RenderedPrompt {
|
||||
prompt: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
}
|
||||
|
||||
const env = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) {
|
||||
throw new Error(`${name} is not set`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
function definedEnvironment(): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const [name, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
result.set(name, value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const API = env("GITEA_API_URL");
|
||||
const REPO = env("GITEA_REPOSITORY");
|
||||
const INDEX = env("ISSUE_INDEX");
|
||||
const EVENT = env("EVENT_NAME");
|
||||
const AUTHOR_TOKEN = env("GITEA_TOKEN");
|
||||
const WORKSPACE = env("GITHUB_WORKSPACE");
|
||||
const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
|
||||
const PROCESS_ENV = sanitizedEnvironment(definedEnvironment());
|
||||
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
|
||||
const CODEX_AUTH = "/root/.codex/auth.json";
|
||||
const SKILLS_INSTALL = "skills add obra/superpowers";
|
||||
|
||||
async function gitea(path: string, body?: unknown): Promise<Response> {
|
||||
const response = await fetch(`${API}/${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: {
|
||||
Authorization: `token ${AUTHOR_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function postAuthorComment(body: string): Promise<void> {
|
||||
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
|
||||
}
|
||||
|
||||
async function runRoot(command: string, args: string[]): Promise<void> {
|
||||
const code = await exitCode(
|
||||
spawn(command, args, {
|
||||
env: Object.fromEntries(PROCESS_ENV),
|
||||
stdio: "ignore",
|
||||
}),
|
||||
);
|
||||
if (code !== 0) {
|
||||
throw new Error(`${command} ${args[0]} exited with ${code}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function configureGitAuthor(): Promise<void> {
|
||||
const userResponse = await gitea("user");
|
||||
const user: unknown = await userResponse.json();
|
||||
const login = field(user, "login");
|
||||
const email = field(user, "email");
|
||||
if (typeof login !== "string" || typeof email !== "string") {
|
||||
throw new TypeError("user: unexpected Gitea response");
|
||||
}
|
||||
await runRoot("git", ["config", "user.name", login]);
|
||||
await runRoot("git", ["config", "user.email", email]);
|
||||
}
|
||||
|
||||
async function readProfileToken(
|
||||
profile: RuntimeProfile,
|
||||
): Promise<string | null> {
|
||||
const token =
|
||||
profile.tokenFile === null
|
||||
? null
|
||||
: await readFile(profile.tokenFile, "utf8");
|
||||
return token;
|
||||
}
|
||||
|
||||
async function runAgentCommand(
|
||||
profile: RuntimeProfile,
|
||||
command: string,
|
||||
args: string[],
|
||||
token?: string,
|
||||
): Promise<void> {
|
||||
const child = spawn(command, args, {
|
||||
cwd: WORKSPACE,
|
||||
env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)),
|
||||
uid: profile.uid,
|
||||
gid: profile.gid,
|
||||
stdio: "ignore",
|
||||
});
|
||||
const code = await exitCode(child);
|
||||
if (code !== 0) {
|
||||
throw new Error(`${command} ${args[0]} exited with ${code}`);
|
||||
}
|
||||
}
|
||||
|
||||
// The agent name the skills installer knows each profile type by.
|
||||
function skillsAgent(type: RuntimeProfile["type"]): string {
|
||||
switch (type) {
|
||||
case "CLAUDE":
|
||||
return "claude-code";
|
||||
case "CODEX":
|
||||
return "codex";
|
||||
case "OPENCODE":
|
||||
return "opencode";
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
|
||||
await runAgentCommand(profile, "git", [
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
"safe.directory",
|
||||
WORKSPACE,
|
||||
]);
|
||||
await runAgentCommand(profile, "npx", [
|
||||
"-y",
|
||||
...SKILLS_INSTALL.split(" "),
|
||||
"--global",
|
||||
"--agent",
|
||||
skillsAgent(profile.type),
|
||||
"--yes",
|
||||
]);
|
||||
}
|
||||
|
||||
// One issue comment as the prompt quotes it.
|
||||
function formatComment(comment: unknown): string {
|
||||
const login = field(field(comment, "user"), "login");
|
||||
const createdAt = field(comment, "created_at");
|
||||
const body = field(comment, "body");
|
||||
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
|
||||
}
|
||||
|
||||
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
|
||||
const reviewPath = await makeProfileFile(profile, "review.md");
|
||||
const anchorsPath = await makeProfileFile(profile, "anchors.json");
|
||||
const commentsResponse = await gitea(
|
||||
`repos/${REPO}/issues/${INDEX}/comments?limit=100`,
|
||||
);
|
||||
const comments: unknown = await commentsResponse.json();
|
||||
if (!Array.isArray(comments)) {
|
||||
throw new TypeError("comments: unexpected Gitea response");
|
||||
}
|
||||
const rulesResponse = await gitea(RULES_PATH);
|
||||
const codeRules = await rulesResponse.text();
|
||||
const values = new Map<string, string>([
|
||||
["COMMENT", env("COMMENT")],
|
||||
[
|
||||
"ISSUE_COMMENTS",
|
||||
comments.map((comment: unknown) => formatComment(comment)).join("\n"),
|
||||
],
|
||||
["CODE_RULES", codeRules],
|
||||
["EVENT_NAME", EVENT],
|
||||
["GITEA_API_URL", API],
|
||||
["GITEA_REPOSITORY", REPO],
|
||||
["ISSUE_INDEX", INDEX],
|
||||
["REVIEW_PATH", reviewPath],
|
||||
["ANCHORS_PATH", anchorsPath],
|
||||
]);
|
||||
const template = await readFile(
|
||||
new URL("../../prompt.md", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
return {
|
||||
prompt: template.replaceAll(
|
||||
/\$\{(?<name>\w+)\}/gu,
|
||||
(match: string, ...args: unknown[]) => {
|
||||
const groups = args.at(-1);
|
||||
const name = isObject(groups) ? field(groups, "name") : undefined;
|
||||
return typeof name === "string" ? (values.get(name) ?? match) : match;
|
||||
},
|
||||
),
|
||||
reviewPath,
|
||||
anchorsPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function runClaude(
|
||||
profile: RuntimeProfile,
|
||||
token: string,
|
||||
prompt: string,
|
||||
): Promise<string> {
|
||||
const claude = spawn(
|
||||
"claude",
|
||||
[
|
||||
"--print",
|
||||
"--dangerously-skip-permissions",
|
||||
"--model",
|
||||
profile.model,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
prompt,
|
||||
],
|
||||
{
|
||||
cwd: WORKSPACE,
|
||||
env: Object.fromEntries(
|
||||
agentEnvironment(PROCESS_ENV, profile, token).set("IS_SANDBOX", "1"),
|
||||
),
|
||||
uid: profile.uid,
|
||||
gid: profile.gid,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const status = exitCode(claude);
|
||||
let result: ClaudeResult | null = null;
|
||||
let error = "";
|
||||
const copyError = (async (): Promise<void> => {
|
||||
claude.stderr.setEncoding("utf8");
|
||||
for await (const chunk of claude.stderr) {
|
||||
const part = String(chunk);
|
||||
error += part;
|
||||
console.error(part);
|
||||
}
|
||||
})();
|
||||
for await (const line of createInterface({
|
||||
input: claude.stdout,
|
||||
crlfDelay: Infinity,
|
||||
})) {
|
||||
if (line !== "") {
|
||||
const event: unknown = JSON.parse(line);
|
||||
if (isObject(event)) {
|
||||
const content = field(field(event, "message"), "content");
|
||||
for (const part of Array.isArray(content) ? content : []) {
|
||||
if (isObject(part)) {
|
||||
const text =
|
||||
field(part, "thinking") ??
|
||||
field(part, "text") ??
|
||||
field(part, "name");
|
||||
if (typeof text === "string" && text !== "") {
|
||||
console.log(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (field(event, "type") === "result") {
|
||||
const isError = field(event, "is_error");
|
||||
const answer = field(event, "result");
|
||||
const subtype = field(event, "subtype");
|
||||
const apiStatus = field(event, "api_error_status");
|
||||
result = {
|
||||
isError: typeof isError === "boolean" ? isError : undefined,
|
||||
result: typeof answer === "string" ? answer : undefined,
|
||||
subtype: String(subtype),
|
||||
apiErrorStatus:
|
||||
typeof apiStatus === "number" ? apiStatus : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const [code] = await Promise.all([status, copyError]);
|
||||
if (result?.isError === true) {
|
||||
const failure = new Error(
|
||||
result.result ?? `claude ended with ${result.subtype}`,
|
||||
);
|
||||
if (isClaudeAuthFailure(result)) {
|
||||
throw new AgentAuthError("claude sign-in failed", { cause: failure });
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
if (result?.result === undefined) {
|
||||
throw new Error(
|
||||
error.trim() || `claude ended with ${result?.subtype ?? "no result"}`,
|
||||
);
|
||||
}
|
||||
if (code !== 0) {
|
||||
throw new Error(error.trim() || `claude exited with ${code}`);
|
||||
}
|
||||
return result.result;
|
||||
}
|
||||
|
||||
async function codexDeviceLogin(
|
||||
profile: RuntimeProfile,
|
||||
environment: ReadonlyMap<string, string>,
|
||||
): Promise<void> {
|
||||
const login = spawn("codex", ["login", "--device-auth"], {
|
||||
cwd: WORKSPACE,
|
||||
env: Object.fromEntries(environment),
|
||||
uid: profile.uid,
|
||||
gid: profile.gid,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const status = exitCode(login);
|
||||
let shown = "";
|
||||
const collect = async (stream: Readable): Promise<void> => {
|
||||
stream.setEncoding("utf8");
|
||||
for await (const chunk of stream) {
|
||||
shown += String(chunk);
|
||||
}
|
||||
};
|
||||
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
|
||||
await sleep(3000);
|
||||
while (shown.trim() === "") {
|
||||
const exited = await Promise.race([
|
||||
status.then(() => true),
|
||||
sleep(100, false),
|
||||
]);
|
||||
if (exited) {
|
||||
throw new AgentAuthError("AI bot login produced no instructions");
|
||||
}
|
||||
}
|
||||
await postAuthorComment(withProfile(shown, profile));
|
||||
const [code] = await Promise.all([status, drained]);
|
||||
if (code !== 0) {
|
||||
throw new AgentAuthError(`codex login exited with ${code}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function codexApiLogin(
|
||||
profile: RuntimeProfile,
|
||||
environment: ReadonlyMap<string, string>,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const login = spawn("codex", ["login", "--with-api-key"], {
|
||||
cwd: WORKSPACE,
|
||||
env: Object.fromEntries(environment),
|
||||
uid: profile.uid,
|
||||
gid: profile.gid,
|
||||
stdio: ["pipe", "inherit", "pipe"],
|
||||
});
|
||||
const status = exitCode(login);
|
||||
login.stdin.end(`${token}\n`);
|
||||
await finished(login.stdin);
|
||||
const [code, error] = await Promise.all([status, readText(login.stderr)]);
|
||||
if (code !== 0) {
|
||||
throw new Error(error.trim() || `codex login exited with ${code}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function makeProfileFile(
|
||||
profile: RuntimeProfile,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
const path = `${profile.home}/${name}`;
|
||||
await writeFile(path, "", { mode: 0o600 });
|
||||
await chmod(path, 0o600);
|
||||
await chown(path, profile.uid, profile.gid);
|
||||
return path;
|
||||
}
|
||||
|
||||
async function runCodex(
|
||||
profile: RuntimeProfile,
|
||||
prompt: string,
|
||||
authentication: CodexAuthentication,
|
||||
): Promise<string> {
|
||||
const token =
|
||||
authentication.kind === "api-key" ? authentication.token : undefined;
|
||||
const environment = agentEnvironment(PROCESS_ENV, profile, token);
|
||||
if (authentication.kind === "api-key") {
|
||||
await codexApiLogin(profile, environment, authentication.token);
|
||||
} else {
|
||||
const check = spawn("codex", ["login", "status"], {
|
||||
cwd: WORKSPACE,
|
||||
env: Object.fromEntries(environment),
|
||||
uid: profile.uid,
|
||||
gid: profile.gid,
|
||||
stdio: "ignore",
|
||||
});
|
||||
if ((await exitCode(check)) !== 0) {
|
||||
await codexDeviceLogin(profile, environment);
|
||||
}
|
||||
}
|
||||
const outputPath = await makeProfileFile(profile, "result.md");
|
||||
const attempt = async (): Promise<CommandResult> => {
|
||||
const codex = spawn(
|
||||
"codex",
|
||||
[
|
||||
"exec",
|
||||
"--model",
|
||||
profile.model,
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--output-last-message",
|
||||
outputPath,
|
||||
prompt,
|
||||
],
|
||||
{
|
||||
cwd: WORKSPACE,
|
||||
env: Object.fromEntries(environment),
|
||||
uid: profile.uid,
|
||||
gid: profile.gid,
|
||||
stdio: ["ignore", "inherit", "pipe"],
|
||||
},
|
||||
);
|
||||
const status = exitCode(codex);
|
||||
codex.stderr.setEncoding("utf8");
|
||||
let error = "";
|
||||
for await (const chunk of codex.stderr) {
|
||||
const part = String(chunk);
|
||||
await echo(process.stderr, part);
|
||||
error += part;
|
||||
}
|
||||
// Codex echoes the prompt, whose comments may quote auth errors.
|
||||
return { code: await status, error: error.replaceAll(prompt, "") };
|
||||
};
|
||||
const result =
|
||||
authentication.kind === "api-key"
|
||||
? await attempt()
|
||||
: await retryInvalidToken(attempt, async () => {
|
||||
await runAgentCommand(profile, "codex", ["logout"]);
|
||||
await codexDeviceLogin(profile, environment);
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
const failure = new Error(
|
||||
result.error.trim() || `codex exited with ${result.code}`,
|
||||
);
|
||||
if (isCodexAuthFailure(result.error)) {
|
||||
throw new AgentAuthError("codex sign-in failed", { cause: failure });
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
async function runOpenCode(
|
||||
profile: RuntimeProfile,
|
||||
token: string,
|
||||
prompt: string,
|
||||
): Promise<string> {
|
||||
const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
|
||||
cwd: WORKSPACE,
|
||||
env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)),
|
||||
uid: profile.uid,
|
||||
gid: profile.gid,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const status = exitCode(child);
|
||||
const collected = collectOpenCodeOutput(
|
||||
child.stdout,
|
||||
child.stderr,
|
||||
async (chunk) => {
|
||||
await echo(process.stdout, chunk);
|
||||
},
|
||||
async (chunk) => {
|
||||
await echo(process.stderr, chunk);
|
||||
},
|
||||
);
|
||||
const [code, streams] = await Promise.all([status, collected]);
|
||||
const error = streams.stderr.trim();
|
||||
if (code !== 0) {
|
||||
throw (
|
||||
openCodeError(streams.stdout) ??
|
||||
new Error(error || `opencode exited with ${code}`)
|
||||
);
|
||||
}
|
||||
return parseOpenCodeEvents(streams.stdout);
|
||||
}
|
||||
|
||||
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
||||
const token = await readProfileToken(profile);
|
||||
const authentication =
|
||||
profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null;
|
||||
let stagedCodexAuth: string | null = null;
|
||||
let body: string | null = null;
|
||||
let resultPath: string | null = null;
|
||||
let reviewPath: string | null = null;
|
||||
let anchorsPath: string | null = null;
|
||||
try {
|
||||
if (authentication?.kind === "persistent") {
|
||||
stagedCodexAuth = await stageCodexAuth(
|
||||
CODEX_AUTH,
|
||||
profile.home,
|
||||
profile.uid,
|
||||
profile.gid,
|
||||
);
|
||||
}
|
||||
await prepareProfile(profile);
|
||||
const rendered = await renderPrompt(profile);
|
||||
reviewPath = rendered.reviewPath;
|
||||
anchorsPath = rendered.anchorsPath;
|
||||
if (profile.type === "CLAUDE") {
|
||||
body = await runClaude(profile, token ?? "", rendered.prompt);
|
||||
} else if (profile.type === "CODEX") {
|
||||
resultPath = await runCodex(
|
||||
profile,
|
||||
rendered.prompt,
|
||||
authentication ?? codexAuthentication(token ?? undefined),
|
||||
);
|
||||
} else {
|
||||
body = await runOpenCode(profile, token ?? "", rendered.prompt);
|
||||
}
|
||||
} finally {
|
||||
await killProfileProcesses(profile.uid);
|
||||
if (stagedCodexAuth !== null) {
|
||||
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
|
||||
}
|
||||
}
|
||||
if (resultPath !== null) {
|
||||
body = await readProfileFile(profile, resultPath);
|
||||
}
|
||||
if (body === null || reviewPath === null || anchorsPath === null) {
|
||||
throw new Error("agent attempt produced no result");
|
||||
}
|
||||
return {
|
||||
body,
|
||||
reviewPath,
|
||||
anchorsPath,
|
||||
profile: { type: profile.type, model: profile.model },
|
||||
};
|
||||
}
|
||||
|
||||
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
|
||||
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), {
|
||||
mode: 0o600,
|
||||
});
|
||||
await chmod(MANIFEST.outcomeFile, 0o600);
|
||||
}
|
||||
|
||||
const failures: ProfileFailure[] = [];
|
||||
try {
|
||||
await configureGitAuthor();
|
||||
await writeOutcome({
|
||||
ok: true,
|
||||
...(await runProfiles(MANIFEST.profiles, runProfile, failures)),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await writeOutcome({ ok: false, failures });
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { assert, expect, test } from "vite-plus/test";
|
||||
|
||||
import { output } from "./command.ts";
|
||||
import { prepareRuntime } from "./runtime.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
import { IS_ROOT, makeTempDir } from "#testing";
|
||||
|
||||
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
||||
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
||||
|
||||
// Runs `command` as the profile's user, from the root directory.
|
||||
async function runAs(profile: RuntimeProfile, command: string, path: string): Promise<boolean> {
|
||||
const result = await output(command, {
|
||||
args: [path],
|
||||
cwd: "/",
|
||||
uid: profile.uid,
|
||||
gid: profile.gid,
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
async function expectPrivate(path: string, uid: number, mode: number): Promise<void> {
|
||||
const info = await stat(path);
|
||||
expect(info.uid).toStrictEqual(uid);
|
||||
expect(info.mode & 0o777).toBe(mode);
|
||||
}
|
||||
|
||||
test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credentials", async () => {
|
||||
const root = await makeTempDir();
|
||||
await chmod(root, 0o711);
|
||||
const action = await makeTempDir();
|
||||
try {
|
||||
await writeFile(
|
||||
`${action}/profiles.json`,
|
||||
JSON.stringify({
|
||||
"reviewer-token": ENCRYPTED,
|
||||
profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }],
|
||||
}),
|
||||
);
|
||||
|
||||
const manifest = await prepareRuntime(action, root, MASTER_KEY);
|
||||
const serialized = await readFile(manifest.manifestPath, "utf8");
|
||||
assert(!serialized.includes("secret-token"));
|
||||
expect(await readFile(manifest.reviewerFile, "utf8")).toBe("secret-token");
|
||||
await expectPrivate(manifest.reviewerFile, 0, 0o600);
|
||||
|
||||
const [claude, codex] = manifest.profiles;
|
||||
assert(claude !== undefined && codex !== undefined);
|
||||
expect(claude.uid).not.toStrictEqual(codex.uid);
|
||||
await expectPrivate(claude.home, claude.uid, 0o700);
|
||||
assert(claude.tokenFile !== null);
|
||||
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
|
||||
await expectPrivate(claude.tokenFile, claude.uid, 0o600);
|
||||
expect((await stat(codex.home)).uid).toStrictEqual(codex.uid);
|
||||
expect(codex.tokenFile).toBe(null);
|
||||
|
||||
const ownToken = await output("/bin/cat", {
|
||||
args: [claude.tokenFile],
|
||||
cwd: "/",
|
||||
uid: claude.uid,
|
||||
gid: claude.gid,
|
||||
});
|
||||
expect(ownToken.success).toBe(true);
|
||||
expect(new TextDecoder().decode(ownToken.stdout)).toBe("secret-token");
|
||||
expect(await runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
|
||||
expect(await runAs(claude, "/bin/ls", codex.home)).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
await rm(action, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fails", async () => {
|
||||
const root = await makeTempDir();
|
||||
const action = await makeTempDir();
|
||||
try {
|
||||
await writeFile(
|
||||
`${action}/profiles.json`,
|
||||
JSON.stringify({
|
||||
"reviewer-token": ENCRYPTED,
|
||||
profiles: [{ type: "claude", token: ENCRYPTED }],
|
||||
}),
|
||||
);
|
||||
const result = await output(process.execPath, {
|
||||
args: [fileURLToPath(new URL("prepare.ts", import.meta.url))],
|
||||
env: {
|
||||
ACTION_PATH: action,
|
||||
RUNNER_TEMP: root,
|
||||
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
|
||||
GITHUB_OUTPUT: action,
|
||||
},
|
||||
clearEnv: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(await readdir(root)).toStrictEqual([]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
await rm(action, { recursive: true });
|
||||
}
|
||||
});
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { parseCredentials } from "./credentials.ts";
|
||||
import { isMapping } from "./json.ts";
|
||||
import type { BotProfile, ProfileFailure } from "./profiles.ts";
|
||||
|
||||
const FIRST_PROFILE_UID = 20_000;
|
||||
|
||||
export type RuntimeProfile = Pick<BotProfile, "type" | "model"> & {
|
||||
index: number;
|
||||
uid: number;
|
||||
gid: number;
|
||||
home: string;
|
||||
tokenFile: string | null;
|
||||
};
|
||||
|
||||
export interface RuntimeManifest {
|
||||
runtimeDir: string;
|
||||
manifestPath: string;
|
||||
reviewerFile: string;
|
||||
outcomeFile: string;
|
||||
profiles: RuntimeProfile[];
|
||||
}
|
||||
|
||||
export type RuntimeOutcome =
|
||||
| {
|
||||
ok: true;
|
||||
body: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
profile: Pick<RuntimeProfile, "type" | "model">;
|
||||
}
|
||||
| { ok: false; failures: ProfileFailure[] };
|
||||
|
||||
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
|
||||
await writeFile(path, value, { mode: 0o600 });
|
||||
await chmod(path, 0o600);
|
||||
await chown(path, uid, gid);
|
||||
}
|
||||
|
||||
export async function prepareRuntime(
|
||||
actionPath: string,
|
||||
runtimeRoot: string,
|
||||
masterKey: string,
|
||||
): Promise<RuntimeManifest> {
|
||||
const credentials = await parseCredentials(
|
||||
await readFile(join(actionPath, "profiles.json"), "utf8"),
|
||||
masterKey,
|
||||
);
|
||||
const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-"));
|
||||
try {
|
||||
await chmod(runtimeDir, 0o711);
|
||||
const reviewerFile = join(runtimeDir, "reviewer-token");
|
||||
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
|
||||
|
||||
// One profile at a time, in order, as each claims the next UID.
|
||||
const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>(
|
||||
async (previous, profile, index) => {
|
||||
const prepared = await previous;
|
||||
const uid = FIRST_PROFILE_UID + index;
|
||||
const home = join(runtimeDir, `profile-${index}`);
|
||||
await mkdir(home, { mode: 0o700 });
|
||||
await chmod(home, 0o700);
|
||||
await chown(home, uid, uid);
|
||||
const tokenFile =
|
||||
profile.token === undefined || profile.token === "" ? null : join(home, "token");
|
||||
if (tokenFile !== null && profile.token !== undefined) {
|
||||
await writePrivate(tokenFile, profile.token, uid, uid);
|
||||
}
|
||||
prepared.push({
|
||||
index,
|
||||
type: profile.type,
|
||||
model: profile.model,
|
||||
uid,
|
||||
gid: uid,
|
||||
home,
|
||||
tokenFile,
|
||||
});
|
||||
return prepared;
|
||||
},
|
||||
Promise.resolve([]),
|
||||
);
|
||||
|
||||
const manifestPath = join(runtimeDir, "manifest.json");
|
||||
const manifest: RuntimeManifest = {
|
||||
runtimeDir,
|
||||
manifestPath,
|
||||
reviewerFile,
|
||||
outcomeFile: join(runtimeDir, "outcome.json"),
|
||||
profiles,
|
||||
};
|
||||
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
|
||||
return manifest;
|
||||
} catch (error) {
|
||||
await rm(runtimeDir, { recursive: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isRuntimeManifest(value: unknown): value is RuntimeManifest {
|
||||
return (
|
||||
isMapping(value) &&
|
||||
typeof value["runtimeDir"] === "string" &&
|
||||
typeof value["manifestPath"] === "string" &&
|
||||
typeof value["reviewerFile"] === "string" &&
|
||||
typeof value["outcomeFile"] === "string" &&
|
||||
Array.isArray(value["profiles"])
|
||||
);
|
||||
}
|
||||
|
||||
export async function readRuntimeManifest(path: string): Promise<RuntimeManifest> {
|
||||
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||
if (!isRuntimeManifest(value)) {
|
||||
throw new Error(`${path}: invalid runtime manifest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// The runtime the prepare phase writes for the later phases: decrypted
|
||||
// credentials laid out per profile, described by a manifest.
|
||||
export { prepareRuntime, readRuntimeManifest } from "./runtime.ts";
|
||||
export type {
|
||||
RuntimeFailure,
|
||||
RuntimeManifest,
|
||||
RuntimeOutcome,
|
||||
RuntimeProfile,
|
||||
RuntimeSuccess,
|
||||
} from "./runtime.ts";
|
||||
@@ -1,129 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
chmod,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { assert, expect, test } from "vite-plus/test";
|
||||
|
||||
import { IS_ROOT, makeTempDir } from "#testing";
|
||||
|
||||
import { prepareRuntime, type RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
||||
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
||||
|
||||
// Runs `command` as the profile's user, from the root directory.
|
||||
function runAs(
|
||||
profile: RuntimeProfile,
|
||||
command: string,
|
||||
path: string,
|
||||
): boolean {
|
||||
const result = spawnSync(command, [path], {
|
||||
cwd: "/",
|
||||
uid: profile.uid,
|
||||
gid: profile.gid,
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
async function expectPrivate(
|
||||
path: string,
|
||||
uid: number,
|
||||
mode: number,
|
||||
): Promise<void> {
|
||||
const info = await stat(path);
|
||||
expect(info.uid).toStrictEqual(uid);
|
||||
expect(info.mode & 0o777).toBe(mode);
|
||||
}
|
||||
|
||||
test.skipIf(!IS_ROOT)(
|
||||
"prepares root-only reviewer and isolated profile credentials",
|
||||
async () => {
|
||||
const root = await makeTempDir();
|
||||
await chmod(root, 0o711);
|
||||
const action = await makeTempDir();
|
||||
try {
|
||||
await writeFile(
|
||||
`${action}/profiles.json`,
|
||||
JSON.stringify({
|
||||
"reviewer-token": ENCRYPTED,
|
||||
profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }],
|
||||
}),
|
||||
);
|
||||
|
||||
const manifest = await prepareRuntime(action, root, MASTER_KEY);
|
||||
const serialized = await readFile(manifest.manifestPath, "utf8");
|
||||
assert(!serialized.includes("secret-token"));
|
||||
expect(await readFile(manifest.reviewerFile, "utf8")).toBe(
|
||||
"secret-token",
|
||||
);
|
||||
await expectPrivate(manifest.reviewerFile, 0, 0o600);
|
||||
|
||||
const [claude, codex] = manifest.profiles;
|
||||
assert(claude !== undefined && codex !== undefined);
|
||||
expect(claude.uid).not.toStrictEqual(codex.uid);
|
||||
await expectPrivate(claude.home, claude.uid, 0o700);
|
||||
assert(claude.tokenFile !== null);
|
||||
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
|
||||
await expectPrivate(claude.tokenFile, claude.uid, 0o600);
|
||||
const codexHome = await stat(codex.home);
|
||||
expect(codexHome.uid).toStrictEqual(codex.uid);
|
||||
expect(codex.tokenFile).toBe(null);
|
||||
|
||||
const ownToken = spawnSync("/bin/cat", [claude.tokenFile], {
|
||||
cwd: "/",
|
||||
uid: claude.uid,
|
||||
gid: claude.gid,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(ownToken.status).toBe(0);
|
||||
expect(ownToken.stdout).toBe("secret-token");
|
||||
expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
|
||||
expect(runAs(claude, "/bin/ls", codex.home)).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
await rm(action, { recursive: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(!IS_ROOT)(
|
||||
"removes prepared credentials when output publication fails",
|
||||
async () => {
|
||||
const root = await makeTempDir();
|
||||
const action = await makeTempDir();
|
||||
try {
|
||||
await writeFile(
|
||||
`${action}/profiles.json`,
|
||||
JSON.stringify({
|
||||
"reviewer-token": ENCRYPTED,
|
||||
profiles: [{ type: "CLAUDE", token: ENCRYPTED }],
|
||||
}),
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
|
||||
{
|
||||
env: Object.fromEntries([
|
||||
["ACTION_PATH", action],
|
||||
["RUNNER_TEMP", root],
|
||||
["BOT_AGENTS_MASTER_KEY", MASTER_KEY],
|
||||
["GITHUB_OUTPUT", action],
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(await readdir(root)).toStrictEqual([]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
await rm(action, { recursive: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,137 +0,0 @@
|
||||
import {
|
||||
chmod,
|
||||
chown,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import nodePath from "node:path";
|
||||
|
||||
import { field, isMapping } from "#common";
|
||||
import type { ProfileFailure, ProfileIdentity } from "#profiles";
|
||||
|
||||
import { parseCredentials } from "./credentials.ts";
|
||||
|
||||
const FIRST_PROFILE_UID = 20_000;
|
||||
|
||||
export interface RuntimeProfile extends ProfileIdentity {
|
||||
index: number;
|
||||
uid: number;
|
||||
gid: number;
|
||||
home: string;
|
||||
tokenFile: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeManifest {
|
||||
runtimeDir: string;
|
||||
manifestPath: string;
|
||||
reviewerFile: string;
|
||||
outcomeFile: string;
|
||||
profiles: RuntimeProfile[];
|
||||
}
|
||||
|
||||
export interface RuntimeSuccess {
|
||||
ok: true;
|
||||
body: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
profile: ProfileIdentity;
|
||||
}
|
||||
|
||||
export interface RuntimeFailure {
|
||||
ok: false;
|
||||
failures: ProfileFailure[];
|
||||
}
|
||||
|
||||
export type RuntimeOutcome = RuntimeSuccess | RuntimeFailure;
|
||||
|
||||
async function writePrivate(
|
||||
path: string,
|
||||
value: string,
|
||||
uid: number,
|
||||
gid: number,
|
||||
): Promise<void> {
|
||||
await writeFile(path, value, { mode: 0o600 });
|
||||
await chmod(path, 0o600);
|
||||
await chown(path, uid, gid);
|
||||
}
|
||||
|
||||
export async function prepareRuntime(
|
||||
actionPath: string,
|
||||
runtimeRoot: string,
|
||||
masterKey: string,
|
||||
): Promise<RuntimeManifest> {
|
||||
const credentials = await parseCredentials(
|
||||
await readFile(nodePath.join(actionPath, "profiles.json"), "utf8"),
|
||||
masterKey,
|
||||
);
|
||||
const runtimeDir = await mkdtemp(nodePath.join(runtimeRoot, "bot-agents-"));
|
||||
try {
|
||||
await chmod(runtimeDir, 0o711);
|
||||
const reviewerFile = nodePath.join(runtimeDir, "reviewer-token");
|
||||
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
|
||||
|
||||
// One profile at a time, in order, as each claims the next UID.
|
||||
const profiles: RuntimeProfile[] = [];
|
||||
for (const [index, profile] of credentials.profiles.entries()) {
|
||||
const uid = FIRST_PROFILE_UID + index;
|
||||
const home = nodePath.join(runtimeDir, `profile-${index}`);
|
||||
await mkdir(home, { mode: 0o700 });
|
||||
await chmod(home, 0o700);
|
||||
await chown(home, uid, uid);
|
||||
const tokenFile =
|
||||
profile.token === undefined || profile.token === ""
|
||||
? null
|
||||
: nodePath.join(home, "token");
|
||||
if (tokenFile !== null && profile.token !== undefined) {
|
||||
await writePrivate(tokenFile, profile.token, uid, uid);
|
||||
}
|
||||
profiles.push({
|
||||
index,
|
||||
type: profile.type,
|
||||
model: profile.model,
|
||||
uid,
|
||||
gid: uid,
|
||||
home,
|
||||
tokenFile,
|
||||
});
|
||||
}
|
||||
|
||||
const manifestPath = nodePath.join(runtimeDir, "manifest.json");
|
||||
const manifest: RuntimeManifest = {
|
||||
runtimeDir,
|
||||
manifestPath,
|
||||
reviewerFile,
|
||||
outcomeFile: nodePath.join(runtimeDir, "outcome.json"),
|
||||
profiles,
|
||||
};
|
||||
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
|
||||
return manifest;
|
||||
} catch (error) {
|
||||
await rm(runtimeDir, { recursive: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isRuntimeManifest(value: unknown): value is RuntimeManifest {
|
||||
return (
|
||||
isMapping(value) &&
|
||||
typeof field(value, "runtimeDir") === "string" &&
|
||||
typeof field(value, "manifestPath") === "string" &&
|
||||
typeof field(value, "reviewerFile") === "string" &&
|
||||
typeof field(value, "outcomeFile") === "string" &&
|
||||
Array.isArray(field(value, "profiles"))
|
||||
);
|
||||
}
|
||||
|
||||
export async function readRuntimeManifest(
|
||||
path: string,
|
||||
): Promise<RuntimeManifest> {
|
||||
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||
if (!isRuntimeManifest(value)) {
|
||||
throw new Error(`${path}: invalid runtime manifest`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
+10
-28
@@ -1,12 +1,12 @@
|
||||
// Test support: the current user's IDs, a fresh temporary directory, the
|
||||
// inherited environment, and a plain command runner. It imports no source
|
||||
// module, so the repository-level tests in tests/, which reach src/ only
|
||||
// through `#testing`, never touch internals.
|
||||
// Test support: the current user's IDs, a fresh temporary directory, and a
|
||||
// plain command runner. It imports no source module, so the repository-level
|
||||
// tests in tests/, which reach src/ only through `#testing`, never touch
|
||||
// internals.
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import { text } from "node:stream/consumers";
|
||||
|
||||
@@ -24,9 +24,8 @@ export const IS_ROOT: boolean = currentUid() === 0;
|
||||
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
|
||||
export const IS_WINDOWS: boolean = process.platform === "win32";
|
||||
|
||||
export async function makeTempDir(): Promise<string> {
|
||||
const path = await mkdtemp(nodePath.join(tmpdir(), "bot-agents-test-"));
|
||||
return path;
|
||||
export function makeTempDir(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
@@ -34,32 +33,15 @@ export interface CommandResult {
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
// This process's environment as a map from variable name to value.
|
||||
export function inheritedEnvironment(): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const [name, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
result.set(name, value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Runs a command with stdin closed and only the given environment, and
|
||||
// collects its stderr.
|
||||
export async function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
env: ReadonlyMap<string, string>,
|
||||
env: Record<string, string>,
|
||||
): Promise<CommandResult> {
|
||||
const child = spawn(command, args, {
|
||||
env: Object.fromEntries(env),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
const [stderr, closed] = await Promise.all([
|
||||
text(child.stderr),
|
||||
once(child, "close"),
|
||||
]);
|
||||
const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] });
|
||||
const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]);
|
||||
const exitCode: unknown = closed[0];
|
||||
return { success: exitCode === 0, stderr };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import { runCommand } from "#testing";
|
||||
|
||||
// Each phase runs as `node src/<phase>/index.ts` with no build step. Node loads the
|
||||
// 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][] = [
|
||||
@@ -14,14 +15,8 @@ const PHASES: [phase: string, firstVariable: string][] = [
|
||||
];
|
||||
|
||||
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => {
|
||||
const path = fileURLToPath(
|
||||
new URL(`../src/${phase}/index.ts`, import.meta.url),
|
||||
);
|
||||
const result = await runCommand(
|
||||
process.execPath,
|
||||
[path],
|
||||
new Map([["PATH", process.env["PATH"] ?? ""]]),
|
||||
);
|
||||
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url));
|
||||
const result = await runCommand(process.execPath, [path], { PATH: process.env["PATH"] ?? "" });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.stderr).toContain(`${firstVariable} is not set`);
|
||||
});
|
||||
|
||||
+62
-132
@@ -1,29 +1,27 @@
|
||||
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 { inheritedEnvironment, makeTempDir, runCommand } from "#testing";
|
||||
import { makeTempDir, runCommand } from "#testing";
|
||||
|
||||
// A parsed YAML or JSON mapping, keyed by its dynamic field names.
|
||||
type Mapping = Map<string, unknown>;
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
const mapping = (value: unknown, at: string): Mapping => {
|
||||
assert(
|
||||
typeof value === "object" && value !== null && !Array.isArray(value),
|
||||
at,
|
||||
);
|
||||
return new Map(Object.entries(value));
|
||||
const isMapping = (value: unknown): value is JsonObject =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const mapping = (value: unknown, at: string): JsonObject => {
|
||||
assert(isMapping(value), at);
|
||||
return value;
|
||||
};
|
||||
|
||||
const sequence = (value: unknown, at: string): Mapping[] => {
|
||||
const sequence = (value: unknown, at: string): JsonObject[] => {
|
||||
assert(Array.isArray(value), at);
|
||||
return value.map((entry: unknown, index) =>
|
||||
mapping(entry, `${at}[${index}]`),
|
||||
);
|
||||
return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`));
|
||||
};
|
||||
|
||||
async function yaml(path: string): Promise<Mapping> {
|
||||
async function yaml(path: string): Promise<JsonObject> {
|
||||
const value: unknown = parse(await readFile(path, "utf8"));
|
||||
return mapping(value, path);
|
||||
}
|
||||
@@ -36,8 +34,8 @@ async function yamlFiles(): Promise<string[]> {
|
||||
.filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path));
|
||||
}
|
||||
|
||||
const findStep = (steps: Mapping[], key: string, value: string): Mapping => {
|
||||
const step = steps.find((entry) => entry.get(key) === value);
|
||||
const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => {
|
||||
const step = steps.find((entry) => entry[key] === value);
|
||||
assert(step !== undefined, `${key}: ${value}`);
|
||||
return step;
|
||||
};
|
||||
@@ -45,26 +43,16 @@ const findStep = (steps: Mapping[], key: string, value: string): Mapping => {
|
||||
test("all committed YAML parses", async () => {
|
||||
const files = await yamlFiles();
|
||||
assert(files.length > 0);
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
await yaml(file);
|
||||
}),
|
||||
);
|
||||
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([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual([
|
||||
"master-key",
|
||||
]);
|
||||
const steps = sequence(
|
||||
mapping(action.get("runs"), "runs").get("steps"),
|
||||
"runs.steps",
|
||||
);
|
||||
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) {
|
||||
const run = step.get("run");
|
||||
if (typeof run === "string") {
|
||||
expect(run.includes("\n"), String(step.get("name"))).toBe(false);
|
||||
if (typeof step["run"] === "string") {
|
||||
expect(step["run"].includes("\n"), String(step["name"])).toBe(false);
|
||||
}
|
||||
}
|
||||
const checkout = findStep(steps, "uses", "actions/checkout@v4");
|
||||
@@ -72,24 +60,16 @@ test("the action exposes only the master key and scopes it to preparation", asyn
|
||||
const agent = findStep(steps, "id", "agent");
|
||||
const post = findStep(steps, "id", "post");
|
||||
findStep(steps, "id", "source");
|
||||
expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe(
|
||||
"${{ gitea.token }}",
|
||||
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(
|
||||
mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY"),
|
||||
).toBe("${{ inputs.master-key }}");
|
||||
expect(
|
||||
JSON.stringify(Object.fromEntries(agent)).includes("BOT_AGENTS_MASTER_KEY"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
JSON.stringify(Object.fromEntries(post)).includes("BOT_AGENTS_MASTER_KEY"),
|
||||
).toBe(false);
|
||||
expect(mapping(agent.get("env"), "agent.env").get("GITEA_TOKEN")).toBe(
|
||||
"${{ gitea.token }}",
|
||||
);
|
||||
assert(String(post.get("if")).includes("always()"));
|
||||
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.get("run")).includes("steps.source.outputs.path"));
|
||||
assert(String(step["run"]).includes("steps.source.outputs.path"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -97,35 +77,24 @@ test("the source protection script stages immutable action files", async () => {
|
||||
const temp = await makeTempDir();
|
||||
const outputFile = `${temp}/output`;
|
||||
try {
|
||||
const environment = inheritedEnvironment();
|
||||
environment.set("ACTION_PATH", process.cwd());
|
||||
environment.set("GITHUB_OUTPUT", outputFile);
|
||||
environment.set("RUNNER_TEMP", temp);
|
||||
const result = await runCommand(
|
||||
"/bin/bash",
|
||||
["scripts/protect-source.sh"],
|
||||
environment,
|
||||
);
|
||||
const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], {
|
||||
...process.env,
|
||||
ACTION_PATH: process.cwd(),
|
||||
GITHUB_OUTPUT: outputFile,
|
||||
RUNNER_TEMP: temp,
|
||||
});
|
||||
assert(result.success, result.stderr);
|
||||
|
||||
const output = await readFile(outputFile, "utf8");
|
||||
const path = output.trim().slice(5);
|
||||
const path = (await readFile(outputFile, "utf8")).trim().slice(5);
|
||||
const files = [
|
||||
"src/prepare/index.ts",
|
||||
"src/run/index.ts",
|
||||
"src/post/index.ts",
|
||||
"src/runtime/index.ts",
|
||||
"src/agents/auth.ts",
|
||||
"src/prepare.ts",
|
||||
"src/run.ts",
|
||||
"src/post.ts",
|
||||
"package.json",
|
||||
"profiles.json",
|
||||
"prompt.md",
|
||||
];
|
||||
const stats = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const info = await stat(`${path}/${file}`);
|
||||
return info;
|
||||
}),
|
||||
);
|
||||
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);
|
||||
@@ -136,17 +105,12 @@ test("the source protection script stages immutable action files", async () => {
|
||||
});
|
||||
|
||||
test("module-relative runtime assets exist", async () => {
|
||||
const module = new URL("../src/run/index.ts", import.meta.url);
|
||||
const module = new URL("../src/run.ts", import.meta.url);
|
||||
const source = await readFile(module, "utf8");
|
||||
const assets = [
|
||||
...source.matchAll(/new URL\("(?<asset>[^"]+)", import\.meta\.url\)/gu),
|
||||
].map(([, asset = ""]) => new URL(asset, module));
|
||||
const stats = await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
const info = await stat(asset);
|
||||
return info;
|
||||
}),
|
||||
const 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);
|
||||
}
|
||||
@@ -154,46 +118,27 @@ test("module-relative runtime assets exist", async () => {
|
||||
|
||||
test("the repository uses its trusted main action", async () => {
|
||||
const workflow = await yaml(".gitea/workflows/agents.yml");
|
||||
const job = mapping(
|
||||
mapping(workflow.get("jobs"), "jobs").get("agent"),
|
||||
"jobs.agent",
|
||||
);
|
||||
const permissions = mapping(job.get("permissions"), "jobs.agent.permissions");
|
||||
expect(Object.fromEntries(permissions)).toStrictEqual({
|
||||
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.get("steps"), "jobs.agent.steps");
|
||||
expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual(
|
||||
-1,
|
||||
);
|
||||
const trusted = findStep(
|
||||
steps,
|
||||
"uses",
|
||||
"https://git.capsulizers.com/commons/bot-agents@main",
|
||||
);
|
||||
expect(mapping(trusted.get("with"), "trusted.with").get("master-key")).toBe(
|
||||
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(Object.fromEntries(workflow)).includes(
|
||||
"gitea.actor != 'bot'",
|
||||
),
|
||||
).toBe(true);
|
||||
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.get("jobs"), "jobs").get("check"),
|
||||
"jobs.check",
|
||||
);
|
||||
const commands = sequence(job.get("steps"), "jobs.check.steps")
|
||||
.map((step) => {
|
||||
const run = step.get("run");
|
||||
return typeof run === "string" ? run : "";
|
||||
})
|
||||
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.
|
||||
@@ -203,12 +148,11 @@ test("CI checks every phase and the root isolation suite", async () => {
|
||||
});
|
||||
|
||||
test("uses the standard Skills installer without custom discovery", async () => {
|
||||
const runner = await readFile("src/run/index.ts", "utf8");
|
||||
const runner = await readFile("src/run.ts", "utf8");
|
||||
assert(runner.includes("skills add obra/superpowers"));
|
||||
const removed = await stat("superpowers.ts").then(
|
||||
() => false,
|
||||
(error: unknown) =>
|
||||
error instanceof Error && "code" in error && error.code === "ENOENT",
|
||||
(error: unknown) => error instanceof Error && "code" in error && error.code === "ENOENT",
|
||||
);
|
||||
assert(removed, "superpowers.ts should be removed");
|
||||
});
|
||||
@@ -218,30 +162,16 @@ test("installs the OpenCode CLI", async () => {
|
||||
assert(action.includes("opencode-ai"));
|
||||
});
|
||||
|
||||
test("does not install Deno", async () => {
|
||||
const action = await readFile("action.yml", "utf8");
|
||||
expect(action).not.toMatch(/\bdeno\b/iu);
|
||||
});
|
||||
|
||||
test("profile preparation does not receive provider credentials", async () => {
|
||||
const runner = await readFile("src/run/index.ts", "utf8");
|
||||
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 Claude to Codex to OpenCode", async () => {
|
||||
const credentials: unknown = JSON.parse(
|
||||
await readFile("profiles.json", "utf8"),
|
||||
);
|
||||
const profiles = sequence(
|
||||
mapping(credentials, "profiles.json").get("profiles"),
|
||||
"profiles",
|
||||
);
|
||||
expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([
|
||||
"CLAUDE",
|
||||
"CODEX",
|
||||
"OPENCODE",
|
||||
]);
|
||||
expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b");
|
||||
assert(String(profiles[2]?.get("token")).startsWith("v1:"));
|
||||
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:"));
|
||||
});
|
||||
|
||||
+4
-27
@@ -1,36 +1,13 @@
|
||||
import explicitTypes from "eslint-plugin-explicit-types";
|
||||
import importStructure from "eslint-plugin-import-structure";
|
||||
import { defineConfig } from "vite-plus";
|
||||
|
||||
import projectLint, { projectOverrides } from "./scripts/project-lint.ts";
|
||||
import projectLint from "./scripts/project-lint.ts";
|
||||
|
||||
export default defineConfig({
|
||||
fmt: {
|
||||
printWidth: 80,
|
||||
quoteProps: "preserve",
|
||||
embeddedLanguageFormatting: "off",
|
||||
},
|
||||
lint: {
|
||||
plugins: ["typescript", "unicorn", "oxc", "import"],
|
||||
jsPlugins: [
|
||||
"eslint-plugin-explicit-types",
|
||||
"eslint-plugin-import-structure",
|
||||
"./scripts/project-lint.ts",
|
||||
],
|
||||
categories: {
|
||||
correctness: "error",
|
||||
suspicious: "error",
|
||||
pedantic: "error",
|
||||
perf: "error",
|
||||
style: "error",
|
||||
restriction: "error",
|
||||
},
|
||||
rules: {
|
||||
...explicitTypes.configs.all.rules,
|
||||
...importStructure.configs.all.rules,
|
||||
...projectLint.configs.project.rules,
|
||||
},
|
||||
overrides: projectOverrides,
|
||||
jsPlugins: ["./scripts/project-lint.ts"],
|
||||
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
|
||||
rules: projectLint.configs.project.rules,
|
||||
options: {
|
||||
typeAware: true,
|
||||
typeCheck: true,
|
||||
|
||||
Reference in New Issue
Block a user