4 Commits

Author SHA1 Message Date
Danny Kim 744de54fb7 Bump eslint-plugin-import-structure to 0.13.0 (#27)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-11-03 09:20:23 +00:00
temeddix 27225f8825 Bump eslint-plugin-import-structure to 0.12.0 (#26) 2026-10-29 05:26:25 +00:00
Danny Kim 3d42665864 Try Claude before Codex in the central profiles (#25)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-20 14:27:19 +00:00
Danny Kim 063eb09750 Replace NodeJS dict aliases with maps and named field reads
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-20 05:01:49 +00:00
33 changed files with 1887 additions and 651 deletions
@@ -21,13 +21,11 @@ Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
- **Published lint plugins** (2026-10-18, after they reached npm): - **Published lint plugins** (2026-10-18, after they reached npm):
`eslint-plugin-import-structure` (`modules` config) and `eslint-plugin-import-structure` (`modules` config) and
`eslint-plugin-explicit-types` (`concrete` and `absence` configs) are `eslint-plugin-explicit-types` (`concrete` and `absence` configs) are
devDependencies, enabled in `vite.config.ts`. explicit-types' `naming` devDependencies, enabled in `vite.config.ts` through their `all` configs.
config is left off: its `upper-snake-string-unions` and `upper-snake-string-unions` was off while the user decided; since
`no-upper-snake-object-keys` rules fire on wire tokens this repo does not 2026-10-19 it is on and profile types, failure reasons, and anchor sides
name (profile types such as `"claude"`, failure reasons posted to Gitea, are `UPPER_SNAKE` ([[enum-values-upper-snake]]). `eslint-plugin-lit-robust`
environment variable names), which cannot change without changing does not apply (no Lit).
behavior. Open question for the user. `eslint-plugin-lit-robust` does not apply
(no Lit).
- **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.
+48
View File
@@ -0,0 +1,48 @@
---
description: Enum-like string values are UPPER_SNAKE everywhere this repo owns the format, JSON included; external standards are converted at the boundary with explicit switch functions
---
Decided 2026-10-19 (all repos). An enum-like string value, a member of a
string-literal union, is `UPPER_SNAKE` wherever this repo owns the format:
TypeScript unions, `profiles.json`, the runtime outcome file, and the
`anchors.json` the agent writes per `prompt.md`. explicit-types
`upper-snake-string-unions` enforces it and stays on.
A value an outside party defines (a CLI flag, an environment variable name,
an HTTP API field, a human-readable message) is converted at the boundary by
an explicit `switch` function, never by renaming our own values to match it:
```ts
export interface ProfileIdentity {
type: "CLAUDE" | "CODEX" | "OPENCODE";
model: string;
}
// The agent name the skills installer knows each profile type by.
function skillsAgent(type: RuntimeProfile["type"]): string {
switch (type) {
case "CLAUDE":
return "claude-code";
case "CODEX":
return "codex";
case "OPENCODE":
return "opencode";
}
}
```
In this repo:
- Profile `type`: `CLAUDE`, `CODEX`, `OPENCODE` (also in `profiles.json`).
`skillsAgent()` (run phase) and `tokenVariable()` (profiles) map them to the
installer's agent name and the credential environment variable;
`defaultModel()` (credentials) picks a model.
- `FailureReason`: `SIGN_IN_FAILED`, `USAGE_EXHAUSTED`, `FAILED`; `toMessage()`
gives the words that logs and posted comments show.
- Anchor `side`: `NEW` or `OLD` (`prompt.md` tells the agent so); the Gitea
review call turns it into `new_position` / `old_position`.
A lookup object keyed by enum values would need `UPPER_SNAKE` keys, which
explicit-types `no-upper-snake-object-keys` bans, so use a `switch`. TypeScript
checks such a switch for exhaustiveness through the declared return type; no
`default:` branch ([[lint-config]]).
+51
View File
@@ -47,5 +47,56 @@ rules: {
overrides: projectOverrides, overrides: projectOverrides,
``` ```
## Rule decisions (2026-10-19, all repos)
- `typescript/promise-function-async` is on with `require-await` and
`typescript/return-await`. A function that returns a promise is `async`
and awaits it: `async f() { const x = await g(); return x; }`. Test
doubles do the same (`const resolved = await Promise.resolve(v); return
resolved;`), and `expect(promise).rejects` takes the promise itself.
`src/processes/child.ts` is exempt in `projectOverrides`: `exitCode` must
return the very promise it marks handled, or a failed spawn would surface
as an unhandled rejection before callers await it.
- `init-declarations` is on: an uninitialized `let` becomes
`let x: T | null = null;` (`unknown` already includes `null`); a `try` that
assigns and a `catch` that exits still narrow it.
- `no-use-before-define` is on with only `{ functions: false }`.
- `no-await-in-loop` is off (sequential awaits are intentional) and
`unicorn/no-array-reduce` is on: sequential steps are plain `for` loops,
never `reduce` chains or recursion.
- `consistent-return`, `typescript/switch-exhaustiveness-check`, and
`default-case` are off: TypeScript's checks cover them, and no `default:`
branch is added to an exhaustive switch.
- `unknown` is allowed; `any` is banned and the `typescript/no-unsafe-*`
rules are on. Untrusted data is `unknown` plus a type guard.
- `unicorn/no-array-for-each` (use `for...of`),
`unicorn/prefer-structured-clone`, and `typescript/prefer-nullish-coalescing`
(defaults) stay on.
- explicit-types `upper-snake-string-unions` is on
([[enum-values-upper-snake]]); `unicorn/custom-error-definition` is on, so a
custom error has a constructor that sets `this.name`.
- `src/post/` and `src/profiles/` (code and tests) are exempt from
`project/prefer-ascii` in `projectOverrides`: posted Gitea text shows emoji
verdict marks and a middle dot.
## Rule decisions (2026-10-20, all repos)
- `unicorn/number-literal-case` is off: Oxfmt lowercases hex digits back.
- `typescript/consistent-return` is off too: it misfires on complete
switches, like `consistent-return`.
- On with defaults, no offs: `prefer-object-spread`, `unicorn/prefer-spread`,
`unicorn/no-useless-spread`, `typescript/no-misused-spread`, `no-plusplus`
(`x += 1`), `unicorn/no-array-method-this-argument`,
`typescript/method-signature-style` ("property"),
`typescript/unified-signatures`.
- `unicorn/import-style` uses its default: `import nodePath from "node:path"`
and `nodePath.join(...)` (`nodePath`, since `path` is a common variable).
- `no-template-curly-in-string` is on; only `tests/workflow.test.ts`, which
checks literal Gitea `${{ }}` expressions, is exempt in `projectOverrides`.
- Rule defaults are preferred over option tweaks unless a tweak is needed.
- `fmt` matches `deno fmt`: `printWidth: 80`, `quoteProps: "preserve"`,
`embeddedLanguageFormatting: "off"`. `max-lines-per-function` is 124 after
the narrower width.
A new category hit is fixed in the code; an off or exception is added only A new category hit is fixed in the code; an off or exception is added only
for a real collision or churn, with its reason. Rules: [[project-lint-plugin]]. for a real collision or churn, with its reason. Rules: [[project-lint-plugin]].
+12 -5
View File
@@ -11,12 +11,19 @@ Oxlint plugin with `meta.name` `"project"`, loaded through `jsPlugins` in
plugin configs: Deno parity rules, extras, per-rule options, and opt-outs, plugin configs: Deno parity rules, extras, per-rule options, and opt-outs,
built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...). built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...).
- **Custom rules**, enabled as `project/<rule>`. Every repo has - **Custom rules**, enabled as `project/<rule>`. Every repo has
`no-disable-comments` and `no-asset-imports`; bot-agents has no others. `no-disable-comments` and `no-asset-imports`. Since 2026-10-19 every repo
also has `prefer-ascii`, `no-nan` (a "no number" value is `null`, never
`NaN`), and the hugoalh `recommended` ports from
eslint-plugin-import-structure's own lint: `no-duplicate-typeofs`,
`no-empty-yield`, `no-unsafe-number`, `no-useless-continue`,
`no-duplicate-interfaces`, `no-duplicate-types`, `no-useless-type`, and
`no-modifier-private`. `no-duplicate-awaits` and `no-duplicate-voids` are
covered by `unicorn/no-unnecessary-await` and
`typescript/no-meaningless-void-operator`, and `no-enum` by explicit-types.
`scripts/` stays flat; don't add `scripts/rules/`. If the plugin ever needs `scripts/` stays flat; don't add `scripts/rules/`. All rules live in the one
more files, they sit flat beside it and share the prefix `scripts/project-lint.ts` (no file splitting), next to the plugin object and
(`project-lint-modules.ts`, ...); `scripts/project-lint.ts` keeps the plugin the rule list.
object and the rule list.
```ts ```ts
import type { DummyRuleMap } from "vite-plus/lint"; import type { DummyRuleMap } from "vite-plus/lint";
+16 -8
View File
@@ -9,8 +9,8 @@
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.6.1", "eslint-plugin-explicit-types": "^0.7.2",
"eslint-plugin-import-structure": "^0.10.2", "eslint-plugin-import-structure": "^0.13.0",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
} }
@@ -1890,9 +1890,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint-plugin-explicit-types": { "node_modules/eslint-plugin-explicit-types": {
"version": "0.6.1", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.6.1.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.7.2.tgz",
"integrity": "sha512-8JU2Ceoi1aLbhYo4LHwb9Eone/pJuMnzVP1ZlGJn/ETxVzJQFziRnJwOF2dRmInn9dGqvwq05dc3o5tVyTGP2Q==", "integrity": "sha512-ffHVVuyBH2esZwwRItcz2sDnL5xvSRDAoLjSqmQbevT6ETcjtfThtLoWoc+cv5pvtxa6YiXEK1P2D+x/yUBc1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1903,9 +1903,9 @@
} }
}, },
"node_modules/eslint-plugin-import-structure": { "node_modules/eslint-plugin-import-structure": {
"version": "0.10.2", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import-structure/-/eslint-plugin-import-structure-0.10.2.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-import-structure/-/eslint-plugin-import-structure-0.13.0.tgz",
"integrity": "sha512-jvx4sccxEdURGXrDtBJZtCHiKqELkHRVlLrMU5bVZIjglN0U464Joopcfn6JvmNB2zyIh2FkolaJQJaKkvy6Xw==", "integrity": "sha512-bUhXY2oy8qRYazTssw/xDTgNUy+BI39Ptqqm4Mb8gL52SdUA5CuvlPCKxOD//AgQnYLvLYsy2JxXJFb1k/0RAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1913,6 +1913,14 @@
}, },
"engines": { "engines": {
"node": ">=22.18" "node": ">=22.18"
},
"peerDependencies": {
"eslint": ">=9"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
} }
}, },
"node_modules/estree-walker": { "node_modules/estree-walker": {
+2 -2
View File
@@ -18,8 +18,8 @@
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.6.1", "eslint-plugin-explicit-types": "^0.7.2",
"eslint-plugin-import-structure": "^0.10.2", "eslint-plugin-import-structure": "^0.13.0",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
} }
+6 -6
View File
@@ -2,16 +2,16 @@
"reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY", "reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY",
"profiles": [ "profiles": [
{ {
"type": "codex", "type": "CLAUDE",
"model": "gpt-5.6-terra"
},
{
"type": "claude",
"model": "claude-sonnet-5", "model": "claude-sonnet-5",
"token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg" "token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg"
}, },
{ {
"type": "opencode", "type": "CODEX",
"model": "gpt-5.6-terra"
},
{
"type": "OPENCODE",
"model": "openrouter/qwen/qwen3.8-27b", "model": "openrouter/qwen/qwen3.8-27b",
"token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0" "token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0"
} }
+3 -3
View File
@@ -47,9 +47,9 @@ achieved.
Every finding that belongs to one line of the diff goes on that line instead of Every finding that belongs to one line of the diff goes on that line instead of
into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry
`{"path": "<path from the repository root>", "line": <number>, "side": "new" | `{"path": "<path from the repository root>", "line": <number>, "side": "NEW" |
"old", "body": "<the finding>"}`. "OLD", "body": "<the finding>"}`.
`side` is `new` for a line in the head file and `old` for one only in the base `side` is `NEW` for a line in the head file and `OLD` for one only in the base
file; `line` is that file's own line number, and it must be a line the diff file; `line` is that file's own line number, and it must be a line the diff
touches, or Gitea refuses the anchor. Write the file only when there is touches, or Gitea refuses the anchor. Write the file only when there is
something to anchor, and keep each body to the what, the why, and the how, with something to anchor, and keep each body to the what, the why, and the how, with
+666 -38
View File
@@ -1,20 +1,29 @@
/** /**
* `project` — this repository's changes to the Oxlint categories in * `project` - this repository's changes to the Oxlint categories in
* `vite.config.ts`. Its rules of its own are `no-disable-comments` and * `vite.config.ts`. Its rules of its own are `no-disable-comments`,
* `no-asset-imports`. * `no-asset-imports`, `prefer-ascii`, and ports of the hugoalh `recommended`
* rules Oxlint lacks, as in eslint-plugin-import-structure's own lint.
* *
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry. * Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
* *
* @module * @module
*/ */
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core"; import type {
LanguageOptions,
Position,
RuleDefinition,
RuleTextEdit,
RuleVisitor,
TextSourceCode,
} from "@eslint/core";
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types"; import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types";
import type { DummyRuleMap, OxlintOverride } from "vite-plus/lint"; import type { DummyRuleMap, OxlintOverride } from "vite-plus/lint";
/** The source code both hosts hand a rule, with the comment list. */ /** The source code both hosts hand a rule, with the comment list and index lookup. */
interface ProjectSourceCode extends TextSourceCode { interface ProjectSourceCode extends TextSourceCode {
getAllComments: () => TSESTree.Comment[]; getAllComments: () => TSESTree.Comment[];
getLocFromIndex: (index: number) => Position;
} }
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */ /** ESLint's default rule types, reporting TypeScript-ESTree nodes. */
@@ -38,7 +47,7 @@ const DIRECTIVE_PATTERN: RegExp = /^\s*(?:eslint|oxlint)-(?:disable|enable)/u;
const RULE_NAME: string = "no-disable-comments"; const RULE_NAME: string = "no-disable-comments";
/** /**
* `no-disable-comments` — rejects every `eslint-disable*`, `eslint-enable*`, * `no-disable-comments` - rejects every `eslint-disable*`, `eslint-enable*`,
* `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has * `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has
* no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them * no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them
* itself: when a rule fires, fix the source. * itself: when a rule fires, fix the source.
@@ -53,13 +62,18 @@ const noDisableCommentsRule: ProjectRule = {
type: "problem", type: "problem",
docs: { description: "Disallow lint disable and enable comments" }, docs: { description: "Disallow lint disable and enable comments" },
schema: [], schema: [],
messages: { directive: "Lint disable comments are not allowed; fix the source." }, messages: {
directive: "Lint disable comments are not allowed; fix the source.",
},
}, },
create(context) { create(context) {
return { return {
Program(_node: TSESTree.Program): void { Program(_node: TSESTree.Program): void {
for (const comment of context.sourceCode.getAllComments()) { for (const comment of context.sourceCode.getAllComments()) {
if (DIRECTIVE_PATTERN.test(comment.value) && !comment.value.includes(RULE_NAME)) { if (
DIRECTIVE_PATTERN.test(comment.value) &&
!comment.value.includes(RULE_NAME)
) {
context.report({ loc: comment.loc, messageId: "directive" }); context.report({ loc: comment.loc, messageId: "directive" });
} }
} }
@@ -68,6 +82,565 @@ const noDisableCommentsRule: ProjectRule = {
}, },
}; };
/** The highest code point `prefer-ascii` lets through. */
const MAX_ASCII_CODE_POINT: number = 127;
/**
* `prefer-ascii` - every character of the source is ASCII. A file is exempted
* only through an entry in `projectOverrides`.
*/
const preferAsciiRule: ProjectRule = {
meta: {
type: "suggestion",
docs: { description: "Require the source to be ASCII" },
schema: [],
messages: {
nonAscii:
"Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.",
},
},
create(context) {
return {
Program(_node: TSESTree.Program): void {
const { sourceCode } = context;
let index: number = 0;
for (const character of sourceCode.text) {
const codePoint: number = character.codePointAt(0) ?? 0;
if (codePoint > MAX_ASCII_CODE_POINT) {
context.report({
loc: {
start: sourceCode.getLocFromIndex(index),
end: sourceCode.getLocFromIndex(index + character.length),
},
messageId: "nonAscii",
data: {
character,
codePoint: codePoint
.toString(16)
.toUpperCase()
.padStart(4, "0"),
},
});
}
index += character.length;
}
},
};
},
};
/** `no-duplicate-typeofs` - `typeof typeof x` is always `"string"`. */
const noDuplicateTypeofsRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow `typeof` applied twice in a row" },
schema: [],
messages: {
doubled:
'Multiple `typeof`s always return `"string"`, possibly not intended.',
},
},
create(context) {
return {
UnaryExpression(node: TSESTree.UnaryExpression): void {
if (
node.operator === "typeof" &&
node.argument.type === AST_NODE_TYPES.UnaryExpression &&
node.argument.operator === "typeof"
) {
context.report({ node, messageId: "doubled" });
}
},
};
},
};
/** `no-empty-yield` - a `yield` with no expression, likely missing one. */
const noEmptyYieldRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow `yield` without an expression" },
schema: [],
messages: {
empty: "Empty `yield` is not allowed, possibly missing the expression.",
},
},
create(context) {
return {
YieldExpression(node: TSESTree.YieldExpression): void {
if (node.argument === null) {
context.report({ node, messageId: "empty" });
}
},
};
},
};
/** The globals `no-nan` sees through, as in `globalThis.Number.NaN`. */
const GLOBAL_NAMES: ReadonlySet<string> = new Set<string>([
"globalThis",
"self",
"window",
]);
/** The name a member expression's property spells, computed or not. */
function propertyName(node: TSESTree.MemberExpression): string | null {
if (node.property.type === AST_NODE_TYPES.Identifier && !node.computed) {
return node.property.name;
}
if (
node.property.type === AST_NODE_TYPES.Literal &&
typeof node.property.value === "string"
) {
return node.property.value;
}
return null;
}
/** Whether `node` is a global object, such as `globalThis` or `globalThis.self`. */
function isGlobalChain(node: TSESTree.Expression): boolean {
if (node.type === AST_NODE_TYPES.Identifier) {
return GLOBAL_NAMES.has(node.name);
}
return (
node.type === AST_NODE_TYPES.MemberExpression &&
GLOBAL_NAMES.has(propertyName(node) ?? "") &&
isGlobalChain(node.object)
);
}
/**
* Whether a member expression reads the global `NaN`: `Number.NaN`,
* `globalThis.NaN`, or `globalThis.Number.NaN`.
*/
function isNanMember(node: TSESTree.MemberExpression): boolean {
if (propertyName(node) !== "NaN") {
return false;
}
const { object } = node;
if (object.type === AST_NODE_TYPES.Identifier && object.name === "Number") {
return true;
}
return (
isGlobalChain(object) ||
(object.type === AST_NODE_TYPES.MemberExpression &&
propertyName(object) === "Number" &&
isGlobalChain(object.object))
);
}
/** `no-nan` - a `NaN` value is usually an error; use `null` for "no number". */
const noNanRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow `NaN` as a value" },
schema: [],
messages: {
nan: "Using `NaN` as a value is usually an error and not intended.",
},
},
create(context) {
return {
Identifier(node: TSESTree.Identifier): void {
if (
node.name === "NaN" &&
node.parent.type !== AST_NODE_TYPES.MemberExpression
) {
context.report({ node, messageId: "nan" });
}
},
MemberExpression(node: TSESTree.MemberExpression): void {
if (isNanMember(node)) {
context.report({ node, messageId: "nan" });
}
},
};
},
};
/** `no-unsafe-number` - a number literal outside the safe integer range. */
const noUnsafeNumberRule: ProjectRule = {
meta: {
type: "problem",
docs: {
description: "Disallow number literals outside the safe integer range",
},
schema: [],
messages: {
unsafe:
"Number literals with values equal to 2^53 or greater are too large to be represented accurately.",
},
},
create(context) {
return {
Literal(node: TSESTree.Literal): void {
if (
typeof node.value === "number" &&
(node.value < Number.MIN_SAFE_INTEGER ||
node.value > Number.MAX_SAFE_INTEGER)
) {
context.report({ node, messageId: "unsafe" });
}
},
};
},
};
/** A loop statement, whose last `continue` `no-useless-continue` checks. */
type LoopStatement =
| TSESTree.DoWhileStatement
| TSESTree.ForInStatement
| TSESTree.ForOfStatement
| TSESTree.ForStatement
| TSESTree.WhileStatement;
/** A loop's trailing unlabeled `continue`, and whether removing it is a safe fix. */
interface TrailingContinue {
statement: TSESTree.ContinueStatement;
removable: boolean;
}
/**
* The unlabeled `continue` a loop ends with, and whether removing it is a
* safe fix: it is in a block, rather than the loop's whole body.
*/
function trailingContinue(node: LoopStatement): TrailingContinue | null {
if (node.body.type === AST_NODE_TYPES.BlockStatement) {
const last = node.body.body.at(-1);
return last?.type === AST_NODE_TYPES.ContinueStatement &&
last.label === null
? { statement: last, removable: true }
: null;
}
if (
node.body.type === AST_NODE_TYPES.ContinueStatement &&
node.body.label === null
) {
return { statement: node.body, removable: false };
}
return null;
}
/**
* `no-useless-continue` - an unlabeled `continue` that ends a loop body. A
* trailing `continue;` in a block is removed by the fix; a loop whose whole
* body is `continue;` is only reported.
*/
const noUselessContinueRule: ProjectRule = {
meta: {
type: "suggestion",
docs: {
description: "Disallow an unlabeled `continue` at the end of a loop",
},
fixable: "code",
schema: [],
messages: {
useless:
"The statement `continue` at the end of the loop and without a label is useless.",
},
},
create(context) {
function check(node: LoopStatement): void {
const found: TrailingContinue | null = trailingContinue(node);
if (found === null) {
return;
}
if (found.removable) {
context.report({
node: found.statement,
messageId: "useless",
fix: (fixer): RuleTextEdit => fixer.remove(found.statement),
});
} else {
context.report({ node: found.statement, messageId: "useless" });
}
}
return {
DoWhileStatement: check,
ForInStatement: check,
ForOfStatement: check,
ForStatement: check,
WhileStatement: check,
};
},
};
/** Node keys that say where a node is, not what it is, in ESLint and Oxlint. */
const POSITION_KEYS: ReadonlySet<string> = new Set<string>([
"parent",
"range",
"loc",
"start",
"end",
]);
/** Node types whose member lists mean the same in any order. */
const UNORDERED_TYPES: ReadonlySet<string> = new Set<string>([
"TSInterfaceBody",
"TSTypeLiteral",
"TSUnionType",
"TSIntersectionType",
]);
/** Type annotations too simple for an alias of their own (`no-useless-type`). */
const SIMPLE_TYPES: ReadonlySet<string> = new Set<string>([
"TSAnyKeyword",
"TSBigIntKeyword",
"TSBooleanKeyword",
"TSIntrinsicKeyword",
"TSNeverKeyword",
"TSNullKeyword",
"TSNumberKeyword",
"TSObjectKeyword",
"TSStringKeyword",
"TSSymbolKeyword",
"TSUndefinedKeyword",
"TSUnknownKeyword",
"TSVoidKeyword",
]);
/**
* A node's shape as text, without positions, so two declarations compare
* alike wherever they sit. Members of a type literal, interface body,
* union, or intersection are sorted, since their order means nothing.
*/
function serialize(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item: unknown) => serialize(item)).join(",")}]`;
}
if (typeof value !== "object" || value === null) {
return JSON.stringify(value) ?? "undefined";
}
const unordered: boolean =
"type" in value && UNORDERED_TYPES.has(String(value.type));
const parts: string[] = Object.entries(value)
.filter(([key]: [string, unknown]) => !POSITION_KEYS.has(key))
.map(([key, child]: [string, unknown]) => {
const text: string =
unordered && Array.isArray(child)
? `[${child
.map((item: unknown) => serialize(item))
.toSorted()
.join(",")}]`
: serialize(child);
return `${key}:${text}`;
});
return `{${parts.join(",")}}`;
}
/** The top-level declarations in a block, exported or not, that `matches`. */
function declarationsOf<T extends TSESTree.Node>(
statements: TSESTree.Node[],
matches: (node: TSESTree.Node) => node is T,
): T[] {
const found: T[] = [];
for (const statement of statements) {
const declaration: TSESTree.Node | null =
statement.type === AST_NODE_TYPES.ExportNamedDeclaration
? statement.declaration
: statement;
if (declaration !== null && matches(declaration)) {
found.push(declaration);
}
}
return found;
}
/** Whether a node is an interface declaration. */
function isInterface(
node: TSESTree.Node,
): node is TSESTree.TSInterfaceDeclaration {
return node.type === AST_NODE_TYPES.TSInterfaceDeclaration;
}
/** Whether a node is a type alias declaration. */
function isTypeAlias(
node: TSESTree.Node,
): node is TSESTree.TSTypeAliasDeclaration {
return node.type === AST_NODE_TYPES.TSTypeAliasDeclaration;
}
/** Groups nodes by a key and returns the groups with more than one node. */
function duplicateGroups<T>(nodes: T[], keyOf: (node: T) => string): T[][] {
const groups: Map<string, T[]> = Map.groupBy(nodes, keyOf);
return [...groups.values()].filter((group: T[]) => group.length > 1);
}
/** What an interface declares, its `extends` list included. */
function interfaceShape(node: TSESTree.TSInterfaceDeclaration): string {
return `${serialize(node.extends)}&${serialize(node.body)}`;
}
/** An interface's name. */
function interfaceName(node: TSESTree.TSInterfaceDeclaration): string {
return node.id.name;
}
/** What a type alias stands for. */
function typeAliasShape(node: TSESTree.TSTypeAliasDeclaration): string {
return serialize(node.typeAnnotation);
}
/**
* `no-duplicate-interfaces` - two interfaces in one block with the same
* name, which merge silently, or with the same shape, which could be one.
*/
const noDuplicateInterfacesRule: ProjectRule = {
meta: {
type: "suggestion",
docs: {
description:
"Disallow interfaces with the same name or shape in one block",
},
schema: [],
messages: {
sameShape:
"Found multiple interfaces with the same shape, possibly mergeable.",
sameName:
"Found multiple interfaces named '{{name}}', possibly not intended.",
},
},
create(context) {
function check(statements: TSESTree.Node[]): void {
const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf(
statements,
isInterface,
);
for (const node of duplicateGroups(nodes, interfaceShape).flat()) {
context.report({ node, messageId: "sameShape" });
}
for (const node of duplicateGroups(nodes, interfaceName).flat()) {
context.report({
node,
messageId: "sameName",
data: { name: node.id.name },
});
}
}
return {
BlockStatement(node: TSESTree.BlockStatement): void {
check(node.body);
},
Program(node: TSESTree.Program): void {
check(node.body);
},
};
},
};
/** `no-duplicate-types` - two type aliases in one block with the same type. */
const noDuplicateTypesRule: ProjectRule = {
meta: {
type: "suggestion",
docs: {
description: "Disallow type aliases with the same type in one block",
},
schema: [],
messages: {
sameShape:
"Found multiple types with the same shape, possibly mergeable.",
},
},
create(context) {
function check(statements: TSESTree.Node[]): void {
const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf(
statements,
isTypeAlias,
);
for (const node of duplicateGroups(nodes, typeAliasShape).flat()) {
context.report({ node, messageId: "sameShape" });
}
}
return {
BlockStatement(node: TSESTree.BlockStatement): void {
check(node.body);
},
Program(node: TSESTree.Program): void {
check(node.body);
},
};
},
};
/** A class member that can carry the `private` keyword. */
type AccessibilityCarrier =
| TSESTree.AccessorProperty
| TSESTree.MethodDefinition
| TSESTree.PropertyDefinition
| TSESTree.TSAbstractAccessorProperty
| TSESTree.TSAbstractMethodDefinition
| TSESTree.TSAbstractPropertyDefinition
| TSESTree.TSParameterProperty;
/**
* `no-modifier-private` - class members are made private with `#`, never the
* `private` keyword, which only TypeScript enforces. A `private constructor`
* has no `#` form and stays allowed.
*/
const noModifierPrivateRule: ProjectRule = {
meta: {
type: "suggestion",
docs: { description: "Disallow the private modifier" },
schema: [],
messages: {
private: "Use a # private name instead of the private modifier.",
},
},
create(context) {
function check(node: AccessibilityCarrier): void {
const isConstructor: boolean =
node.type === AST_NODE_TYPES.MethodDefinition &&
node.kind === "constructor";
if (node.accessibility === "private" && !isConstructor) {
context.report({ node, messageId: "private" });
}
}
return {
AccessorProperty: check,
MethodDefinition: check,
PropertyDefinition: check,
TSAbstractAccessorProperty: check,
TSAbstractMethodDefinition: check,
TSAbstractPropertyDefinition: check,
TSParameterProperty: check,
};
},
};
/** Whether a type is too simple to need an alias: a keyword, literal, or bare name. */
function isSimpleType(node: TSESTree.TypeNode): boolean {
if (SIMPLE_TYPES.has(node.type)) {
return true;
}
if (node.type === AST_NODE_TYPES.TSLiteralType) {
return node.literal.type === AST_NODE_TYPES.Literal;
}
return (
node.type === AST_NODE_TYPES.TSTypeReference &&
node.typeName.type === AST_NODE_TYPES.Identifier &&
node.typeArguments === undefined
);
}
/** `no-useless-type` - a type alias for a keyword, literal, or bare type name. */
const noUselessTypeRule: ProjectRule = {
meta: {
type: "suggestion",
docs: { description: "Disallow type aliases too simple to need a name" },
schema: [],
messages: { simple: "The type is too simple to need an alias." },
},
create(context) {
return {
TSTypeAliasDeclaration(node: TSESTree.TSTypeAliasDeclaration): void {
if (isSimpleType(node.typeAnnotation)) {
context.report({ node, messageId: "simple" });
}
},
};
},
};
/** A module specifier read as a path: relative, absolute, or a `#` alias. */ /** A module specifier read as a path: relative, absolute, or a `#` alias. */
const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u; const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
@@ -76,7 +649,12 @@ const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
* `.mts`, or `.mjs` package subpaths. `.cts`, `.cjs`, `.tsx`, and `.jsx` are * `.mts`, or `.mjs` package subpaths. `.cts`, `.cjs`, `.tsx`, and `.jsx` are
* not among them. * not among them.
*/ */
const SCRIPT_EXTENSIONS: ReadonlySet<string> = new Set([".ts", ".js", ".mts", ".mjs"]); const SCRIPT_EXTENSIONS: ReadonlySet<string> = new Set([
".ts",
".js",
".mts",
".mjs",
]);
/** /**
* Whether `source` names an asset rather than a script module: a query string * Whether `source` names an asset rather than a script module: a query string
@@ -96,7 +674,11 @@ function isAssetSource(source: string): boolean {
} }
} }
const lastSegment: string = segments.at(-1) ?? ""; const lastSegment: string = segments.at(-1) ?? "";
if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) { if (
lastSegment === "." ||
lastSegment === ".." ||
!lastSegment.includes(".")
) {
return false; return false;
} }
const extension: string = lastSegment.slice(lastSegment.lastIndexOf(".")); const extension: string = lastSegment.slice(lastSegment.lastIndexOf("."));
@@ -104,7 +686,7 @@ function isAssetSource(source: string): boolean {
} }
/** /**
* `no-asset-imports` — rejects importing anything but a script module: a * `no-asset-imports` - rejects importing anything but a script module: a
* source path, static or a dynamic `import()` with a string literal, ending in * source path, static or a dynamic `import()` with a string literal, ending in
* an extension other than `.ts`, `.js`, `.mts`, or `.mjs` (`.css`, `.json`, * an extension other than `.ts`, `.js`, `.mts`, or `.mjs` (`.css`, `.json`,
* `.svg`, `.jsx`, `.tsx`, `.cjs`) or carrying a query string (`?raw`, `?url`), * `.svg`, `.jsx`, `.tsx`, `.cjs`) or carrying a query string (`?raw`, `?url`),
@@ -117,7 +699,10 @@ function isAssetSource(source: string): boolean {
const noAssetImportsRule: ProjectRule = { const noAssetImportsRule: ProjectRule = {
meta: { meta: {
type: "problem", type: "problem",
docs: { description: "Disallow importing assets instead of .ts, .js, .mts, or .mjs modules" }, docs: {
description:
"Disallow importing assets instead of .ts, .js, .mts, or .mjs modules",
},
schema: [], schema: [],
messages: { messages: {
extension: "Only .ts, .js, .mts and .mjs modules may be imported.", extension: "Only .ts, .js, .mts and .mjs modules may be imported.",
@@ -149,7 +734,10 @@ const noAssetImportsRule: ProjectRule = {
ExportNamedDeclaration: checkDeclaration, ExportNamedDeclaration: checkDeclaration,
ExportAllDeclaration: checkDeclaration, ExportAllDeclaration: checkDeclaration,
ImportExpression(node: TSESTree.ImportExpression): void { ImportExpression(node: TSESTree.ImportExpression): void {
if (node.source.type === AST_NODE_TYPES.Literal && typeof node.source.value === "string") { if (
node.source.type === AST_NODE_TYPES.Literal &&
typeof node.source.value === "string"
) {
checkSource(node.source); checkSource(node.source);
} }
if (node.options !== null) { if (node.options !== null) {
@@ -172,7 +760,12 @@ const rules: DummyRuleMap = {
"id-match": [ "id-match": [
"error", "error",
"^_*[^_]*_*$|^[^a-z]*$", "^_*[^_]*_*$|^[^a-z]*$",
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true }, {
properties: false,
classFields: false,
onlyDeclarations: false,
ignoreDestructuring: true,
},
], ],
// On Node, these come from an explicit `node:` import instead of the global scope. // On Node, these come from an explicit `node:` import instead of the global scope.
"no-restricted-globals": [ "no-restricted-globals": [
@@ -190,12 +783,14 @@ const rules: DummyRuleMap = {
"one-var": ["error", "never"], "one-var": ["error", "never"],
"no-use-before-define": ["error", { functions: false }], "no-use-before-define": ["error", { functions: false }],
"func-style": ["error", "declaration", { allowArrowFunctions: true }], "func-style": ["error", "declaration", { allowArrowFunctions: true }],
"unicorn/import-style": ["error", { styles: { "node:path": { named: true } } }],
// Each limit is the smallest value the current code passes. // Each limit is the smallest value the current code passes.
complexity: ["error", 26], complexity: ["error", 26],
"max-depth": ["error", 6], "max-depth": ["error", 6],
"max-lines": ["error", { max: 2048, skipBlankLines: false, skipComments: false }], "max-lines": [
"max-lines-per-function": ["error", 96], "error",
{ max: 2048, skipBlankLines: false, skipComments: false },
],
"max-lines-per-function": ["error", 124],
"max-params": ["error", 4], "max-params": ["error", 4],
"max-statements": ["error", 32], "max-statements": ["error", 32],
"import/max-dependencies": ["error", { max: 13 }], "import/max-dependencies": ["error", { max: 13 }],
@@ -203,6 +798,17 @@ const rules: DummyRuleMap = {
// No lint suppression and only `.ts` imports, as in the plugin template. // No lint suppression and only `.ts` imports, as in the plugin template.
"project/no-disable-comments": "error", "project/no-disable-comments": "error",
"project/no-asset-imports": "error", "project/no-asset-imports": "error",
// Ports of the hugoalh `recommended` rules Oxlint lacks, and ASCII-only source.
"project/prefer-ascii": "error",
"project/no-duplicate-typeofs": "error",
"project/no-empty-yield": "error",
"project/no-nan": "error",
"project/no-unsafe-number": "error",
"project/no-useless-continue": "error",
"project/no-duplicate-interfaces": "error",
"project/no-duplicate-types": "error",
"project/no-modifier-private": "error",
"project/no-useless-type": "error",
// Off, collides with a repo or plugin rule, Oxfmt, or another category rule: // Off, collides with a repo or plugin rule, Oxfmt, or another category rule:
// Collides with `#private`: members are public unless marked so. // Collides with `#private`: members are public unless marked so.
"typescript/explicit-member-accessibility": "off", "typescript/explicit-member-accessibility": "off",
@@ -242,15 +848,8 @@ const rules: DummyRuleMap = {
"import/no-named-export": "off", "import/no-named-export": "off",
// Collides with `typescript/consistent-type-imports`. // Collides with `typescript/consistent-type-imports`.
"import/consistent-type-specifier-style": "off", "import/consistent-type-specifier-style": "off",
// Collides with `require-await`, which fails an `async` wrapper with no `await`. // Collides with Oxfmt, which lowercases hex digits back.
"typescript/promise-function-async": "off", "unicorn/number-literal-case": "off",
// Collides with explicit-types `no-undefined-type`: a `let` that `try`
// assigns would need `T | undefined`.
"init-declarations": "off",
// Collides with `no-await-in-loop`: sequential steps chain through `reduce`.
"unicorn/no-array-reduce": "off",
// Collides with literal Gitea Actions `${{ }}` expressions in workflow strings.
"no-template-curly-in-string": "off",
// Node code, everywhere: this action runs on Node. // Node code, everywhere: this action runs on Node.
// Node built-ins are the action's runtime. // Node built-ins are the action's runtime.
"import/no-nodejs-modules": "off", "import/no-nodejs-modules": "off",
@@ -258,12 +857,16 @@ const rules: DummyRuleMap = {
"unicorn/no-process-exit": "off", "unicorn/no-process-exit": "off",
// The action reports to the job log through the console. // The action reports to the job log through the console.
"no-console": "off", "no-console": "off",
// PENDING USER: union members are values in profiles.json, the Gitea API, // Sequential awaits are intentional; workarounds cost more than they save.
// and posted comments; renaming them changes behavior. "no-await-in-loop": "off",
"explicit-types/upper-snake-string-unions": "off", // Not type-aware; TypeScript's `noImplicitReturns` covers it.
// PENDING USER: a fitting `name` needs a constructor and changes logged "consistent-return": "off",
// error names. // Misfires on complete switches, like `consistent-return`; TypeScript covers it.
"unicorn/custom-error-definition": "off", "typescript/consistent-return": "off",
// TypeScript's type checking covers exhaustive switches.
"typescript/switch-exhaustiveness-check": "off",
// TypeScript checks completeness; a forced `default:` hides missing cases.
"default-case": "off",
// Off, too much churn: // Off, too much churn:
// Literal numbers read better than a constant per use. // Literal numbers read better than a constant per use.
"no-magic-numbers": "off", "no-magic-numbers": "off",
@@ -295,12 +898,6 @@ const rules: DummyRuleMap = {
const nodeRules: DummyRuleMap = { const nodeRules: DummyRuleMap = {
// Vite and the Oxlint plugin host read a default export. // Vite and the Oxlint plugin host read a default export.
"import/no-default-export": "off", "import/no-default-export": "off",
// Node code: tooling imports Node's built-ins.
"import/no-nodejs-modules": "off",
// Node code: a CLI exits with a status.
"unicorn/no-process-exit": "off",
// Node code: a CLI reports through the console.
"no-console": "off",
}; };
/** /**
@@ -313,6 +910,27 @@ export const projectOverrides: OxlintOverride[] = [
files: ["vite.config.ts", "scripts/**"], files: ["vite.config.ts", "scripts/**"],
rules: nodeRules, rules: nodeRules,
}, },
// exitCode must return the promise it marks handled; an async wrapper's
// promise would reject unhandled before callers await it.
{
files: ["src/processes/child.ts"],
rules: { "typescript/promise-function-async": "off" },
},
// Posted Gitea text shows emoji verdict marks and a middle dot.
{
files: [
"src/post/post.ts",
"src/post/post.test.ts",
"src/profiles/profiles.ts",
"src/profiles/profiles.test.ts",
],
rules: { "project/prefer-ascii": "off" },
},
// The workflow test checks literal Gitea Actions `${{ }}` expressions.
{
files: ["tests/workflow.test.ts"],
rules: { "no-template-curly-in-string": "off" },
},
]; ];
/** /**
@@ -324,6 +942,16 @@ const plugin = {
rules: { rules: {
"no-disable-comments": noDisableCommentsRule, "no-disable-comments": noDisableCommentsRule,
"no-asset-imports": noAssetImportsRule, "no-asset-imports": noAssetImportsRule,
"prefer-ascii": preferAsciiRule,
"no-duplicate-typeofs": noDuplicateTypeofsRule,
"no-empty-yield": noEmptyYieldRule,
"no-nan": noNanRule,
"no-unsafe-number": noUnsafeNumberRule,
"no-useless-continue": noUselessContinueRule,
"no-duplicate-interfaces": noDuplicateInterfacesRule,
"no-duplicate-types": noDuplicateTypesRule,
"no-modifier-private": noModifierPrivateRule,
"no-useless-type": noUselessTypeRule,
}, },
configs: { project: { rules } }, configs: { project: { rules } },
}; };
+93 -61
View File
@@ -1,4 +1,11 @@
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import {
mkdir,
readFile,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing"; import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
@@ -16,14 +23,19 @@ test("retries once after an invalid refresh token", async () => {
let attempts = 0; let attempts = 0;
let relogins = 0; let relogins = 0;
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => async () => {
Promise.resolve({ const resolved = await Promise.resolve({
code: 1, code: 1,
error: (attempts += 1) === 1 ? "ERROR: invalid_refresh_token" : "still failed", error:
}), (attempts += 1) === 1
() => { ? "ERROR: invalid_refresh_token"
: "still failed",
});
return resolved;
},
async () => {
relogins += 1; relogins += 1;
return Promise.resolve(); await Promise.resolve();
}, },
); );
@@ -38,16 +50,17 @@ test("does not retry an unrelated failure", async () => {
let attempts = 0; let attempts = 0;
let relogins = 0; let relogins = 0;
await retryInvalidToken( await retryInvalidToken(
() => { async () => {
attempts += 1; attempts += 1;
return Promise.resolve({ const resolved = await Promise.resolve({
code: 1, code: 1,
error: "rate limited", error: "rate limited",
}); });
return resolved;
}, },
() => { async () => {
relogins += 1; relogins += 1;
return Promise.resolve(); await Promise.resolve();
}, },
); );
@@ -85,14 +98,16 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
let relogins = 0; let relogins = 0;
let attempts = 0; let attempts = 0;
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => async () => {
Promise.resolve({ const resolved = await Promise.resolve({
code: (attempts += 1) > 1 ? 0 : 1, code: (attempts += 1) > 1 ? 0 : 1,
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "", error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}), });
() => { return resolved;
},
async () => {
relogins += 1; relogins += 1;
return Promise.resolve(); await Promise.resolve();
}, },
); );
@@ -106,15 +121,19 @@ test("recognizes a rejected Claude OAuth token", () => {
expect( expect(
isClaudeAuthFailure({ isClaudeAuthFailure({
apiErrorStatus: 401, apiErrorStatus: 401,
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.", result:
"Failed to authenticate. API Error: 401 OAuth access token is invalid.",
}), }),
).toBe(true); ).toBe(true);
expect( expect(
isClaudeAuthFailure({ isClaudeAuthFailure({
result: "OAuth token has expired. Please obtain a new token or refresh your existing token.", result:
"OAuth token has expired. Please obtain a new token or refresh your existing token.",
}), }),
).toBe(true); ).toBe(true);
expect(isClaudeAuthFailure({ apiErrorStatus: 429, result: "API Error: 429" })).toBe(false); expect(
isClaudeAuthFailure({ apiErrorStatus: 429, result: "API Error: 429" }),
).toBe(false);
}); });
test("selects API-key login only for a configured Codex token", () => { test("selects API-key login only for a configured Codex token", () => {
@@ -133,49 +152,62 @@ const AUTH_TOKENS = {
}; };
const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS }); const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS });
test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login", async () => { test.skipIf(IS_WINDOWS)(
const root = await makeTempDir(); "stages and persists only a regular private Codex login",
try { async () => {
const canonical = `${root}/persistent/auth.json`; const root = await makeTempDir();
await mkdir(`${root}/persistent`); try {
await writeFile(canonical, AUTH, { mode: 0o600 }); const canonical = `${root}/persistent/auth.json`;
const home = `${root}/profile`; await mkdir(`${root}/persistent`);
await mkdir(home); await writeFile(canonical, AUTH, { mode: 0o600 });
const home = `${root}/profile`;
await mkdir(home);
const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid()); const staged = await stageCodexAuth(
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH); canonical,
const stagedStat = await stat(staged); home,
expect(stagedStat.mode & 0o777).toBe(0o600); currentUid(),
currentGid(),
);
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH);
const stagedStat = await stat(staged);
expect(stagedStat.mode & 0o777).toBe(0o600);
const refreshed = JSON.stringify({ const refreshed = JSON.stringify({
auth_mode: "chatgpt", auth_mode: "chatgpt",
tokens: { ...AUTH_TOKENS, access_token: "refreshed" }, tokens: { ...AUTH_TOKENS, access_token: "refreshed" },
}); });
await writeFile(staged, refreshed); await writeFile(staged, refreshed);
await persistCodexAuth(staged, canonical); await persistCodexAuth(staged, canonical);
expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed); expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed);
const canonicalStat = await stat(canonical); const canonicalStat = await stat(canonical);
expect(canonicalStat.mode & 0o777).toBe(0o600); expect(canonicalStat.mode & 0o777).toBe(0o600);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
} }
}); },
);
test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () => { test.skipIf(IS_WINDOWS)(
const root = await makeTempDir(); "rejects a symlink or malformed Codex login",
try { async () => {
const canonical = `${root}/auth.json`; const root = await makeTempDir();
const malformed = `${root}/malformed.json`; try {
const linked = `${root}/linked.json`; const canonical = `${root}/auth.json`;
await writeFile(canonical, AUTH, { mode: 0o600 }); const malformed = `${root}/malformed.json`;
await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 }); const linked = `${root}/linked.json`;
await symlink(canonical, linked); await writeFile(canonical, AUTH, { mode: 0o600 });
await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 });
await symlink(canonical, linked);
await expect(() => persistCodexAuth(linked, canonical)).rejects.toThrow("regular file"); await expect(persistCodexAuth(linked, canonical)).rejects.toThrow(
await expect(() => persistCodexAuth(malformed, canonical)).rejects.toThrow( "regular file",
"invalid Codex login", );
); await expect(persistCodexAuth(malformed, canonical)).rejects.toThrow(
} finally { "invalid Codex login",
await rm(root, { recursive: true }); );
} } finally {
}); await rm(root, { recursive: true });
}
},
);
+22 -13
View File
@@ -10,7 +10,7 @@ import {
rm, rm,
writeFile, writeFile,
} from "node:fs/promises"; } from "node:fs/promises";
import { dirname, join } from "node:path"; import nodePath from "node:path";
import { field, isNotFound, isObject } from "#common"; import { field, isNotFound, isObject } from "#common";
@@ -26,7 +26,9 @@ interface CodexPersistent {
export type CodexAuthentication = CodexApiKey | CodexPersistent; export type CodexAuthentication = CodexApiKey | CodexPersistent;
export function codexAuthentication(token?: string): CodexAuthentication { export function codexAuthentication(token?: string): CodexAuthentication {
return token === undefined ? { kind: "persistent" } : { kind: "api-key", token }; return token === undefined
? { kind: "persistent" }
: { kind: "api-key", token };
} }
// Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys // Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys
@@ -83,7 +85,7 @@ async function readCodexAuth(path: string): Promise<string> {
throw new Error("Codex login must be private"); throw new Error("Codex login must be private");
} }
const text = await readFile(path, "utf8"); const text = await readFile(path, "utf8");
let value: unknown; let value: unknown = null;
try { try {
value = JSON.parse(text); value = JSON.parse(text);
} catch { } catch {
@@ -95,10 +97,12 @@ async function readCodexAuth(path: string): Promise<string> {
typeof authMode !== "string" || typeof authMode !== "string" ||
authMode === "" || authMode === "" ||
!isObject(tokens) || !isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every((name) => { !["access_token", "refresh_token", "id_token", "account_id"].every(
const token = field(tokens, name); (name) => {
return typeof token === "string" && token !== ""; const token = field(tokens, name);
}) return typeof token === "string" && token !== "";
},
)
) { ) {
throw new Error("invalid Codex login"); throw new Error("invalid Codex login");
} }
@@ -111,13 +115,15 @@ export async function stageCodexAuth(
uid: number, uid: number,
gid: number, gid: number,
): Promise<string> { ): Promise<string> {
const codexHome = join(profileHome, ".codex"); const codexHome = nodePath.join(profileHome, ".codex");
await mkdir(codexHome, { recursive: true, mode: 0o700 }); await mkdir(codexHome, { recursive: true, mode: 0o700 });
await chmod(codexHome, 0o700); await chmod(codexHome, 0o700);
await chown(codexHome, uid, gid); await chown(codexHome, uid, gid);
const stagedPath = join(codexHome, "auth.json"); const stagedPath = nodePath.join(codexHome, "auth.json");
try { try {
await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 }); await writeFile(stagedPath, await readCodexAuth(canonicalPath), {
mode: 0o600,
});
await chmod(stagedPath, 0o600); await chmod(stagedPath, 0o600);
await chown(stagedPath, uid, gid); await chown(stagedPath, uid, gid);
} catch (error) { } catch (error) {
@@ -130,15 +136,18 @@ export async function stageCodexAuth(
// Creates an empty private file under a fresh name. // Creates an empty private file under a fresh name.
async function makeTempFile(directory: string): Promise<string> { async function makeTempFile(directory: string): Promise<string> {
const path = join(directory, `.tmp-${randomUUID()}`); const path = nodePath.join(directory, `.tmp-${randomUUID()}`);
const handle = await open(path, "wx", 0o600); const handle = await open(path, "wx", 0o600);
await handle.close(); await handle.close();
return path; return path;
} }
export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise<void> { export async function persistCodexAuth(
stagedPath: string,
canonicalPath: string,
): Promise<void> {
const text = await readCodexAuth(stagedPath); const text = await readCodexAuth(stagedPath);
const directory = dirname(canonicalPath); const directory = nodePath.dirname(canonicalPath);
await mkdir(directory, { recursive: true, mode: 0o700 }); await mkdir(directory, { recursive: true, mode: 0o700 });
const temporary = await makeTempFile(directory); const temporary = await makeTempFile(directory);
try { try {
+5 -1
View File
@@ -8,7 +8,11 @@ export {
retryInvalidToken, retryInvalidToken,
stageCodexAuth, stageCodexAuth,
} from "./auth.ts"; } from "./auth.ts";
export type { ClaudeFailureFields, CodexAuthentication, CommandResult } from "./auth.ts"; export type {
ClaudeFailureFields,
CodexAuthentication,
CommandResult,
} from "./auth.ts";
export { export {
collectOpenCodeOutput, collectOpenCodeOutput,
openCodeArgs, openCodeArgs,
+13 -6
View File
@@ -10,7 +10,9 @@ import {
} from "./opencode.ts"; } from "./opencode.ts";
test("builds a non-interactive auto-approved OpenCode command", () => { test("builds a non-interactive auto-approved OpenCode command", () => {
expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([ expect(
openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"),
).toStrictEqual([
"run", "run",
"--format", "--format",
"json", "json",
@@ -70,7 +72,10 @@ test("streams OpenCode output while collecting it", async () => {
}, },
); );
expect(result).toStrictEqual({ stdout: "first\nsecond\n", stderr: "warning\n" }); expect(result).toStrictEqual({
stdout: "first\nsecond\n",
stderr: "warning\n",
});
expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]); expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]);
}); });
@@ -94,12 +99,14 @@ test("reports a rejected OpenRouter key as a sign-in failure", () => {
assert.instanceOf(error, AgentAuthError); assert.instanceOf(error, AgentAuthError);
expect(error.message).toBe("opencode sign-in failed"); expect(error.message).toBe("opencode sign-in failed");
assert.instanceOf(error.cause, Error); assert.instanceOf(error.cause, Error);
expect(error.cause.message).toBe("opencode APIError (API error 401): User not found."); expect(error.cause.message).toBe(
"opencode APIError (API error 401): User not found.",
);
}); });
test("surfaces an OpenCode error event instead of empty output", () => { test("surfaces an OpenCode error event instead of empty output", () => {
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow( expect(() =>
"API error 429", 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"}}')).toBeNull();
}); });
+4 -2
View File
@@ -52,7 +52,7 @@ export function openCodeError(output: string): Error | null {
if (line.trim() === "") { if (line.trim() === "") {
continue; continue;
} }
let event: unknown; let event: unknown = null;
try { try {
event = JSON.parse(line); event = JSON.parse(line);
} catch { } catch {
@@ -96,7 +96,9 @@ export function parseOpenCodeEvents(output: string): string {
} }
} }
if (result === null) { if (result === null) {
throw openCodeError(output) ?? new Error("OpenCode produced no text output"); throw (
openCodeError(output) ?? new Error("OpenCode produced no text output")
);
} }
return result; return result;
} }
+3 -1
View File
@@ -3,7 +3,9 @@ import { expect, test } from "vite-plus/test";
import { field, isMapping, isObject } from "./json.ts"; import { field, isMapping, isObject } from "./json.ts";
test("reads a named field of a parsed object", () => { test("reads a named field of a parsed object", () => {
const value: unknown = JSON.parse('{"login":"bot","user":{"login":"nested"}}'); const value: unknown = JSON.parse(
'{"login":"bot","user":{"login":"nested"}}',
);
expect(field(value, "login")).toBe("bot"); expect(field(value, "login")).toBe("bot");
expect(field(field(value, "user"), "login")).toBe("nested"); expect(field(field(value, "user"), "login")).toBe("nested");
expect(field(value, "missing")).toBeUndefined(); expect(field(value, "missing")).toBeUndefined();
+61 -42
View File
@@ -65,7 +65,7 @@ function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
profiles: [ profiles: [
{ {
index: 0, index: 0,
type: "claude", type: "CLAUDE",
model: "test", model: "test",
uid: currentUid(), uid: currentUid(),
gid: currentGid(), gid: currentGid(),
@@ -89,7 +89,7 @@ async function fixture(
const outcome: RuntimeOutcome = source.ok const outcome: RuntimeOutcome = source.ok
? { ? {
...source, ...source,
profile: { type: "codex", model: "gpt-test" }, profile: { type: "CODEX", model: "gpt-test" },
reviewPath: `${profileHome}/review.md`, reviewPath: `${profileHome}/review.md`,
anchorsPath: `${profileHome}/anchors.json`, anchorsPath: `${profileHome}/anchors.json`,
} }
@@ -126,9 +126,10 @@ function requestBody(init?: RequestInit): unknown {
// A fake Gitea that records each request body and answers with `status`. // A fake Gitea that records each request body and answers with `status`.
function recordingFetch(bodies: unknown[], status = 201): Fetcher { function recordingFetch(bodies: unknown[], status = 201): Fetcher {
return (_url, init) => { return async (_url, init) => {
bodies.push(requestBody(init)); bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status })); const resolved = await Promise.resolve(new Response(null, { status }));
return resolved;
}; };
} }
@@ -136,14 +137,17 @@ test("posts a successful issue result and removes runtime credentials", async ()
const result = await fixture({ ok: true, body: "done" }); const result = await fixture({ ok: true, body: "done" });
const requests: RecordedRequest[] = []; const requests: RecordedRequest[] = [];
await postRuntime(result.manifestPath, context, (url, init) => { await postRuntime(result.manifestPath, context, async (url, init) => {
requests.push({ url, init }); requests.push({ url, init });
return Promise.resolve(new Response(null, { status: 201 })); const resolved = await Promise.resolve(new Response(null, { status: 201 }));
return resolved;
}); });
expect(requests.length).toBe(1); expect(requests.length).toBe(1);
const [request] = requests; const [request] = requests;
expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`); expect(request?.url).toStrictEqual(
`${context.apiUrl}/repos/owner/repo/issues/7/comments`,
);
expect(requestBody(request?.init)).toStrictEqual({ expect(requestBody(request?.init)).toStrictEqual({
body: "done\n\n> Profile: `codex` · `gpt-test`", body: "done\n\n> Profile: `codex` · `gpt-test`",
}); });
@@ -155,9 +159,11 @@ test("posts a successful issue result and removes runtime credentials", async ()
}); });
test("retries a rejected inline review without anchors", async () => { test("retries a rejected inline review without anchors", async () => {
const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [ const result = await fixture(
{ path: "run.ts", line: 1, side: "new", body: "Finding" }, { ok: true, body: "ignored final response" },
]); "Approved\nReady",
[{ path: "run.ts", line: 1, side: "NEW", body: "Finding" }],
);
const bodies: unknown[] = []; const bodies: unknown[] = [];
await postRuntime( await postRuntime(
@@ -166,9 +172,12 @@ test("retries a rejected inline review without anchors", async () => {
...context, ...context,
eventName: "pull_request", eventName: "pull_request",
}, },
(_url, init) => { async (_url, init) => {
bodies.push(requestBody(init)); bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 })); const resolved = await Promise.resolve(
new Response(null, { status: bodies.length === 1 ? 422 : 200 }),
);
return resolved;
}, },
); );
@@ -186,14 +195,14 @@ test("reports each tried profile and keeps the action failed", async () => {
const result = await fixture({ const result = await fixture({
ok: false, ok: false,
failures: [ failures: [
{ type: "codex", model: "gpt-test", reason: "sign-in failed" }, { type: "CODEX", model: "gpt-test", reason: "SIGN_IN_FAILED" },
{ type: "claude", model: "claude-test", reason: "usage exhausted" }, { type: "CLAUDE", model: "claude-test", reason: "USAGE_EXHAUSTED" },
{ type: "opencode", model: "openrouter/test", reason: "failed" }, { type: "OPENCODE", model: "openrouter/test", reason: "FAILED" },
], ],
}); });
const bodies: unknown[] = []; const bodies: unknown[] = [];
await expect(() => await expect(
postRuntime(result.manifestPath, context, recordingFetch(bodies)), postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed"); ).rejects.toThrow("agent phase failed");
@@ -216,15 +225,15 @@ test("drops failure entries that are not known labels", async () => {
JSON.stringify({ JSON.stringify({
ok: false, ok: false,
failures: [ failures: [
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" }, { type: "CODEX", model: "gpt-test", reason: "token sk-secret leaked" },
{ type: "shell", model: "x", reason: "failed" }, { type: "shell", model: "x", reason: "FAILED" },
{ type: "claude", model: "claude-test", reason: "sign-in failed" }, { type: "CLAUDE", model: "claude-test", reason: "SIGN_IN_FAILED" },
], ],
}), }),
); );
const bodies: unknown[] = []; const bodies: unknown[] = [];
await expect(() => await expect(
postRuntime(result.manifestPath, context, recordingFetch(bodies)), postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed"); ).rejects.toThrow("agent phase failed");
@@ -240,7 +249,7 @@ test("reports a generic failure when the runner wrote no outcome", async () => {
await rm(result.outcomeFile); await rm(result.outcomeFile);
const bodies: unknown[] = []; const bodies: unknown[] = [];
await expect(() => await expect(
postRuntime(result.manifestPath, context, recordingFetch(bodies)), postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed"); ).rejects.toThrow("agent phase failed");
@@ -251,10 +260,13 @@ test("reports a generic failure when the runner wrote no outcome", async () => {
test("removes runtime credentials when posting fails", async () => { test("removes runtime credentials when posting fails", async () => {
const result = await fixture({ ok: true, body: "done" }); const result = await fixture({ ok: true, body: "done" });
await expect(() => await expect(
postRuntime(result.manifestPath, context, () => postRuntime(result.manifestPath, context, async () => {
Promise.resolve(new Response("failed", { status: 500 })), const resolved = await Promise.resolve(
), new Response("failed", { status: 500 }),
);
return resolved;
}),
).rejects.toThrow("500"); ).rejects.toThrow("500");
assert(await missing(result.runtimeDir)); assert(await missing(result.runtimeDir));
}); });
@@ -263,10 +275,11 @@ test("preserves a Gitea transport failure without retrying a comment", async ()
const result = await fixture({ ok: true, body: "done" }); const result = await fixture({ ok: true, body: "done" });
let requests = 0; let requests = 0;
await expect(() => await expect(
postRuntime(result.manifestPath, context, () => { postRuntime(result.manifestPath, context, async () => {
requests += 1; requests += 1;
return Promise.reject(new Error(`network failure ${requests}`)); await Promise.resolve();
throw new Error(`network failure ${requests}`);
}), }),
).rejects.toThrow("network failure 1"); ).rejects.toThrow("network failure 1");
@@ -274,27 +287,33 @@ test("preserves a Gitea transport failure without retrying a comment", async ()
assert(await missing(result.runtimeDir)); assert(await missing(result.runtimeDir));
}); });
test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () => { test.skipIf(IS_WINDOWS)(
const result = await fixture({ ok: true, body: "done" }); "refuses to follow an agent artifact symlink",
await symlink(result.reviewerFile, result.reviewPath); async () => {
let requests = 0; const result = await fixture({ ok: true, body: "done" });
await symlink(result.reviewerFile, result.reviewPath);
let requests = 0;
await expect(() => await expect(
postRuntime(result.manifestPath, context, () => { postRuntime(result.manifestPath, context, async () => {
requests += 1; requests += 1;
return Promise.resolve(new Response(null, { status: 200 })); const resolved = await Promise.resolve(
}), new Response(null, { status: 200 }),
).rejects.toThrow("regular private file"); );
return resolved;
}),
).rejects.toThrow("regular private file");
expect(requests).toBe(1); expect(requests).toBe(1);
assert(await missing(result.runtimeDir)); assert(await missing(result.runtimeDir));
}); },
);
test("reports an attributed failure when a pull request review is missing", async () => { test("reports an attributed failure when a pull request review is missing", async () => {
const result = await fixture({ ok: true, body: "done" }); const result = await fixture({ ok: true, body: "done" });
const bodies: unknown[] = []; const bodies: unknown[] = [];
await expect(() => await expect(
postRuntime( postRuntime(
result.manifestPath, result.manifestPath,
{ {
+102 -44
View File
@@ -1,10 +1,19 @@
import type { Stats } from "node:fs"; import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises"; import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path"; import nodePath from "node:path";
import { field, isNotFound, isObject } from "#common"; import { field, isNotFound, isObject } from "#common";
import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles"; import {
import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime"; failureFooter,
type FailureReason,
type ProfileFailure,
withProfile,
} from "#profiles";
import {
readRuntimeManifest,
type RuntimeManifest,
type RuntimeOutcome,
} from "#runtime";
export interface PostContext { export interface PostContext {
apiUrl: string; apiUrl: string;
@@ -16,7 +25,7 @@ export interface PostContext {
interface Anchor { interface Anchor {
path: string; path: string;
line: number; line: number;
side: "new" | "old"; side: "NEW" | "OLD";
body: string; body: string;
} }
@@ -26,17 +35,33 @@ const VERDICTS = new Map<string, [event: string, mark: string]>([
["Approved", ["APPROVED", "✅"]], ["Approved", ["APPROVED", "✅"]],
["Changes requested", ["REQUEST_CHANGES", "🛑"]], ["Changes requested", ["REQUEST_CHANGES", "🛑"]],
]); ]);
const PROFILE_TYPES = new Set<unknown>(["claude", "codex", "opencode"]); const PROFILE_TYPES = new Set<unknown>(["CLAUDE", "CODEX", "OPENCODE"]);
const FAILURE_REASONS = new Set<unknown>(["sign-in failed", "usage exhausted", "failed"]); const FAILURE_REASONS = new Set<unknown>([
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value); "SIGN_IN_FAILED",
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value); "USAGE_EXHAUSTED",
"FAILED",
]);
// Anchors written before the UPPER_SNAKE switch spell sides in lowercase.
const OLD_SIDES = new Map<unknown, Anchor["side"]>([
["new", "NEW"],
["old", "OLD"],
]);
const isProfileType = (value: unknown): value is ProfileFailure["type"] =>
PROFILE_TYPES.has(value);
const isFailureReason = (value: unknown): value is FailureReason =>
FAILURE_REASONS.has(value);
const failureComment = (failures: ProfileFailure[]): string => const failureComment = (failures: ProfileFailure[]): string =>
`AI bot failed.\n\n${failureFooter(failures)}`; `AI bot failed.\n\n${failureFooter(failures)}`;
// The `fetch` subset the reviewer posts through, which tests replace. // The `fetch` subset the reviewer posts through, which tests replace.
export type Fetcher = (url: string, init: RequestInit) => Promise<Response>; export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
class GiteaRequestError extends Error {} class GiteaRequestError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "GiteaRequestError";
}
}
function parseAnchors(text: string, sourcePath: string): Anchor[] { function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text); const entries: unknown = JSON.parse(text);
@@ -51,7 +76,8 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] {
const path = field(entry, "path"); const path = field(entry, "path");
const line = field(entry, "line"); const line = field(entry, "line");
const givenSide = field(entry, "side"); const givenSide = field(entry, "side");
const side = givenSide === undefined ? "new" : givenSide; const side =
givenSide === undefined ? "NEW" : (OLD_SIDES.get(givenSide) ?? givenSide);
const body = field(entry, "body"); const body = field(entry, "body");
if (typeof path !== "string" || path === "") { if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`); throw new TypeError(`${at}.path: expected a repository path`);
@@ -59,8 +85,8 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] {
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) { if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new TypeError(`${at}.line: expected a line number`); throw new TypeError(`${at}.line: expected a line number`);
} }
if (side !== "new" && side !== "old") { if (side !== "NEW" && side !== "OLD") {
throw new Error(`${at}.side: expected "new" or "old"`); throw new Error(`${at}.side: expected "NEW" or "OLD"`);
} }
if (typeof body !== "string" || body.trim() === "") { if (typeof body !== "string" || body.trim() === "") {
throw new TypeError(`${at}.body: expected the finding`); throw new TypeError(`${at}.body: expected the finding`);
@@ -85,7 +111,9 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
const type = field(entry, "type"); const type = field(entry, "type");
const model = field(entry, "model"); const model = field(entry, "model");
const reason = field(entry, "reason"); const reason = field(entry, "reason");
return isProfileType(type) && typeof model === "string" && isFailureReason(reason) return isProfileType(type) &&
typeof model === "string" &&
isFailureReason(reason)
? [{ type, model, reason }] ? [{ type, model, reason }]
: []; : [];
}), }),
@@ -117,11 +145,13 @@ async function readAgentArtifact(
path: string, path: string,
name: string, name: string,
): Promise<string | null> { ): Promise<string | null> {
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path); const profile = manifest.profiles.find(
(entry) => nodePath.join(entry.home, name) === path,
);
if (profile === undefined) { if (profile === undefined) {
throw new Error(`${name}: outside profile home`); throw new Error(`${name}: outside profile home`);
} }
let stat: Stats; let stat: Stats | null = null;
try { try {
stat = await lstat(path); stat = await lstat(path);
} catch (error) { } catch (error) {
@@ -152,7 +182,7 @@ export async function postRuntime(
const reviewerText = await readFile(manifest.reviewerFile, "utf8"); const reviewerText = await readFile(manifest.reviewerFile, "utf8");
const reviewerToken = reviewerText.trim(); const reviewerToken = reviewerText.trim();
const request = async (path: string, body: unknown): Promise<void> => { const request = async (path: string, body: unknown): Promise<void> => {
let response: Response; let response: Response | null = null;
try { try {
response = await fetcher(`${context.apiUrl}/${path}`, { response = await fetcher(`${context.apiUrl}/${path}`, {
method: "POST", method: "POST",
@@ -167,15 +197,21 @@ export async function postRuntime(
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error }); throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
} }
if (!response.ok) { if (!response.ok) {
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`); throw new GiteaRequestError(
`${path}: ${response.status} ${await response.text()}`,
);
} }
}; };
const postComment = (body: string): Promise<void> => const postComment = async (body: string): Promise<void> => {
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, { await request(
body: stripAnsi(body), `repos/${context.repository}/issues/${context.issueIndex}/comments`,
}); {
body: stripAnsi(body),
},
);
};
let outcome: RuntimeOutcome; let outcome: RuntimeOutcome | null = null;
try { try {
outcome = await readOutcome(manifest.outcomeFile); outcome = await readOutcome(manifest.outcomeFile);
} catch (error) { } catch (error) {
@@ -188,7 +224,11 @@ export async function postRuntime(
} }
try { try {
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md"); const review = await readAgentArtifact(
manifest,
outcome.reviewPath,
"review.md",
);
if (review === null) { if (review === null) {
if (context.eventName === "pull_request") { if (context.eventName === "pull_request") {
throw new Error("agent did not write a pull request review"); throw new Error("agent did not write a pull request review");
@@ -199,21 +239,35 @@ export async function postRuntime(
const [verdict = "", ...rest] = review.split("\n"); const [verdict = "", ...rest] = review.split("\n");
const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "💬"]; const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "💬"];
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); const anchorText = await readAgentArtifact(
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); manifest,
const postReview = (entries: Anchor[]): Promise<void> => outcome.anchorsPath,
request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { "anchors.json",
body: stripAnsi( );
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile), const anchors =
), anchorText === null
event, ? []
comments: entries.map(({ path, line, side, body }) => ({ : parseAnchors(anchorText, outcome.anchorsPath);
path, const postReview = async (entries: Anchor[]): Promise<void> => {
body: stripAnsi(withProfile(body, outcome.profile)), await request(
new_position: side === "new" ? line : 0, `repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
old_position: side === "old" ? line : 0, {
})), body: stripAnsi(
}); withProfile(
[`${mark} ${verdict.trim()}`, ...rest].join("\n"),
outcome.profile,
),
),
event,
comments: entries.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(withProfile(body, outcome.profile)),
new_position: side === "NEW" ? line : 0,
old_position: side === "OLD" ? line : 0,
})),
},
);
};
await postReview(anchors).catch(async (error: unknown) => { await postReview(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) { if (anchors.length === 0) {
throw error; throw error;
@@ -227,14 +281,18 @@ export async function postRuntime(
if (error instanceof GiteaRequestError) { if (error instanceof GiteaRequestError) {
throw error; throw error;
} }
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); await postComment(
failureComment([{ ...outcome.profile, reason: "FAILED" }]),
);
throw error; throw error;
} }
} finally { } finally {
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => { await rm(manifest.runtimeDir, { recursive: true }).catch(
if (!isNotFound(error)) { (error: unknown) => {
throw error; if (!isNotFound(error)) {
} throw error;
}); }
},
);
} }
} }
+9 -3
View File
@@ -6,8 +6,12 @@ import { isNotFound } from "#common";
import { exitCode } from "./child.ts"; import { exitCode } from "./child.ts";
test("reports the exit code", async () => { test("reports the exit code", async () => {
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }))).toBe(0); expect(
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" }))).toBe(3); 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 () => { test("reports a signal as 128 plus its number", async () => {
@@ -16,7 +20,9 @@ test("reports a signal as 128 plus its number", async () => {
}); });
test("resolves on exit while a grandchild still holds the output open", async () => { 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 child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], {
stdio: ["ignore", "pipe", "pipe"],
});
const started = Date.now(); const started = Date.now();
expect(await exitCode(child)).toBe(5); expect(await exitCode(child)).toBe(5);
expect(Date.now() - started).toBeLessThan(1500); expect(Date.now() - started).toBeLessThan(1500);
+4 -1
View File
@@ -23,7 +23,10 @@ export function exitCode(child: ChildProcess): Promise<number> {
} }
// Writes a chunk, waiting for the stream to drain when its buffer is full. // 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> { export async function echo(
stream: Writable,
chunk: string | Uint8Array,
): Promise<void> {
if (!stream.write(chunk)) { if (!stream.write(chunk)) {
await once(stream, "drain"); await once(stream, "drain");
} }
+104 -73
View File
@@ -1,5 +1,13 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process"; import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import {
chmod,
chown,
mkdir,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
@@ -7,11 +15,15 @@ import { isNotFound } from "#common";
import type { RuntimeProfile } from "#runtime"; import type { RuntimeProfile } from "#runtime";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing"; import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts"; import {
isRunnableProfileProcess,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
const profile = (uid: number, home: string): RuntimeProfile => ({ const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000, index: uid - 20_000,
type: "claude", type: "CLAUDE",
model: "test", model: "test",
uid, uid,
gid: uid, gid: uid,
@@ -41,77 +53,96 @@ test("zombie process entries are already quiescent", () => {
expect(isRunnableProfileProcess(status, 20_010)).toBe(false); expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
}); });
test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", async () => { test.skipIf(IS_WINDOWS)(
const root = await makeTempDir(); "refuses to read a profile result symlink as root",
try { async () => {
const secret = `${root}/reviewer-token`; const root = await makeTempDir();
const result = `${root}/result.md`; try {
await writeFile(secret, "reviewer-secret", { mode: 0o600 }); const secret = `${root}/reviewer-token`;
await symlink(secret, result); const result = `${root}/result.md`;
await writeFile(secret, "reviewer-secret", { mode: 0o600 });
await symlink(secret, result);
await expect(() => readProfileFile(profile(currentUid(), root), result)).rejects.toThrow( await expect(
"regular private file", readProfileFile(profile(currentUid(), root), result),
); ).rejects.toThrow("regular private file");
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("a profile process can read only its own private files", async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
try {
const homeA = `${root}/a`;
const homeB = `${root}/b`;
await mkdir(homeA, { mode: 0o700 });
await mkdir(homeB, { mode: 0o700 });
await chown(homeA, 20_000, 20_000);
await chown(homeB, 20_001, 20_001);
await writeFile(`${homeA}/token`, "own", { mode: 0o600 });
await chown(`${homeA}/token`, 20_000, 20_000);
await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 });
await chown(`${homeB}/token`, 20_001, 20_001);
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
const selected = profile(20_000, homeA);
const own = runAs(selected, "/bin/cat", [`${homeA}/token`]);
const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]);
const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]);
expect(own.stdout).toBe("own");
expect(own.status).toBe(0);
expect(sibling.status).not.toBe(0);
expect(reviewer.status).not.toBe(0);
} finally {
await rm(root, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => {
const selected = profile(20_010, "/tmp");
const launched = runAs(selected, "/bin/sh", ["-c", "sleep 60 >/dev/null 2>&1 & echo $!"]);
const pid = Number(launched.stdout.trim());
expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid);
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
if (isNotFound(error)) {
return null;
} }
throw error; },
}); );
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
});
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", () => { test.skipIf(!IS_ROOT)(
const selected = profile(20_020, "/tmp"); "a profile process can read only its own private files",
const result = runAs( async () => {
selected, const root = await makeTempDir();
"/usr/bin/cat", await chmod(root, 0o711);
[`/proc/${process.pid}/environ`], try {
new Map([["PARENT_SECRET", "must-not-cross-boundary"]]), const homeA = `${root}/a`;
); const homeB = `${root}/b`;
await mkdir(homeA, { mode: 0o700 });
await mkdir(homeB, { mode: 0o700 });
await chown(homeA, 20_000, 20_000);
await chown(homeB, 20_001, 20_001);
await writeFile(`${homeA}/token`, "own", { mode: 0o600 });
await chown(`${homeA}/token`, 20_000, 20_000);
await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 });
await chown(`${homeB}/token`, 20_001, 20_001);
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
expect(result.status).not.toBe(0); const selected = profile(20_000, homeA);
}); const own = runAs(selected, "/bin/cat", [`${homeA}/token`]);
const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]);
const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]);
expect(own.stdout).toBe("own");
expect(own.status).toBe(0);
expect(sibling.status).not.toBe(0);
expect(reviewer.status).not.toBe(0);
} finally {
await rm(root, { recursive: true });
}
},
);
test.skipIf(!IS_ROOT)(
"cleanup leaves no runnable daemonized process",
async () => {
const selected = profile(20_010, "/tmp");
const launched = runAs(selected, "/bin/sh", [
"-c",
"sleep 60 >/dev/null 2>&1 & echo $!",
]);
const pid = Number(launched.stdout.trim());
expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid);
const status = await readFile(`/proc/${pid}/status`, "utf8").catch(
(error: unknown) => {
if (isNotFound(error)) {
return null;
}
throw error;
},
);
expect(
status === null || !isRunnableProfileProcess(status, selected.uid),
).toBe(true);
},
);
test.skipIf(!IS_ROOT)(
"the agent process cannot read its coordinator environment",
() => {
const selected = profile(20_020, "/tmp");
const result = runAs(
selected,
"/usr/bin/cat",
[`/proc/${process.pid}/environ`],
new Map([["PARENT_SECRET", "must-not-cross-boundary"]]),
);
expect(result.status).not.toBe(0);
},
);
+21 -6
View File
@@ -6,8 +6,8 @@ import { isNotFound } from "#common";
import type { RuntimeProfile } from "#runtime"; import type { RuntimeProfile } from "#runtime";
export function isRunnableProfileProcess(status: string, uid: number): boolean { export function isRunnableProfileProcess(status: string, uid: number): boolean {
const owner = /^Uid:\s+(?<uid>\d+)/mu.exec(status)?.[1]; const owner = /^Uid:\s+(?<uid>\d+)/mu.exec(status)?.groups?.["uid"];
const state = /^State:\s+(?<state>\w)/mu.exec(status)?.[1]; const state = /^State:\s+(?<state>\w)/mu.exec(status)?.groups?.["state"];
return owner !== undefined && Number(owner) === uid && state !== "Z"; return owner !== undefined && Number(owner) === uid && state !== "Z";
} }
@@ -17,18 +17,30 @@ async function profileProcessIds(uid: number): Promise<number[]> {
.filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name)) .filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name))
.map((entry) => entry.name); .map((entry) => entry.name);
const statuses = await Promise.all( const statuses = await Promise.all(
pids.map((pid) => readFile(`/proc/${pid}/status`, "utf8").catch(() => null)), pids.map(async (pid) => {
const status = await readFile(`/proc/${pid}/status`, "utf8").catch(
() => null,
);
return status;
}),
); );
return pids return pids
.filter((_pid, index) => { .filter((_pid, index) => {
const status = statuses[index]; const status = statuses[index];
return status !== null && status !== undefined && isRunnableProfileProcess(status, uid); return (
status !== null &&
status !== undefined &&
isRunnableProfileProcess(status, uid)
);
}) })
.map(Number); .map(Number);
} }
// Kills the UID's processes every 10 ms until none is left, for up to 100 rounds. // Kills the UID's processes every 10 ms until none is left, for up to 100 rounds.
export async function killProfileProcesses(uid: number, attempt = 0): Promise<void> { export async function killProfileProcesses(
uid: number,
attempt = 0,
): Promise<void> {
if (attempt === 100) { if (attempt === 100) {
throw new Error(`profile UID ${uid} still owns processes after cleanup`); throw new Error(`profile UID ${uid} still owns processes after cleanup`);
} }
@@ -49,7 +61,10 @@ export async function killProfileProcesses(uid: number, attempt = 0): Promise<vo
await killProfileProcesses(uid, attempt + 1); await killProfileProcesses(uid, attempt + 1);
} }
export async function readProfileFile(profile: RuntimeProfile, path: string): Promise<string> { export async function readProfileFile(
profile: RuntimeProfile,
path: string,
): Promise<string> {
const stat = await lstat(path); const stat = await lstat(path);
if ( if (
!stat.isFile() || !stat.isFile() ||
+6 -1
View File
@@ -9,4 +9,9 @@ export {
sanitizedEnvironment, sanitizedEnvironment,
withProfile, withProfile,
} from "./profiles.ts"; } from "./profiles.ts";
export type { BotProfile, FailureReason, ProfileFailure, ProfileIdentity } from "./profiles.ts"; export type {
BotProfile,
FailureReason,
ProfileFailure,
ProfileIdentity,
} from "./profiles.ts";
+66 -53
View File
@@ -11,42 +11,44 @@ import {
} from "./profiles.ts"; } from "./profiles.ts";
test("formats profile attribution as a Markdown quote", () => { test("formats profile attribution as a Markdown quote", () => {
expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe( expect(
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`", profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" }),
); ).toBe("> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`");
}); });
test("falls through only after usage exhaustion", async () => { test("falls through only after usage exhaustion", async () => {
const visited: string[] = []; const visited: string[] = [];
const result = await runProfiles( const result = await runProfiles(
[ [
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
], ],
(profile) => { async (profile) => {
visited.push(profile.type); visited.push(profile.type);
if (profile.type === "claude") { if (profile.type === "CLAUDE") {
throw new Error("You've hit your usage limit"); throw new Error("You've hit your usage limit");
} }
return Promise.resolve("done"); const resolved = await Promise.resolve("done");
return resolved;
}, },
); );
expect(result).toBe("done"); expect(result).toBe("done");
expect(visited).toStrictEqual(["claude", "codex"]); expect(visited).toStrictEqual(["CLAUDE", "CODEX"]);
}); });
test("recognizes Claude's weekly limit message", async () => { test("recognizes Claude's weekly limit message", async () => {
const result = await runProfiles( const result = await runProfiles(
[ [
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
], ],
(profile) => { async (profile) => {
if (profile.type === "claude") { if (profile.type === "CLAUDE") {
throw new Error("You've hit your weekly limit · resets 12am (UTC)"); throw new Error("You've hit your weekly limit · resets 12am (UTC)");
} }
return Promise.resolve("fallback"); const resolved = await Promise.resolve("fallback");
return resolved;
}, },
); );
@@ -55,19 +57,23 @@ test("recognizes Claude's weekly limit message", async () => {
test("recognizes provider API exhaustion status", async () => { test("recognizes provider API exhaustion status", async () => {
const results = await Promise.all( const results = await Promise.all(
["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) => ["API Error: 402 Insufficient funds", "HTTP error: 429"].map(
runProfiles( async (message) => {
[ const result = await runProfiles(
{ type: "claude", model: "claude-sonnet-5", token: "first" }, [
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
], { type: "CODEX", model: "gpt-5.6-terra" },
(profile) => { ],
if (profile.type === "claude") { async (profile) => {
throw new Error(message); if (profile.type === "CLAUDE") {
} throw new Error(message);
return Promise.resolve("fallback"); }
}, const resolved = await Promise.resolve("fallback");
), return resolved;
},
);
return result;
},
), ),
); );
@@ -78,26 +84,27 @@ test("falls through after a sign-in failure and records each try", async () => {
const failures: ProfileFailure[] = []; const failures: ProfileFailure[] = [];
const result = await runProfiles( const result = await runProfiles(
[ [
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }, { type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" },
], ],
(profile) => { async (profile) => {
if (profile.type === "codex") { if (profile.type === "CODEX") {
throw new AgentAuthError("codex sign-in failed"); throw new AgentAuthError("codex sign-in failed");
} }
if (profile.type === "claude") { if (profile.type === "CLAUDE") {
throw new Error("API Error: 429"); throw new Error("API Error: 429");
} }
return Promise.resolve("fallback"); const resolved = await Promise.resolve("fallback");
return resolved;
}, },
failures, failures,
); );
expect(result).toBe("fallback"); expect(result).toBe("fallback");
expect(failures).toStrictEqual([ expect(failures).toStrictEqual([
{ type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" }, { type: "CODEX", model: "gpt-5.6-terra", reason: "SIGN_IN_FAILED" },
{ type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" }, { type: "CLAUDE", model: "claude-sonnet-5", reason: "USAGE_EXHAUSTED" },
]); ]);
expect(failureFooter(failures)).toStrictEqual( expect(failureFooter(failures)).toStrictEqual(
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " + "> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " +
@@ -107,11 +114,11 @@ test("falls through after a sign-in failure and records each try", async () => {
test("throws the last sign-in failure when every profile fails", async () => { test("throws the last sign-in failure when every profile fails", async () => {
const failures: ProfileFailure[] = []; const failures: ProfileFailure[] = [];
await expect(() => await expect(
runProfiles( runProfiles(
[ [
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
], ],
(profile) => { (profile) => {
throw new AgentAuthError(`${profile.type} sign-in failed`); throw new AgentAuthError(`${profile.type} sign-in failed`);
@@ -121,19 +128,23 @@ test("throws the last sign-in failure when every profile fails", async () => {
).rejects.toThrow( ).rejects.toThrow(
expect.toSatisfy( expect.toSatisfy(
(error: unknown) => (error: unknown) =>
error instanceof AgentAuthError && error.message.includes("claude sign-in failed"), error instanceof AgentAuthError &&
error.message.includes("CLAUDE sign-in failed"),
), ),
); );
expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]); expect(failures.map(({ reason }) => reason)).toStrictEqual([
"SIGN_IN_FAILED",
"SIGN_IN_FAILED",
]);
}); });
test("preserves an ordinary agent failure", async () => { test("preserves an ordinary agent failure", async () => {
const visited: string[] = []; const visited: string[] = [];
await expect(() => await expect(
runProfiles( runProfiles(
[ [
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
], ],
(profile) => { (profile) => {
visited.push(profile.type); visited.push(profile.type);
@@ -141,16 +152,16 @@ test("preserves an ordinary agent failure", async () => {
}, },
), ),
).rejects.toThrow("tests failed"); ).rejects.toThrow("tests failed");
expect(visited).toStrictEqual(["claude"]); expect(visited).toStrictEqual(["CLAUDE"]);
}); });
test("does not mistake incidental quota text for exhaustion", async () => { test("does not mistake incidental quota text for exhaustion", async () => {
const visited: string[] = []; const visited: string[] = [];
await expect(() => await expect(
runProfiles( runProfiles(
[ [
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
], ],
(profile) => { (profile) => {
visited.push(profile.type); visited.push(profile.type);
@@ -158,7 +169,7 @@ test("does not mistake incidental quota text for exhaustion", async () => {
}, },
), ),
).rejects.toThrow("quota parser test failed"); ).rejects.toThrow("quota parser test failed");
expect(visited).toStrictEqual(["claude"]); expect(visited).toStrictEqual(["CLAUDE"]);
}); });
test("exposes only the selected profile credential to the agent", () => { test("exposes only the selected profile credential to the agent", () => {
@@ -178,7 +189,7 @@ test("exposes only the selected profile credential to the agent", () => {
]); ]);
const profile = { const profile = {
index: 0, index: 0,
type: "claude" as const, type: "CLAUDE" as const,
model: "claude-sonnet-5", model: "claude-sonnet-5",
uid: 20_000, uid: 20_000,
gid: 20_000, gid: 20_000,
@@ -204,7 +215,7 @@ test("exposes only the selected profile credential to the agent", () => {
test("uses a private Codex home without inventing an API credential", () => { test("uses a private Codex home without inventing an API credential", () => {
const profile = { const profile = {
index: 1, index: 1,
type: "codex" as const, type: "CODEX" as const,
model: "gpt-5.6-terra", model: "gpt-5.6-terra",
uid: 20_001, uid: 20_001,
gid: 20_001, gid: 20_001,
@@ -224,7 +235,7 @@ test("uses a private Codex home without inventing an API credential", () => {
test("exposes an OpenRouter key only to the OpenCode profile", () => { test("exposes an OpenRouter key only to the OpenCode profile", () => {
const profile = { const profile = {
index: 2, index: 2,
type: "opencode" as const, type: "OPENCODE" as const,
model: "openrouter/qwen/qwen3.8-27b", model: "openrouter/qwen/qwen3.8-27b",
uid: 20_002, uid: 20_002,
gid: 20_002, gid: 20_002,
@@ -232,7 +243,9 @@ test("exposes an OpenRouter key only to the OpenCode profile", () => {
tokenFile: "/runtime/profile-2/token", tokenFile: "/runtime/profile-2/token",
}; };
expect(agentEnvironment(new Map([["PATH", "/bin"]]), profile, "selected")).toStrictEqual( expect(
agentEnvironment(new Map([["PATH", "/bin"]]), profile, "selected"),
).toStrictEqual(
new Map([ new Map([
["PATH", "/bin"], ["PATH", "/bin"],
["HOME", "/runtime/profile-2"], ["HOME", "/runtime/profile-2"],
+71 -40
View File
@@ -1,6 +1,6 @@
// The agent and model a profile runs, which every report names. // The agent and model a profile runs, which every report names.
export interface ProfileIdentity { export interface ProfileIdentity {
type: "claude" | "codex" | "opencode"; type: "CLAUDE" | "CODEX" | "OPENCODE";
model: string; model: string;
} }
@@ -8,15 +8,39 @@ export interface BotProfile extends ProfileIdentity {
token?: string; token?: string;
} }
// The lowercase name a profile type reads as in posted comments.
function profileName(type: ProfileIdentity["type"]): string {
switch (type) {
case "CLAUDE":
return "claude";
case "CODEX":
return "codex";
case "OPENCODE":
return "opencode";
}
}
export function profileFooter(profile: ProfileIdentity): string { export function profileFooter(profile: ProfileIdentity): string {
return `> Profile: \`${profile.type}\` · \`${profile.model}\``; return `> Profile: \`${profileName(profile.type)}\` · \`${profile.model}\``;
} }
export function withProfile(body: string, profile: ProfileIdentity): string { export function withProfile(body: string, profile: ProfileIdentity): string {
return `${body}\n\n${profileFooter(profile)}`; return `${body}\n\n${profileFooter(profile)}`;
} }
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed"; export type FailureReason = "SIGN_IN_FAILED" | "USAGE_EXHAUSTED" | "FAILED";
// The words a failure reason reads as in logs and posted comments.
function toMessage(reason: FailureReason): string {
switch (reason) {
case "SIGN_IN_FAILED":
return "sign-in failed";
case "USAGE_EXHAUSTED":
return "usage exhausted";
case "FAILED":
return "failed";
}
}
export interface ProfileFailure extends ProfileIdentity { export interface ProfileFailure extends ProfileIdentity {
reason: FailureReason; reason: FailureReason;
@@ -29,7 +53,12 @@ interface ProfileHome {
} }
// Raised once an agent's credentials are rejected and any re-login is spent. // Raised once an agent's credentials are rejected and any re-login is spent.
export class AgentAuthError extends Error {} export class AgentAuthError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "AgentAuthError";
}
}
function isUsageExhausted(error: unknown): boolean { function isUsageExhausted(error: unknown): boolean {
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test( return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test(
@@ -39,9 +68,9 @@ function isUsageExhausted(error: unknown): boolean {
function failureReason(error: unknown): FailureReason { function failureReason(error: unknown): FailureReason {
if (error instanceof AgentAuthError) { if (error instanceof AgentAuthError) {
return "sign-in failed"; return "SIGN_IN_FAILED";
} }
return isUsageExhausted(error) ? "usage exhausted" : "failed"; return isUsageExhausted(error) ? "USAGE_EXHAUSTED" : "FAILED";
} }
export function failureFooter(failures: ProfileFailure[]): string { export function failureFooter(failures: ProfileFailure[]): string {
@@ -49,41 +78,35 @@ export function failureFooter(failures: ProfileFailure[]): string {
return "> Profile: none"; return "> Profile: none";
} }
return `> Tried: ${failures return `> Tried: ${failures
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`) .map(
({ type, model, reason }) =>
`\`${profileName(type)}\` · \`${model}\` (${toMessage(reason)})`,
)
.join(", ")}`; .join(", ")}`;
} }
// Tries the profiles from `index` on, moving on only after a sign-in failure // Tries the profiles in order, moving on only after a sign-in failure or
// or exhausted usage, and records every profile it tried. // exhausted usage, and records every profile it tried.
async function attemptFrom<T, P extends ProfileIdentity>( export async function runProfiles<T, P extends ProfileIdentity>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[],
index: number,
): Promise<T> {
const profile = profiles[index];
if (profile === undefined) {
throw new Error("profiles must contain at least one profile");
}
try {
return await attempt(profile);
} catch (error) {
const reason = failureReason(error);
failures.push({ type: profile.type, model: profile.model, reason });
if (reason === "failed" || index === profiles.length - 1) {
throw error;
}
console.error(`${profile.type} ${reason}; trying next profile`);
return attemptFrom(profiles, attempt, failures, index + 1);
}
}
export function runProfiles<T, P extends ProfileIdentity>(
profiles: P[], profiles: P[],
attempt: (profile: P) => Promise<T>, attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [], failures: ProfileFailure[] = [],
): Promise<T> { ): Promise<T> {
return attemptFrom(profiles, attempt, failures, 0); for (const [index, profile] of profiles.entries()) {
try {
return await attempt(profile);
} catch (error) {
const reason = failureReason(error);
failures.push({ type: profile.type, model: profile.model, reason });
if (reason === "FAILED" || index === profiles.length - 1) {
throw error;
}
console.error(
`${profile.type} ${toMessage(reason)}; trying next profile`,
);
}
}
throw new Error("profiles must contain at least one profile");
} }
// Environments are maps from variable name to value, converted to the object // Environments are maps from variable name to value, converted to the object
@@ -126,6 +149,18 @@ export function sanitizedEnvironment(
return result; return result;
} }
// The environment variable each agent CLI reads its credential from.
function tokenVariable(type: ProfileIdentity["type"]): string {
switch (type) {
case "CLAUDE":
return "CLAUDE_CODE_OAUTH_TOKEN";
case "CODEX":
return "OPENAI_API_KEY";
case "OPENCODE":
return "OPENROUTER_API_KEY";
}
}
export function agentEnvironment( export function agentEnvironment(
environment: ReadonlyMap<string, string>, environment: ReadonlyMap<string, string>,
profile: ProfileHome, profile: ProfileHome,
@@ -133,15 +168,11 @@ export function agentEnvironment(
): Map<string, string> { ): Map<string, string> {
const result = sanitizedEnvironment(environment); const result = sanitizedEnvironment(environment);
result.set("HOME", profile.home); result.set("HOME", profile.home);
if (profile.type === "codex") { if (profile.type === "CODEX") {
result.set("CODEX_HOME", `${profile.home}/.codex`); result.set("CODEX_HOME", `${profile.home}/.codex`);
} }
if (token !== undefined && token !== "") { if (token !== undefined && token !== "") {
const tokenName = { const tokenName = tokenVariable(profile.type);
claude: "CLAUDE_CODE_OAUTH_TOKEN",
codex: "OPENAI_API_KEY",
opencode: "OPENROUTER_API_KEY",
}[profile.type];
result.set(tokenName, token); result.set(tokenName, token);
} }
return result; return result;
+116 -44
View File
@@ -25,7 +25,12 @@ import {
stageCodexAuth, stageCodexAuth,
} from "#agents"; } from "#agents";
import { field, isObject } from "#common"; import { field, isObject } from "#common";
import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes"; import {
echo,
exitCode,
killProfileProcesses,
readProfileFile,
} from "#processes";
import { import {
AgentAuthError, AgentAuthError,
agentEnvironment, agentEnvironment,
@@ -35,7 +40,11 @@ import {
sanitizedEnvironment, sanitizedEnvironment,
withProfile, withProfile,
} from "#profiles"; } from "#profiles";
import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "#runtime"; import {
readRuntimeManifest,
type RuntimeOutcome,
type RuntimeProfile,
} from "#runtime";
interface AttemptResult { interface AttemptResult {
body: string; body: string;
@@ -107,7 +116,10 @@ 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( const code = await exitCode(
spawn(command, args, { env: Object.fromEntries(PROCESS_ENV), stdio: "ignore" }), spawn(command, args, {
env: Object.fromEntries(PROCESS_ENV),
stdio: "ignore",
}),
); );
if (code !== 0) { if (code !== 0) {
throw new Error(`${command} ${args[0]} exited with ${code}`); throw new Error(`${command} ${args[0]} exited with ${code}`);
@@ -126,8 +138,13 @@ async function configureGitAuthor(): Promise<void> {
await runRoot("git", ["config", "user.email", email]); await runRoot("git", ["config", "user.email", email]);
} }
async function readProfileToken(profile: RuntimeProfile): Promise<string | null> { async function readProfileToken(
const token = profile.tokenFile === null ? null : await readFile(profile.tokenFile, "utf8"); profile: RuntimeProfile,
): Promise<string | null> {
const token =
profile.tokenFile === null
? null
: await readFile(profile.tokenFile, "utf8");
return token; return token;
} }
@@ -150,6 +167,18 @@ async function runAgentCommand(
} }
} }
// The agent name the skills installer knows each profile type by.
function skillsAgent(type: RuntimeProfile["type"]): string {
switch (type) {
case "CLAUDE":
return "claude-code";
case "CODEX":
return "codex";
case "OPENCODE":
return "opencode";
}
}
async function prepareProfile(profile: RuntimeProfile): Promise<void> { async function prepareProfile(profile: RuntimeProfile): Promise<void> {
await runAgentCommand(profile, "git", [ await runAgentCommand(profile, "git", [
"config", "config",
@@ -163,7 +192,7 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
...SKILLS_INSTALL.split(" "), ...SKILLS_INSTALL.split(" "),
"--global", "--global",
"--agent", "--agent",
profile.type === "claude" ? "claude-code" : profile.type, skillsAgent(profile.type),
"--yes", "--yes",
]); ]);
} }
@@ -179,15 +208,22 @@ function formatComment(comment: unknown): string {
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> { async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
const reviewPath = await makeProfileFile(profile, "review.md"); const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json"); const anchorsPath = await makeProfileFile(profile, "anchors.json");
const commentsResponse = await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`); const commentsResponse = await gitea(
`repos/${REPO}/issues/${INDEX}/comments?limit=100`,
);
const comments: unknown = await commentsResponse.json(); const comments: unknown = await commentsResponse.json();
if (!Array.isArray(comments)) { if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response"); throw new TypeError("comments: unexpected Gitea response");
} }
const rulesResponse = await gitea(RULES_PATH);
const codeRules = await rulesResponse.text();
const values = new Map<string, string>([ const values = new Map<string, string>([
["COMMENT", env("COMMENT")], ["COMMENT", env("COMMENT")],
["ISSUE_COMMENTS", comments.map((comment: unknown) => formatComment(comment)).join("\n")], [
["CODE_RULES", await gitea(RULES_PATH).then((response) => response.text())], "ISSUE_COMMENTS",
comments.map((comment: unknown) => formatComment(comment)).join("\n"),
],
["CODE_RULES", codeRules],
["EVENT_NAME", EVENT], ["EVENT_NAME", EVENT],
["GITEA_API_URL", API], ["GITEA_API_URL", API],
["GITEA_REPOSITORY", REPO], ["GITEA_REPOSITORY", REPO],
@@ -195,18 +231,29 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
["REVIEW_PATH", reviewPath], ["REVIEW_PATH", reviewPath],
["ANCHORS_PATH", anchorsPath], ["ANCHORS_PATH", anchorsPath],
]); ]);
const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8"); const template = await readFile(
new URL("../../prompt.md", import.meta.url),
"utf8",
);
return { return {
prompt: template.replaceAll( prompt: template.replaceAll(
/\$\{(?<name>\w+)\}/gu, /\$\{(?<name>\w+)\}/gu,
(match: string, name: string) => values.get(name) ?? match, (match: string, ...args: unknown[]) => {
const groups = args.at(-1);
const name = isObject(groups) ? field(groups, "name") : undefined;
return typeof name === "string" ? (values.get(name) ?? match) : match;
},
), ),
reviewPath, reviewPath,
anchorsPath, anchorsPath,
}; };
} }
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 = spawn(
"claude", "claude",
[ [
@@ -221,7 +268,9 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
], ],
{ {
cwd: WORKSPACE, cwd: WORKSPACE,
env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token).set("IS_SANDBOX", "1")), env: Object.fromEntries(
agentEnvironment(PROCESS_ENV, profile, token).set("IS_SANDBOX", "1"),
),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
@@ -238,14 +287,20 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
console.error(part); console.error(part);
} }
})(); })();
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 = field(field(event, "message"), "content"); const content = field(field(event, "message"), "content");
for (const part of Array.isArray(content) ? content : []) { for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) { if (isObject(part)) {
const text = field(part, "thinking") ?? field(part, "text") ?? field(part, "name"); const text =
field(part, "thinking") ??
field(part, "text") ??
field(part, "name");
if (typeof text === "string" && text !== "") { if (typeof text === "string" && text !== "") {
console.log(text); console.log(text);
} }
@@ -260,7 +315,8 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
isError: typeof isError === "boolean" ? isError : undefined, isError: typeof isError === "boolean" ? isError : undefined,
result: typeof answer === "string" ? answer : undefined, result: typeof answer === "string" ? answer : undefined,
subtype: String(subtype), subtype: String(subtype),
apiErrorStatus: typeof apiStatus === "number" ? apiStatus : undefined, apiErrorStatus:
typeof apiStatus === "number" ? apiStatus : undefined,
}; };
} }
} }
@@ -268,14 +324,18 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
} }
const [code] = await Promise.all([status, copyError]); const [code] = await Promise.all([status, copyError]);
if (result?.isError === true) { if (result?.isError === true) {
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`); const failure = new Error(
result.result ?? `claude ended with ${result.subtype}`,
);
if (isClaudeAuthFailure(result)) { if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure }); throw new AgentAuthError("claude sign-in failed", { cause: failure });
} }
throw failure; throw failure;
} }
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 (code !== 0) {
throw new Error(error.trim() || `claude exited with ${code}`); throw new Error(error.trim() || `claude exited with ${code}`);
@@ -283,21 +343,6 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
return result.result; return result.result;
} }
// Polls every 100 ms until the login prints something, failing if it exits first.
async function pollForInstructions(
shownText: () => string,
status: Promise<number>,
): Promise<void> {
if (shownText().trim() !== "") {
return;
}
const exited = await Promise.race([status.then(() => true), sleep(100, false)]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
await pollForInstructions(shownText, status);
}
async function codexDeviceLogin( async function codexDeviceLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: ReadonlyMap<string, string>, environment: ReadonlyMap<string, string>,
@@ -319,7 +364,15 @@ async function codexDeviceLogin(
}; };
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]); const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
await sleep(3000); await sleep(3000);
await pollForInstructions(() => shown, status); while (shown.trim() === "") {
const exited = await Promise.race([
status.then(() => true),
sleep(100, false),
]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
}
await postAuthorComment(withProfile(shown, profile)); await postAuthorComment(withProfile(shown, profile));
const [code] = await Promise.all([status, drained]); const [code] = await Promise.all([status, drained]);
if (code !== 0) { if (code !== 0) {
@@ -348,7 +401,10 @@ async function codexApiLogin(
} }
} }
async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> { async function makeProfileFile(
profile: RuntimeProfile,
name: string,
): Promise<string> {
const path = `${profile.home}/${name}`; const path = `${profile.home}/${name}`;
await writeFile(path, "", { mode: 0o600 }); await writeFile(path, "", { mode: 0o600 });
await chmod(path, 0o600); await chmod(path, 0o600);
@@ -361,7 +417,8 @@ async function runCodex(
prompt: string, prompt: string,
authentication: CodexAuthentication, authentication: CodexAuthentication,
): Promise<string> { ): Promise<string> {
const token = authentication.kind === "api-key" ? authentication.token : undefined; const token =
authentication.kind === "api-key" ? authentication.token : undefined;
const environment = agentEnvironment(PROCESS_ENV, profile, token); const environment = agentEnvironment(PROCESS_ENV, profile, token);
if (authentication.kind === "api-key") { if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token); await codexApiLogin(profile, environment, authentication.token);
@@ -417,7 +474,9 @@ async function runCodex(
await codexDeviceLogin(profile, environment); await codexDeviceLogin(profile, environment);
}); });
if (result.code !== 0) { if (result.code !== 0) {
const failure = new Error(result.error.trim() || `codex exited with ${result.code}`); const failure = new Error(
result.error.trim() || `codex exited with ${result.code}`,
);
if (isCodexAuthFailure(result.error)) { if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure }); throw new AgentAuthError("codex sign-in failed", { cause: failure });
} }
@@ -442,20 +501,28 @@ async function runOpenCode(
const collected = collectOpenCodeOutput( const collected = collectOpenCodeOutput(
child.stdout, child.stdout,
child.stderr, child.stderr,
(chunk) => echo(process.stdout, chunk), async (chunk) => {
(chunk) => echo(process.stderr, chunk), await echo(process.stdout, chunk);
},
async (chunk) => {
await echo(process.stderr, chunk);
},
); );
const [code, streams] = await Promise.all([status, collected]); const [code, streams] = await Promise.all([status, collected]);
const error = streams.stderr.trim(); const error = streams.stderr.trim();
if (code !== 0) { if (code !== 0) {
throw openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${code}`); throw (
openCodeError(streams.stdout) ??
new Error(error || `opencode exited with ${code}`)
);
} }
return parseOpenCodeEvents(streams.stdout); return parseOpenCodeEvents(streams.stdout);
} }
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> { async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile); const token = await readProfileToken(profile);
const authentication = profile.type === "codex" ? codexAuthentication(token ?? undefined) : null; const authentication =
profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null;
let stagedCodexAuth: string | null = null; let stagedCodexAuth: string | null = null;
let body: string | null = null; let body: string | null = null;
let resultPath: string | null = null; let resultPath: string | null = null;
@@ -463,15 +530,20 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
let anchorsPath: string | null = null; let anchorsPath: string | null = null;
try { try {
if (authentication?.kind === "persistent") { if (authentication?.kind === "persistent") {
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid); stagedCodexAuth = await stageCodexAuth(
CODEX_AUTH,
profile.home,
profile.uid,
profile.gid,
);
} }
await prepareProfile(profile); await prepareProfile(profile);
const rendered = await renderPrompt(profile); const rendered = await renderPrompt(profile);
reviewPath = rendered.reviewPath; reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath; anchorsPath = rendered.anchorsPath;
if (profile.type === "claude") { if (profile.type === "CLAUDE") {
body = await runClaude(profile, token ?? "", rendered.prompt); body = await runClaude(profile, token ?? "", rendered.prompt);
} else if (profile.type === "codex") { } else if (profile.type === "CODEX") {
resultPath = await runCodex( resultPath = await runCodex(
profile, profile,
rendered.prompt, rendered.prompt,
+42 -16
View File
@@ -1,6 +1,10 @@
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts"; import {
decryptCredential,
encryptCredential,
parseCredentials,
} from "./credentials.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"; const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
@@ -12,16 +16,21 @@ test("decrypts a known AES-256-GCM credential", async () => {
}); });
test("encrypts a credential with a fresh supplied nonce", async () => { test("encrypts a credential with a fresh supplied nonce", async () => {
expect(await encryptCredential("secret-token", MASTER_KEY, NONCE)).toStrictEqual(ENCRYPTED); expect(
await encryptCredential("secret-token", MASTER_KEY, NONCE),
).toStrictEqual(ENCRYPTED);
}); });
test("rejects a credential encrypted by another master key", async () => { test("rejects a credential encrypted by another master key", async () => {
await expect(() => decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow(); await expect(decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow();
}); });
test("rejects a credential with a nonstandard nonce", async () => { test("rejects a credential with a nonstandard nonce", async () => {
await expect(() => await expect(
decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY), decryptCredential(
"v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA",
MASTER_KEY,
),
).rejects.toThrow("nonce must be 12 bytes"); ).rejects.toThrow("nonce must be 12 bytes");
}); });
@@ -31,9 +40,9 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
JSON.stringify({ JSON.stringify({
"reviewer-token": ENCRYPTED, "reviewer-token": ENCRYPTED,
profiles: [ profiles: [
{ type: "codex", model: "gpt-custom" }, { type: "CODEX", model: "gpt-custom" },
{ type: "claude", token: ENCRYPTED }, { type: "CLAUDE", token: ENCRYPTED },
{ type: "opencode", token: ENCRYPTED }, { type: "OPENCODE", token: ENCRYPTED },
], ],
}), }),
MASTER_KEY, MASTER_KEY,
@@ -41,14 +50,14 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
).toStrictEqual({ ).toStrictEqual({
reviewerToken: "secret-token", reviewerToken: "secret-token",
profiles: [ profiles: [
{ type: "codex", model: "gpt-custom" }, { type: "CODEX", model: "gpt-custom" },
{ {
type: "claude", type: "CLAUDE",
model: "claude-sonnet-5", model: "claude-sonnet-5",
token: "secret-token", token: "secret-token",
}, },
{ {
type: "opencode", type: "OPENCODE",
model: "openrouter/qwen/qwen3.8-27b", model: "openrouter/qwen/qwen3.8-27b",
token: "secret-token", token: "secret-token",
}, },
@@ -57,13 +66,30 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
}); });
test("rejects malformed central credentials", async () => { test("rejects malformed central credentials", async () => {
await expect(() => parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow( await expect(
"reviewer-token", parseCredentials('{"profiles": []}', MASTER_KEY),
); ).rejects.toThrow("reviewer-token");
await expect(() => await expect(
parseCredentials( parseCredentials(
JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }), JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "CLAUDE" }],
}),
MASTER_KEY, MASTER_KEY,
), ),
).rejects.toThrow("profiles[0].token"); ).rejects.toThrow("profiles[0].token");
}); });
test("reads lowercase profile types from older credentials", async () => {
const credentials = await parseCredentials(
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "codex" }, { type: "claude", token: ENCRYPTED }],
}),
MASTER_KEY,
);
expect(credentials.profiles.map(({ type }) => type)).toStrictEqual([
"CODEX",
"CLAUDE",
]);
});
+80 -28
View File
@@ -3,18 +3,18 @@ import { webcrypto } from "node:crypto";
import { field, isMapping } from "#common"; import { field, isMapping } from "#common";
import type { BotProfile } from "#profiles"; import type { BotProfile } from "#profiles";
interface DefaultModels { // The model a profile runs when profiles.json names none.
claude: string; function defaultModel(type: BotProfile["type"]): string {
codex: string; switch (type) {
opencode: string; case "CLAUDE":
return "claude-sonnet-5";
case "CODEX":
return "gpt-5.6-terra";
case "OPENCODE":
return "openrouter/qwen/qwen3.8-27b";
}
} }
const DEFAULT_MODELS: DefaultModels = {
claude: "claude-sonnet-5",
codex: "gpt-5.6-terra",
opencode: "openrouter/qwen/qwen3.8-27b",
};
export interface BotCredentials { export interface BotCredentials {
reviewerToken: string; reviewerToken: string;
profiles: BotProfile[]; profiles: BotProfile[];
@@ -23,27 +23,55 @@ export interface BotCredentials {
function decodeBase64Url(value: string): Uint8Array { function decodeBase64Url(value: string): Uint8Array {
const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (character) => character.codePointAt(0) ?? 0); return Uint8Array.from(
atob(padded),
(character) => character.codePointAt(0) ?? 0,
);
} }
function encodeBase64Url(value: Uint8Array): string { function encodeBase64Url(value: Uint8Array): string {
const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join(""); const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join(
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); "",
);
return btoa(binary)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
} }
const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer; const buffer = (value: Uint8Array): ArrayBuffer =>
Uint8Array.from(value).buffer;
function importMasterKey(encoded: string, usage: webcrypto.KeyUsage): Promise<webcrypto.CryptoKey> { async function importMasterKey(
encoded: string,
usage: webcrypto.KeyUsage,
): Promise<webcrypto.CryptoKey> {
const bytes = decodeBase64Url(encoded); const bytes = decodeBase64Url(encoded);
if (bytes.length !== 32) { if (bytes.length !== 32) {
throw new Error("master-key must be 32 bytes encoded as base64url"); throw new Error("master-key must be 32 bytes encoded as base64url");
} }
return webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]); const key = await webcrypto.subtle.importKey(
"raw",
buffer(bytes),
"AES-GCM",
false,
[usage],
);
return key;
} }
export async function decryptCredential(encrypted: string, masterKey: string): Promise<string> { export async function decryptCredential(
const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = encrypted.split(":"); encrypted: string,
if (version !== "v1" || encodedNonce === "" || encodedCiphertext === "" || extra !== "") { masterKey: string,
): Promise<string> {
const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] =
encrypted.split(":");
if (
version !== "v1" ||
encodedNonce === "" ||
encodedCiphertext === "" ||
extra !== ""
) {
throw new Error("credential must use v1:<nonce>:<ciphertext>"); throw new Error("credential must use v1:<nonce>:<ciphertext>");
} }
const nonce = decodeBase64Url(encodedNonce); const nonce = decodeBase64Url(encodedNonce);
@@ -74,7 +102,22 @@ export async function encryptCredential(
return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`; return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`;
} }
async function parseProfile(entry: unknown, index: number, masterKey: string): Promise<BotProfile> { // profiles.json files written before the UPPER_SNAKE switch spell types in lowercase.
const OLD_PROFILE_TYPES = new Map<unknown, BotProfile["type"]>([
["claude", "CLAUDE"],
["codex", "CODEX"],
["opencode", "OPENCODE"],
]);
function currentSpelling(type: unknown): unknown {
return OLD_PROFILE_TYPES.get(type) ?? type;
}
async function parseProfile(
entry: unknown,
index: number,
masterKey: string,
): Promise<BotProfile> {
const at = `profiles[${index}]`; const at = `profiles[${index}]`;
if (!isMapping(entry)) { if (!isMapping(entry)) {
throw new TypeError(`${at}: expected a mapping`); throw new TypeError(`${at}: expected a mapping`);
@@ -84,16 +127,16 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
throw new Error(`${at}.${key}: unknown field`); throw new Error(`${at}.${key}: unknown field`);
} }
} }
const type = field(entry, "type"); const type = currentSpelling(field(entry, "type"));
const model = field(entry, "model"); const model = field(entry, "model");
const token = field(entry, "token"); const token = field(entry, "token");
if (type !== "claude" && type !== "codex" && type !== "opencode") { if (type !== "CLAUDE" && type !== "CODEX" && type !== "OPENCODE") {
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`); throw new Error(`${at}.type: expected "CLAUDE", "CODEX", or "OPENCODE"`);
} }
if (model !== undefined && (typeof model !== "string" || model === "")) { if (model !== undefined && (typeof model !== "string" || model === "")) {
throw new Error(`${at}.model: expected a model name`); throw new Error(`${at}.model: expected a model name`);
} }
if ((type === "claude" || type === "opencode") && typeof token !== "string") { if ((type === "CLAUDE" || type === "OPENCODE") && typeof token !== "string") {
throw new Error(`${at}.token: expected an encrypted credential`); throw new Error(`${at}.token: expected an encrypted credential`);
} }
if (token !== undefined && typeof token !== "string") { if (token !== undefined && typeof token !== "string") {
@@ -101,14 +144,17 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
} }
return { return {
type, type,
model: model ?? DEFAULT_MODELS[type], model: model ?? defaultModel(type),
...(typeof token === "string" && { ...(typeof token === "string" && {
token: await decryptCredential(token, masterKey), token: await decryptCredential(token, masterKey),
}), }),
}; };
} }
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> { export async function parseCredentials(
text: string,
masterKey: string,
): Promise<BotCredentials> {
const value: unknown = JSON.parse(text); const value: unknown = JSON.parse(text);
if (!isMapping(value)) { if (!isMapping(value)) {
throw new TypeError("credentials must be a mapping"); throw new TypeError("credentials must be a mapping");
@@ -127,7 +173,13 @@ export async function parseCredentials(text: string, masterKey: string): Promise
throw new Error("profiles must contain at least one profile"); throw new Error("profiles must contain at least one profile");
} }
const parsed = await Promise.all( const parsed = await Promise.all(
profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)), profiles.map(async (entry: unknown, index) => {
const profile = await parseProfile(entry, index, masterKey);
return profile;
}),
); );
return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed }; return {
reviewerToken: await decryptCredential(reviewerToken, masterKey),
profiles: parsed,
};
} }
+103 -76
View File
@@ -1,5 +1,12 @@
import { spawnSync } from "node:child_process"; 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";
@@ -12,91 +19,111 @@ 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 { function runAs(
const result = spawnSync(command, [path], { cwd: "/", uid: profile.uid, gid: profile.gid }); profile: RuntimeProfile,
command: string,
path: string,
): boolean {
const result = spawnSync(command, [path], {
cwd: "/",
uid: profile.uid,
gid: profile.gid,
});
return result.status === 0; return result.status === 0;
} }
async function expectPrivate(path: string, uid: number, mode: number): Promise<void> { async function expectPrivate(
path: string,
uid: number,
mode: number,
): Promise<void> {
const info = await stat(path); const info = await stat(path);
expect(info.uid).toStrictEqual(uid); expect(info.uid).toStrictEqual(uid);
expect(info.mode & 0o777).toBe(mode); expect(info.mode & 0o777).toBe(mode);
} }
test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credentials", async () => { test.skipIf(!IS_ROOT)(
const root = await makeTempDir(); "prepares root-only reviewer and isolated profile credentials",
await chmod(root, 0o711); async () => {
const action = await makeTempDir(); const root = await makeTempDir();
try { await chmod(root, 0o711);
await writeFile( const action = await makeTempDir();
`${action}/profiles.json`, try {
JSON.stringify({ await writeFile(
"reviewer-token": ENCRYPTED, `${action}/profiles.json`,
profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }], JSON.stringify({
}), "reviewer-token": ENCRYPTED,
); profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }],
}),
);
const manifest = await prepareRuntime(action, root, MASTER_KEY); const manifest = await prepareRuntime(action, root, MASTER_KEY);
const serialized = await readFile(manifest.manifestPath, "utf8"); const serialized = await readFile(manifest.manifestPath, "utf8");
assert(!serialized.includes("secret-token")); assert(!serialized.includes("secret-token"));
expect(await readFile(manifest.reviewerFile, "utf8")).toBe("secret-token"); expect(await readFile(manifest.reviewerFile, "utf8")).toBe(
await expectPrivate(manifest.reviewerFile, 0, 0o600); "secret-token",
);
await expectPrivate(manifest.reviewerFile, 0, 0o600);
const [claude, codex] = manifest.profiles; const [claude, codex] = manifest.profiles;
assert(claude !== undefined && codex !== undefined); assert(claude !== undefined && codex !== undefined);
expect(claude.uid).not.toStrictEqual(codex.uid); expect(claude.uid).not.toStrictEqual(codex.uid);
await expectPrivate(claude.home, claude.uid, 0o700); await expectPrivate(claude.home, claude.uid, 0o700);
assert(claude.tokenFile !== null); assert(claude.tokenFile !== null);
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token"); expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
await expectPrivate(claude.tokenFile, claude.uid, 0o600); await expectPrivate(claude.tokenFile, claude.uid, 0o600);
const codexHome = await stat(codex.home); const codexHome = await stat(codex.home);
expect(codexHome.uid).toStrictEqual(codex.uid); expect(codexHome.uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null); expect(codex.tokenFile).toBe(null);
const ownToken = spawnSync("/bin/cat", [claude.tokenFile], { const ownToken = spawnSync("/bin/cat", [claude.tokenFile], {
cwd: "/", cwd: "/",
uid: claude.uid, uid: claude.uid,
gid: claude.gid, gid: claude.gid,
encoding: "utf8", encoding: "utf8",
}); });
expect(ownToken.status).toBe(0); expect(ownToken.status).toBe(0);
expect(ownToken.stdout).toBe("secret-token"); expect(ownToken.stdout).toBe("secret-token");
expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false); expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
expect(runAs(claude, "/bin/ls", codex.home)).toBe(false); expect(runAs(claude, "/bin/ls", codex.home)).toBe(false);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
await rm(action, { recursive: true }); await rm(action, { recursive: true });
} }
}); },
);
test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fails", async () => { test.skipIf(!IS_ROOT)(
const root = await makeTempDir(); "removes prepared credentials when output publication fails",
const action = await makeTempDir(); async () => {
try { const root = await makeTempDir();
await writeFile( const action = await makeTempDir();
`${action}/profiles.json`, try {
JSON.stringify({ await writeFile(
"reviewer-token": ENCRYPTED, `${action}/profiles.json`,
profiles: [{ type: "claude", token: ENCRYPTED }], JSON.stringify({
}), "reviewer-token": ENCRYPTED,
); profiles: [{ type: "CLAUDE", token: ENCRYPTED }],
const result = spawnSync( }),
process.execPath, );
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))], const result = spawnSync(
{ process.execPath,
env: Object.fromEntries([ [fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
["ACTION_PATH", action], {
["RUNNER_TEMP", root], env: Object.fromEntries([
["BOT_AGENTS_MASTER_KEY", MASTER_KEY], ["ACTION_PATH", action],
["GITHUB_OUTPUT", action], ["RUNNER_TEMP", root],
]), ["BOT_AGENTS_MASTER_KEY", MASTER_KEY],
}, ["GITHUB_OUTPUT", action],
); ]),
},
);
expect(result.status).not.toBe(0); expect(result.status).not.toBe(0);
expect(await readdir(root)).toStrictEqual([]); expect(await readdir(root)).toStrictEqual([]);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
await rm(action, { recursive: true }); await rm(action, { recursive: true });
} }
}); },
);
+48 -35
View File
@@ -1,5 +1,13 @@
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import {
import { join } from "node:path"; chmod,
chown,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import nodePath from "node:path";
import { field, isMapping } from "#common"; import { field, isMapping } from "#common";
import type { ProfileFailure, ProfileIdentity } from "#profiles"; import type { ProfileFailure, ProfileIdentity } from "#profiles";
@@ -39,7 +47,12 @@ export interface RuntimeFailure {
export type RuntimeOutcome = RuntimeSuccess | RuntimeFailure; export type RuntimeOutcome = RuntimeSuccess | RuntimeFailure;
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> { async function writePrivate(
path: string,
value: string,
uid: number,
gid: number,
): Promise<void> {
await writeFile(path, value, { mode: 0o600 }); await writeFile(path, value, { mode: 0o600 });
await chmod(path, 0o600); await chmod(path, 0o600);
await chown(path, uid, gid); await chown(path, uid, gid);
@@ -51,49 +64,47 @@ export async function prepareRuntime(
masterKey: string, masterKey: string,
): Promise<RuntimeManifest> { ): Promise<RuntimeManifest> {
const credentials = await parseCredentials( const credentials = await parseCredentials(
await readFile(join(actionPath, "profiles.json"), "utf8"), await readFile(nodePath.join(actionPath, "profiles.json"), "utf8"),
masterKey, masterKey,
); );
const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-")); const runtimeDir = await mkdtemp(nodePath.join(runtimeRoot, "bot-agents-"));
try { try {
await chmod(runtimeDir, 0o711); await chmod(runtimeDir, 0o711);
const reviewerFile = join(runtimeDir, "reviewer-token"); const reviewerFile = nodePath.join(runtimeDir, "reviewer-token");
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0); await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
// One profile at a time, in order, as each claims the next UID. // One profile at a time, in order, as each claims the next UID.
const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>( const profiles: RuntimeProfile[] = [];
async (previous, profile, index) => { for (const [index, profile] of credentials.profiles.entries()) {
const prepared = await previous; const uid = FIRST_PROFILE_UID + index;
const uid = FIRST_PROFILE_UID + index; const home = nodePath.join(runtimeDir, `profile-${index}`);
const home = join(runtimeDir, `profile-${index}`); await mkdir(home, { mode: 0o700 });
await mkdir(home, { mode: 0o700 }); await chmod(home, 0o700);
await chmod(home, 0o700); await chown(home, uid, uid);
await chown(home, uid, uid); const tokenFile =
const tokenFile = profile.token === undefined || profile.token === ""
profile.token === undefined || profile.token === "" ? null : join(home, "token"); ? null
if (tokenFile !== null && profile.token !== undefined) { : nodePath.join(home, "token");
await writePrivate(tokenFile, profile.token, uid, uid); if (tokenFile !== null && profile.token !== undefined) {
} await writePrivate(tokenFile, profile.token, uid, uid);
prepared.push({ }
index, profiles.push({
type: profile.type, index,
model: profile.model, type: profile.type,
uid, model: profile.model,
gid: uid, uid,
home, gid: uid,
tokenFile, home,
}); tokenFile,
return prepared; });
}, }
Promise.resolve([]),
);
const manifestPath = join(runtimeDir, "manifest.json"); const manifestPath = nodePath.join(runtimeDir, "manifest.json");
const manifest: RuntimeManifest = { const manifest: RuntimeManifest = {
runtimeDir, runtimeDir,
manifestPath, manifestPath,
reviewerFile, reviewerFile,
outcomeFile: join(runtimeDir, "outcome.json"), outcomeFile: nodePath.join(runtimeDir, "outcome.json"),
profiles, profiles,
}; };
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0); await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
@@ -115,7 +126,9 @@ function isRuntimeManifest(value: unknown): value is RuntimeManifest {
); );
} }
export async function readRuntimeManifest(path: string): Promise<RuntimeManifest> { export async function readRuntimeManifest(
path: string,
): Promise<RuntimeManifest> {
const value: unknown = JSON.parse(await readFile(path, "utf8")); const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isRuntimeManifest(value)) { if (!isRuntimeManifest(value)) {
throw new Error(`${path}: invalid runtime manifest`); throw new Error(`${path}: invalid runtime manifest`);
+11 -7
View File
@@ -1,12 +1,12 @@
// Test support: the current user's IDs, a fresh temporary directory, the // Test support: the current user's IDs, a fresh temporary directory, the
// inherited environment, and a plain command runner. It imports no source module, so the repository-level // inherited environment, and a plain command runner. It imports no source
// tests in tests/, which reach src/ only through `#testing`, never touch // module, so the repository-level tests in tests/, which reach src/ only
// internals. // through `#testing`, never touch internals.
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { once } from "node:events"; import { once } from "node:events";
import { mkdtemp } from "node:fs/promises"; import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import nodePath from "node:path";
import process from "node:process"; import process from "node:process";
import { text } from "node:stream/consumers"; import { text } from "node:stream/consumers";
@@ -24,8 +24,9 @@ export const IS_ROOT: boolean = currentUid() === 0;
// Symlinks and POSIX modes, as these tests use them, need a Unix host. // Symlinks and POSIX modes, as these tests use them, need a Unix host.
export const IS_WINDOWS: boolean = process.platform === "win32"; export const IS_WINDOWS: boolean = process.platform === "win32";
export function makeTempDir(): Promise<string> { export async function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "bot-agents-test-")); const path = await mkdtemp(nodePath.join(tmpdir(), "bot-agents-test-"));
return path;
} }
export interface CommandResult { export interface CommandResult {
@@ -55,7 +56,10 @@ export async function runCommand(
env: Object.fromEntries(env), env: Object.fromEntries(env),
stdio: ["ignore", "ignore", "pipe"], stdio: ["ignore", "ignore", "pipe"],
}); });
const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]); const [stderr, closed] = await Promise.all([
text(child.stderr),
once(child, "close"),
]);
const exitCode: unknown = closed[0]; const exitCode: unknown = closed[0];
return { success: exitCode === 0, stderr }; return { success: exitCode === 0, stderr };
} }
+3 -1
View File
@@ -14,7 +14,9 @@ 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}/index.ts`, import.meta.url)); const path = fileURLToPath(
new URL(`../src/${phase}/index.ts`, import.meta.url),
);
const result = await runCommand( const result = await runCommand(
process.execPath, process.execPath,
[path], [path],
+86 -28
View File
@@ -9,13 +9,18 @@ import { inheritedEnvironment, makeTempDir, runCommand } from "#testing";
type Mapping = Map<string, unknown>; type Mapping = Map<string, unknown>;
const mapping = (value: unknown, at: string): Mapping => { const mapping = (value: unknown, at: string): Mapping => {
assert(typeof value === "object" && value !== null && !Array.isArray(value), at); assert(
typeof value === "object" && value !== null && !Array.isArray(value),
at,
);
return new Map(Object.entries(value)); return new Map(Object.entries(value));
}; };
const sequence = (value: unknown, at: string): Mapping[] => { const sequence = (value: unknown, at: string): Mapping[] => {
assert(Array.isArray(value), at); assert(Array.isArray(value), at);
return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`)); return value.map((entry: unknown, index) =>
mapping(entry, `${at}[${index}]`),
);
}; };
async function yaml(path: string): Promise<Mapping> { async function yaml(path: string): Promise<Mapping> {
@@ -40,13 +45,22 @@ const findStep = (steps: Mapping[], key: string, value: string): Mapping => {
test("all committed YAML parses", async () => { test("all committed YAML parses", async () => {
const files = await yamlFiles(); const files = await yamlFiles();
assert(files.length > 0); assert(files.length > 0);
await Promise.all(files.map((file) => yaml(file))); await Promise.all(
files.map(async (file) => {
await yaml(file);
}),
);
}); });
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([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual(["master-key"]); expect([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual([
const steps = sequence(mapping(action.get("runs"), "runs").get("steps"), "runs.steps"); "master-key",
]);
const steps = sequence(
mapping(action.get("runs"), "runs").get("steps"),
"runs.steps",
);
for (const step of steps) { for (const step of steps) {
const run = step.get("run"); const run = step.get("run");
if (typeof run === "string") { if (typeof run === "string") {
@@ -58,13 +72,21 @@ 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.get("with"), "checkout.with").get("token")).toBe("${{ gitea.token }}"); expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe(
expect(mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY")).toBe( "${{ gitea.token }}",
"${{ inputs.master-key }}", );
expect(
mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY"),
).toBe("${{ inputs.master-key }}");
expect(
JSON.stringify(Object.fromEntries(agent)).includes("BOT_AGENTS_MASTER_KEY"),
).toBe(false);
expect(
JSON.stringify(Object.fromEntries(post)).includes("BOT_AGENTS_MASTER_KEY"),
).toBe(false);
expect(mapping(agent.get("env"), "agent.env").get("GITEA_TOKEN")).toBe(
"${{ gitea.token }}",
); );
expect(JSON.stringify(Object.fromEntries(agent)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(Object.fromEntries(post)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(mapping(agent.get("env"), "agent.env").get("GITEA_TOKEN")).toBe("${{ gitea.token }}");
assert(String(post.get("if")).includes("always()")); assert(String(post.get("if")).includes("always()"));
for (const step of [prepare, agent, post]) { for (const step of [prepare, agent, post]) {
assert(String(step.get("run")).includes("steps.source.outputs.path")); assert(String(step.get("run")).includes("steps.source.outputs.path"));
@@ -79,7 +101,11 @@ test("the source protection script stages immutable action files", async () => {
environment.set("ACTION_PATH", process.cwd()); environment.set("ACTION_PATH", process.cwd());
environment.set("GITHUB_OUTPUT", outputFile); environment.set("GITHUB_OUTPUT", outputFile);
environment.set("RUNNER_TEMP", temp); environment.set("RUNNER_TEMP", temp);
const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], environment); const result = await runCommand(
"/bin/bash",
["scripts/protect-source.sh"],
environment,
);
assert(result.success, result.stderr); assert(result.success, result.stderr);
const output = await readFile(outputFile, "utf8"); const output = await readFile(outputFile, "utf8");
@@ -94,7 +120,12 @@ test("the source protection script stages immutable action files", async () => {
"profiles.json", "profiles.json",
"prompt.md", "prompt.md",
]; ];
const stats = await Promise.all(files.map((file) => stat(`${path}/${file}`))); const stats = await Promise.all(
files.map(async (file) => {
const info = await stat(`${path}/${file}`);
return info;
}),
);
for (const [index, info] of stats.entries()) { for (const [index, info] of stats.entries()) {
assert(info.isFile(), files[index]); assert(info.isFile(), files[index]);
expect(info.mode & 0o022, files[index]).toStrictEqual(0); expect(info.mode & 0o022, files[index]).toStrictEqual(0);
@@ -107,10 +138,15 @@ test("the source protection script stages immutable action files", async () => {
test("module-relative runtime assets exist", async () => { test("module-relative runtime assets exist", async () => {
const module = new URL("../src/run/index.ts", import.meta.url); const module = new URL("../src/run/index.ts", import.meta.url);
const source = await readFile(module, "utf8"); const source = await readFile(module, "utf8");
const assets = [...source.matchAll(/new URL\("(?<asset>[^"]+)", import\.meta\.url\)/gu)].map( const assets = [
([, asset = ""]) => new URL(asset, module), ...source.matchAll(/new URL\("(?<asset>[^"]+)", import\.meta\.url\)/gu),
].map(([, asset = ""]) => new URL(asset, module));
const stats = await Promise.all(
assets.map(async (asset) => {
const info = await stat(asset);
return info;
}),
); );
const stats = await Promise.all(assets.map((asset) => stat(asset)));
for (const [index, info] of stats.entries()) { for (const [index, info] of stats.entries()) {
assert(info.isFile(), assets[index]?.pathname); assert(info.isFile(), assets[index]?.pathname);
} }
@@ -118,7 +154,10 @@ 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.get("jobs"), "jobs").get("agent"), "jobs.agent"); const job = mapping(
mapping(workflow.get("jobs"), "jobs").get("agent"),
"jobs.agent",
);
const permissions = mapping(job.get("permissions"), "jobs.agent.permissions"); const permissions = mapping(job.get("permissions"), "jobs.agent.permissions");
expect(Object.fromEntries(permissions)).toStrictEqual({ expect(Object.fromEntries(permissions)).toStrictEqual({
code: "write", code: "write",
@@ -126,17 +165,30 @@ test("the repository uses its trusted main action", async () => {
"pull-requests": "write", "pull-requests": "write",
}); });
const steps = sequence(job.get("steps"), "jobs.agent.steps"); const steps = sequence(job.get("steps"), "jobs.agent.steps");
expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual(-1); expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual(
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main"); -1,
);
const trusted = findStep(
steps,
"uses",
"https://git.capsulizers.com/commons/bot-agents@main",
);
expect(mapping(trusted.get("with"), "trusted.with").get("master-key")).toBe( expect(mapping(trusted.get("with"), "trusted.with").get("master-key")).toBe(
"${{ secrets.BOT_AGENTS_MASTER_KEY }}", "${{ secrets.BOT_AGENTS_MASTER_KEY }}",
); );
expect(JSON.stringify(Object.fromEntries(workflow)).includes("gitea.actor != 'bot'")).toBe(true); expect(
JSON.stringify(Object.fromEntries(workflow)).includes(
"gitea.actor != 'bot'",
),
).toBe(true);
}); });
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.get("jobs"), "jobs").get("check"), "jobs.check"); const job = mapping(
mapping(workflow.get("jobs"), "jobs").get("check"),
"jobs.check",
);
const commands = sequence(job.get("steps"), "jobs.check.steps") const commands = sequence(job.get("steps"), "jobs.check.steps")
.map((step) => { .map((step) => {
const run = step.get("run"); const run = step.get("run");
@@ -155,7 +207,8 @@ test("uses the standard Skills installer without custom discovery", async () =>
assert(runner.includes("skills add obra/superpowers")); assert(runner.includes("skills add obra/superpowers"));
const removed = await stat("superpowers.ts").then( const removed = await stat("superpowers.ts").then(
() => false, () => false,
(error: unknown) => error instanceof Error && "code" in error && error.code === "ENOENT", (error: unknown) =>
error instanceof Error && "code" in error && error.code === "ENOENT",
); );
assert(removed, "superpowers.ts should be removed"); assert(removed, "superpowers.ts should be removed");
}); });
@@ -176,13 +229,18 @@ test("profile preparation does not receive provider credentials", async () => {
expect(runner.includes("prepareProfile(profile, token)")).toBe(false); expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
}); });
test("central profiles fall back from Codex to Claude to OpenCode", async () => { test("central profiles fall back from Claude to Codex to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); const credentials: unknown = JSON.parse(
const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles"); await readFile("profiles.json", "utf8"),
);
const profiles = sequence(
mapping(credentials, "profiles.json").get("profiles"),
"profiles",
);
expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([ expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([
"codex", "CLAUDE",
"claude", "CODEX",
"opencode", "OPENCODE",
]); ]);
expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b"); expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.get("token")).startsWith("v1:")); assert(String(profiles[2]?.get("token")).startsWith("v1:"));
+5
View File
@@ -5,6 +5,11 @@ import { defineConfig } from "vite-plus";
import projectLint, { projectOverrides } from "./scripts/project-lint.ts"; import projectLint, { projectOverrides } from "./scripts/project-lint.ts";
export default defineConfig({ export default defineConfig({
fmt: {
printWidth: 80,
quoteProps: "preserve",
embeddedLanguageFormatting: "off",
},
lint: { lint: {
plugins: ["typescript", "unicorn", "oxc", "import"], plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: [ jsPlugins: [