7 Commits

25 changed files with 989 additions and 304 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):
`eslint-plugin-import-structure` (`modules` config) and
`eslint-plugin-explicit-types` (`concrete` and `absence` configs) are
devDependencies, enabled in `vite.config.ts`. explicit-types' `naming`
config is left off: its `upper-snake-string-unions` and
`no-upper-snake-object-keys` rules fire on wire tokens this repo does not
name (profile types such as `"claude"`, failure reasons posted to Gitea,
environment variable names), which cannot change without changing
behavior. Open question for the user. `eslint-plugin-lit-robust` does not apply
(no Lit).
devDependencies, enabled in `vite.config.ts` through their `all` configs.
`upper-snake-string-unions` was off while the user decided; since
2026-10-19 it is on and profile types, failure reasons, and anchor sides
are `UPPER_SNAKE` ([[enum-values-upper-snake]]). `eslint-plugin-lit-robust`
does not apply (no Lit).
- **Stricter input checks were accepted** in the migration: Gitea `/user` and
comment responses, the runtime manifest, and the outcome's profile type are
validated; some type errors became `TypeError` with the same messages.
+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]]).
+32
View File
@@ -47,5 +47,37 @@ rules: {
overrides: projectOverrides,
```
## Rule decisions (2026-10-19, all repos)
- `typescript/promise-function-async` is on with `require-await` and
`typescript/return-await`. A function that returns a promise is `async`
and awaits it: `async f() { const x = await g(); return x; }`. Test
doubles do the same (`const resolved = await Promise.resolve(v); return
resolved;`), and `expect(promise).rejects` takes the promise itself.
`src/processes/child.ts` is exempt in `projectOverrides`: `exitCode` must
return the very promise it marks handled, or a failed spawn would surface
as an unhandled rejection before callers await it.
- `init-declarations` is on: an uninitialized `let` becomes
`let x: T | null = null;` (`unknown` already includes `null`); a `try` that
assigns and a `catch` that exits still narrow it.
- `no-use-before-define` is on with only `{ functions: false }`.
- `no-await-in-loop` is off (sequential awaits are intentional) and
`unicorn/no-array-reduce` is on: sequential steps are plain `for` loops,
never `reduce` chains or recursion.
- `consistent-return`, `typescript/switch-exhaustiveness-check`, and
`default-case` are off: TypeScript's checks cover them, and no `default:`
branch is added to an exhaustive switch.
- `unknown` is allowed; `any` is banned and the `typescript/no-unsafe-*`
rules are on. Untrusted data is `unknown` plus a type guard.
- `unicorn/no-array-for-each` (use `for...of`),
`unicorn/prefer-structured-clone`, and `typescript/prefer-nullish-coalescing`
(defaults) stay on.
- explicit-types `upper-snake-string-unions` is on
([[enum-values-upper-snake]]); `unicorn/custom-error-definition` is on, so a
custom error has a constructor that sets `this.name`.
- `src/post/` and `src/profiles/` (code and tests) are exempt from
`project/prefer-ascii` in `projectOverrides`: posted Gitea text shows emoji
verdict marks and a middle dot.
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]].
+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,
built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...).
- **Custom rules**, enabled as `project/<rule>`. Every repo has
`no-disable-comments` and `no-asset-imports`; 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
more files, they sit flat beside it and share the prefix
(`project-lint-modules.ts`, ...); `scripts/project-lint.ts` keeps the plugin
object and the rule list.
`scripts/` stays flat; don't add `scripts/rules/`. All rules live in the one
`scripts/project-lint.ts` (no file splitting), next to the plugin object and
the rule list.
```ts
import type { DummyRuleMap } from "vite-plus/lint";
+4 -4
View File
@@ -9,7 +9,7 @@
"@eslint/core": "^0.17.0",
"@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.6.1",
"eslint-plugin-explicit-types": "^0.7.0",
"eslint-plugin-import-structure": "^0.10.2",
"vite-plus": "^1.2.0",
"yaml": "^2.9.1"
@@ -1890,9 +1890,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-explicit-types": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.6.1.tgz",
"integrity": "sha512-8JU2Ceoi1aLbhYo4LHwb9Eone/pJuMnzVP1ZlGJn/ETxVzJQFziRnJwOF2dRmInn9dGqvwq05dc3o5tVyTGP2Q==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.7.0.tgz",
"integrity": "sha512-TCX40NrxEKH+zXhIPvuY6e9dWjhHfAlEbkNlcyvhY7fUFG+sSIt3VMT0C/U2QGFbtVhDnV1xE7JkOlGGXzPcZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -18,7 +18,7 @@
"@eslint/core": "^0.17.0",
"@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.6.1",
"eslint-plugin-explicit-types": "^0.7.0",
"eslint-plugin-import-structure": "^0.10.2",
"vite-plus": "^1.2.0",
"yaml": "^2.9.1"
+3 -3
View File
@@ -2,16 +2,16 @@
"reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY",
"profiles": [
{
"type": "codex",
"type": "CODEX",
"model": "gpt-5.6-terra"
},
{
"type": "claude",
"type": "CLAUDE",
"model": "claude-sonnet-5",
"token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg"
},
{
"type": "opencode",
"type": "OPENCODE",
"model": "openrouter/qwen/qwen3.8-27b",
"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
into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry
`{"path": "<path from the repository root>", "line": <number>, "side": "new" |
"old", "body": "<the finding>"}`.
`side` is `new` for a line in the head file and `old` for one only in the base
`{"path": "<path from the repository root>", "line": <number>, "side": "NEW" |
"OLD", "body": "<the finding>"}`.
`side` is `NEW` for a line in the head file and `OLD` for one only in the base
file; `line` is that file's own line number, and it must be a line the diff
touches, or Gitea refuses the anchor. Write the file only when there is
something to anchor, and keep each body to the what, the why, and the how, with
+557 -21
View File
@@ -1,20 +1,29 @@
/**
* `project` — this repository's changes to the Oxlint categories in
* `vite.config.ts`. Its rules of its own are `no-disable-comments` and
* `no-asset-imports`.
* `project` - this repository's changes to the Oxlint categories in
* `vite.config.ts`. Its rules of its own are `no-disable-comments`,
* `no-asset-imports`, `prefer-ascii`, and ports of the hugoalh `recommended`
* rules Oxlint lacks, as in eslint-plugin-import-structure's own lint.
*
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
*
* @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 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 {
getAllComments: () => TSESTree.Comment[];
getLocFromIndex: (index: number) => Position;
}
/** 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";
/**
* `no-disable-comments` — rejects every `eslint-disable*`, `eslint-enable*`,
* `no-disable-comments` - rejects every `eslint-disable*`, `eslint-enable*`,
* `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has
* no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them
* itself: when a rule fires, fix the source.
@@ -68,6 +77,501 @@ const noDisableCommentsRule: ProjectRule = {
},
};
/** The highest code point `prefer-ascii` lets through. */
const MAX_ASCII_CODE_POINT: number = 127;
/**
* `prefer-ascii` - every character of the source is ASCII. A file is exempted
* only through an entry in `projectOverrides`.
*/
const preferAsciiRule: ProjectRule = {
meta: {
type: "suggestion",
docs: { description: "Require the source to be ASCII" },
schema: [],
messages: {
nonAscii: "Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.",
},
},
create(context) {
return {
Program(_node: TSESTree.Program): void {
const { sourceCode } = context;
let index: number = 0;
for (const character of sourceCode.text) {
const codePoint: number = character.codePointAt(0) ?? 0;
if (codePoint > MAX_ASCII_CODE_POINT) {
context.report({
loc: {
start: sourceCode.getLocFromIndex(index),
end: sourceCode.getLocFromIndex(index + character.length),
},
messageId: "nonAscii",
data: { character, codePoint: codePoint.toString(16).toUpperCase().padStart(4, "0") },
});
}
index += character.length;
}
},
};
},
};
/** `no-duplicate-typeofs` - `typeof typeof x` is always `"string"`. */
const noDuplicateTypeofsRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow `typeof` applied twice in a row" },
schema: [],
messages: { doubled: 'Multiple `typeof`s always return `"string"`, possibly not intended.' },
},
create(context) {
return {
UnaryExpression(node: TSESTree.UnaryExpression): void {
if (
node.operator === "typeof" &&
node.argument.type === AST_NODE_TYPES.UnaryExpression &&
node.argument.operator === "typeof"
) {
context.report({ node, messageId: "doubled" });
}
},
};
},
};
/** `no-empty-yield` - a `yield` with no expression, likely missing one. */
const noEmptyYieldRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow `yield` without an expression" },
schema: [],
messages: { empty: "Empty `yield` is not allowed, possibly missing the expression." },
},
create(context) {
return {
YieldExpression(node: TSESTree.YieldExpression): void {
if (node.argument === null) {
context.report({ node, messageId: "empty" });
}
},
};
},
};
/** The globals `no-nan` sees through, as in `globalThis.Number.NaN`. */
const GLOBAL_NAMES: ReadonlySet<string> = new Set<string>(["globalThis", "self", "window"]);
/** The name a member expression's property spells, computed or not. */
function propertyName(node: TSESTree.MemberExpression): string | null {
if (node.property.type === AST_NODE_TYPES.Identifier && !node.computed) {
return node.property.name;
}
if (node.property.type === AST_NODE_TYPES.Literal && typeof node.property.value === "string") {
return node.property.value;
}
return null;
}
/** Whether `node` is a global object, such as `globalThis` or `globalThis.self`. */
function isGlobalChain(node: TSESTree.Expression): boolean {
if (node.type === AST_NODE_TYPES.Identifier) {
return GLOBAL_NAMES.has(node.name);
}
return (
node.type === AST_NODE_TYPES.MemberExpression &&
GLOBAL_NAMES.has(propertyName(node) ?? "") &&
isGlobalChain(node.object)
);
}
/**
* Whether a member expression reads the global `NaN`: `Number.NaN`,
* `globalThis.NaN`, or `globalThis.Number.NaN`.
*/
function isNanMember(node: TSESTree.MemberExpression): boolean {
if (propertyName(node) !== "NaN") {
return false;
}
const { object } = node;
if (object.type === AST_NODE_TYPES.Identifier && object.name === "Number") {
return true;
}
return (
isGlobalChain(object) ||
(object.type === AST_NODE_TYPES.MemberExpression &&
propertyName(object) === "Number" &&
isGlobalChain(object.object))
);
}
/** `no-nan` - a `NaN` value is usually an error; use `null` for "no number". */
const noNanRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow `NaN` as a value" },
schema: [],
messages: { nan: "Using `NaN` as a value is usually an error and not intended." },
},
create(context) {
return {
Identifier(node: TSESTree.Identifier): void {
if (node.name === "NaN" && node.parent.type !== AST_NODE_TYPES.MemberExpression) {
context.report({ node, messageId: "nan" });
}
},
MemberExpression(node: TSESTree.MemberExpression): void {
if (isNanMember(node)) {
context.report({ node, messageId: "nan" });
}
},
};
},
};
/** `no-unsafe-number` - a number literal outside the safe integer range. */
const noUnsafeNumberRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow number literals outside the safe integer range" },
schema: [],
messages: {
unsafe:
"Number literals with values equal to 2^53 or greater are too large to be represented accurately.",
},
},
create(context) {
return {
Literal(node: TSESTree.Literal): void {
if (
typeof node.value === "number" &&
(node.value < Number.MIN_SAFE_INTEGER || node.value > Number.MAX_SAFE_INTEGER)
) {
context.report({ node, messageId: "unsafe" });
}
},
};
},
};
/** A loop statement, whose last `continue` `no-useless-continue` checks. */
type LoopStatement =
| TSESTree.DoWhileStatement
| TSESTree.ForInStatement
| TSESTree.ForOfStatement
| TSESTree.ForStatement
| TSESTree.WhileStatement;
/** A loop's trailing unlabeled `continue`, and whether removing it is a safe fix. */
interface TrailingContinue {
statement: TSESTree.ContinueStatement;
removable: boolean;
}
/**
* The unlabeled `continue` a loop ends with, and whether removing it is a
* safe fix: it is in a block, rather than the loop's whole body.
*/
function trailingContinue(node: LoopStatement): TrailingContinue | null {
if (node.body.type === AST_NODE_TYPES.BlockStatement) {
const last = node.body.body.at(-1);
return last?.type === AST_NODE_TYPES.ContinueStatement && last.label === null
? { statement: last, removable: true }
: null;
}
if (node.body.type === AST_NODE_TYPES.ContinueStatement && node.body.label === null) {
return { statement: node.body, removable: false };
}
return null;
}
/**
* `no-useless-continue` - an unlabeled `continue` that ends a loop body. A
* trailing `continue;` in a block is removed by the fix; a loop whose whole
* body is `continue;` is only reported.
*/
const noUselessContinueRule: ProjectRule = {
meta: {
type: "suggestion",
docs: { description: "Disallow an unlabeled `continue` at the end of a loop" },
fixable: "code",
schema: [],
messages: {
useless: "The statement `continue` at the end of the loop and without a label is useless.",
},
},
create(context) {
function check(node: LoopStatement): void {
const found: TrailingContinue | null = trailingContinue(node);
if (found === null) {
return;
}
if (found.removable) {
context.report({
node: found.statement,
messageId: "useless",
fix: (fixer): RuleTextEdit => fixer.remove(found.statement),
});
} else {
context.report({ node: found.statement, messageId: "useless" });
}
}
return {
DoWhileStatement: check,
ForInStatement: check,
ForOfStatement: check,
ForStatement: check,
WhileStatement: check,
};
},
};
/** Node keys that say where a node is, not what it is, in ESLint and Oxlint. */
const POSITION_KEYS: ReadonlySet<string> = new Set<string>([
"parent",
"range",
"loc",
"start",
"end",
]);
/** Node types whose member lists mean the same in any order. */
const UNORDERED_TYPES: ReadonlySet<string> = new Set<string>([
"TSInterfaceBody",
"TSTypeLiteral",
"TSUnionType",
"TSIntersectionType",
]);
/** Type annotations too simple for an alias of their own (`no-useless-type`). */
const SIMPLE_TYPES: ReadonlySet<string> = new Set<string>([
"TSAnyKeyword",
"TSBigIntKeyword",
"TSBooleanKeyword",
"TSIntrinsicKeyword",
"TSNeverKeyword",
"TSNullKeyword",
"TSNumberKeyword",
"TSObjectKeyword",
"TSStringKeyword",
"TSSymbolKeyword",
"TSUndefinedKeyword",
"TSUnknownKeyword",
"TSVoidKeyword",
]);
/**
* A node's shape as text, without positions, so two declarations compare
* alike wherever they sit. Members of a type literal, interface body,
* union, or intersection are sorted, since their order means nothing.
*/
function serialize(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item: unknown) => serialize(item)).join(",")}]`;
}
if (typeof value !== "object" || value === null) {
return JSON.stringify(value) ?? "undefined";
}
const unordered: boolean = "type" in value && UNORDERED_TYPES.has(String(value.type));
const parts: string[] = Object.entries(value)
.filter(([key]: [string, unknown]) => !POSITION_KEYS.has(key))
.map(([key, child]: [string, unknown]) => {
const text: string =
unordered && Array.isArray(child)
? `[${child
.map((item: unknown) => serialize(item))
.toSorted()
.join(",")}]`
: serialize(child);
return `${key}:${text}`;
});
return `{${parts.join(",")}}`;
}
/** The top-level declarations in a block, exported or not, that `matches`. */
function declarationsOf<T extends TSESTree.Node>(
statements: TSESTree.Node[],
matches: (node: TSESTree.Node) => node is T,
): T[] {
const found: T[] = [];
for (const statement of statements) {
const declaration: TSESTree.Node | null =
statement.type === AST_NODE_TYPES.ExportNamedDeclaration ? statement.declaration : statement;
if (declaration !== null && matches(declaration)) {
found.push(declaration);
}
}
return found;
}
/** Whether a node is an interface declaration. */
function isInterface(node: TSESTree.Node): node is TSESTree.TSInterfaceDeclaration {
return node.type === AST_NODE_TYPES.TSInterfaceDeclaration;
}
/** Whether a node is a type alias declaration. */
function isTypeAlias(node: TSESTree.Node): node is TSESTree.TSTypeAliasDeclaration {
return node.type === AST_NODE_TYPES.TSTypeAliasDeclaration;
}
/** Groups nodes by a key and returns the groups with more than one node. */
function duplicateGroups<T>(nodes: T[], keyOf: (node: T) => string): T[][] {
const groups: Map<string, T[]> = Map.groupBy(nodes, keyOf);
return [...groups.values()].filter((group: T[]) => group.length > 1);
}
/** What an interface declares, its `extends` list included. */
function interfaceShape(node: TSESTree.TSInterfaceDeclaration): string {
return `${serialize(node.extends)}&${serialize(node.body)}`;
}
/** An interface's name. */
function interfaceName(node: TSESTree.TSInterfaceDeclaration): string {
return node.id.name;
}
/** What a type alias stands for. */
function typeAliasShape(node: TSESTree.TSTypeAliasDeclaration): string {
return serialize(node.typeAnnotation);
}
/**
* `no-duplicate-interfaces` - two interfaces in one block with the same
* name, which merge silently, or with the same shape, which could be one.
*/
const noDuplicateInterfacesRule: ProjectRule = {
meta: {
type: "suggestion",
docs: { description: "Disallow interfaces with the same name or shape in one block" },
schema: [],
messages: {
sameShape: "Found multiple interfaces with the same shape, possibly mergeable.",
sameName: "Found multiple interfaces named '{{name}}', possibly not intended.",
},
},
create(context) {
function check(statements: TSESTree.Node[]): void {
const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf(statements, isInterface);
for (const node of duplicateGroups(nodes, interfaceShape).flat()) {
context.report({ node, messageId: "sameShape" });
}
for (const node of duplicateGroups(nodes, interfaceName).flat()) {
context.report({ node, messageId: "sameName", data: { name: node.id.name } });
}
}
return {
BlockStatement(node: TSESTree.BlockStatement): void {
check(node.body);
},
Program(node: TSESTree.Program): void {
check(node.body);
},
};
},
};
/** `no-duplicate-types` - two type aliases in one block with the same type. */
const noDuplicateTypesRule: ProjectRule = {
meta: {
type: "suggestion",
docs: { description: "Disallow type aliases with the same type in one block" },
schema: [],
messages: { sameShape: "Found multiple types with the same shape, possibly mergeable." },
},
create(context) {
function check(statements: TSESTree.Node[]): void {
const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf(statements, isTypeAlias);
for (const node of duplicateGroups(nodes, typeAliasShape).flat()) {
context.report({ node, messageId: "sameShape" });
}
}
return {
BlockStatement(node: TSESTree.BlockStatement): void {
check(node.body);
},
Program(node: TSESTree.Program): void {
check(node.body);
},
};
},
};
/** A class member that can carry the `private` keyword. */
type AccessibilityCarrier =
| TSESTree.AccessorProperty
| TSESTree.MethodDefinition
| TSESTree.PropertyDefinition
| TSESTree.TSAbstractAccessorProperty
| TSESTree.TSAbstractMethodDefinition
| TSESTree.TSAbstractPropertyDefinition
| TSESTree.TSParameterProperty;
/**
* `no-modifier-private` - class members are made private with `#`, never the
* `private` keyword, which only TypeScript enforces. A `private constructor`
* has no `#` form and stays allowed.
*/
const noModifierPrivateRule: ProjectRule = {
meta: {
type: "suggestion",
docs: { description: "Disallow the private modifier" },
schema: [],
messages: { private: "Use a # private name instead of the private modifier." },
},
create(context) {
function check(node: AccessibilityCarrier): void {
const isConstructor: boolean =
node.type === AST_NODE_TYPES.MethodDefinition && node.kind === "constructor";
if (node.accessibility === "private" && !isConstructor) {
context.report({ node, messageId: "private" });
}
}
return {
AccessorProperty: check,
MethodDefinition: check,
PropertyDefinition: check,
TSAbstractAccessorProperty: check,
TSAbstractMethodDefinition: check,
TSAbstractPropertyDefinition: check,
TSParameterProperty: check,
};
},
};
/** Whether a type is too simple to need an alias: a keyword, literal, or bare name. */
function isSimpleType(node: TSESTree.TypeNode): boolean {
if (SIMPLE_TYPES.has(node.type)) {
return true;
}
if (node.type === AST_NODE_TYPES.TSLiteralType) {
return node.literal.type === AST_NODE_TYPES.Literal;
}
return (
node.type === AST_NODE_TYPES.TSTypeReference &&
node.typeName.type === AST_NODE_TYPES.Identifier &&
node.typeArguments === undefined
);
}
/** `no-useless-type` - a type alias for a keyword, literal, or bare type name. */
const noUselessTypeRule: ProjectRule = {
meta: {
type: "suggestion",
docs: { description: "Disallow type aliases too simple to need a name" },
schema: [],
messages: { simple: "The type is too simple to need an alias." },
},
create(context) {
return {
TSTypeAliasDeclaration(node: TSESTree.TSTypeAliasDeclaration): void {
if (isSimpleType(node.typeAnnotation)) {
context.report({ node, messageId: "simple" });
}
},
};
},
};
/** A module specifier read as a path: relative, absolute, or a `#` alias. */
const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
@@ -104,7 +608,7 @@ function isAssetSource(source: string): boolean {
}
/**
* `no-asset-imports` — rejects importing anything but a script module: a
* `no-asset-imports` - rejects importing anything but a script module: a
* source path, static or a dynamic `import()` with a string literal, ending in
* an extension other than `.ts`, `.js`, `.mts`, or `.mjs` (`.css`, `.json`,
* `.svg`, `.jsx`, `.tsx`, `.cjs`) or carrying a query string (`?raw`, `?url`),
@@ -195,7 +699,7 @@ const rules: DummyRuleMap = {
complexity: ["error", 26],
"max-depth": ["error", 6],
"max-lines": ["error", { max: 2048, skipBlankLines: false, skipComments: false }],
"max-lines-per-function": ["error", 96],
"max-lines-per-function": ["error", 98],
"max-params": ["error", 4],
"max-statements": ["error", 32],
"import/max-dependencies": ["error", { max: 13 }],
@@ -203,6 +707,17 @@ const rules: DummyRuleMap = {
// No lint suppression and only `.ts` imports, as in the plugin template.
"project/no-disable-comments": "error",
"project/no-asset-imports": "error",
// Ports of the hugoalh `recommended` rules Oxlint lacks, and ASCII-only source.
"project/prefer-ascii": "error",
"project/no-duplicate-typeofs": "error",
"project/no-empty-yield": "error",
"project/no-nan": "error",
"project/no-unsafe-number": "error",
"project/no-useless-continue": "error",
"project/no-duplicate-interfaces": "error",
"project/no-duplicate-types": "error",
"project/no-modifier-private": "error",
"project/no-useless-type": "error",
// Off, collides with a repo or plugin rule, Oxfmt, or another category rule:
// Collides with `#private`: members are public unless marked so.
"typescript/explicit-member-accessibility": "off",
@@ -242,13 +757,6 @@ const rules: DummyRuleMap = {
"import/no-named-export": "off",
// Collides with `typescript/consistent-type-imports`.
"import/consistent-type-specifier-style": "off",
// Collides with `require-await`, which fails an `async` wrapper with no `await`.
"typescript/promise-function-async": "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.
@@ -258,12 +766,14 @@ const rules: DummyRuleMap = {
"unicorn/no-process-exit": "off",
// The action reports to the job log through the console.
"no-console": "off",
// PENDING USER: union members are values in profiles.json, the Gitea API,
// and posted comments; renaming them changes behavior.
"explicit-types/upper-snake-string-unions": "off",
// PENDING USER: a fitting `name` needs a constructor and changes logged
// error names.
"unicorn/custom-error-definition": "off",
// Sequential awaits are intentional; workarounds cost more than they save.
"no-await-in-loop": "off",
// Not type-aware; TypeScript's `noImplicitReturns` covers it.
"consistent-return": "off",
// TypeScript's type checking covers exhaustive switches.
"typescript/switch-exhaustiveness-check": "off",
// A `default` on a switch over a union would hide a new member from TS.
"default-case": "off",
// Off, too much churn:
// Literal numbers read better than a constant per use.
"no-magic-numbers": "off",
@@ -313,6 +823,22 @@ export const projectOverrides: OxlintOverride[] = [
files: ["vite.config.ts", "scripts/**"],
rules: nodeRules,
},
// exitCode must return the promise it marks handled; an async wrapper's
// promise would reject unhandled before callers await it.
{
files: ["src/processes/child.ts"],
rules: { "typescript/promise-function-async": "off" },
},
// Posted Gitea text shows emoji verdict marks and a middle dot.
{
files: [
"src/post/post.ts",
"src/post/post.test.ts",
"src/profiles/profiles.ts",
"src/profiles/profiles.test.ts",
],
rules: { "project/prefer-ascii": "off" },
},
];
/**
@@ -324,6 +850,16 @@ const plugin = {
rules: {
"no-disable-comments": noDisableCommentsRule,
"no-asset-imports": noAssetImportsRule,
"prefer-ascii": preferAsciiRule,
"no-duplicate-typeofs": noDuplicateTypeofsRule,
"no-empty-yield": noEmptyYieldRule,
"no-nan": noNanRule,
"no-unsafe-number": noUnsafeNumberRule,
"no-useless-continue": noUselessContinueRule,
"no-duplicate-interfaces": noDuplicateInterfacesRule,
"no-duplicate-types": noDuplicateTypesRule,
"no-modifier-private": noModifierPrivateRule,
"no-useless-type": noUselessTypeRule,
},
configs: { project: { rules } },
};
+21 -18
View File
@@ -16,14 +16,16 @@ test("retries once after an invalid refresh token", async () => {
let attempts = 0;
let relogins = 0;
const result = await retryInvalidToken(
() =>
Promise.resolve({
async () => {
const resolved = await Promise.resolve({
code: 1,
error: (attempts += 1) === 1 ? "ERROR: invalid_refresh_token" : "still failed",
}),
() => {
});
return resolved;
},
async () => {
relogins += 1;
return Promise.resolve();
await Promise.resolve();
},
);
@@ -38,16 +40,17 @@ test("does not retry an unrelated failure", async () => {
let attempts = 0;
let relogins = 0;
await retryInvalidToken(
() => {
async () => {
attempts += 1;
return Promise.resolve({
const resolved = await Promise.resolve({
code: 1,
error: "rate limited",
});
return resolved;
},
() => {
async () => {
relogins += 1;
return Promise.resolve();
await Promise.resolve();
},
);
@@ -85,14 +88,16 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
let relogins = 0;
let attempts = 0;
const result = await retryInvalidToken(
() =>
Promise.resolve({
async () => {
const resolved = await Promise.resolve({
code: (attempts += 1) > 1 ? 0 : 1,
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}),
() => {
});
return resolved;
},
async () => {
relogins += 1;
return Promise.resolve();
await Promise.resolve();
},
);
@@ -171,10 +176,8 @@ test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () =
await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 });
await symlink(canonical, linked);
await expect(() => persistCodexAuth(linked, canonical)).rejects.toThrow("regular file");
await expect(() => persistCodexAuth(malformed, canonical)).rejects.toThrow(
"invalid Codex login",
);
await expect(persistCodexAuth(linked, canonical)).rejects.toThrow("regular file");
await expect(persistCodexAuth(malformed, canonical)).rejects.toThrow("invalid Codex login");
} finally {
await rm(root, { recursive: true });
}
+1 -1
View File
@@ -83,7 +83,7 @@ async function readCodexAuth(path: string): Promise<string> {
throw new Error("Codex login must be private");
}
const text = await readFile(path, "utf8");
let value: unknown;
let value: unknown = null;
try {
value = JSON.parse(text);
} catch {
+1 -1
View File
@@ -52,7 +52,7 @@ export function openCodeError(output: string): Error | null {
if (line.trim() === "") {
continue;
}
let event: unknown;
let event: unknown = null;
try {
event = JSON.parse(line);
} catch {
+51 -43
View File
@@ -65,7 +65,7 @@ function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
profiles: [
{
index: 0,
type: "claude",
type: "CLAUDE",
model: "test",
uid: currentUid(),
gid: currentGid(),
@@ -89,7 +89,7 @@ async function fixture(
const outcome: RuntimeOutcome = source.ok
? {
...source,
profile: { type: "codex", model: "gpt-test" },
profile: { type: "CODEX", model: "gpt-test" },
reviewPath: `${profileHome}/review.md`,
anchorsPath: `${profileHome}/anchors.json`,
}
@@ -126,9 +126,10 @@ function requestBody(init?: RequestInit): unknown {
// A fake Gitea that records each request body and answers with `status`.
function recordingFetch(bodies: unknown[], status = 201): Fetcher {
return (_url, init) => {
return async (_url, init) => {
bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status }));
const resolved = await Promise.resolve(new Response(null, { status }));
return resolved;
};
}
@@ -136,16 +137,17 @@ test("posts a successful issue result and removes runtime credentials", async ()
const result = await fixture({ ok: true, body: "done" });
const requests: RecordedRequest[] = [];
await postRuntime(result.manifestPath, context, (url, init) => {
await postRuntime(result.manifestPath, context, async (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);
const [request] = requests;
expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`);
expect(requestBody(request?.init)).toStrictEqual({
body: "done\n\n> Profile: `codex` · `gpt-test`",
body: "done\n\n> Profile: `CODEX` · `gpt-test`",
});
expect(request?.init?.headers).toStrictEqual({
Authorization: "token reviewer-secret",
@@ -156,7 +158,7 @@ test("posts a successful issue result and removes runtime credentials", async ()
test("retries a rejected inline review without anchors", async () => {
const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [
{ path: "run.ts", line: 1, side: "new", body: "Finding" },
{ path: "run.ts", line: 1, side: "NEW", body: "Finding" },
]);
const bodies: unknown[] = [];
@@ -166,18 +168,21 @@ test("retries a rejected inline review without anchors", async () => {
...context,
eventName: "pull_request",
},
(_url, init) => {
async (_url, 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;
},
);
expect(bodies[0]).toMatchObject({
comments: [{ body: "Finding\n\n> Profile: `codex` · `gpt-test`" }],
comments: [{ body: "Finding\n\n> Profile: `CODEX` · `gpt-test`" }],
});
expect(bodies[1]).toMatchObject({
comments: [],
body: "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
body: "✅ Approved\nReady\n\n> Profile: `CODEX` · `gpt-test`",
});
assert(await missing(result.runtimeDir));
});
@@ -186,23 +191,23 @@ test("reports each tried profile and keeps the action failed", async () => {
const result = await fixture({
ok: false,
failures: [
{ type: "codex", model: "gpt-test", reason: "sign-in failed" },
{ type: "claude", model: "claude-test", reason: "usage exhausted" },
{ type: "opencode", model: "openrouter/test", reason: "failed" },
{ type: "CODEX", model: "gpt-test", reason: "SIGN_IN_FAILED" },
{ type: "CLAUDE", model: "claude-test", reason: "USAGE_EXHAUSTED" },
{ type: "OPENCODE", model: "openrouter/test", reason: "FAILED" },
],
});
const bodies: unknown[] = [];
await expect(() =>
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed");
await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow(
"agent phase failed",
);
expect(bodies).toStrictEqual([
{
body:
"AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " +
"`claude` · `claude-test` (usage exhausted), " +
"`opencode` · `openrouter/test` (failed)",
"AI bot failed.\n\n> Tried: `CODEX` · `gpt-test` (sign-in failed), " +
"`CLAUDE` · `claude-test` (usage exhausted), " +
"`OPENCODE` · `openrouter/test` (failed)",
},
]);
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
@@ -216,21 +221,21 @@ test("drops failure entries that are not known labels", async () => {
JSON.stringify({
ok: false,
failures: [
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" },
{ type: "shell", model: "x", reason: "failed" },
{ type: "claude", model: "claude-test", reason: "sign-in failed" },
{ type: "CODEX", model: "gpt-test", reason: "token sk-secret leaked" },
{ type: "shell", model: "x", reason: "FAILED" },
{ type: "CLAUDE", model: "claude-test", reason: "SIGN_IN_FAILED" },
],
}),
);
const bodies: unknown[] = [];
await expect(() =>
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed");
await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow(
"agent phase failed",
);
expect(bodies).toStrictEqual([
{
body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)",
body: "AI bot failed.\n\n> Tried: `CLAUDE` · `claude-test` (sign-in failed)",
},
]);
});
@@ -240,9 +245,9 @@ test("reports a generic failure when the runner wrote no outcome", async () => {
await rm(result.outcomeFile);
const bodies: unknown[] = [];
await expect(() =>
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed");
await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow(
"agent phase failed",
);
expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]);
assert(await missing(result.runtimeDir));
@@ -251,10 +256,11 @@ test("reports a generic failure when the runner wrote no outcome", async () => {
test("removes runtime credentials when posting fails", async () => {
const result = await fixture({ ok: true, body: "done" });
await expect(() =>
postRuntime(result.manifestPath, context, () =>
Promise.resolve(new Response("failed", { status: 500 })),
),
await expect(
postRuntime(result.manifestPath, context, async () => {
const resolved = await Promise.resolve(new Response("failed", { status: 500 }));
return resolved;
}),
).rejects.toThrow("500");
assert(await missing(result.runtimeDir));
});
@@ -263,10 +269,11 @@ test("preserves a Gitea transport failure without retrying a comment", async ()
const result = await fixture({ ok: true, body: "done" });
let requests = 0;
await expect(() =>
postRuntime(result.manifestPath, context, () => {
await expect(
postRuntime(result.manifestPath, context, async () => {
requests += 1;
return Promise.reject(new Error(`network failure ${requests}`));
await Promise.resolve();
throw new Error(`network failure ${requests}`);
}),
).rejects.toThrow("network failure 1");
@@ -279,10 +286,11 @@ test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async ()
await symlink(result.reviewerFile, result.reviewPath);
let requests = 0;
await expect(() =>
postRuntime(result.manifestPath, context, () => {
await expect(
postRuntime(result.manifestPath, context, async () => {
requests += 1;
return Promise.resolve(new Response(null, { status: 200 }));
const resolved = await Promise.resolve(new Response(null, { status: 200 }));
return resolved;
}),
).rejects.toThrow("regular private file");
@@ -294,7 +302,7 @@ test("reports an attributed failure when a pull request review is missing", asyn
const result = await fixture({ ok: true, body: "done" });
const bodies: unknown[] = [];
await expect(() =>
await expect(
postRuntime(
result.manifestPath,
{
@@ -307,7 +315,7 @@ test("reports an attributed failure when a pull request review is missing", asyn
expect(bodies).toStrictEqual([
{
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
body: "AI bot failed.\n\n> Tried: `CODEX` · `gpt-test` (failed)",
},
]);
assert(await missing(result.runtimeDir));
+24 -17
View File
@@ -16,7 +16,7 @@ export interface PostContext {
interface Anchor {
path: string;
line: number;
side: "new" | "old";
side: "NEW" | "OLD";
body: string;
}
@@ -26,8 +26,8 @@ const VERDICTS = new Map<string, [event: string, mark: string]>([
["Approved", ["APPROVED", "✅"]],
["Changes requested", ["REQUEST_CHANGES", "🛑"]],
]);
const PROFILE_TYPES = new Set<unknown>(["claude", "codex", "opencode"]);
const FAILURE_REASONS = new Set<unknown>(["sign-in failed", "usage exhausted", "failed"]);
const PROFILE_TYPES = new Set<unknown>(["CLAUDE", "CODEX", "OPENCODE"]);
const FAILURE_REASONS = new Set<unknown>(["SIGN_IN_FAILED", "USAGE_EXHAUSTED", "FAILED"]);
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value);
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
const failureComment = (failures: ProfileFailure[]): string =>
@@ -36,7 +36,12 @@ const failureComment = (failures: ProfileFailure[]): string =>
// The `fetch` subset the reviewer posts through, which tests replace.
export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
class GiteaRequestError extends Error {}
class GiteaRequestError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "GiteaRequestError";
}
}
function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text);
@@ -51,7 +56,7 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] {
const path = field(entry, "path");
const line = field(entry, "line");
const givenSide = field(entry, "side");
const side = givenSide === undefined ? "new" : givenSide;
const side = givenSide === undefined ? "NEW" : givenSide;
const body = field(entry, "body");
if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`);
@@ -59,8 +64,8 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] {
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new TypeError(`${at}.line: expected a line number`);
}
if (side !== "new" && side !== "old") {
throw new Error(`${at}.side: expected "new" or "old"`);
if (side !== "NEW" && side !== "OLD") {
throw new Error(`${at}.side: expected "NEW" or "OLD"`);
}
if (typeof body !== "string" || body.trim() === "") {
throw new TypeError(`${at}.body: expected the finding`);
@@ -121,7 +126,7 @@ async function readAgentArtifact(
if (profile === undefined) {
throw new Error(`${name}: outside profile home`);
}
let stat: Stats;
let stat: Stats | null = null;
try {
stat = await lstat(path);
} catch (error) {
@@ -152,7 +157,7 @@ export async function postRuntime(
const reviewerText = await readFile(manifest.reviewerFile, "utf8");
const reviewerToken = reviewerText.trim();
const request = async (path: string, body: unknown): Promise<void> => {
let response: Response;
let response: Response | null = null;
try {
response = await fetcher(`${context.apiUrl}/${path}`, {
method: "POST",
@@ -170,12 +175,13 @@ export async function postRuntime(
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
}
};
const postComment = (body: string): Promise<void> =>
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
const postComment = async (body: string): Promise<void> => {
await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
body: stripAnsi(body),
});
};
let outcome: RuntimeOutcome;
let outcome: RuntimeOutcome | null = null;
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
@@ -201,8 +207,8 @@ export async function postRuntime(
const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "💬"];
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
const postReview = (entries: Anchor[]): Promise<void> =>
request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
const postReview = async (entries: Anchor[]): Promise<void> => {
await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
body: stripAnsi(
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
),
@@ -210,10 +216,11 @@ export async function postRuntime(
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,
new_position: side === "NEW" ? line : 0,
old_position: side === "OLD" ? line : 0,
})),
});
};
await postReview(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) {
throw error;
@@ -227,7 +234,7 @@ export async function postRuntime(
if (error instanceof GiteaRequestError) {
throw error;
}
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
await postComment(failureComment([{ ...outcome.profile, reason: "FAILED" }]));
throw error;
}
} finally {
+2 -2
View File
@@ -11,7 +11,7 @@ import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from
const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000,
type: "claude",
type: "CLAUDE",
model: "test",
uid,
gid: uid,
@@ -49,7 +49,7 @@ test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", asyn
await writeFile(secret, "reviewer-secret", { mode: 0o600 });
await symlink(secret, result);
await expect(() => readProfileFile(profile(currentUid(), root), result)).rejects.toThrow(
await expect(readProfileFile(profile(currentUid(), root), result)).rejects.toThrow(
"regular private file",
);
} finally {
+4 -1
View File
@@ -17,7 +17,10 @@ async function profileProcessIds(uid: number): Promise<number[]> {
.filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name))
.map((entry) => entry.name);
const statuses = await Promise.all(
pids.map((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
.filter((_pid, index) => {
+54 -49
View File
@@ -11,8 +11,8 @@ import {
} from "./profiles.ts";
test("formats profile attribution as a Markdown quote", () => {
expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
expect(profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
"> Profile: `OPENCODE` · `openrouter/qwen/qwen3.8-27b`",
);
});
@@ -20,33 +20,35 @@ test("falls through only after usage exhaustion", async () => {
const visited: string[] = [];
const result = await runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
(profile) => {
async (profile) => {
visited.push(profile.type);
if (profile.type === "claude") {
if (profile.type === "CLAUDE") {
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(visited).toStrictEqual(["claude", "codex"]);
expect(visited).toStrictEqual(["CLAUDE", "CODEX"]);
});
test("recognizes Claude's weekly limit message", async () => {
const result = await runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
(profile) => {
if (profile.type === "claude") {
async (profile) => {
if (profile.type === "CLAUDE") {
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,20 +57,22 @@ test("recognizes Claude's weekly limit message", async () => {
test("recognizes provider API exhaustion status", async () => {
const results = await Promise.all(
["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) =>
runProfiles(
["API Error: 402 Insufficient funds", "HTTP error: 429"].map(async (message) => {
const result = await runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
(profile) => {
if (profile.type === "claude") {
async (profile) => {
if (profile.type === "CLAUDE") {
throw new Error(message);
}
return Promise.resolve("fallback");
const resolved = await Promise.resolve("fallback");
return resolved;
},
),
),
);
return result;
}),
);
expect(results).toStrictEqual(["fallback", "fallback"]);
@@ -78,40 +82,41 @@ test("falls through after a sign-in failure and records each try", async () => {
const failures: ProfileFailure[] = [];
const result = await runProfiles(
[
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" },
{ type: "CODEX", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" },
],
(profile) => {
if (profile.type === "codex") {
async (profile) => {
if (profile.type === "CODEX") {
throw new AgentAuthError("codex sign-in failed");
}
if (profile.type === "claude") {
if (profile.type === "CLAUDE") {
throw new Error("API Error: 429");
}
return Promise.resolve("fallback");
const resolved = await Promise.resolve("fallback");
return resolved;
},
failures,
);
expect(result).toBe("fallback");
expect(failures).toStrictEqual([
{ type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" },
{ type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" },
{ type: "CODEX", model: "gpt-5.6-terra", reason: "SIGN_IN_FAILED" },
{ type: "CLAUDE", model: "claude-sonnet-5", reason: "USAGE_EXHAUSTED" },
]);
expect(failureFooter(failures)).toStrictEqual(
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " +
"`claude` · `claude-sonnet-5` (usage exhausted)",
"> Tried: `CODEX` · `gpt-5.6-terra` (sign-in failed), " +
"`CLAUDE` · `claude-sonnet-5` (usage exhausted)",
);
});
test("throws the last sign-in failure when every profile fails", async () => {
const failures: ProfileFailure[] = [];
await expect(() =>
await expect(
runProfiles(
[
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
],
(profile) => {
throw new AgentAuthError(`${profile.type} sign-in failed`);
@@ -121,19 +126,19 @@ test("throws the last sign-in failure when every profile fails", async () => {
).rejects.toThrow(
expect.toSatisfy(
(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 () => {
const visited: string[] = [];
await expect(() =>
await expect(
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) => {
visited.push(profile.type);
@@ -141,16 +146,16 @@ test("preserves an ordinary agent failure", async () => {
},
),
).rejects.toThrow("tests failed");
expect(visited).toStrictEqual(["claude"]);
expect(visited).toStrictEqual(["CLAUDE"]);
});
test("does not mistake incidental quota text for exhaustion", async () => {
const visited: string[] = [];
await expect(() =>
await expect(
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) => {
visited.push(profile.type);
@@ -158,7 +163,7 @@ test("does not mistake incidental quota text for exhaustion", async () => {
},
),
).rejects.toThrow("quota parser test failed");
expect(visited).toStrictEqual(["claude"]);
expect(visited).toStrictEqual(["CLAUDE"]);
});
test("exposes only the selected profile credential to the agent", () => {
@@ -178,7 +183,7 @@ test("exposes only the selected profile credential to the agent", () => {
]);
const profile = {
index: 0,
type: "claude" as const,
type: "CLAUDE" as const,
model: "claude-sonnet-5",
uid: 20_000,
gid: 20_000,
@@ -204,7 +209,7 @@ test("exposes only the selected profile credential to the agent", () => {
test("uses a private Codex home without inventing an API credential", () => {
const profile = {
index: 1,
type: "codex" as const,
type: "CODEX" as const,
model: "gpt-5.6-terra",
uid: 20_001,
gid: 20_001,
@@ -224,7 +229,7 @@ test("uses a private Codex home without inventing an API credential", () => {
test("exposes an OpenRouter key only to the OpenCode profile", () => {
const profile = {
index: 2,
type: "opencode" as const,
type: "OPENCODE" as const,
model: "openrouter/qwen/qwen3.8-27b",
uid: 20_002,
gid: 20_002,
+53 -39
View File
@@ -1,6 +1,6 @@
// The agent and model a profile runs, which every report names.
export interface ProfileIdentity {
type: "claude" | "codex" | "opencode";
type: "CLAUDE" | "CODEX" | "OPENCODE";
model: string;
}
@@ -16,7 +16,19 @@ export function withProfile(body: string, profile: ProfileIdentity): string {
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 {
reason: FailureReason;
@@ -29,7 +41,12 @@ interface ProfileHome {
}
// 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 {
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 +56,9 @@ function isUsageExhausted(error: unknown): boolean {
function failureReason(error: unknown): FailureReason {
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 {
@@ -49,41 +66,30 @@ export function failureFooter(failures: ProfileFailure[]): string {
return "> Profile: none";
}
return `> Tried: ${failures
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${toMessage(reason)})`)
.join(", ")}`;
}
// Tries the profiles from `index` on, moving on only after a sign-in failure
// or exhausted usage, and records every profile it tried.
async function attemptFrom<T, P extends 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>(
// Tries the profiles in order, moving on only after a sign-in failure or
// exhausted usage, and records every profile it tried.
export async function runProfiles<T, P extends ProfileIdentity>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [],
): Promise<T> {
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
@@ -126,6 +132,18 @@ export function sanitizedEnvironment(
return result;
}
// The environment variable each agent CLI reads its credential from.
function tokenVariable(type: ProfileIdentity["type"]): string {
switch (type) {
case "CLAUDE":
return "CLAUDE_CODE_OAUTH_TOKEN";
case "CODEX":
return "OPENAI_API_KEY";
case "OPENCODE":
return "OPENROUTER_API_KEY";
}
}
export function agentEnvironment(
environment: ReadonlyMap<string, string>,
profile: ProfileHome,
@@ -133,15 +151,11 @@ export function agentEnvironment(
): Map<string, string> {
const result = sanitizedEnvironment(environment);
result.set("HOME", profile.home);
if (profile.type === "codex") {
if (profile.type === "CODEX") {
result.set("CODEX_HOME", `${profile.home}/.codex`);
}
if (token !== undefined && token !== "") {
const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN",
codex: "OPENAI_API_KEY",
opencode: "OPENROUTER_API_KEY",
}[profile.type];
const tokenName = tokenVariable(profile.type);
result.set(tokenName, token);
}
return result;
+31 -23
View File
@@ -150,6 +150,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> {
await runAgentCommand(profile, "git", [
"config",
@@ -163,7 +175,7 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
...SKILLS_INSTALL.split(" "),
"--global",
"--agent",
profile.type === "claude" ? "claude-code" : profile.type,
skillsAgent(profile.type),
"--yes",
]);
}
@@ -184,10 +196,12 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response");
}
const rulesResponse = await gitea(RULES_PATH);
const codeRules = await rulesResponse.text();
const values = new Map<string, string>([
["COMMENT", env("COMMENT")],
["ISSUE_COMMENTS", comments.map((comment: unknown) => formatComment(comment)).join("\n")],
["CODE_RULES", await gitea(RULES_PATH).then((response) => response.text())],
["CODE_RULES", codeRules],
["EVENT_NAME", EVENT],
["GITEA_API_URL", API],
["GITEA_REPOSITORY", REPO],
@@ -283,21 +297,6 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
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(
profile: RuntimeProfile,
environment: ReadonlyMap<string, string>,
@@ -319,7 +318,12 @@ async function codexDeviceLogin(
};
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
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));
const [code] = await Promise.all([status, drained]);
if (code !== 0) {
@@ -442,8 +446,12 @@ async function runOpenCode(
const collected = collectOpenCodeOutput(
child.stdout,
child.stderr,
(chunk) => echo(process.stdout, chunk),
(chunk) => echo(process.stderr, chunk),
async (chunk) => {
await echo(process.stdout, chunk);
},
async (chunk) => {
await echo(process.stderr, chunk);
},
);
const [code, streams] = await Promise.all([status, collected]);
const error = streams.stderr.trim();
@@ -455,7 +463,7 @@ async function runOpenCode(
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile);
const authentication = profile.type === "codex" ? codexAuthentication(token ?? undefined) : null;
const authentication = profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null;
let stagedCodexAuth: string | null = null;
let body: string | null = null;
let resultPath: string | null = null;
@@ -469,9 +477,9 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const rendered = await renderPrompt(profile);
reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath;
if (profile.type === "claude") {
if (profile.type === "CLAUDE") {
body = await runClaude(profile, token ?? "", rendered.prompt);
} else if (profile.type === "codex") {
} else if (profile.type === "CODEX") {
resultPath = await runCodex(
profile,
rendered.prompt,
+11 -13
View File
@@ -16,11 +16,11 @@ test("encrypts a credential with a fresh supplied nonce", 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 () => {
await expect(() =>
await expect(
decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY),
).rejects.toThrow("nonce must be 12 bytes");
});
@@ -31,9 +31,9 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [
{ type: "codex", model: "gpt-custom" },
{ type: "claude", token: ENCRYPTED },
{ type: "opencode", token: ENCRYPTED },
{ type: "CODEX", model: "gpt-custom" },
{ type: "CLAUDE", token: ENCRYPTED },
{ type: "OPENCODE", token: ENCRYPTED },
],
}),
MASTER_KEY,
@@ -41,14 +41,14 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
).toStrictEqual({
reviewerToken: "secret-token",
profiles: [
{ type: "codex", model: "gpt-custom" },
{ type: "CODEX", model: "gpt-custom" },
{
type: "claude",
type: "CLAUDE",
model: "claude-sonnet-5",
token: "secret-token",
},
{
type: "opencode",
type: "OPENCODE",
model: "openrouter/qwen/qwen3.8-27b",
token: "secret-token",
},
@@ -57,12 +57,10 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
});
test("rejects malformed central credentials", async () => {
await expect(() => parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow(
"reviewer-token",
);
await expect(() =>
await expect(parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow("reviewer-token");
await expect(
parseCredentials(
JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }),
JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "CLAUDE" }] }),
MASTER_KEY,
),
).rejects.toThrow("profiles[0].token");
+24 -17
View File
@@ -3,18 +3,18 @@ import { webcrypto } from "node:crypto";
import { field, isMapping } from "#common";
import type { BotProfile } from "#profiles";
interface DefaultModels {
claude: string;
codex: string;
opencode: string;
// The model a profile runs when profiles.json names none.
function defaultModel(type: BotProfile["type"]): string {
switch (type) {
case "CLAUDE":
return "claude-sonnet-5";
case "CODEX":
return "gpt-5.6-terra";
case "OPENCODE":
return "openrouter/qwen/qwen3.8-27b";
}
}
const DEFAULT_MODELS: DefaultModels = {
claude: "claude-sonnet-5",
codex: "gpt-5.6-terra",
opencode: "openrouter/qwen/qwen3.8-27b",
};
export interface BotCredentials {
reviewerToken: string;
profiles: BotProfile[];
@@ -33,12 +33,16 @@ function encodeBase64Url(value: Uint8Array): string {
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);
if (bytes.length !== 32) {
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> {
@@ -87,13 +91,13 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
const type = field(entry, "type");
const model = field(entry, "model");
const token = field(entry, "token");
if (type !== "claude" && type !== "codex" && type !== "opencode") {
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`);
if (type !== "CLAUDE" && type !== "CODEX" && type !== "OPENCODE") {
throw new Error(`${at}.type: expected "CLAUDE", "CODEX", or "OPENCODE"`);
}
if (model !== undefined && (typeof model !== "string" || model === "")) {
throw new Error(`${at}.model: expected a model name`);
}
if ((type === "claude" || type === "opencode") && typeof token !== "string") {
if ((type === "CLAUDE" || type === "OPENCODE") && typeof token !== "string") {
throw new Error(`${at}.token: expected an encrypted credential`);
}
if (token !== undefined && typeof token !== "string") {
@@ -101,7 +105,7 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
}
return {
type,
model: model ?? DEFAULT_MODELS[type],
model: model ?? defaultModel(type),
...(typeof token === "string" && {
token: await decryptCredential(token, masterKey),
}),
@@ -127,7 +131,10 @@ export async function parseCredentials(text: string, masterKey: string): Promise
throw new Error("profiles must contain at least one profile");
}
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 };
}
+2 -2
View File
@@ -32,7 +32,7 @@ test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credenti
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }],
profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }],
}),
);
@@ -77,7 +77,7 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "claude", token: ENCRYPTED }],
profiles: [{ type: "CLAUDE", token: ENCRYPTED }],
}),
);
const result = spawnSync(
+22 -26
View File
@@ -61,32 +61,28 @@ export async function prepareRuntime(
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
// One profile at a time, in order, as each claims the next UID.
const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>(
async (previous, profile, index) => {
const prepared = await previous;
const uid = FIRST_PROFILE_UID + index;
const home = join(runtimeDir, `profile-${index}`);
await mkdir(home, { mode: 0o700 });
await chmod(home, 0o700);
await chown(home, uid, uid);
const tokenFile =
profile.token === undefined || profile.token === "" ? null : join(home, "token");
if (tokenFile !== null && profile.token !== undefined) {
await writePrivate(tokenFile, profile.token, uid, uid);
}
prepared.push({
index,
type: profile.type,
model: profile.model,
uid,
gid: uid,
home,
tokenFile,
});
return prepared;
},
Promise.resolve([]),
);
const profiles: RuntimeProfile[] = [];
for (const [index, profile] of credentials.profiles.entries()) {
const uid = FIRST_PROFILE_UID + index;
const home = join(runtimeDir, `profile-${index}`);
await mkdir(home, { mode: 0o700 });
await chmod(home, 0o700);
await chown(home, uid, uid);
const tokenFile =
profile.token === undefined || profile.token === "" ? null : join(home, "token");
if (tokenFile !== null && profile.token !== undefined) {
await writePrivate(tokenFile, profile.token, uid, uid);
}
profiles.push({
index,
type: profile.type,
model: profile.model,
uid,
gid: uid,
home,
tokenFile,
});
}
const manifestPath = join(runtimeDir, "manifest.json");
const manifest: RuntimeManifest = {
+3 -2
View File
@@ -24,8 +24,9 @@ export const IS_ROOT: boolean = currentUid() === 0;
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
export const IS_WINDOWS: boolean = process.platform === "win32";
export function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
export async function makeTempDir(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), "bot-agents-test-"));
return path;
}
export interface CommandResult {
+20 -6
View File
@@ -40,7 +40,11 @@ const findStep = (steps: Mapping[], key: string, value: string): Mapping => {
test("all committed YAML parses", async () => {
const files = await yamlFiles();
assert(files.length > 0);
await Promise.all(files.map((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 () => {
@@ -94,7 +98,12 @@ test("the source protection script stages immutable action files", async () => {
"profiles.json",
"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()) {
assert(info.isFile(), files[index]);
expect(info.mode & 0o022, files[index]).toStrictEqual(0);
@@ -110,7 +119,12 @@ test("module-relative runtime assets exist", async () => {
const assets = [...source.matchAll(/new URL\("(?<asset>[^"]+)", import\.meta\.url\)/gu)].map(
([, asset = ""]) => new URL(asset, module),
);
const stats = await Promise.all(assets.map((asset) => stat(asset)));
const stats = await Promise.all(
assets.map(async (asset) => {
const info = await stat(asset);
return info;
}),
);
for (const [index, info] of stats.entries()) {
assert(info.isFile(), assets[index]?.pathname);
}
@@ -180,9 +194,9 @@ test("central profiles fall back from Codex to Claude to OpenCode", async () =>
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles");
expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([
"codex",
"claude",
"opencode",
"CODEX",
"CLAUDE",
"OPENCODE",
]);
expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.get("token")).startsWith("v1:"));