Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b97fffcebd | |||
| 3d42665864 | |||
| 063eb09750 |
@@ -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.
|
||||||
|
|||||||
@@ -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]]).
|
||||||
@@ -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]].
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Generated
+16
-8
@@ -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.12.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.12.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.12.0.tgz",
|
||||||
"integrity": "sha512-jvx4sccxEdURGXrDtBJZtCHiKqELkHRVlLrMU5bVZIjglN0U464Joopcfn6JvmNB2zyIh2FkolaJQJaKkvy6Xw==",
|
"integrity": "sha512-axo8TH0mik9NmsnW+J5rNwNXlOxxqOhULwN7zE4EJaNRdk/Ci/aRac3BAeeIi/SDUyPgN6Ig34l+mFlLQYWkfw==",
|
||||||
"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
@@ -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.12.0",
|
||||||
"vite-plus": "^1.2.0",
|
"vite-plus": "^1.2.0",
|
||||||
"yaml": "^2.9.1"
|
"yaml": "^2.9.1"
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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;
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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() ||
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,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: [
|
||||||
|
|||||||
Reference in New Issue
Block a user