27 Commits

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