7 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
30 changed files with 506 additions and 379 deletions
@@ -1,39 +0,0 @@
---
description: Child processes use plain node:child_process spawn (explicit stdio, uid/gid, whole env); src/child.ts has exitCode (128+signo) and echo; no execFile maxBuffer
---
Every call site calls `spawn` from `node:child_process` itself, with
everything explicit:
- **`uid`/`gid`** of the profile for anything an agent profile runs (each
profile is its own UID, 20000+). Never drop them.
- **`env`** is the whole environment the child gets (a sanitized one for
agents); nothing from the coordinator is merged in.
- **`stdio`**: stdin is `"ignore"` so an agent CLI never waits on input
(only `codex login --with-api-key` pipes the token in). Output that is not
read is `"ignore"`; output that is read is `"pipe"` and streamed.
- No `execFile`/`exec`: their `maxBuffer` (1 MiB) kills a chatty child. Read
piped streams incrementally instead.
`src/child.ts` holds what the call sites share:
- **`exitCode(child)`**: call it right after `spawn`. It settles on `exit`
(not `close`, which a daemonized grandchild can hold open), maps a signal to
`128 + signo` as a shell does, and rejects on a failed start. The rejection
is pre-caught so it is not reported as unhandled before the caller awaits it.
- **`echo(stream, chunk)`**: writes and waits for `drain` when the buffer is
full, so echoing never outruns a slow reader.
Tests use `spawnSync` for short commands.
Other port details:
- **`src/errors.ts`** `isNotFound` checks `ENOENT` (file) or `ESRCH` (process).
- Temporary files are an exclusive `wx` create with mode `0600`.
- `src/json.ts` holds type guards used instead of `as` casts on parsed JSON.
- `process` and `Buffer` must come from `node:` imports:
`no-restricted-globals` in `scripts/project-lint.ts` enforces it
([[lint-parity-never-shrinks]]).
Related: how each agent CLI reports bad credentials is in
[[agent-cli-auth-failures]].
@@ -1,5 +1,5 @@
--- ---
description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, no @cunarist plugins needed 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 Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
@@ -15,13 +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 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`. `engines.node` - **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. There is no
is `>=24.2`, which the user accepted for this repo `engines` (user decision 2026-10-18): the `node:24-bookworm` job image
([[bot-agents-runtime-no-build]]). decides the version ([[bot-agents-runtime-no-build]]).
- **No `@cunarist/eslint-plugin-*`.** The Deno setup enabled no plugin, so - **Deno stays in the action's global npm install** (`action.yml`), only
this repo depends on none and CI passes without them being published. because the agents still check Deno-based consumer repos (memona,
Planned (2026-10-17): once the plugins are on npm, add memona-home). Remove it once those repos have moved; it is not a
`@cunarist/eslint-plugin-modules` here for its import checks. 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 - **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.
@@ -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,8 +10,9 @@ 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`; `check.yml` prints be 24.2+ for `import.meta.main` in `post.ts`. That image is the only Node
`node --version` to confirm the cached image's minor. 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 - **Root-owned source copy:** `scripts/protect-source.sh` copies the action
into `$RUNNER_TEMP` so an agent editing the checkout can't swap a later into `$RUNNER_TEMP` so an agent editing the checkout can't swap a later
phase. It copies exactly `src/*.ts`, `package.json` (`"type": "module"` phase. It copies exactly `src/*.ts`, `package.json` (`"type": "module"`
+4 -4
View File
@@ -1,5 +1,5 @@
--- ---
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 `prepare.ts` Some tests exercise real UID isolation, process cleanup, and `prepare.ts`
@@ -7,10 +7,10 @@ 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 61 and skips 5. In CI (self-hosted - As a normal user `vp test` passes 56 and skips 5. In CI (self-hosted
runner, root) all 66 run. runner, root) all 61 run.
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`, - When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`,
or `child.ts`, run the suite as root in `node:24-bookworm` (e.g. a 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 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
+11 -9
View File
@@ -11,23 +11,25 @@ unbuilt. `vp pack` inlines them, so they never reach package consumers.
One rule for every file, never tied to folder or file names (user rule One rule for every file, never tied to folder or file names (user rule
2026-10-17; it replaces the earlier "aliases only point at direct `src/` 2026-10-17; it replaces the earlier "aliases only point at direct `src/`
subfolders" rule as the general rule, and the "modules plugin checks only subfolders" rule as the general rule, and the "import-structure plugin
aliased folders" scope; this repo keeps the stricter rule as a local checks only aliased folders" scope; this repo keeps the stricter rule as a
convention, see the last section): local convention, see the last section):
- **An alias may point anywhere** and owns its target. An alias to a - **An alias may point anywhere** and owns its target. An alias to a
folder's `index.ts` owns that folder and everything below it; an alias to folder's `index.ts` owns that folder and everything below it; an alias to
any other file owns only that file. any other file owns only that file.
- **No overlap.** No file may be owned by 2 aliases: no alias inside another - **No overlap.** No file may be owned by 2 aliases: no alias inside another
alias's area, and no 2 aliases with the same target. The modules plugin's alias's area, and no 2 aliases with the same target. The import-structure
`enforce-layer-order` reports it once at the top of every file 2 aliases plugin's `enforce-layer-order` reports it once at the top of every file 2
claim. Fix by merging the aliases or moving files apart. aliases claim. Fix by merging the aliases or moving files apart.
- **Outside an area, only the exact alias reaches it.** A deeper alias - **Outside an area, only the exact alias reaches it.** A deeper alias
specifier (`#a/one.ts`, `no-barrel-bypass`) and a relative path into the specifier (`#a/one.ts`, `no-barrel-bypass`) and a relative path into the
area (`./a/one.ts`, `no-relative-bypass`; `./b/f.ts` to a file alias, area (`./a/one.ts`, `no-relative-bypass`; `./b/f.ts` to a file alias,
`prefer-alias-import`, fixed to the alias) are bypasses. `prefer-alias-import`, fixed to the alias) are bypasses.
- **Inside an area, relative imports of siblings and subfolders are free.** - **Inside an area, relative imports of siblings and subfolders are free.**
- **`../` is banned in every file**, owned or not (`no-parent-import`). - **A `../` import is banned in every file**, owned or not (`no-parent-import`).
Runtime file URLs (`new URL("../prompt.md", import.meta.url)`) are not
imports and are allowed.
Anything above a file needs an alias. Anything above a file needs an alias.
- **Files no alias owns** need no `index.ts` and no alias, and import `./` - **Files no alias owns** need no `index.ts` and no alias, and import `./`
siblings and subfolders freely. siblings and subfolders freely.
@@ -89,8 +91,8 @@ Relative imports are written with the `.ts` extension.
## Local convention: aliases target direct `src/` subfolders ## Local convention: aliases target direct `src/` subfolders
This is a convention of this repo only, stricter than the general rule above This is a convention of this repo only, stricter than the general rule above
(which the published modules plugin checks and which allows more). It is not (which the published import-structure plugin checks and which allows more).
a plugin rule. 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"`.
+3 -3
View File
@@ -8,7 +8,7 @@ turn on whole categories, then list only what parity or a real need adds.
```ts ```ts
lint: { lint: {
plugins: ["typescript", "unicorn", "oxc", "import"], plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["@cunarist/eslint-plugin-explicit-types", "./scripts/project-lint.ts"], jsPlugins: ["eslint-plugin-explicit-types", "./scripts/project-lint.ts"],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" }, categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: { ...explicitTypes.configs.concrete.rules, ...projectLint.configs.project.rules }, rules: { ...explicitTypes.configs.concrete.rules, ...projectLint.configs.project.rules },
ignorePatterns: ["src/generated/**"], // generated files only, see below ignorePatterns: ["src/generated/**"], // generated files only, see below
@@ -40,8 +40,8 @@ lint: {
Hand-written code is never ignored. Hand-written code is never ignored.
- **No lint disable comments**: [[lint-no-disable-comments]]. - **No lint disable comments**: [[lint-no-disable-comments]].
- **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]]. - **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]].
- Don't add a second import sorter; the modules plugin's `enforce-import-order` is - Don't add a second import sorter; the import-structure plugin's
the only one, and Oxfmt's `sortImports` stays off. `enforce-import-order` is the only one, and Oxfmt's `sortImports` stays off.
`perf` came last: in the template it flagged nine `.includes` on constant `perf` came last: in the template it flagged nine `.includes` on constant
arrays, fixed as `ReadonlySet` + `.has()` rather than by turning a rule off. arrays, fixed as `ReadonlySet` + `.has()` rather than by turning a rule off.
+5 -3
View File
@@ -9,19 +9,21 @@ Oxfmt's standard `package.json` sorting is accepted: there is no
replaces the earlier hand-written order (metadata, `scripts`, `exports`, replaces the earlier hand-written order (metadata, `scripts`, `exports`,
`imports`, `devDependencies`, `dependencies`). `imports`, `devDependencies`, `dependencies`).
The `imports` key order can carry meaning: the modules plugin's The `imports` key order can carry meaning: the import-structure plugin's
`enforce-layer-order` reads it as the layer order. Never reorder it casually. `enforce-layer-order` reads it as the layer order. Never reorder it casually.
## Fields ## Fields
- **Standard fields only.** `engines` has only `node` (never `engines.bun`). - **Standard fields only.** App repos have no `engines` field (user decision
2026-10-18; images pin Node, [[toolchain-node-vite-plus]]). Published
packages keep `engines` with only `node` (never `engines.bun`).
A tool-owned key (`eslint-doc-generator`) is allowed only because the tool A tool-owned key (`eslint-doc-generator`) is allowed only because the tool
reads nothing else and no other config file is allowed. reads nothing else and no other config file is allowed.
- **No `packageManager`.** npm is detected from `package-lock.json`. - **No `packageManager`.** npm is detected from `package-lock.json`.
- **No `peerDependencies`, ever** (user preference, 2026-10-14). If something - **No `peerDependencies`, ever** (user preference, 2026-10-14). If something
is needed at runtime, it goes in `dependencies`. is needed at runtime, it goes in `dependencies`.
- **`dependencies`** = what runs or is type-referenced at runtime by consumers - **`dependencies`** = what runs or is type-referenced at runtime by consumers
(the lit plugin ships `parse5`, `oxc-parser`, `oxc-resolver`, and (the lit-robust plugin ships `parse5`, `oxc-parser`, `oxc-resolver`, and
`@eslint/core` for its public types; they stay external, never bundled). `@eslint/core` for its public types; they stay external, never bundled).
- **`devDependencies`** = everything else: `vite-plus`, `eslint`, - **`devDependencies`** = everything else: `vite-plus`, `eslint`,
`@typescript-eslint/parser`, `eslint-doc-generator`, `esbuild`, `@typescript-eslint/parser`, `eslint-doc-generator`, `esbuild`,
+12 -4
View File
@@ -1,12 +1,20 @@
--- ---
description: Node.js 22+ with Vite+ (vp) and npm; only package.json, tsconfig.json, vite.config.ts plus a committed package-lock.json description: Node.js (no engines in apps; images pin 24) with Vite+ (vp) and npm; only package.json, tsconfig.json, vite.config.ts plus a committed package-lock.json
--- ---
## The stack ## The stack
- **Runtime:** Node.js 22 or newer. `engines` is `{ "node": ">=22" }` and - **Runtime:** Node.js. App repos have no `engines` field (user decision
nothing else. Containers and the publish job use Node 24 (LTS); a repo may 2026-10-18): the container or runner image decides the version, so the
need more where a feature demands it (bot-agents uses Node 24.2+). Containerfile base image (`node:24-alpine`) and CI's `node-version: "24"`
are the pins. Published packages (the lint plugins) keep their own
`engines.node` floor. `npm run lit-check` runs
`node scripts/lit-check.ts`, which needs Node 22.18+ (unflagged type
stripping and `module.registerHooks`); Node 24 satisfies it. Without
`engines` or a `.node-version`, local `vp` uses its default Node
(`vp env default`). bot-agents has no Containerfile or setup step: the
self-hosted runner's `node:24-bookworm` job image runs the action and its
checks, and `src/post.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.
+11 -5
View File
@@ -6,11 +6,17 @@ The user's rule, from the Deno days and restated for Node (2026-10-12): "No
JS, not even JSX/TSX, only TS." Source, scripts, tests, and configs are all JS, not even JSX/TSX, only TS." Source, scripts, tests, and configs are all
plain `.ts`. Build output in a gitignored folder is the only JS. plain `.ts`. Build output in a gitignored folder is the only JS.
`tsconfig.json`: no `allowJs`, no `jsx`, and the template's strict flags `tsconfig.json`: no `allowJs`, no `jsx`, and exactly this strictness set (user
(`verbatimModuleSyntax`, `erasableSyntaxOnly`, `allowImportingTsExtensions`, decision, 2026-10-18): `strict` (written out), `noUncheckedIndexedAccess`,
`isolatedDeclarations` in packages, `exactOptionalPropertyTypes`, `noPropertyAccessFromIndexSignature`, `noImplicitReturns`, `noUnusedLocals`,
`noUncheckedIndexedAccess`). If a tool or framework seems to need a JS or TSX `noUnusedParameters`, `noImplicitOverride`, `noFallthroughCasesInSwitch`,
file, stop and flag it instead of adding one. `erasableSyntaxOnly`, and `allowUnreachableCode: false` plus
`allowUnusedLabels: false` (user decision, 2026-10-18; on in the Deno setup),
plus the mechanical `verbatimModuleSyntax`, `allowImportingTsExtensions`, and
`noEmit` (packages also keep `declaration` and `isolatedDeclarations`).
`exactOptionalPropertyTypes` is off everywhere: write `{ key: maybeUndefined }`
rather than leaving the key out to please it. If a tool or framework seems to
need a JS or TSX file, stop and flag it instead of adding one.
## No asset imports (2026-10-14, widened 2026-10-16 and 2026-10-17) ## No asset imports (2026-10-14, widened 2026-10-16 and 2026-10-17)
+22 -16
View File
@@ -13,11 +13,15 @@ explicit permission.
# Runtime and checks # Runtime and checks
The action's TypeScript runs directly on the job image's Node.js (24.2 or The action's TypeScript runs directly on the job image's Node.js (24.2 or
newer), which strips the types itself: there is no build step and no runtime newer; `package.json` has no `engines`, the `node:24-bookworm` image pins it),
which strips the types itself: there is no build step and no runtime
dependency. Keep the sources to erasable syntax and `.ts` imports, which dependency. Keep the sources to erasable syntax and `.ts` imports, which
`tsconfig.json` enforces. Checks use Vite+: `vp check` formats, lints, and `tsconfig.json` enforces. Its strictness set is fixed and leaves
type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as `exactOptionalPropertyTypes` off
`src/*.test.ts`; repository-level tests live in `tests/`. ([.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 `src/*.test.ts`;
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
target: an alias to a folder's `index.ts` owns that folder and everything target: an alias to a folder's `index.ts` owns that folder and everything
@@ -25,16 +29,17 @@ below it; an alias to any other file owns only that file. No overlap: no file
may be owned by 2 aliases, so no alias inside another alias's area and no 2 may be owned by 2 aliases, so no alias inside another alias's area and no 2
aliases with the same target. Outside an area, only the exact alias reaches aliases with the same target. Outside an area, only the exact alias reaches
it; a deeper alias specifier or a relative path into the area is a bypass. it; a deeper alias specifier or a relative path into the area is a bypass.
Inside an area, relative imports of siblings and subfolders are free. `../` Inside an area, relative imports of siblings and subfolders are free. A `../`
is banned in every file, owned or not import is banned in every file, owned or not
(`import/no-relative-parent-imports`). Files no alias owns need no `index.ts` (`import/no-relative-parent-imports`); runtime file URLs such as
and no alias, and import `./` siblings and subfolders freely. The one alias `new URL("../prompt.md", import.meta.url)` are paths, not imports, and stay.
is `#testing` (`./src/testing/index.ts`): every test imports it by alias, and Files no alias owns need no `index.ts` and no alias, and import `./` siblings
`tests/` reaches `src/` only through it. Local convention, stricter than the and subfolders freely. The one alias is `#testing` (`./src/testing/index.ts`):
general rule above: in this repo, aliases point only at a direct subfolder of tests that use its helpers import it by alias, and `tests/` imports `src/`
`src/` (`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses only through it. Local convention, stricter than the general rule above: in
`./` relative imports. `scripts/` is flat: one file per this repo, aliases point only at a direct subfolder of `src/`
tool, no subfolders and no `index.ts`, imported directly by file path (`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses `./`
relative imports. `scripts/` is flat: one file per tool, no subfolders and no `index.ts`, imported directly by file path
([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)). ([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)).
The only config files are `package.json`, `tsconfig.json`, and The only config files are `package.json`, `tsconfig.json`, and
@@ -43,5 +48,6 @@ 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; do not treat those Claude Code, Codex, and OpenCode command-line tools, plus Deno for agents that
vendor executables as repository TypeScript dependencies. check Deno-based repositories; do not treat those vendor executables as
repository TypeScript dependencies.
+3 -2
View File
@@ -16,10 +16,11 @@ 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. # its TypeScript on that Node directly; Deno stays for the agents, which
# 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 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 # 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
-3
View File
@@ -11,9 +11,6 @@
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
},
"engines": {
"node": ">=24.2"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
-3
View File
@@ -12,8 +12,5 @@
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
},
"engines": {
"node": ">=24.2"
} }
} }
+5 -5
View File
@@ -17,7 +17,7 @@ test("retries once after an invalid refresh token", async () => {
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => () =>
Promise.resolve({ Promise.resolve({
code: 1, status: { success: false },
error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed", error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed",
}), }),
() => { () => {
@@ -40,7 +40,7 @@ test("does not retry an unrelated failure", async () => {
() => { () => {
attempts++; attempts++;
return Promise.resolve({ return Promise.resolve({
code: 1, status: { success: false },
error: "rate limited", error: "rate limited",
}); });
}, },
@@ -86,7 +86,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({
code: attempts++ > 0 ? 0 : 1, status: { success: attempts++ > 0 },
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "", error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}), }),
() => { () => {
@@ -95,9 +95,9 @@ 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, relogins: 1,
code: 0, success: true,
}); });
}); });
+6 -6
View File
@@ -48,12 +48,12 @@ export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
); );
} }
export async function retryInvalidToken<T extends { code: number; error: string }>( export async function retryInvalidToken<T extends { status: { success: boolean }; error: string }>(
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.code !== 0 && isCodexAuthFailure(result.error)) { if (!result.status.success && isCodexAuthFailure(result.error)) {
await relogin(); await relogin();
result = await attempt(); result = await attempt();
} }
@@ -75,11 +75,11 @@ async function readCodexAuth(path: string): Promise<string> {
} catch { } catch {
throw new Error("invalid Codex login"); throw new Error("invalid Codex login");
} }
const tokens = isObject(value) ? value.tokens : undefined; const tokens = isObject(value) ? value["tokens"] : undefined;
if ( if (
!isObject(value) || !isObject(value) ||
typeof value.auth_mode !== "string" || typeof value["auth_mode"] !== "string" ||
value.auth_mode === "" || value["auth_mode"] === "" ||
!isObject(tokens) || !isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every( !["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "", (name) => typeof tokens[name] === "string" && tokens[name] !== "",
@@ -111,7 +111,7 @@ export async function stageCodexAuth(
return stagedPath; 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> { 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);
-35
View File
@@ -1,35 +0,0 @@
import { spawn } from "node:child_process";
import { expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts";
import { exitCode } from "./child.ts";
test("reports the exit code", async () => {
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }))).toBe(0);
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" }))).toBe(3);
});
test("reports a signal as 128 plus its number", async () => {
const child = spawn("/bin/sh", ["-c", "kill -KILL $$"], { stdio: "ignore" });
expect(await exitCode(child)).toBe(137);
});
test("resolves on exit while a grandchild still holds the output open", async () => {
const child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], { stdio: ["ignore", "pipe", "pipe"] });
const started = Date.now();
expect(await exitCode(child)).toBe(5);
expect(Date.now() - started).toBeLessThan(1500);
child.stdout.destroy();
child.stderr.destroy();
});
test("rejects when the command does not exist", async () => {
const child = spawn("/nonexistent/command", [], { stdio: "ignore" });
const code = exitCode(child);
// The rejection must stay unhandled-safe until it is awaited.
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
await expect(code).rejects.toSatisfy(isNotFound);
});
-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");
}
}
+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);
}
+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 { export function isNotFound(error: unknown): boolean {
if (!(error instanceof Error) || !("code" in error)) { if (!(error instanceof Error) || !("code" in error)) {
return false; return false;
+34 -34
View File
@@ -1,11 +1,14 @@
import { spawnSync } from "node:child_process";
import type { SpawnSyncReturns } from "node:child_process";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
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 { isNotFound } from "./errors.ts"; import { isNotFound } from "./errors.ts";
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts"; import {
agentCommand,
isRunnableProfileProcess,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.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";
@@ -19,23 +22,6 @@ 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?: Record<string, string>,
): SpawnSyncReturns<string> {
return spawnSync(command, args, {
cwd: "/",
uid: selected.uid,
gid: selected.gid,
encoding: "utf8",
...(env !== undefined && { env }),
});
}
test("zombie process entries are already quiescent", () => { 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);
@@ -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 }); await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
const selected = profile(20_000, homeA); const selected = profile(20_000, homeA);
const own = runAs(selected, "/bin/cat", [`${homeA}/token`]); const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], {
const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]); cwd: "/",
const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]); }).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(new TextDecoder().decode(own.stdout)).toBe("own");
expect(own.status).toBe(0); expect(own.success).toBe(true);
expect(sibling.status).not.toBe(0); expect(sibling.success).toBe(false);
expect(reviewer.status).not.toBe(0); expect(reviewer.success).toBe(false);
} finally { } finally {
await rm(root, { recursive: true }); 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 () => { 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 = runAs(selected, "/bin/sh", ["-c", "sleep 60 >/dev/null 2>&1 & echo $!"]); const launched = await agentCommand(
const pid = Number(launched.stdout.trim()); 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); expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid); 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); 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 selected = profile(20_020, "/tmp");
const result = runAs(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], { const result = await agentCommand(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
PARENT_SECRET: "must-not-cross-boundary", cwd: "/",
}); env: { PARENT_SECRET: "must-not-cross-boundary" },
clearEnv: true,
stderr: "piped",
}).output();
expect(result.status).not.toBe(0); expect(result.success).toBe(false);
}); });
+22
View File
@@ -2,9 +2,31 @@ 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 type { CommandChild, CommandOptions, CommandOutput } from "./command.ts";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "./errors.ts";
import type { RuntimeProfile } from "./runtime.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];
const state = /^State:\s+(\w)/mu.exec(status)?.[1]; const state = /^State:\s+(\w)/mu.exec(status)?.[1];
+14 -14
View File
@@ -70,9 +70,9 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
const outcome = value; const outcome = value;
if (outcome.ok === false) { if (outcome["ok"] === false) {
// Only known labels reach the comment, never raw agent output. // Only known labels reach the comment, never raw agent output.
const failures: unknown[] = Array.isArray(outcome.failures) ? outcome.failures : []; const failures: unknown[] = Array.isArray(outcome["failures"]) ? outcome["failures"] : [];
return { return {
ok: false, ok: false,
failures: failures.flatMap((entry: unknown) => { failures: failures.flatMap((entry: unknown) => {
@@ -84,26 +84,26 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
}; };
} }
if ( if (
outcome.ok !== true || outcome["ok"] !== true ||
typeof outcome.body !== "string" || typeof outcome["body"] !== "string" ||
typeof outcome.reviewPath !== "string" || typeof outcome["reviewPath"] !== "string" ||
typeof outcome.anchorsPath !== "string" || typeof outcome["anchorsPath"] !== "string" ||
!isObject(outcome.profile) !isObject(outcome["profile"])
) { ) {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
const profile = outcome.profile; const profile = outcome["profile"];
if (!isProfileType(profile.type) || typeof profile.model !== "string") { if (!isProfileType(profile["type"]) || typeof profile["model"] !== "string") {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
return { return {
ok: true, ok: true,
body: outcome.body, body: outcome["body"],
reviewPath: outcome.reviewPath, reviewPath: outcome["reviewPath"],
anchorsPath: outcome.anchorsPath, anchorsPath: outcome["anchorsPath"],
profile: { profile: {
type: profile.type, type: profile["type"],
model: profile.model, model: profile["model"],
}, },
}; };
} }
+2 -2
View File
@@ -110,8 +110,8 @@ export function agentEnvironment(
token?: string, token?: string,
): Record<string, string> { ): Record<string, string> {
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`;
if (token !== undefined && token !== "") { if (token !== undefined && token !== "") {
const tokenName = { const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN", claude: "CLAUDE_CODE_OAUTH_TOKEN",
+94 -96
View File
@@ -1,12 +1,10 @@
// 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 {
@@ -18,8 +16,9 @@ import {
stageCodexAuth, stageCodexAuth,
} from "./auth.ts"; } from "./auth.ts";
import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts"; import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts";
import { echo, exitCode } from "./child.ts"; import { chunks, endWith, output, writeChunk } from "./command.ts";
import { killProfileProcesses, readProfileFile } from "./isolation.ts"; import type { CommandStatus } from "./command.ts";
import { agentCommand, killProfileProcesses, readProfileFile } from "./isolation.ts";
import { isObject } from "./json.ts"; import { isObject } from "./json.ts";
import { import {
collectOpenCodeOutput, collectOpenCodeOutput,
@@ -86,7 +85,7 @@ async function gitea(path: string, body?: unknown): Promise<Response> {
Authorization: `token ${AUTHOR_TOKEN}`, Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: body === undefined ? null : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`); throw new Error(`${path}: ${response.status} ${await response.text()}`);
@@ -99,17 +98,17 @@ 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 code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" })); const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true });
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`); if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
} }
async function configureGitAuthor(): Promise<void> { async function configureGitAuthor(): Promise<void> {
const user: unknown = await (await gitea("user")).json(); const user: unknown = await (await gitea("user")).json();
if (!isObject(user) || typeof user.login !== "string" || typeof user.email !== "string") { if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") {
throw new Error("user: unexpected Gitea response"); throw new Error("user: unexpected Gitea response");
} }
await runRoot("git", ["config", "user.name", user.login]); await runRoot("git", ["config", "user.name", user["login"]]);
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 | undefined> {
@@ -123,15 +122,12 @@ async function runAgentCommand(
args: string[], args: string[],
token?: string, token?: string,
): Promise<void> { ): Promise<void> {
const child = spawn(command, args, { const { success, code } = await agentCommand(profile, command, args, {
cwd: WORKSPACE, cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token), env: agentEnvironment(PROCESS_ENV, profile, token),
uid: profile.uid, clearEnv: true,
gid: profile.gid, }).output();
stdio: "ignore", if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
});
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,7 +151,7 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
// One issue comment as the prompt quotes it. // One issue comment as the prompt quotes it.
function formatComment(comment: unknown): string { function formatComment(comment: unknown): string {
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {}; const { user, created_at: createdAt, body } = isObject(comment) ? comment : {};
const login = isObject(user) ? user.login : undefined; const login = isObject(user) ? user["login"] : undefined;
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`; return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
} }
@@ -193,7 +189,8 @@ 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 = spawn( const claude = agentCommand(
profile,
"claude", "claude",
[ [
"--print", "--print",
@@ -211,50 +208,49 @@ 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",
}, },
uid: profile.uid, clearEnv: true,
gid: profile.gid, stdout: "piped",
stdio: ["ignore", "pipe", "pipe"], stderr: "piped",
}, },
); ).spawn();
const status = exitCode(claude);
let result: ClaudeResult | undefined; let result: ClaudeResult | undefined;
let error = ""; let error = "";
const copyError = (async (): Promise<void> => { const copyError = (async (): Promise<void> => {
claude.stderr.setEncoding("utf8"); const decoder = new TextDecoder();
for await (const chunk of claude.stderr) { for await (const chunk of chunks(claude.stderr)) {
const part = String(chunk); const part = decoder.decode(chunk, { stream: true });
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 !== "") {
const event: unknown = JSON.parse(line); const event: unknown = JSON.parse(line);
if (isObject(event)) { if (isObject(event)) {
const content = isObject(event.message) ? event.message.content : undefined; const content = isObject(event["message"]) ? event["message"]["content"] : undefined;
for (const part of Array.isArray(content) ? content : []) { for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) { if (isObject(part)) {
const text = part.thinking ?? part.text ?? part.name; const text = part["thinking"] ?? part["text"] ?? part["name"];
if (typeof text === "string" && text !== "") { if (typeof text === "string" && text !== "") {
console.log(text); console.log(text);
} }
} }
} }
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 = {
...(typeof isError === "boolean" && { is_error: isError }), is_error: typeof isError === "boolean" ? isError : undefined,
...(typeof answer === "string" && { result: answer }), result: typeof answer === "string" ? answer : undefined,
subtype: String(subtype), subtype: String(subtype),
...((typeof apiStatus === "number" || apiStatus === null) && { api_error_status:
api_error_status: apiStatus, typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined,
}),
}; };
} }
} }
} }
} }
const [code] = await Promise.all([status, copyError]); const [status] = await Promise.all([claude.status, copyError]);
if (result?.is_error === true) { if (result?.is_error === 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)) {
@@ -265,8 +261,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 (code !== 0) { if (!status.success) {
throw new Error(error.trim() || `claude exited with ${code}`); throw new Error(error.trim() || `claude exited with ${status.code}`);
} }
return result.result; return result.result;
} }
@@ -274,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. // 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<number>, status: Promise<CommandStatus>,
): Promise<void> { ): Promise<void> {
if (shownText().trim() !== "") { if (shownText().trim() !== "") {
return; return;
@@ -290,28 +286,29 @@ async function codexDeviceLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: Record<string, string>, environment: Record<string, string>,
): Promise<void> { ): Promise<void> {
const login = spawn("codex", ["login", "--device-auth"], { const login = agentCommand(profile, "codex", ["login", "--device-auth"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: environment,
uid: profile.uid, clearEnv: true,
gid: profile.gid, stdout: "piped",
stdio: ["ignore", "pipe", "pipe"], stderr: "piped",
}); }).spawn();
const status = exitCode(login);
let shown = ""; let shown = "";
const collect = async (stream: Readable): Promise<void> => { const collect = async (stream: Readable): Promise<void> => {
stream.setEncoding("utf8"); const decoder = new TextDecoder();
for await (const chunk of stream) { for await (const chunk of chunks(stream)) {
shown += String(chunk); shown += decoder.decode(chunk, { stream: true });
} }
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 [code] = await Promise.all([status, drained]); const [result] = await Promise.all([status, drained]);
if (code !== 0) { if (!result.success) {
throw new AgentAuthError(`codex login exited with ${code}`); throw new AgentAuthError(`codex login exited with ${result.code}`);
} }
} }
@@ -320,19 +317,17 @@ async function codexApiLogin(
environment: Record<string, string>, environment: Record<string, string>,
token: string, token: string,
): Promise<void> { ): Promise<void> {
const login = spawn("codex", ["login", "--with-api-key"], { const login = agentCommand(profile, "codex", ["login", "--with-api-key"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: environment,
uid: profile.uid, clearEnv: true,
gid: profile.gid, stdin: "piped",
stdio: ["pipe", "inherit", "pipe"], stderr: "piped",
}); }).spawn();
const status = exitCode(login); await endWith(login.stdin, `${token}\n`);
login.stdin.end(`${token}\n`); const [status, error] = await Promise.all([login.status, readText(login.stderr)]);
await finished(login.stdin); if (!status.success) {
const [code, error] = await Promise.all([status, readText(login.stderr)]); throw new Error(error.trim() || `codex login exited with ${status.code}`);
if (code !== 0) {
throw new Error(error.trim() || `codex login exited with ${code}`);
} }
} }
@@ -354,18 +349,17 @@ 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 check = spawn("codex", ["login", "status"], { const loggedIn = await agentCommand(profile, "codex", ["login", "status"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: environment,
uid: profile.uid, clearEnv: true,
gid: profile.gid, }).output();
stdio: "ignore", if (!loggedIn.success) await codexDeviceLogin(profile, environment);
});
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<{ code: number; error: string }> => { const attempt = async (): Promise<{ status: CommandStatus; error: string }> => {
const codex = spawn( const codex = agentCommand(
profile,
"codex", "codex",
[ [
"exec", "exec",
@@ -379,21 +373,20 @@ async function runCodex(
{ {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: environment,
uid: profile.uid, clearEnv: true,
gid: profile.gid, stdout: "inherit",
stdio: ["ignore", "inherit", "pipe"], stderr: "piped",
}, },
); ).spawn();
const status = exitCode(codex); const decoder = new TextDecoder();
codex.stderr.setEncoding("utf8");
let error = ""; let error = "";
for await (const chunk of codex.stderr) { for await (const chunk of chunks(codex.stderr)) {
const part = String(chunk); await writeChunk(process.stderr, chunk);
await echo(process.stderr, part); error += decoder.decode(chunk, { stream: true });
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 { code: await status, error: error.replaceAll(prompt, "") }; return { status: await codex.status, error: error.replaceAll(prompt, "") };
}; };
const result = const result =
authentication.kind === "api-key" authentication.kind === "api-key"
@@ -402,8 +395,8 @@ async function runCodex(
await runAgentCommand(profile, "codex", ["logout"]); await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment); await codexDeviceLogin(profile, environment);
}); });
if (result.code !== 0) { if (!result.status.success) {
const failure = new Error(result.error.trim() || `codex exited with ${result.code}`); const failure = new Error(result.error.trim() || `codex exited with ${result.status.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 });
} }
@@ -417,24 +410,29 @@ async function runOpenCode(
token: string, token: string,
prompt: string, prompt: string,
): Promise<string> { ): Promise<string> {
const child = spawn("opencode", openCodeArgs(profile.model, prompt), { const child = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE, cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token), env: agentEnvironment(PROCESS_ENV, profile, token),
uid: profile.uid, clearEnv: true,
gid: profile.gid, stdout: "piped",
stdio: ["ignore", "pipe", "pipe"], stderr: "piped",
}); }).spawn();
const status = exitCode(child);
const collected = collectOpenCodeOutput( const collected = collectOpenCodeOutput(
child.stdout, chunks(child.stdout),
child.stderr, chunks(child.stderr),
(chunk) => echo(process.stdout, chunk), async (chunk) => {
(chunk) => echo(process.stderr, 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(); const error = streams.stderr.trim();
if (code !== 0) { if (!status.success) {
throw openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${code}`); throw (
openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`)
);
} }
return parseOpenCodeEvents(streams.stdout); return parseOpenCodeEvents(streams.stdout);
} }
+25 -22
View File
@@ -1,10 +1,10 @@
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 { prepareRuntime } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts"; import type { RuntimeProfile } from "./runtime.ts";
import { IS_ROOT, makeTempDir } from "#testing"; import { IS_ROOT, makeTempDir } from "#testing";
@@ -13,9 +13,14 @@ 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.
function runAs(profile: RuntimeProfile, command: string, path: string): boolean { async function runAs(profile: RuntimeProfile, command: string, path: string): Promise<boolean> {
const result = spawnSync(command, [path], { cwd: "/", uid: profile.uid, gid: profile.gid }); const result = await output(command, {
return result.status === 0; args: [path],
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> {
@@ -53,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((await stat(codex.home)).uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null); expect(codex.tokenFile).toBe(null);
const ownToken = spawnSync("/bin/cat", [claude.tokenFile], { const ownToken = await output("/bin/cat", {
args: [claude.tokenFile],
cwd: "/", cwd: "/",
uid: claude.uid, uid: claude.uid,
gid: claude.gid, gid: claude.gid,
encoding: "utf8",
}); });
expect(ownToken.status).toBe(0); expect(ownToken.success).toBe(true);
expect(ownToken.stdout).toBe("secret-token"); expect(new TextDecoder().decode(ownToken.stdout)).toBe("secret-token");
expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false); expect(await runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
expect(runAs(claude, "/bin/ls", codex.home)).toBe(false); expect(await 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 });
@@ -80,20 +85,18 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
profiles: [{ type: "claude", token: ENCRYPTED }], profiles: [{ type: "claude", token: ENCRYPTED }],
}), }),
); );
const result = spawnSync( const result = await output(process.execPath, {
process.execPath, args: [fileURLToPath(new URL("prepare.ts", import.meta.url))],
[fileURLToPath(new URL("prepare.ts", import.meta.url))], env: {
{ ACTION_PATH: action,
env: { RUNNER_TEMP: root,
ACTION_PATH: action, BOT_AGENTS_MASTER_KEY: MASTER_KEY,
RUNNER_TEMP: root, GITHUB_OUTPUT: action,
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([]); expect(await readdir(root)).toStrictEqual([]);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
+5 -5
View File
@@ -101,11 +101,11 @@ export async function prepareRuntime(
function isRuntimeManifest(value: unknown): value is RuntimeManifest { function isRuntimeManifest(value: unknown): value is RuntimeManifest {
return ( return (
isMapping(value) && isMapping(value) &&
typeof value.runtimeDir === "string" && typeof value["runtimeDir"] === "string" &&
typeof value.manifestPath === "string" && typeof value["manifestPath"] === "string" &&
typeof value.reviewerFile === "string" && typeof value["reviewerFile"] === "string" &&
typeof value.outcomeFile === "string" && typeof value["outcomeFile"] === "string" &&
Array.isArray(value.profiles) Array.isArray(value["profiles"])
); );
} }
+1 -1
View File
@@ -16,7 +16,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}.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`);
}); });
+21 -26
View File
@@ -48,11 +48,11 @@ test("all committed YAML parses", async () => {
test("the action exposes only the master key and scopes it to preparation", async () => { test("the action exposes only the master key and scopes it to preparation", async () => {
const action = await yaml("action.yml"); const action = await yaml("action.yml");
expect(Object.keys(mapping(action.inputs, "inputs"))).toStrictEqual(["master-key"]); expect(Object.keys(mapping(action["inputs"], "inputs"))).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps"); const steps = sequence(mapping(action["runs"], "runs")["steps"], "runs.steps");
for (const step of steps) { for (const step of steps) {
if (typeof step.run === "string") { if (typeof step["run"] === "string") {
expect(step.run.includes("\n"), String(step.name)).toBe(false); expect(step["run"].includes("\n"), String(step["name"])).toBe(false);
} }
} }
const checkout = findStep(steps, "uses", "actions/checkout@v4"); const checkout = findStep(steps, "uses", "actions/checkout@v4");
@@ -60,16 +60,16 @@ test("the action exposes only the master key and scopes it to preparation", asyn
const agent = findStep(steps, "id", "agent"); const agent = findStep(steps, "id", "agent");
const post = findStep(steps, "id", "post"); const post = findStep(steps, "id", "post");
findStep(steps, "id", "source"); findStep(steps, "id", "source");
expect(mapping(checkout.with, "checkout.with").token).toBe("${{ gitea.token }}"); expect(mapping(checkout["with"], "checkout.with")["token"]).toBe("${{ gitea.token }}");
expect(mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY).toBe( expect(mapping(prepare["env"], "prepare.env")["BOT_AGENTS_MASTER_KEY"]).toBe(
"${{ inputs.master-key }}", "${{ inputs.master-key }}",
); );
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(mapping(agent.env, "agent.env").GITEA_TOKEN).toBe("${{ gitea.token }}"); expect(mapping(agent["env"], "agent.env")["GITEA_TOKEN"]).toBe("${{ gitea.token }}");
assert(String(post.if).includes("always()")); assert(String(post["if"]).includes("always()"));
for (const step of [prepare, agent, post]) { for (const step of [prepare, agent, post]) {
assert(String(step.run).includes("steps.source.outputs.path")); assert(String(step["run"]).includes("steps.source.outputs.path"));
} }
}); });
@@ -118,17 +118,17 @@ test("module-relative runtime assets exist", async () => {
test("the repository uses its trusted main action", async () => { test("the repository uses its trusted main action", async () => {
const workflow = await yaml(".gitea/workflows/agents.yml"); const workflow = await yaml(".gitea/workflows/agents.yml");
const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent"); const job = mapping(mapping(workflow["jobs"], "jobs")["agent"], "jobs.agent");
const permissions = mapping(job.permissions, "jobs.agent.permissions"); const permissions = mapping(job["permissions"], "jobs.agent.permissions");
expect(permissions).toStrictEqual({ expect(permissions).toStrictEqual({
code: "write", code: "write",
issues: "write", issues: "write",
"pull-requests": "write", "pull-requests": "write",
}); });
const steps = sequence(job.steps, "jobs.agent.steps"); const steps = sequence(job["steps"], "jobs.agent.steps");
expect(steps.findIndex((step) => step.uses === "./")).toStrictEqual(-1); expect(steps.findIndex((step) => step["uses"] === "./")).toStrictEqual(-1);
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main"); const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main");
expect(mapping(trusted.with, "trusted.with")["master-key"]).toBe( expect(mapping(trusted["with"], "trusted.with")["master-key"]).toBe(
"${{ secrets.BOT_AGENTS_MASTER_KEY }}", "${{ secrets.BOT_AGENTS_MASTER_KEY }}",
); );
expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true); expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true);
@@ -136,9 +136,9 @@ test("the repository uses its trusted main action", async () => {
test("CI checks every phase and the root isolation suite", async () => { test("CI checks every phase and the root isolation suite", async () => {
const workflow = await yaml(".gitea/workflows/check.yml"); const workflow = await yaml(".gitea/workflows/check.yml");
const job = mapping(mapping(workflow.jobs, "jobs").check, "jobs.check"); const job = mapping(mapping(workflow["jobs"], "jobs")["check"], "jobs.check");
const commands = sequence(job.steps, "jobs.check.steps") const commands = sequence(job["steps"], "jobs.check.steps")
.map((step) => (typeof step.run === "string" ? step.run : "")) .map((step) => (typeof step["run"] === "string" ? step["run"] : ""))
.join("\n"); .join("\n");
// `vp check` type-checks every phase, and `vp test` runs each of them under // `vp check` type-checks every phase, and `vp test` runs each of them under
// Node as well as the root-only isolation tests. // Node as well as the root-only isolation tests.
@@ -162,11 +162,6 @@ 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.ts", "utf8");
assert(runner.includes("await prepareProfile(profile);")); assert(runner.includes("await prepareProfile(profile);"));
@@ -175,8 +170,8 @@ test("profile preparation does not receive provider credentials", async () => {
test("central profiles fall back from Codex to Claude to OpenCode", async () => { test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json").profiles, "profiles"); const profiles = sequence(mapping(credentials, "profiles.json")["profiles"], "profiles");
expect(profiles.map((profile) => profile.type)).toStrictEqual(["codex", "claude", "opencode"]); expect(profiles.map((profile) => profile["type"])).toStrictEqual(["codex", "claude", "opencode"]);
expect(profiles[2]?.model).toBe("openrouter/qwen/qwen3.8-27b"); expect(profiles[2]?.["model"]).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.token).startsWith("v1:")); assert(String(profiles[2]?.["token"]).startsWith("v1:"));
}); });
+1 -1
View File
@@ -9,8 +9,8 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"strict": true, "strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,