4 Commits

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

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

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

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

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

Reviewed-on: #21
Reviewed-by: bot <noreply@capsulizers.com>
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-18 14:37:49 +00:00
45 changed files with 614 additions and 548 deletions
@@ -0,0 +1,39 @@
---
description: Child processes use plain node:child_process spawn (explicit stdio, uid/gid, whole env); src/processes/child.ts has exitCode (128+signo) and echo; no execFile maxBuffer
---
Every call site calls `spawn` from `node:child_process` itself, with
everything explicit:
- **`uid`/`gid`** of the profile for anything an agent profile runs (each
profile is its own UID, 20000+). Never drop them.
- **`env`** is the whole environment the child gets (a sanitized one for
agents); nothing from the coordinator is merged in.
- **`stdio`**: stdin is `"ignore"` so an agent CLI never waits on input
(only `codex login --with-api-key` pipes the token in). Output that is not
read is `"ignore"`; output that is read is `"pipe"` and streamed.
- No `execFile`/`exec`: their `maxBuffer` (1 MiB) kills a chatty child. Read
piped streams incrementally instead.
`src/processes/child.ts` holds what the call sites share:
- **`exitCode(child)`**: call it right after `spawn`. It settles on `exit`
(not `close`, which a daemonized grandchild can hold open), maps a signal to
`128 + signo` as a shell does, and rejects on a failed start. The rejection
is pre-caught so it is not reported as unhandled before the caller awaits it.
- **`echo(stream, chunk)`**: writes and waits for `drain` when the buffer is
full, so echoing never outruns a slow reader.
Tests use `spawnSync` for short commands.
Other port details:
- **`src/common/errors.ts`** `isNotFound` checks `ENOENT` (file) or `ESRCH` (process).
- Temporary files are an exclusive `wx` create with mode `0600`.
- `src/common/json.ts` holds type guards used instead of `as` casts on parsed JSON.
- `process` and `Buffer` must come from `node:` imports:
`no-restricted-globals` in `scripts/project-lint.ts` enforces it
([[lint-parity-never-shrinks]]).
Related: how each agent CLI reports bad credentials is in
[[agent-cli-auth-failures]].
+1 -1
View File
@@ -15,7 +15,7 @@ when the migration PR was approved:
([[lint-overrides-policy]]). ([[lint-overrides-policy]]).
Size and complexity limits match Deno, which had none here, so every Oxlint 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 limit rule is `"off"` ([[lint-config-shape]]). `src/run/index.ts` and `src/post/index.ts` keep
main's single-file layout (2026-10-17 revert of the size splits). Sequential 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 awaits in loops became recursion or a `reduce` chain (`no-await-in-loop`) to
stay sequential. Add no other opt-out without the user. 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+, Deno kept only for agents, no eslint-plugin-* packages needed description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, lints with eslint-plugin-import-structure and explicit-types (concrete, absence)
--- ---
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
@@ -15,20 +15,19 @@ Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
removed the only runtime dependency (`@std/yaml`); keep it free of runtime removed the only runtime dependency (`@std/yaml`); keep it free of runtime
dependencies (`@eslint/core` and the other devDependencies serve only the dependencies (`@eslint/core` and the other devDependencies serve only the
tooling). tooling).
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. There is no - **Node 24.2+**, not 22: `src/post/index.ts` uses `import.meta.main`. There is no
`engines` (user decision 2026-10-18): the `node:24-bookworm` job image `engines` (user decision 2026-10-18): the `node:24-bookworm` job image
decides the version ([[bot-agents-runtime-no-build]]). decides the version ([[bot-agents-runtime-no-build]]).
- **Deno stays in the action's global npm install** (`action.yml`), only - **Published lint plugins** (2026-10-18, after they reached npm):
because the agents still check Deno-based consumer repos (memona, `eslint-plugin-import-structure` (`modules` config) and
memona-home). Remove it once those repos have moved; it is not a `eslint-plugin-explicit-types` (`concrete` and `absence` configs) are
dependency of this repo's TypeScript. devDependencies, enabled in `vite.config.ts`. explicit-types' `naming`
- **No `eslint-plugin-*` packages** (`eslint-plugin-explicit-types`, config is left off: its `upper-snake-string-unions` and
`eslint-plugin-import-structure`, `eslint-plugin-lit-robust`; unscoped `no-upper-snake-object-keys` rules fire on wire tokens this repo does not
since 2026-10-18, formerly `@cunarist/...`). The Deno setup enabled no name (profile types such as `"claude"`, failure reasons posted to Gitea,
plugin, so this repo depends on none and CI passes without them being environment variable names), which cannot change without changing
published. Planned (2026-10-17): once the plugins are on npm, add behavior. Open question for the user. `eslint-plugin-lit-robust` does not apply
`eslint-plugin-import-structure` (rule prefix `import-structure/`) here for (no Lit).
its import checks.
- **Stricter input checks were accepted** in the migration: Gitea `/user` and - **Stricter input checks were accepted** in the migration: Gitea `/user` and
comment responses, the runtime manifest, and the outcome's profile type are comment responses, the runtime manifest, and the outcome's profile type are
validated; some type errors became `TypeError` with the same messages. validated; some type errors became `TypeError` with the same messages.
@@ -1,25 +0,0 @@
---
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. properties, no decorators) and relative `.ts` imports.
- **Image:** the steps assume the `node:24-bookworm` job container. Node must - **Image:** the steps assume the `node:24-bookworm` job container. Node must
be 24.2+ for `import.meta.main` in `post.ts`. That image is the only Node be 24.2+ for `import.meta.main` in `src/post/index.ts`. That image is the only Node
pin (`package.json` has no `engines`); `check.yml` prints `node --version` pin (`package.json` has no `engines`); `check.yml` prints `node --version`
to confirm the cached image's minor. to confirm the cached image's minor.
- **Root-owned source copy:** `scripts/protect-source.sh` copies the action - **Root-owned source copy:** `scripts/protect-source.sh` copies the action
+8 -8
View File
@@ -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 61 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 66 run; verify isolation changes in node:24-bookworm as root
--- ---
Some tests exercise real UID isolation, process cleanup, and `prepare.ts` Some tests exercise real UID isolation, process cleanup, and the prepare phase's
credential cleanup. They need root and are written as credential cleanup. They need root and are written as
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show `test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show
as skipped instead of silently passing. as skipped instead of silently passing.
- As a normal user `vp test` passes 56 and skips 5. In CI (self-hosted - As a normal user `vp test` passes 61 and skips 5. In CI (self-hosted
runner, root) all 61 run. runner, root) all 66 run.
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`, - When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare/index.ts`,
or `command.ts`, run the suite as root in `node:24-bookworm` (e.g. a or `child.ts`, run the suite as root in `node:24-bookworm` (e.g. a
container with the repo mounted) before pushing; a green non-root run container with the repo mounted) before pushing; a green non-root run
proves nothing about those paths. proves nothing about those paths.
- The migration also ran an end-to-end smoke as root with fake - The migration also ran an end-to-end smoke as root with fake
`codex`/`claude`/`npx` binaries and a fake Gitea through `codex`/`claude`/`npx` binaries and a fake Gitea through
`protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.ts`; repeat that `protect-source.sh` -> `prepare/index.ts` -> `run/index.ts` -> `post/index.ts`; repeat that
for changes to the phase flow ([[bot-agents-runtime-no-build]]). for changes to the phase flow ([[bot-agents-runtime-no-build]]).
- `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs, - `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs,
`runCommand`) behind the `#testing` alias. Every test imports it as `runCommand`) behind the `#testing` alias. Every test imports it as
`#testing`. It imports no source module, so `tests/` (which may not use `#testing`. It imports no source module, so `tests/` (which may not use
`../`) never reaches internals. Tests follow `../`) never reaches internals. Tests follow
[[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in [[tests-layout-vitest]] (unit tests next to their code in `src/`, repo-level ones in
`tests/`). `tests/`).
+2 -1
View File
@@ -97,7 +97,8 @@ It is not a plugin rule.
- Every `package.json` `imports` alias points only at the `index.ts` of a - Every `package.json` `imports` alias points only at the `index.ts` of a
direct subfolder of `src/`, e.g. `"#helpers": "./src/helpers/index.ts"`. direct subfolder of `src/`, e.g. `"#helpers": "./src/helpers/index.ts"`.
No alias to a single file, a deeper folder, or anything outside `src/`. No alias to a single file, a deeper folder, or anything outside `src/`.
Here the one alias is `"#testing": "./src/testing/index.ts"`. Here the aliases are `#prepare`, `#run`, `#post`, `#agents`, `#processes`, `#runtime`, `#profiles`,
`#common`, and `#testing`, in that layer order.
- Code outside `src/` (`scripts/`, `tests/`, `vite.config.ts`) gets no alias - Code outside `src/` (`scripts/`, `tests/`, `vite.config.ts`) gets no alias
of its own and imports its own files by `./` relative paths. It reaches of its own and imports its own files by `./` relative paths. It reaches
`src/` only through the aliases, since `../` is banned. `src/` only through the aliases, since `../` is banned.
+1 -1
View File
@@ -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 `engines` or a `.node-version`, local `vp` uses its default Node
(`vp env default`). bot-agents has no Containerfile or setup step: the (`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 self-hosted runner's `node:24-bookworm` job image runs the action and its
checks, and `src/post.ts` needs 24.2+ (`import.meta.main`). checks, and `src/post/index.ts` needs 24.2+ (`import.meta.main`).
- **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or - **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 runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
`vp` brings its own Node, the way uv brings its own Python. `vp` brings its own Node, the way uv brings its own Python.
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# The action runs on this same image; src/post.ts needs Node 24.2+. # The action runs on this same image; src/post/index.ts needs Node 24.2+.
- run: node --version - run: node --version
- run: npm ci - run: npm ci
+13 -7
View File
@@ -20,7 +20,7 @@ dependency. Keep the sources to erasable syntax and `.ts` imports, which
`exactOptionalPropertyTypes` off `exactOptionalPropertyTypes` off
([.agents/memory/ts-only-files-and-imports.md](.agents/memory/ts-only-files-and-imports.md)). ([.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` Checks use Vite+: `vp check` formats, lints, and type-checks, and `vp test`
runs Vitest. Unit tests sit next to the code as `src/*.test.ts`; runs Vitest. Unit tests sit next to the code as `*.test.ts` in `src/`;
repository-level tests live in `tests/`. repository-level tests live in `tests/`.
A `#` alias in `package.json` `imports` may point anywhere and owns its A `#` alias in `package.json` `imports` may point anywhere and owns its
@@ -34,9 +34,16 @@ import is banned in every file, owned or not
(`import/no-relative-parent-imports`); runtime file URLs such as (`import/no-relative-parent-imports`); runtime file URLs such as
`new URL("../prompt.md", import.meta.url)` are paths, not imports, and stay. `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 Files no alias owns need no `index.ts` and no alias, and import `./` siblings
and subfolders freely. The one alias is `#testing` (`./src/testing/index.ts`): and subfolders freely. Every top-level entry of `src/` is an aliased module
tests that use its helpers import it by alias, and `tests/` imports `src/` folder. The aliases, in layer order, are the three phases `#prepare`, `#run`,
only through it. Local convention, stricter than the general rule above: in and `#post`, then `#agents` (Codex,
Claude Code and OpenCode specifics), `#processes` (child processes and the
isolated profile users), `#runtime` (the prepared runtime and its
credentials), `#profiles`, `#common` (error and JSON checks), and `#testing`
(test helpers), each with a `src/<name>/index.ts` barrel. A phase's
`index.ts` is also its entry point, which `action.yml` runs as
`node src/<phase>/index.ts`. Tests that use the test
helpers import `#testing`, and `tests/` imports `src/` only through it. Local convention, stricter than the general rule above: in
this repo, aliases point only at a direct subfolder of `src/` this repo, aliases point only at a direct subfolder of `src/`
(`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses `./` (`"#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 relative imports. `scripts/` is flat: one file per tool, no subfolders and no `index.ts`, imported directly by file path
@@ -48,6 +55,5 @@ list live in `scripts/project-lint.ts`. Lint disable comments are not allowed;
fix the source instead. fix the source instead.
The composite action uses npm only to install the externally distributed The composite action uses npm only to install the externally distributed
Claude Code, Codex, and OpenCode command-line tools, plus Deno for agents that Claude Code, Codex, and OpenCode command-line tools; do not treat those
check Deno-based repositories; do not treat those vendor executables as vendor executables as repository TypeScript dependencies.
repository TypeScript dependencies.
+5 -6
View File
@@ -16,11 +16,10 @@ runs:
fetch-depth: 0 fetch-depth: 0
token: ${{ gitea.token }} token: ${{ gitea.token }}
# This step assumes this is `node:24-bookworm` container. The action runs # This step assumes this is `node:24-bookworm` container. The action runs
# its TypeScript on that Node directly; Deno stays for the agents, which # its TypeScript on that Node directly.
# still check Deno-based repositories with it.
- name: Install dependencies - name: Install dependencies
shell: bash shell: bash
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai
# A local action lives inside the writable checkout. Execute a root-owned # A local action lives inside the writable checkout. Execute a root-owned
# copy so an agent changing this repository cannot replace a later phase. # copy so an agent changing this repository cannot replace a later phase.
- name: Protect action source - name: Protect action source
@@ -36,7 +35,7 @@ runs:
- name: Prepare credentials - name: Prepare credentials
id: prepare id: prepare
shell: bash shell: bash
run: node "${{ steps.source.outputs.path }}/src/prepare.ts" run: node "${{ steps.source.outputs.path }}/src/prepare/index.ts"
env: env:
ACTION_PATH: ${{ steps.source.outputs.path }} ACTION_PATH: ${{ steps.source.outputs.path }}
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }} BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
@@ -44,7 +43,7 @@ runs:
- name: Run bot - name: Run bot
id: agent id: agent
shell: bash shell: bash
run: node "${{ steps.source.outputs.path }}/src/run.ts" run: node "${{ steps.source.outputs.path }}/src/run/index.ts"
env: env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }} BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_TOKEN: ${{ gitea.token }} GITEA_TOKEN: ${{ gitea.token }}
@@ -57,7 +56,7 @@ runs:
id: post id: post
if: always() && steps.prepare.outputs.manifest != '' if: always() && steps.prepare.outputs.manifest != ''
shell: bash shell: bash
run: node "${{ steps.source.outputs.path }}/src/post.ts" run: node "${{ steps.source.outputs.path }}/src/post/index.ts"
env: env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }} BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_API_URL: ${{ gitea.api_url }} GITEA_API_URL: ${{ gitea.api_url }}
+28
View File
@@ -9,6 +9,8 @@
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.6.1",
"eslint-plugin-import-structure": "^0.10.2",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
} }
@@ -1887,6 +1889,32 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint-plugin-explicit-types": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.6.1.tgz",
"integrity": "sha512-8JU2Ceoi1aLbhYo4LHwb9Eone/pJuMnzVP1ZlGJn/ETxVzJQFziRnJwOF2dRmInn9dGqvwq05dc3o5tVyTGP2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/core": "^0.17.0"
},
"engines": {
"node": ">=22.18"
}
},
"node_modules/eslint-plugin-import-structure": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-import-structure/-/eslint-plugin-import-structure-0.10.2.tgz",
"integrity": "sha512-jvx4sccxEdURGXrDtBJZtCHiKqELkHRVlLrMU5bVZIjglN0U464Joopcfn6JvmNB2zyIh2FkolaJQJaKkvy6Xw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/core": "^0.17.0"
},
"engines": {
"node": ">=22.18"
}
},
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+10
View File
@@ -4,12 +4,22 @@
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.", "description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
"type": "module", "type": "module",
"imports": { "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" "#testing": "./src/testing/index.ts"
}, },
"devDependencies": { "devDependencies": {
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.6.1",
"eslint-plugin-import-structure": "^0.10.2",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
} }
+3 -3
View File
@@ -2,9 +2,9 @@
set -euo pipefail set -euo pipefail
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX") path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
mkdir "${path}/src" cp -a "${ACTION_PATH}/src" "${path}/"
cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/" # package.json makes Node load the TypeScript sources as ES modules and
# package.json makes Node load the TypeScript sources as ES modules. # resolves their `#` aliases.
cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/" cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/"
chmod -R go-w "${path}" chmod -R go-w "${path}"
printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}" printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}"
+10 -9
View File
@@ -1,5 +1,7 @@
import { expect, test } from "vite-plus/test";
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; 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 { import {
codexAuthentication, codexAuthentication,
@@ -9,7 +11,6 @@ import {
retryInvalidToken, retryInvalidToken,
stageCodexAuth, stageCodexAuth,
} from "./auth.ts"; } from "./auth.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
test("retries once after an invalid refresh token", async () => { test("retries once after an invalid refresh token", async () => {
let attempts = 0; let attempts = 0;
@@ -17,7 +18,7 @@ test("retries once after an invalid refresh token", async () => {
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => () =>
Promise.resolve({ Promise.resolve({
status: { success: false }, code: 1,
error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed", error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed",
}), }),
() => { () => {
@@ -40,7 +41,7 @@ test("does not retry an unrelated failure", async () => {
() => { () => {
attempts++; attempts++;
return Promise.resolve({ return Promise.resolve({
status: { success: false }, code: 1,
error: "rate limited", error: "rate limited",
}); });
}, },
@@ -86,7 +87,7 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => () =>
Promise.resolve({ Promise.resolve({
status: { success: attempts++ > 0 }, code: attempts++ > 0 ? 0 : 1,
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "", error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}), }),
() => { () => {
@@ -95,16 +96,16 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
}, },
); );
expect({ relogins, success: result.status.success }).toStrictEqual({ expect({ relogins, code: result.code }).toStrictEqual({
relogins: 1, relogins: 1,
success: true, code: 0,
}); });
}); });
test("recognizes a rejected Claude OAuth token", () => { test("recognizes a rejected Claude OAuth token", () => {
expect( expect(
isClaudeAuthFailure({ isClaudeAuthFailure({
api_error_status: 401, apiErrorStatus: 401,
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.", result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.",
}), }),
).toBe(true); ).toBe(true);
@@ -113,7 +114,7 @@ test("recognizes a rejected Claude OAuth token", () => {
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); ).toBe(true);
expect(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" })).toBe(false); expect(isClaudeAuthFailure({ apiErrorStatus: 429, result: "API Error: 429" })).toBe(false);
}); });
test("selects API-key login only for a configured Codex token", () => { test("selects API-key login only for a configured Codex token", () => {
+24 -10
View File
@@ -12,10 +12,18 @@ import {
} from "node:fs/promises"; } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { isNotFound } from "./errors.ts"; import { isNotFound, isObject } from "#common";
import { isObject } from "./json.ts";
export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" }; interface CodexApiKey {
kind: "api-key";
token: string;
}
interface CodexPersistent {
kind: "persistent";
}
export type CodexAuthentication = CodexApiKey | CodexPersistent;
export function codexAuthentication(token?: string): CodexAuthentication { export function codexAuthentication(token?: string): CodexAuthentication {
return token === undefined ? { kind: "persistent" } : { kind: "api-key", token }; return token === undefined ? { kind: "persistent" } : { kind: "api-key", token };
@@ -34,26 +42,32 @@ export function isCodexAuthFailure(error: string): boolean {
// stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth // stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth
// access token is invalid." with api_error_status 401. // access token is invalid." with api_error_status 401.
// The fields of Claude Code's stream-json `result` event that tell a sign-in // The fields of Claude Code's stream-json `result` event that tell a sign-in
// failure apart, under their own names. // failure apart, as `apiErrorStatus` and `result`.
export type ClaudeFailureFields = Partial<Record<"api_error_status", number | null>> & { export interface ClaudeFailureFields {
apiErrorStatus?: number;
result?: string; result?: string;
}; }
export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean { export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
return ( return (
result.api_error_status === 401 || result.apiErrorStatus === 401 ||
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test( /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 ?? "", result.result ?? "",
) )
); );
} }
export async function retryInvalidToken<T extends { status: { success: boolean }; error: string }>( export interface CommandResult {
code: number;
error: string;
}
export async function retryInvalidToken<T extends CommandResult>(
attempt: () => Promise<T>, attempt: () => Promise<T>,
relogin: () => Promise<void>, relogin: () => Promise<void>,
): Promise<T> { ): Promise<T> {
let result = await attempt(); let result = await attempt();
if (!result.status.success && isCodexAuthFailure(result.error)) { if (result.code !== 0 && isCodexAuthFailure(result.error)) {
await relogin(); await relogin();
result = await attempt(); result = await attempt();
} }
@@ -111,7 +125,7 @@ export async function stageCodexAuth(
return stagedPath; return stagedPath;
} }
// Creates an empty private file under a fresh name, as Deno.makeTempFile did. // Creates an empty private file under a fresh name.
async function makeTempFile(directory: string): Promise<string> { async function makeTempFile(directory: string): Promise<string> {
const path = join(directory, `.tmp-${randomUUID()}`); const path = join(directory, `.tmp-${randomUUID()}`);
const handle = await open(path, "wx", 0o600); const handle = await open(path, "wx", 0o600);
+17
View File
@@ -0,0 +1,17 @@
// Agent command-line specifics: Codex and Claude Code sign-in handling and
// OpenCode's arguments and output.
export {
codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
export type { ClaudeFailureFields, CodexAuthentication, CommandResult } from "./auth.ts";
export {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
@@ -1,11 +1,13 @@
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { AgentAuthError } from "#profiles";
import { import {
collectOpenCodeOutput, collectOpenCodeOutput,
openCodeArgs, openCodeArgs,
openCodeError, openCodeError,
parseOpenCodeEvents, parseOpenCodeEvents,
} from "./opencode.ts"; } from "./opencode.ts";
import { AgentAuthError } from "./profiles.ts";
test("builds a non-interactive auto-approved OpenCode command", () => { 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([
@@ -99,5 +101,5 @@ test("surfaces an OpenCode error event instead of empty output", () => {
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow( expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow(
"API error 429", "API error 429",
); );
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBe(undefined); expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBeNull();
}); });
+16 -8
View File
@@ -1,5 +1,5 @@
import { isObject } from "./json.ts"; import { isObject } from "#common";
import { AgentAuthError } from "./profiles.ts"; import { AgentAuthError } from "#profiles";
export function openCodeArgs(model: string, prompt: string): string[] { export function openCodeArgs(model: string, prompt: string): string[] {
return ["run", "--format", "json", "--auto", "--model", model, prompt]; return ["run", "--format", "json", "--auto", "--model", model, prompt];
@@ -7,13 +7,21 @@ export function openCodeArgs(model: string, prompt: string): string[] {
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>; type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
interface OpenCodeOutput {
stdout: string;
stderr: string;
}
export async function collectOpenCodeOutput( export async function collectOpenCodeOutput(
stdout: AsyncIterable<Uint8Array>, stdout: AsyncIterable<Uint8Array>,
stderr: AsyncIterable<Uint8Array>, stderr: AsyncIterable<Uint8Array>,
writeStdout: ChunkWriter, writeStdout: ChunkWriter,
writeStderr: ChunkWriter, writeStderr: ChunkWriter,
): Promise<{ stdout: string; stderr: string }> { ): Promise<OpenCodeOutput> {
const collect = async (stream: AsyncIterable<Uint8Array>, write: ChunkWriter) => { const collect = async (
stream: AsyncIterable<Uint8Array>,
write: ChunkWriter,
): Promise<string> => {
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
for await (const chunk of stream) { for await (const chunk of stream) {
chunks.push(chunk); chunks.push(chunk);
@@ -38,8 +46,8 @@ export async function collectOpenCodeOutput(
// OpenCode reports provider failures, including a rejected OpenRouter key // 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 // (401 "User not found."), as a JSON error event on stdout and exits 1 with an
// empty stderr. // empty stderr.
export function openCodeError(output: string): Error | undefined { export function openCodeError(output: string): Error | null {
let failure: Error | undefined; let failure: Error | null = null;
for (const line of output.split("\n")) { for (const line of output.split("\n")) {
if (line.trim() === "") continue; if (line.trim() === "") continue;
let event: unknown; let event: unknown;
@@ -69,7 +77,7 @@ export function openCodeError(output: string): Error | undefined {
} }
export function parseOpenCodeEvents(output: string): string { export function parseOpenCodeEvents(output: string): string {
let result: string | undefined; let result: string | null = null;
for (const line of output.split("\n")) { for (const line of output.split("\n")) {
if (line.trim() === "") continue; if (line.trim() === "") continue;
const event: unknown = JSON.parse(line); const event: unknown = JSON.parse(line);
@@ -79,7 +87,7 @@ export function parseOpenCodeEvents(output: string): string {
const { text } = part; const { text } = part;
if (typeof text === "string" && text.trim() !== "") result = text; if (typeof text === "string" && text.trim() !== "") result = text;
} }
if (result === undefined) { if (result === null) {
throw openCodeError(output) ?? new Error("OpenCode produced no text output"); throw openCodeError(output) ?? new Error("OpenCode produced no text output");
} }
return result; return result;
-160
View File
@@ -1,160 +0,0 @@
// 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 -2
View File
@@ -1,5 +1,4 @@
// Node reports a missing file as ENOENT and a missing process as ESRCH, where // A missing file or a missing process: Node reports them as ENOENT and ESRCH.
// Deno threw `Deno.errors.NotFound` for both.
export function isNotFound(error: unknown): boolean { export function isNotFound(error: unknown): boolean {
if (!(error instanceof Error) || !("code" in error)) { if (!(error instanceof Error) || !("code" in error)) {
return false; return false;
+4
View File
@@ -0,0 +1,4 @@
// Helpers every phase shares: error and JSON value checks.
export { isNotFound } from "./errors.ts";
export { isMapping, isObject } from "./json.ts";
export type { JsonObject } from "./json.ts";
+1 -1
View File
@@ -1,5 +1,5 @@
// Type guards for values read from JSON, which arrive as `unknown`. // Type guards for values read from JSON, which arrive as `unknown`.
export type JsonObject = Record<string, unknown>; export type JsonObject = NodeJS.Dict<unknown>;
// Any non-null object, arrays included: reading a missing key yields undefined. // Any non-null object, arrays included: reading a missing key yields undefined.
export function isObject(value: unknown): value is JsonObject { export function isObject(value: unknown): value is JsonObject {
+22
View File
@@ -0,0 +1,22 @@
// The post phase: posts the agent's result as the reviewer and removes the
// runtime credentials.
import process from "node:process";
import { postRuntime } from "./post.ts";
export { type Fetcher, type PostContext, postRuntime } from "./post.ts";
function required(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
return value;
}
if (import.meta.main) {
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
apiUrl: required("GITEA_API_URL"),
repository: required("GITEA_REPOSITORY"),
issueIndex: required("ISSUE_INDEX"),
eventName: required("EVENT_NAME"),
});
}
+20 -10
View File
@@ -1,13 +1,12 @@
import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "#common";
import { postRuntime } from "./post.ts"; import type { RuntimeFailure, RuntimeManifest, RuntimeOutcome } from "#runtime";
import type { PostContext } from "./post.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing"; import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import { type Fetcher, type PostContext, postRuntime } from "./post.ts";
const context: PostContext = { const context: PostContext = {
apiUrl: "https://gitea.example/api/v1", apiUrl: "https://gitea.example/api/v1",
repository: "owner/repo", repository: "owner/repo",
@@ -23,7 +22,18 @@ interface Fixture {
outcomeFile: string; outcomeFile: string;
} }
type FixtureSource = Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string }; interface FixtureSuccess {
ok: true;
body: string;
}
type FixtureSource = RuntimeFailure | FixtureSuccess;
// One request the fake Gitea received.
interface RecordedRequest {
url: string;
init: RequestInit;
}
async function writePrivate(path: string, text: string): Promise<void> { async function writePrivate(path: string, text: string): Promise<void> {
await writeFile(path, text, { mode: 0o600 }); await writeFile(path, text, { mode: 0o600 });
@@ -32,8 +42,8 @@ async function writePrivate(path: string, text: string): Promise<void> {
async function writeArtifacts( async function writeArtifacts(
outcome: RuntimeOutcome, outcome: RuntimeOutcome,
review: string | undefined, review?: string,
anchors: unknown, anchors?: unknown,
): Promise<void> { ): Promise<void> {
if (!outcome.ok) return; if (!outcome.ok) return;
if (review !== undefined) await writePrivate(outcome.reviewPath, review); if (review !== undefined) await writePrivate(outcome.reviewPath, review);
@@ -107,7 +117,7 @@ function requestBody(init?: RequestInit): unknown {
} }
// A fake Gitea that records each request body and answers with `status`. // A fake Gitea that records each request body and answers with `status`.
function recordingFetch(bodies: unknown[], status = 201): typeof fetch { function recordingFetch(bodies: unknown[], status = 201): Fetcher {
return (_url, init) => { return (_url, init) => {
bodies.push(requestBody(init)); bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status })); return Promise.resolve(new Response(null, { status }));
@@ -116,7 +126,7 @@ function recordingFetch(bodies: unknown[], status = 201): typeof fetch {
test("posts a successful issue result and removes runtime credentials", async () => { test("posts a successful issue result and removes runtime credentials", async () => {
const result = await fixture({ ok: true, body: "done" }); const result = await fixture({ ok: true, body: "done" });
const requests: { url: unknown; init: RequestInit | undefined }[] = []; const requests: RecordedRequest[] = [];
await postRuntime(result.manifestPath, context, (url, init) => { await postRuntime(result.manifestPath, context, (url, init) => {
requests.push({ url, init }); requests.push({ url, init });
+8 -24
View File
@@ -1,14 +1,10 @@
import type { Stats } from "node:fs"; import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises"; import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import process from "node:process";
import { isNotFound } from "./errors.ts"; import { isNotFound, isObject } from "#common";
import { isObject } from "./json.ts"; import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles";
import { failureFooter, withProfile } from "./profiles.ts"; import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime";
import type { FailureReason, ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
export interface PostContext { export interface PostContext {
apiUrl: string; apiUrl: string;
@@ -26,7 +22,7 @@ interface Anchor {
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu"); const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
const stripAnsi = (text: string): string => text.replace(ANSI, ""); const stripAnsi = (text: string): string => text.replace(ANSI, "");
const VERDICTS: Record<string, [event: string, mark: string]> = { const VERDICTS: NodeJS.Dict<[event: string, mark: string]> = {
Approved: ["APPROVED", "✅"], Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"], "Changes requested": ["REQUEST_CHANGES", "🛑"],
}; };
@@ -37,6 +33,9 @@ const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REAS
const failureComment = (failures: ProfileFailure[]): string => const failureComment = (failures: ProfileFailure[]): string =>
`AI bot failed.\n\n${failureFooter(failures)}`; `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 {} class GiteaRequestError extends Error {}
function parseAnchors(text: string, sourcePath: string): Anchor[] { function parseAnchors(text: string, sourcePath: string): Anchor[] {
@@ -137,7 +136,7 @@ async function readAgentArtifact(
export async function postRuntime( export async function postRuntime(
manifestPath: string, manifestPath: string,
context: PostContext, context: PostContext,
fetcher: typeof fetch = fetch, fetcher: Fetcher = fetch,
): Promise<void> { ): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath); const manifest = await readRuntimeManifest(manifestPath);
try { try {
@@ -223,18 +222,3 @@ export async function postRuntime(
}); });
} }
} }
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 -1
View File
@@ -1,7 +1,7 @@
import { appendFile, rm } from "node:fs/promises"; import { appendFile, rm } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { prepareRuntime } from "./runtime.ts"; import { prepareRuntime } from "#runtime";
const env = (name: string): string => { const env = (name: string): string => {
const value = process.env[name]; const value = process.env[name];
+35
View File
@@ -0,0 +1,35 @@
import { spawn } from "node:child_process";
import { expect, test } from "vite-plus/test";
import { isNotFound } from "#common";
import { exitCode } from "./child.ts";
test("reports the exit code", async () => {
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }))).toBe(0);
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" }))).toBe(3);
});
test("reports a signal as 128 plus its number", async () => {
const child = spawn("/bin/sh", ["-c", "kill -KILL $$"], { stdio: "ignore" });
expect(await exitCode(child)).toBe(137);
});
test("resolves on exit while a grandchild still holds the output open", async () => {
const child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], { stdio: ["ignore", "pipe", "pipe"] });
const started = Date.now();
expect(await exitCode(child)).toBe(5);
expect(Date.now() - started).toBeLessThan(1500);
child.stdout.destroy();
child.stderr.destroy();
});
test("rejects when the command does not exist", async () => {
const child = spawn("/nonexistent/command", [], { stdio: "ignore" });
const code = exitCode(child);
// The rejection must stay unhandled-safe until it is awaited.
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
await expect(code).rejects.toSatisfy(isNotFound);
});
+30
View File
@@ -0,0 +1,30 @@
// What the child process call sites share: the exit status, read right after
// `spawn` so the exit event cannot pass unseen while the caller reads the
// streams, and echoing output without outrunning a slow reader.
import type { ChildProcess } from "node:child_process";
import { once } from "node:events";
import { constants } from "node:os";
import type { Writable } from "node:stream";
// Resolves once the process exits, before its streams close, with its exit
// code, or with 128 plus the signal number if a signal killed it, as a shell
// reports. A process that fails to start rejects with the spawn error.
export function exitCode(child: ChildProcess): Promise<number> {
const code = new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (exit, signal) => {
resolve(exit ?? (signal === null ? 1 : 128 + constants.signals[signal]));
});
});
// Callers await it after draining the streams; a failed start must not be
// reported as an unhandled rejection before then.
code.catch(() => {});
return code;
}
// Writes a chunk, waiting for the stream to drain when its buffer is full.
export async function echo(stream: Writable, chunk: string | Uint8Array): Promise<void> {
if (!stream.write(chunk)) {
await once(stream, "drain");
}
}
+4
View File
@@ -0,0 +1,4 @@
// Child processes: waiting on and echoing them, and the isolated profile
// user's processes and files.
export { echo, exitCode } from "./child.ts";
export { killProfileProcesses, readProfileFile } from "./isolation.ts";
@@ -1,17 +1,14 @@
import { expect, test } from "vite-plus/test"; import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "#common";
import { import type { RuntimeProfile } from "#runtime";
agentCommand,
isRunnableProfileProcess,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing"; import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
const profile = (uid: number, home: string): RuntimeProfile => ({ const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000, index: uid - 20_000,
type: "claude", type: "claude",
@@ -22,6 +19,23 @@ const profile = (uid: number, home: string): RuntimeProfile => ({
tokenFile: null, tokenFile: null,
}); });
// Runs a command as the profile's user from the root directory. Without `env`
// it inherits this process's environment.
function runAs(
selected: RuntimeProfile,
command: string,
args: string[],
env?: NodeJS.ProcessEnv,
): SpawnSyncReturns<string> {
return spawnSync(command, args, {
cwd: "/",
uid: selected.uid,
gid: selected.gid,
encoding: "utf8",
...(env !== undefined && { env }),
});
}
test("zombie process entries are already quiescent", () => { test("zombie process entries are already quiescent", () => {
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n"; const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
expect(isRunnableProfileProcess(status, 20_010)).toBe(false); expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
@@ -60,20 +74,14 @@ test.skipIf(!IS_ROOT)("a profile process can read only its own private files", a
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 }); await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
const selected = profile(20_000, homeA); const selected = profile(20_000, homeA);
const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], { const own = runAs(selected, "/bin/cat", [`${homeA}/token`]);
cwd: "/", const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]);
}).output(); const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]);
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.stdout).toBe("own");
expect(own.success).toBe(true); expect(own.status).toBe(0);
expect(sibling.success).toBe(false); expect(sibling.status).not.toBe(0);
expect(reviewer.success).toBe(false); expect(reviewer.status).not.toBe(0);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
} }
@@ -81,13 +89,8 @@ test.skipIf(!IS_ROOT)("a profile process can read only its own private files", a
test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => { test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => {
const selected = profile(20_010, "/tmp"); const selected = profile(20_010, "/tmp");
const launched = await agentCommand( const launched = runAs(selected, "/bin/sh", ["-c", "sleep 60 >/dev/null 2>&1 & echo $!"]);
selected, const pid = Number(launched.stdout.trim());
"/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); expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid); await killProfileProcesses(selected.uid);
@@ -99,14 +102,11 @@ test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async ()
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true); expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
}); });
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", async () => { test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", () => {
const selected = profile(20_020, "/tmp"); const selected = profile(20_020, "/tmp");
const result = await agentCommand(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], { const result = runAs(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
cwd: "/", PARENT_SECRET: "must-not-cross-boundary",
env: { PARENT_SECRET: "must-not-cross-boundary" }, });
clearEnv: true,
stderr: "piped", expect(result.status).not.toBe(0);
}).output();
expect(result.success).toBe(false);
}); });
@@ -2,30 +2,8 @@ import { lstat, readdir, readFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { output, spawn } from "./command.ts"; import { isNotFound } from "#common";
import type { CommandChild, CommandOptions, CommandOutput } from "./command.ts"; import type { RuntimeProfile } from "#runtime";
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 { export function isRunnableProfileProcess(status: string, uid: number): boolean {
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1]; const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
+12
View File
@@ -0,0 +1,12 @@
// Bot profiles: their footers, failure reports, ordered runs, and the
// environment each agent gets.
export {
AgentAuthError,
agentEnvironment,
failureFooter,
profileFooter,
runProfiles,
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
export type { BotProfile, FailureReason, ProfileFailure, ProfileIdentity } from "./profiles.ts";
@@ -1,4 +1,5 @@
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { import {
AgentAuthError, AgentAuthError,
agentEnvironment, agentEnvironment,
+22 -12
View File
@@ -1,22 +1,32 @@
export interface BotProfile { // The agent and model a profile runs, which every report names.
export interface ProfileIdentity {
type: "claude" | "codex" | "opencode"; type: "claude" | "codex" | "opencode";
model: string; model: string;
}
export interface BotProfile extends ProfileIdentity {
token?: string; token?: string;
} }
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string { export function profileFooter(profile: ProfileIdentity): string {
return `> Profile: \`${profile.type}\` · \`${profile.model}\``; return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
} }
export function withProfile(body: string, profile: Pick<BotProfile, "type" | "model">): string { export function withProfile(body: string, profile: ProfileIdentity): string {
return `${body}\n\n${profileFooter(profile)}`; return `${body}\n\n${profileFooter(profile)}`;
} }
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed"; export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & { export interface ProfileFailure extends ProfileIdentity {
reason: FailureReason; 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. // Raised once an agent's credentials are rejected and any re-login is spent.
export class AgentAuthError extends Error {} export class AgentAuthError extends Error {}
@@ -41,7 +51,7 @@ export function failureFooter(failures: ProfileFailure[]): string {
// Tries the profiles from `index` on, moving on only after a sign-in failure // Tries the profiles from `index` on, moving on only after a sign-in failure
// or exhausted usage, and records every profile it tried. // or exhausted usage, and records every profile it tried.
async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>( async function attemptFrom<T, P extends ProfileIdentity>(
profiles: P[], profiles: P[],
attempt: (profile: P) => Promise<T>, attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[], failures: ProfileFailure[],
@@ -62,7 +72,7 @@ async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
} }
} }
export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>( export function runProfiles<T, P extends ProfileIdentity>(
profiles: P[], profiles: P[],
attempt: (profile: P) => Promise<T>, attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [], failures: ProfileFailure[] = [],
@@ -70,8 +80,8 @@ export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
return attemptFrom(profiles, attempt, failures, 0); return attemptFrom(profiles, attempt, failures, 0);
} }
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> { export function sanitizedEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const result: Record<string, string> = {}; const result: NodeJS.ProcessEnv = {};
for (const name of [ for (const name of [
"PATH", "PATH",
"HOME", "HOME",
@@ -105,10 +115,10 @@ export function sanitizedEnvironment(environment: Record<string, string>): Recor
} }
export function agentEnvironment( export function agentEnvironment(
environment: Record<string, string>, environment: NodeJS.ProcessEnv,
profile: Pick<BotProfile, "type"> & { home: string }, profile: ProfileHome,
token?: string, token?: string,
): Record<string, string> { ): NodeJS.ProcessEnv {
const result = sanitizedEnvironment(environment); const result = sanitizedEnvironment(environment);
result["HOME"] = profile.home; result["HOME"] = profile.home;
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`; if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`;
+125 -122
View File
@@ -1,54 +1,60 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting // Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner. // live in separate processes so neither privileged secret reaches this runner.
import { spawn } from "node:child_process";
import { chmod, chown, readFile, writeFile } from "node:fs/promises"; import { chmod, chown, readFile, writeFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import type { Readable } from "node:stream"; import type { Readable } from "node:stream";
import { text as readText } from "node:stream/consumers"; import { text as readText } from "node:stream/consumers";
import { finished } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { import {
type ClaudeFailureFields,
type CodexAuthentication,
codexAuthentication, codexAuthentication,
collectOpenCodeOutput,
type CommandResult,
isClaudeAuthFailure, isClaudeAuthFailure,
isCodexAuthFailure, 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, openCodeArgs,
openCodeError, openCodeError,
parseOpenCodeEvents, parseOpenCodeEvents,
} from "./opencode.ts"; persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "#agents";
import { isObject } from "#common";
import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes";
import { import {
AgentAuthError, AgentAuthError,
agentEnvironment, agentEnvironment,
type ProfileFailure,
type ProfileIdentity,
runProfiles, runProfiles,
sanitizedEnvironment, sanitizedEnvironment,
withProfile, withProfile,
} from "./profiles.ts"; } from "#profiles";
import type { ProfileFailure } from "./profiles.ts"; import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "#runtime";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
interface AttemptResult { interface AttemptResult {
body: string; body: string;
reviewPath: string; reviewPath: string;
anchorsPath: string; anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">; profile: ProfileIdentity;
} }
// Claude Code's stream-json `result` event, in its own field names. // Claude Code's stream-json `result` event, with camelCase field names.
type ClaudeResult = ClaudeFailureFields & interface ClaudeResult extends ClaudeFailureFields {
Partial<Record<"is_error", boolean>> & { isError?: boolean;
subtype: string; subtype: string;
}; }
interface RenderedPrompt {
prompt: string;
reviewPath: string;
anchorsPath: string;
}
const env = (name: string): string => { const env = (name: string): string => {
const value = process.env[name]; const value = process.env[name];
@@ -56,8 +62,8 @@ const env = (name: string): string => {
return value; return value;
}; };
function definedEnvironment(): Record<string, string> { function definedEnvironment(): NodeJS.ProcessEnv {
const result: Record<string, string> = {}; const result: NodeJS.ProcessEnv = {};
for (const [name, value] of Object.entries(process.env)) { for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) { if (value !== undefined) {
result[name] = value; result[name] = value;
@@ -98,8 +104,8 @@ async function postAuthorComment(body: string): Promise<void> {
} }
async function runRoot(command: string, args: string[]): Promise<void> { async function runRoot(command: string, args: string[]): Promise<void> {
const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true }); const code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" }));
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`); if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
} }
async function configureGitAuthor(): Promise<void> { async function configureGitAuthor(): Promise<void> {
@@ -111,8 +117,8 @@ async function configureGitAuthor(): Promise<void> {
await runRoot("git", ["config", "user.email", user["email"]]); await runRoot("git", ["config", "user.email", user["email"]]);
} }
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> { async function readProfileToken(profile: RuntimeProfile): Promise<string | null> {
const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8"); const token = profile.tokenFile === null ? null : await readFile(profile.tokenFile, "utf8");
return token; return token;
} }
@@ -122,12 +128,15 @@ async function runAgentCommand(
args: string[], args: string[],
token?: string, token?: string,
): Promise<void> { ): Promise<void> {
const { success, code } = await agentCommand(profile, command, args, { const child = spawn(command, args, {
cwd: WORKSPACE, cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token), env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true, uid: profile.uid,
}).output(); gid: profile.gid,
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`); stdio: "ignore",
});
const code = await exitCode(child);
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
} }
async function prepareProfile(profile: RuntimeProfile): Promise<void> { async function prepareProfile(profile: RuntimeProfile): Promise<void> {
@@ -155,9 +164,7 @@ function formatComment(comment: unknown): string {
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`; return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
} }
async function renderPrompt( async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
profile: RuntimeProfile,
): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> {
const reviewPath = await makeProfileFile(profile, "review.md"); const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json"); const anchorsPath = await makeProfileFile(profile, "anchors.json");
const comments: unknown = await ( const comments: unknown = await (
@@ -166,7 +173,7 @@ async function renderPrompt(
if (!Array.isArray(comments)) { if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response"); throw new TypeError("comments: unexpected Gitea response");
} }
const values: Record<string, string> = { const values: NodeJS.ProcessEnv = {
COMMENT: env("COMMENT"), COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"), ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
CODE_RULES: await (await gitea(RULES_PATH)).text(), CODE_RULES: await (await gitea(RULES_PATH)).text(),
@@ -177,7 +184,7 @@ async function renderPrompt(
REVIEW_PATH: reviewPath, REVIEW_PATH: reviewPath,
ANCHORS_PATH: anchorsPath, ANCHORS_PATH: anchorsPath,
}; };
const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8"); const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8");
return { return {
prompt: template.replaceAll( prompt: template.replaceAll(
/\$\{(\w+)\}/gu, /\$\{(\w+)\}/gu,
@@ -189,8 +196,7 @@ async function renderPrompt(
} }
async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise<string> { async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise<string> {
const claude = agentCommand( const claude = spawn(
profile,
"claude", "claude",
[ [
"--print", "--print",
@@ -208,21 +214,21 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
...agentEnvironment(PROCESS_ENV, profile, token), ...agentEnvironment(PROCESS_ENV, profile, token),
IS_SANDBOX: "1", IS_SANDBOX: "1",
}, },
clearEnv: true, uid: profile.uid,
stdout: "piped", gid: profile.gid,
stderr: "piped", stdio: ["ignore", "pipe", "pipe"],
}, },
).spawn(); );
let result: ClaudeResult | undefined; const status = exitCode(claude);
let result: ClaudeResult | null = null;
let error = ""; let error = "";
const copyError = (async (): Promise<void> => { const copyError = (async (): Promise<void> => {
const decoder = new TextDecoder(); claude.stderr.setEncoding("utf8");
for await (const chunk of chunks(claude.stderr)) { for await (const chunk of claude.stderr) {
const part = decoder.decode(chunk, { stream: true }); const part = String(chunk);
error += part; error += part;
console.error(part); console.error(part);
} }
error += decoder.decode();
})(); })();
for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) { for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) {
if (line !== "") { if (line !== "") {
@@ -240,18 +246,17 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
if (event["type"] === "result") { if (event["type"] === "result") {
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event; const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event;
result = { result = {
is_error: typeof isError === "boolean" ? isError : undefined, isError: typeof isError === "boolean" ? isError : undefined,
result: typeof answer === "string" ? answer : undefined, result: typeof answer === "string" ? answer : undefined,
subtype: String(subtype), subtype: String(subtype),
api_error_status: apiErrorStatus: typeof apiStatus === "number" ? apiStatus : undefined,
typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined,
}; };
} }
} }
} }
} }
const [status] = await Promise.all([claude.status, copyError]); const [code] = await Promise.all([status, copyError]);
if (result?.is_error === true) { if (result?.isError === true) {
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`); const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
if (isClaudeAuthFailure(result)) { if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure }); throw new AgentAuthError("claude sign-in failed", { cause: failure });
@@ -261,8 +266,8 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
if (result?.result === undefined) { if (result?.result === undefined) {
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`); throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
} }
if (!status.success) { if (code !== 0) {
throw new Error(error.trim() || `claude exited with ${status.code}`); throw new Error(error.trim() || `claude exited with ${code}`);
} }
return result.result; return result.result;
} }
@@ -270,7 +275,7 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
// Polls every 100 ms until the login prints something, failing if it exits first. // Polls every 100 ms until the login prints something, failing if it exits first.
async function pollForInstructions( async function pollForInstructions(
shownText: () => string, shownText: () => string,
status: Promise<CommandStatus>, status: Promise<number>,
): Promise<void> { ): Promise<void> {
if (shownText().trim() !== "") { if (shownText().trim() !== "") {
return; return;
@@ -284,50 +289,51 @@ async function pollForInstructions(
async function codexDeviceLogin( async function codexDeviceLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: Record<string, string>, environment: NodeJS.ProcessEnv,
): Promise<void> { ): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--device-auth"], { const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: environment,
clearEnv: true, uid: profile.uid,
stdout: "piped", gid: profile.gid,
stderr: "piped", stdio: ["ignore", "pipe", "pipe"],
}).spawn(); });
const status = exitCode(login);
let shown = ""; let shown = "";
const collect = async (stream: Readable): Promise<void> => { const collect = async (stream: Readable): Promise<void> => {
const decoder = new TextDecoder(); stream.setEncoding("utf8");
for await (const chunk of chunks(stream)) { for await (const chunk of stream) {
shown += decoder.decode(chunk, { stream: true }); shown += String(chunk);
} }
shown += decoder.decode();
}; };
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]); const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
const status = login.status;
await sleep(3000); await sleep(3000);
await pollForInstructions(() => shown, status); await pollForInstructions(() => shown, status);
await postAuthorComment(withProfile(shown, profile)); await postAuthorComment(withProfile(shown, profile));
const [result] = await Promise.all([status, drained]); const [code] = await Promise.all([status, drained]);
if (!result.success) { if (code !== 0) {
throw new AgentAuthError(`codex login exited with ${result.code}`); throw new AgentAuthError(`codex login exited with ${code}`);
} }
} }
async function codexApiLogin( async function codexApiLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: Record<string, string>, environment: NodeJS.ProcessEnv,
token: string, token: string,
): Promise<void> { ): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--with-api-key"], { const login = spawn("codex", ["login", "--with-api-key"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: environment,
clearEnv: true, uid: profile.uid,
stdin: "piped", gid: profile.gid,
stderr: "piped", stdio: ["pipe", "inherit", "pipe"],
}).spawn(); });
await endWith(login.stdin, `${token}\n`); const status = exitCode(login);
const [status, error] = await Promise.all([login.status, readText(login.stderr)]); login.stdin.end(`${token}\n`);
if (!status.success) { await finished(login.stdin);
throw new Error(error.trim() || `codex login exited with ${status.code}`); const [code, error] = await Promise.all([status, readText(login.stderr)]);
if (code !== 0) {
throw new Error(error.trim() || `codex login exited with ${code}`);
} }
} }
@@ -349,17 +355,18 @@ async function runCodex(
if (authentication.kind === "api-key") { if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token); await codexApiLogin(profile, environment, authentication.token);
} else { } else {
const loggedIn = await agentCommand(profile, "codex", ["login", "status"], { const check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: environment,
clearEnv: true, uid: profile.uid,
}).output(); gid: profile.gid,
if (!loggedIn.success) await codexDeviceLogin(profile, environment); stdio: "ignore",
});
if ((await exitCode(check)) !== 0) await codexDeviceLogin(profile, environment);
} }
const outputPath = await makeProfileFile(profile, "result.md"); const outputPath = await makeProfileFile(profile, "result.md");
const attempt = async (): Promise<{ status: CommandStatus; error: string }> => { const attempt = async (): Promise<CommandResult> => {
const codex = agentCommand( const codex = spawn(
profile,
"codex", "codex",
[ [
"exec", "exec",
@@ -373,20 +380,21 @@ async function runCodex(
{ {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: environment,
clearEnv: true, uid: profile.uid,
stdout: "inherit", gid: profile.gid,
stderr: "piped", stdio: ["ignore", "inherit", "pipe"],
}, },
).spawn(); );
const decoder = new TextDecoder(); const status = exitCode(codex);
codex.stderr.setEncoding("utf8");
let error = ""; let error = "";
for await (const chunk of chunks(codex.stderr)) { for await (const chunk of codex.stderr) {
await writeChunk(process.stderr, chunk); const part = String(chunk);
error += decoder.decode(chunk, { stream: true }); await echo(process.stderr, part);
error += part;
} }
error += decoder.decode();
// Codex echoes the prompt, whose comments may quote auth errors. // Codex echoes the prompt, whose comments may quote auth errors.
return { status: await codex.status, error: error.replaceAll(prompt, "") }; return { code: await status, error: error.replaceAll(prompt, "") };
}; };
const result = const result =
authentication.kind === "api-key" authentication.kind === "api-key"
@@ -395,8 +403,8 @@ async function runCodex(
await runAgentCommand(profile, "codex", ["logout"]); await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment); await codexDeviceLogin(profile, environment);
}); });
if (!result.status.success) { if (result.code !== 0) {
const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`); const failure = new Error(result.error.trim() || `codex exited with ${result.code}`);
if (isCodexAuthFailure(result.error)) { if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure }); throw new AgentAuthError("codex sign-in failed", { cause: failure });
} }
@@ -410,41 +418,36 @@ async function runOpenCode(
token: string, token: string,
prompt: string, prompt: string,
): Promise<string> { ): Promise<string> {
const child = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), { const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE, cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token), env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true, uid: profile.uid,
stdout: "piped", gid: profile.gid,
stderr: "piped", stdio: ["ignore", "pipe", "pipe"],
}).spawn(); });
const status = exitCode(child);
const collected = collectOpenCodeOutput( const collected = collectOpenCodeOutput(
chunks(child.stdout), child.stdout,
chunks(child.stderr), child.stderr,
async (chunk) => { (chunk) => echo(process.stdout, chunk),
await writeChunk(process.stdout, chunk); (chunk) => echo(process.stderr, chunk),
},
async (chunk) => {
await writeChunk(process.stderr, chunk);
},
); );
const [status, streams] = await Promise.all([child.status, collected]); const [code, streams] = await Promise.all([status, collected]);
const error = streams.stderr.trim(); const error = streams.stderr.trim();
if (!status.success) { if (code !== 0) {
throw ( throw openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${code}`);
openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`)
);
} }
return parseOpenCodeEvents(streams.stdout); return parseOpenCodeEvents(streams.stdout);
} }
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> { async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile); const token = await readProfileToken(profile);
const authentication = profile.type === "codex" ? codexAuthentication(token) : null; const authentication = profile.type === "codex" ? codexAuthentication(token ?? undefined) : null;
let stagedCodexAuth: string | null = null; let stagedCodexAuth: string | null = null;
let body: string | undefined; let body: string | null = null;
let resultPath: string | null = null; let resultPath: string | null = null;
let reviewPath: string | undefined; let reviewPath: string | null = null;
let anchorsPath: string | undefined; let anchorsPath: string | null = null;
try { try {
if (authentication?.kind === "persistent") { if (authentication?.kind === "persistent") {
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid); stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
@@ -459,7 +462,7 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
resultPath = await runCodex( resultPath = await runCodex(
profile, profile,
rendered.prompt, rendered.prompt,
authentication ?? codexAuthentication(token), authentication ?? codexAuthentication(token ?? undefined),
); );
} else { } else {
body = await runOpenCode(profile, token ?? "", rendered.prompt); body = await runOpenCode(profile, token ?? "", rendered.prompt);
@@ -471,7 +474,7 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
} }
} }
if (resultPath !== null) body = await readProfileFile(profile, resultPath); if (resultPath !== null) body = await readProfileFile(profile, resultPath);
if (body === undefined || reviewPath === undefined || anchorsPath === undefined) { if (body === null || reviewPath === null || anchorsPath === null) {
throw new Error("agent attempt produced no result"); throw new Error("agent attempt produced no result");
} }
return { return {
@@ -1,4 +1,5 @@
import { expect, test } from "vite-plus/test"; 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 MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
@@ -1,9 +1,15 @@
import { webcrypto } from "node:crypto"; import { webcrypto } from "node:crypto";
import { isMapping } from "./json.ts"; import { isMapping } from "#common";
import type { BotProfile } from "./profiles.ts"; import type { BotProfile } from "#profiles";
const DEFAULT_MODELS: Record<BotProfile["type"], string> = { interface DefaultModels {
claude: string;
codex: string;
opencode: string;
}
const DEFAULT_MODELS: DefaultModels = {
claude: "claude-sonnet-5", claude: "claude-sonnet-5",
codex: "gpt-5.6-terra", codex: "gpt-5.6-terra",
opencode: "openrouter/qwen/qwen3.8-27b", opencode: "openrouter/qwen/qwen3.8-27b",
+10
View File
@@ -0,0 +1,10 @@
// The runtime the prepare phase writes for the later phases: decrypted
// credentials laid out per profile, described by a manifest.
export { prepareRuntime, readRuntimeManifest } from "./runtime.ts";
export type {
RuntimeFailure,
RuntimeManifest,
RuntimeOutcome,
RuntimeProfile,
RuntimeSuccess,
} from "./runtime.ts";
@@ -1,26 +1,20 @@
import { spawnSync } from "node:child_process";
import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { assert, expect, test } from "vite-plus/test"; 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"; import { IS_ROOT, makeTempDir } from "#testing";
import { prepareRuntime, type RuntimeProfile } from "./runtime.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
// Runs `command` as the profile's user, from the root directory. // Runs `command` as the profile's user, from the root directory.
async function runAs(profile: RuntimeProfile, command: string, path: string): Promise<boolean> { function runAs(profile: RuntimeProfile, command: string, path: string): boolean {
const result = await output(command, { const result = spawnSync(command, [path], { cwd: "/", uid: profile.uid, gid: profile.gid });
args: [path], return result.status === 0;
cwd: "/",
uid: profile.uid,
gid: profile.gid,
});
return result.success;
} }
async function expectPrivate(path: string, uid: number, mode: number): Promise<void> { async function expectPrivate(path: string, uid: number, mode: number): Promise<void> {
@@ -58,16 +52,16 @@ test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credenti
expect((await stat(codex.home)).uid).toStrictEqual(codex.uid); expect((await stat(codex.home)).uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null); expect(codex.tokenFile).toBe(null);
const ownToken = await output("/bin/cat", { const ownToken = spawnSync("/bin/cat", [claude.tokenFile], {
args: [claude.tokenFile],
cwd: "/", cwd: "/",
uid: claude.uid, uid: claude.uid,
gid: claude.gid, gid: claude.gid,
encoding: "utf8",
}); });
expect(ownToken.success).toBe(true); expect(ownToken.status).toBe(0);
expect(new TextDecoder().decode(ownToken.stdout)).toBe("secret-token"); expect(ownToken.stdout).toBe("secret-token");
expect(await runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false); expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
expect(await runAs(claude, "/bin/ls", codex.home)).toBe(false); expect(runAs(claude, "/bin/ls", codex.home)).toBe(false);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
await rm(action, { recursive: true }); await rm(action, { recursive: true });
@@ -85,18 +79,20 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
profiles: [{ type: "claude", token: ENCRYPTED }], profiles: [{ type: "claude", token: ENCRYPTED }],
}), }),
); );
const result = await output(process.execPath, { const result = spawnSync(
args: [fileURLToPath(new URL("prepare.ts", import.meta.url))], process.execPath,
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
{
env: { env: {
ACTION_PATH: action, ACTION_PATH: action,
RUNNER_TEMP: root, RUNNER_TEMP: root,
BOT_AGENTS_MASTER_KEY: MASTER_KEY, BOT_AGENTS_MASTER_KEY: MASTER_KEY,
GITHUB_OUTPUT: action, GITHUB_OUTPUT: action,
}, },
clearEnv: true, },
}); );
expect(result.success).toBe(false); expect(result.status).not.toBe(0);
expect(await readdir(root)).toStrictEqual([]); expect(await readdir(root)).toStrictEqual([]);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
+14 -8
View File
@@ -1,19 +1,20 @@
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { isMapping } from "#common";
import type { ProfileFailure, ProfileIdentity } from "#profiles";
import { parseCredentials } from "./credentials.ts"; import { parseCredentials } from "./credentials.ts";
import { isMapping } from "./json.ts";
import type { BotProfile, ProfileFailure } from "./profiles.ts";
const FIRST_PROFILE_UID = 20_000; const FIRST_PROFILE_UID = 20_000;
export type RuntimeProfile = Pick<BotProfile, "type" | "model"> & { export interface RuntimeProfile extends ProfileIdentity {
index: number; index: number;
uid: number; uid: number;
gid: number; gid: number;
home: string; home: string;
tokenFile: string | null; tokenFile: string | null;
}; }
export interface RuntimeManifest { export interface RuntimeManifest {
runtimeDir: string; runtimeDir: string;
@@ -23,15 +24,20 @@ export interface RuntimeManifest {
profiles: RuntimeProfile[]; profiles: RuntimeProfile[];
} }
export type RuntimeOutcome = export interface RuntimeSuccess {
| {
ok: true; ok: true;
body: string; body: string;
reviewPath: string; reviewPath: string;
anchorsPath: string; anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">; profile: ProfileIdentity;
} }
| { ok: false; failures: ProfileFailure[] };
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> { async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
await writeFile(path, value, { mode: 0o600 }); await writeFile(path, value, { mode: 0o600 });
+1 -1
View File
@@ -38,7 +38,7 @@ export interface CommandResult {
export async function runCommand( export async function runCommand(
command: string, command: string,
args: string[], args: string[],
env: Record<string, string>, env: NodeJS.ProcessEnv,
): Promise<CommandResult> { ): Promise<CommandResult> {
const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] }); const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] });
const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]); const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]);
+2 -3
View File
@@ -1,11 +1,10 @@
import process from "node:process"; import process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { runCommand } from "#testing"; import { runCommand } from "#testing";
// Each phase runs as `node src/<phase>.ts` with no build step. Node loads the // Each phase runs as `node src/<phase>/index.ts` with no build step. Node loads the
// whole import graph, stripping types, before the phase reads its first // whole import graph, stripping types, before the phase reads its first
// variable, so failing on that variable proves every module loads. // variable, so failing on that variable proves every module loads.
const PHASES: [phase: string, firstVariable: string][] = [ const PHASES: [phase: string, firstVariable: string][] = [
@@ -15,7 +14,7 @@ const PHASES: [phase: string, firstVariable: string][] = [
]; ];
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => { test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => {
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url)); const path = fileURLToPath(new URL(`../src/${phase}/index.ts`, import.meta.url));
const result = await runCommand(process.execPath, [path], { PATH: process.env["PATH"] ?? "" }); const result = await runCommand(process.execPath, [path], { PATH: process.env["PATH"] ?? "" });
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.stderr).toContain(`${firstVariable} is not set`); expect(result.stderr).toContain(`${firstVariable} is not set`);
+14 -8
View File
@@ -1,12 +1,11 @@
import { readdir, readFile, rm, stat } from "node:fs/promises"; import { readdir, readFile, rm, stat } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { parse } from "yaml"; import { parse } from "yaml";
import { makeTempDir, runCommand } from "#testing"; import { makeTempDir, runCommand } from "#testing";
type JsonObject = Record<string, unknown>; type JsonObject = NodeJS.Dict<unknown>;
const isMapping = (value: unknown): value is JsonObject => const isMapping = (value: unknown): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value); typeof value === "object" && value !== null && !Array.isArray(value);
@@ -87,9 +86,11 @@ test("the source protection script stages immutable action files", async () => {
const path = (await readFile(outputFile, "utf8")).trim().slice(5); const path = (await readFile(outputFile, "utf8")).trim().slice(5);
const files = [ const files = [
"src/prepare.ts", "src/prepare/index.ts",
"src/run.ts", "src/run/index.ts",
"src/post.ts", "src/post/index.ts",
"src/runtime/index.ts",
"src/agents/auth.ts",
"package.json", "package.json",
"profiles.json", "profiles.json",
"prompt.md", "prompt.md",
@@ -105,7 +106,7 @@ test("the source protection script stages immutable action files", async () => {
}); });
test("module-relative runtime assets exist", async () => { test("module-relative runtime assets exist", async () => {
const module = new URL("../src/run.ts", import.meta.url); const module = new URL("../src/run/index.ts", import.meta.url);
const source = await readFile(module, "utf8"); const source = await readFile(module, "utf8");
const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map( const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map(
([, asset = ""]) => new URL(asset, module), ([, asset = ""]) => new URL(asset, module),
@@ -148,7 +149,7 @@ test("CI checks every phase and the root isolation suite", async () => {
}); });
test("uses the standard Skills installer without custom discovery", async () => { test("uses the standard Skills installer without custom discovery", async () => {
const runner = await readFile("src/run.ts", "utf8"); const runner = await readFile("src/run/index.ts", "utf8");
assert(runner.includes("skills add obra/superpowers")); assert(runner.includes("skills add obra/superpowers"));
const removed = await stat("superpowers.ts").then( const removed = await stat("superpowers.ts").then(
() => false, () => false,
@@ -162,8 +163,13 @@ test("installs the OpenCode CLI", async () => {
assert(action.includes("opencode-ai")); 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 () => { test("profile preparation does not receive provider credentials", async () => {
const runner = await readFile("src/run.ts", "utf8"); const runner = await readFile("src/run/index.ts", "utf8");
assert(runner.includes("await prepareProfile(profile);")); assert(runner.includes("await prepareProfile(profile);"));
expect(runner.includes("prepareProfile(profile, token)")).toBe(false); expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
}); });
+13 -2
View File
@@ -1,3 +1,5 @@
import explicitTypes from "eslint-plugin-explicit-types";
import importStructure from "eslint-plugin-import-structure";
import { defineConfig } from "vite-plus"; import { defineConfig } from "vite-plus";
import projectLint from "./scripts/project-lint.ts"; import projectLint from "./scripts/project-lint.ts";
@@ -5,9 +7,18 @@ import projectLint from "./scripts/project-lint.ts";
export default defineConfig({ export default defineConfig({
lint: { lint: {
plugins: ["typescript", "unicorn", "oxc", "import"], plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["./scripts/project-lint.ts"], jsPlugins: [
"eslint-plugin-explicit-types",
"eslint-plugin-import-structure",
"./scripts/project-lint.ts",
],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" }, categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: projectLint.configs.project.rules, rules: {
...explicitTypes.configs.concrete.rules,
...explicitTypes.configs.absence.rules,
...importStructure.configs.modules.rules,
...projectLint.configs.project.rules,
},
options: { options: {
typeAware: true, typeAware: true,
typeCheck: true, typeCheck: true,