Apply batch 4 lint decisions and deno fmt settings
Check / check (pull_request) Successful in 56s

This commit is contained in:
Danny Kim
2026-10-20 09:27:50 +09:00
parent ca619888b2
commit a429e6ffd0
26 changed files with 937 additions and 430 deletions
+19
View File
@@ -79,5 +79,24 @@ overrides: projectOverrides,
`project/prefer-ascii` in `projectOverrides`: posted Gitea text shows emoji `project/prefer-ascii` in `projectOverrides`: posted Gitea text shows emoji
verdict marks and a middle dot. 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]].
+138 -40
View File
@@ -62,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" });
} }
} }
@@ -90,7 +95,8 @@ const preferAsciiRule: ProjectRule = {
docs: { description: "Require the source to be ASCII" }, docs: { description: "Require the source to be ASCII" },
schema: [], schema: [],
messages: { messages: {
nonAscii: "Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.", nonAscii:
"Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.",
}, },
}, },
create(context) { create(context) {
@@ -107,7 +113,13 @@ const preferAsciiRule: ProjectRule = {
end: sourceCode.getLocFromIndex(index + character.length), end: sourceCode.getLocFromIndex(index + character.length),
}, },
messageId: "nonAscii", messageId: "nonAscii",
data: { character, codePoint: codePoint.toString(16).toUpperCase().padStart(4, "0") }, data: {
character,
codePoint: codePoint
.toString(16)
.toUpperCase()
.padStart(4, "0"),
},
}); });
} }
index += character.length; index += character.length;
@@ -123,7 +135,10 @@ const noDuplicateTypeofsRule: ProjectRule = {
type: "problem", type: "problem",
docs: { description: "Disallow `typeof` applied twice in a row" }, docs: { description: "Disallow `typeof` applied twice in a row" },
schema: [], schema: [],
messages: { doubled: 'Multiple `typeof`s always return `"string"`, possibly not intended.' }, messages: {
doubled:
'Multiple `typeof`s always return `"string"`, possibly not intended.',
},
}, },
create(context) { create(context) {
return { return {
@@ -146,7 +161,9 @@ const noEmptyYieldRule: ProjectRule = {
type: "problem", type: "problem",
docs: { description: "Disallow `yield` without an expression" }, docs: { description: "Disallow `yield` without an expression" },
schema: [], schema: [],
messages: { empty: "Empty `yield` is not allowed, possibly missing the expression." }, messages: {
empty: "Empty `yield` is not allowed, possibly missing the expression.",
},
}, },
create(context) { create(context) {
return { return {
@@ -160,14 +177,21 @@ const noEmptyYieldRule: ProjectRule = {
}; };
/** The globals `no-nan` sees through, as in `globalThis.Number.NaN`. */ /** The globals `no-nan` sees through, as in `globalThis.Number.NaN`. */
const GLOBAL_NAMES: ReadonlySet<string> = new Set<string>(["globalThis", "self", "window"]); const GLOBAL_NAMES: ReadonlySet<string> = new Set<string>([
"globalThis",
"self",
"window",
]);
/** The name a member expression's property spells, computed or not. */ /** The name a member expression's property spells, computed or not. */
function propertyName(node: TSESTree.MemberExpression): string | null { function propertyName(node: TSESTree.MemberExpression): string | null {
if (node.property.type === AST_NODE_TYPES.Identifier && !node.computed) { if (node.property.type === AST_NODE_TYPES.Identifier && !node.computed) {
return node.property.name; return node.property.name;
} }
if (node.property.type === AST_NODE_TYPES.Literal && typeof node.property.value === "string") { if (
node.property.type === AST_NODE_TYPES.Literal &&
typeof node.property.value === "string"
) {
return node.property.value; return node.property.value;
} }
return null; return null;
@@ -211,12 +235,17 @@ const noNanRule: ProjectRule = {
type: "problem", type: "problem",
docs: { description: "Disallow `NaN` as a value" }, docs: { description: "Disallow `NaN` as a value" },
schema: [], schema: [],
messages: { nan: "Using `NaN` as a value is usually an error and not intended." }, messages: {
nan: "Using `NaN` as a value is usually an error and not intended.",
},
}, },
create(context) { create(context) {
return { return {
Identifier(node: TSESTree.Identifier): void { Identifier(node: TSESTree.Identifier): void {
if (node.name === "NaN" && node.parent.type !== AST_NODE_TYPES.MemberExpression) { if (
node.name === "NaN" &&
node.parent.type !== AST_NODE_TYPES.MemberExpression
) {
context.report({ node, messageId: "nan" }); context.report({ node, messageId: "nan" });
} }
}, },
@@ -233,7 +262,9 @@ const noNanRule: ProjectRule = {
const noUnsafeNumberRule: ProjectRule = { const noUnsafeNumberRule: ProjectRule = {
meta: { meta: {
type: "problem", type: "problem",
docs: { description: "Disallow number literals outside the safe integer range" }, docs: {
description: "Disallow number literals outside the safe integer range",
},
schema: [], schema: [],
messages: { messages: {
unsafe: unsafe:
@@ -245,7 +276,8 @@ const noUnsafeNumberRule: ProjectRule = {
Literal(node: TSESTree.Literal): void { Literal(node: TSESTree.Literal): void {
if ( if (
typeof node.value === "number" && typeof node.value === "number" &&
(node.value < Number.MIN_SAFE_INTEGER || node.value > Number.MAX_SAFE_INTEGER) (node.value < Number.MIN_SAFE_INTEGER ||
node.value > Number.MAX_SAFE_INTEGER)
) { ) {
context.report({ node, messageId: "unsafe" }); context.report({ node, messageId: "unsafe" });
} }
@@ -275,11 +307,15 @@ interface TrailingContinue {
function trailingContinue(node: LoopStatement): TrailingContinue | null { function trailingContinue(node: LoopStatement): TrailingContinue | null {
if (node.body.type === AST_NODE_TYPES.BlockStatement) { if (node.body.type === AST_NODE_TYPES.BlockStatement) {
const last = node.body.body.at(-1); const last = node.body.body.at(-1);
return last?.type === AST_NODE_TYPES.ContinueStatement && last.label === null return last?.type === AST_NODE_TYPES.ContinueStatement &&
last.label === null
? { statement: last, removable: true } ? { statement: last, removable: true }
: null; : null;
} }
if (node.body.type === AST_NODE_TYPES.ContinueStatement && node.body.label === null) { if (
node.body.type === AST_NODE_TYPES.ContinueStatement &&
node.body.label === null
) {
return { statement: node.body, removable: false }; return { statement: node.body, removable: false };
} }
return null; return null;
@@ -293,11 +329,14 @@ function trailingContinue(node: LoopStatement): TrailingContinue | null {
const noUselessContinueRule: ProjectRule = { const noUselessContinueRule: ProjectRule = {
meta: { meta: {
type: "suggestion", type: "suggestion",
docs: { description: "Disallow an unlabeled `continue` at the end of a loop" }, docs: {
description: "Disallow an unlabeled `continue` at the end of a loop",
},
fixable: "code", fixable: "code",
schema: [], schema: [],
messages: { messages: {
useless: "The statement `continue` at the end of the loop and without a label is useless.", useless:
"The statement `continue` at the end of the loop and without a label is useless.",
}, },
}, },
create(context) { create(context) {
@@ -372,7 +411,8 @@ function serialize(value: unknown): string {
if (typeof value !== "object" || value === null) { if (typeof value !== "object" || value === null) {
return JSON.stringify(value) ?? "undefined"; return JSON.stringify(value) ?? "undefined";
} }
const unordered: boolean = "type" in value && UNORDERED_TYPES.has(String(value.type)); const unordered: boolean =
"type" in value && UNORDERED_TYPES.has(String(value.type));
const parts: string[] = Object.entries(value) const parts: string[] = Object.entries(value)
.filter(([key]: [string, unknown]) => !POSITION_KEYS.has(key)) .filter(([key]: [string, unknown]) => !POSITION_KEYS.has(key))
.map(([key, child]: [string, unknown]) => { .map(([key, child]: [string, unknown]) => {
@@ -396,7 +436,9 @@ function declarationsOf<T extends TSESTree.Node>(
const found: T[] = []; const found: T[] = [];
for (const statement of statements) { for (const statement of statements) {
const declaration: TSESTree.Node | null = const declaration: TSESTree.Node | null =
statement.type === AST_NODE_TYPES.ExportNamedDeclaration ? statement.declaration : statement; statement.type === AST_NODE_TYPES.ExportNamedDeclaration
? statement.declaration
: statement;
if (declaration !== null && matches(declaration)) { if (declaration !== null && matches(declaration)) {
found.push(declaration); found.push(declaration);
} }
@@ -405,12 +447,16 @@ function declarationsOf<T extends TSESTree.Node>(
} }
/** Whether a node is an interface declaration. */ /** Whether a node is an interface declaration. */
function isInterface(node: TSESTree.Node): node is TSESTree.TSInterfaceDeclaration { function isInterface(
node: TSESTree.Node,
): node is TSESTree.TSInterfaceDeclaration {
return node.type === AST_NODE_TYPES.TSInterfaceDeclaration; return node.type === AST_NODE_TYPES.TSInterfaceDeclaration;
} }
/** Whether a node is a type alias declaration. */ /** Whether a node is a type alias declaration. */
function isTypeAlias(node: TSESTree.Node): node is TSESTree.TSTypeAliasDeclaration { function isTypeAlias(
node: TSESTree.Node,
): node is TSESTree.TSTypeAliasDeclaration {
return node.type === AST_NODE_TYPES.TSTypeAliasDeclaration; return node.type === AST_NODE_TYPES.TSTypeAliasDeclaration;
} }
@@ -442,21 +488,33 @@ function typeAliasShape(node: TSESTree.TSTypeAliasDeclaration): string {
const noDuplicateInterfacesRule: ProjectRule = { const noDuplicateInterfacesRule: ProjectRule = {
meta: { meta: {
type: "suggestion", type: "suggestion",
docs: { description: "Disallow interfaces with the same name or shape in one block" }, docs: {
description:
"Disallow interfaces with the same name or shape in one block",
},
schema: [], schema: [],
messages: { messages: {
sameShape: "Found multiple interfaces with the same shape, possibly mergeable.", sameShape:
sameName: "Found multiple interfaces named '{{name}}', possibly not intended.", "Found multiple interfaces with the same shape, possibly mergeable.",
sameName:
"Found multiple interfaces named '{{name}}', possibly not intended.",
}, },
}, },
create(context) { create(context) {
function check(statements: TSESTree.Node[]): void { function check(statements: TSESTree.Node[]): void {
const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf(statements, isInterface); const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf(
statements,
isInterface,
);
for (const node of duplicateGroups(nodes, interfaceShape).flat()) { for (const node of duplicateGroups(nodes, interfaceShape).flat()) {
context.report({ node, messageId: "sameShape" }); context.report({ node, messageId: "sameShape" });
} }
for (const node of duplicateGroups(nodes, interfaceName).flat()) { for (const node of duplicateGroups(nodes, interfaceName).flat()) {
context.report({ node, messageId: "sameName", data: { name: node.id.name } }); context.report({
node,
messageId: "sameName",
data: { name: node.id.name },
});
} }
} }
return { return {
@@ -474,13 +532,21 @@ const noDuplicateInterfacesRule: ProjectRule = {
const noDuplicateTypesRule: ProjectRule = { const noDuplicateTypesRule: ProjectRule = {
meta: { meta: {
type: "suggestion", type: "suggestion",
docs: { description: "Disallow type aliases with the same type in one block" }, docs: {
description: "Disallow type aliases with the same type in one block",
},
schema: [], schema: [],
messages: { sameShape: "Found multiple types with the same shape, possibly mergeable." }, messages: {
sameShape:
"Found multiple types with the same shape, possibly mergeable.",
},
}, },
create(context) { create(context) {
function check(statements: TSESTree.Node[]): void { function check(statements: TSESTree.Node[]): void {
const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf(statements, isTypeAlias); const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf(
statements,
isTypeAlias,
);
for (const node of duplicateGroups(nodes, typeAliasShape).flat()) { for (const node of duplicateGroups(nodes, typeAliasShape).flat()) {
context.report({ node, messageId: "sameShape" }); context.report({ node, messageId: "sameShape" });
} }
@@ -516,12 +582,15 @@ const noModifierPrivateRule: ProjectRule = {
type: "suggestion", type: "suggestion",
docs: { description: "Disallow the private modifier" }, docs: { description: "Disallow the private modifier" },
schema: [], schema: [],
messages: { private: "Use a # private name instead of the private modifier." }, messages: {
private: "Use a # private name instead of the private modifier.",
},
}, },
create(context) { create(context) {
function check(node: AccessibilityCarrier): void { function check(node: AccessibilityCarrier): void {
const isConstructor: boolean = const isConstructor: boolean =
node.type === AST_NODE_TYPES.MethodDefinition && node.kind === "constructor"; node.type === AST_NODE_TYPES.MethodDefinition &&
node.kind === "constructor";
if (node.accessibility === "private" && !isConstructor) { if (node.accessibility === "private" && !isConstructor) {
context.report({ node, messageId: "private" }); context.report({ node, messageId: "private" });
} }
@@ -580,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
@@ -600,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("."));
@@ -621,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.",
@@ -653,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) {
@@ -676,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": [
@@ -694,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", 98], "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 }],
@@ -757,8 +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 literal Gitea Actions `${{ }}` expressions in workflow strings. // Collides with Oxfmt, which lowercases hex digits back.
"no-template-curly-in-string": "off", "unicorn/number-literal-case": "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",
@@ -770,6 +861,8 @@ const rules: DummyRuleMap = {
"no-await-in-loop": "off", "no-await-in-loop": "off",
// Not type-aware; TypeScript's `noImplicitReturns` covers it. // Not type-aware; TypeScript's `noImplicitReturns` covers it.
"consistent-return": "off", "consistent-return": "off",
// Misfires on complete switches, like `consistent-return`; TypeScript covers it.
"typescript/consistent-return": "off",
// TypeScript's type checking covers exhaustive switches. // TypeScript's type checking covers exhaustive switches.
"typescript/switch-exhaustiveness-check": "off", "typescript/switch-exhaustiveness-check": "off",
// TypeScript checks completeness; a forced `default:` hides missing cases. // TypeScript checks completeness; a forced `default:` hides missing cases.
@@ -833,6 +926,11 @@ export const projectOverrides: OxlintOverride[] = [
], ],
rules: { "project/prefer-ascii": "off" }, 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" },
},
]; ];
/** /**
+74 -45
View File
@@ -1,4 +1,11 @@
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import {
mkdir,
readFile,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing"; import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
@@ -19,7 +26,10 @@ test("retries once after an invalid refresh token", async () => {
async () => { async () => {
const resolved = await 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; return resolved;
}, },
@@ -111,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", () => {
@@ -138,47 +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("invalid Codex login"); "regular file",
} finally { );
await rm(root, { recursive: true }); await expect(persistCodexAuth(malformed, canonical)).rejects.toThrow(
} "invalid Codex login",
}); );
} finally {
await rm(root, { recursive: true });
}
},
);
+21 -12
View File
@@ -10,7 +10,7 @@ import {
rm, rm,
writeFile, writeFile,
} from "node:fs/promises"; } from "node:fs/promises";
import { dirname, join } from "node:path"; import nodePath from "node:path";
import { field, isNotFound, isObject } from "#common"; import { field, isNotFound, isObject } from "#common";
@@ -26,7 +26,9 @@ interface CodexPersistent {
export type CodexAuthentication = CodexApiKey | CodexPersistent; export type CodexAuthentication = CodexApiKey | CodexPersistent;
export function codexAuthentication(token?: string): CodexAuthentication { export function codexAuthentication(token?: string): CodexAuthentication {
return token === undefined ? { kind: "persistent" } : { kind: "api-key", token }; return token === undefined
? { kind: "persistent" }
: { kind: "api-key", token };
} }
// Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys // Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys
@@ -95,10 +97,12 @@ async function readCodexAuth(path: string): Promise<string> {
typeof authMode !== "string" || typeof authMode !== "string" ||
authMode === "" || authMode === "" ||
!isObject(tokens) || !isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every((name) => { !["access_token", "refresh_token", "id_token", "account_id"].every(
const token = field(tokens, name); (name) => {
return typeof token === "string" && token !== ""; const token = field(tokens, name);
}) return typeof token === "string" && token !== "";
},
)
) { ) {
throw new Error("invalid Codex login"); throw new Error("invalid Codex login");
} }
@@ -111,13 +115,15 @@ export async function stageCodexAuth(
uid: number, uid: number,
gid: number, gid: number,
): Promise<string> { ): Promise<string> {
const codexHome = join(profileHome, ".codex"); const codexHome = nodePath.join(profileHome, ".codex");
await mkdir(codexHome, { recursive: true, mode: 0o700 }); await mkdir(codexHome, { recursive: true, mode: 0o700 });
await chmod(codexHome, 0o700); await chmod(codexHome, 0o700);
await chown(codexHome, uid, gid); await chown(codexHome, uid, gid);
const stagedPath = join(codexHome, "auth.json"); const stagedPath = nodePath.join(codexHome, "auth.json");
try { try {
await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 }); await writeFile(stagedPath, await readCodexAuth(canonicalPath), {
mode: 0o600,
});
await chmod(stagedPath, 0o600); await chmod(stagedPath, 0o600);
await chown(stagedPath, uid, gid); await chown(stagedPath, uid, gid);
} catch (error) { } catch (error) {
@@ -130,15 +136,18 @@ export async function stageCodexAuth(
// Creates an empty private file under a fresh name. // Creates an empty private file under a fresh name.
async function makeTempFile(directory: string): Promise<string> { async function makeTempFile(directory: string): Promise<string> {
const path = join(directory, `.tmp-${randomUUID()}`); const path = nodePath.join(directory, `.tmp-${randomUUID()}`);
const handle = await open(path, "wx", 0o600); const handle = await open(path, "wx", 0o600);
await handle.close(); await handle.close();
return path; return path;
} }
export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise<void> { export async function persistCodexAuth(
stagedPath: string,
canonicalPath: string,
): Promise<void> {
const text = await readCodexAuth(stagedPath); const text = await readCodexAuth(stagedPath);
const directory = dirname(canonicalPath); const directory = nodePath.dirname(canonicalPath);
await mkdir(directory, { recursive: true, mode: 0o700 }); await mkdir(directory, { recursive: true, mode: 0o700 });
const temporary = await makeTempFile(directory); const temporary = await makeTempFile(directory);
try { try {
+5 -1
View File
@@ -8,7 +8,11 @@ export {
retryInvalidToken, retryInvalidToken,
stageCodexAuth, stageCodexAuth,
} from "./auth.ts"; } from "./auth.ts";
export type { ClaudeFailureFields, CodexAuthentication, CommandResult } from "./auth.ts"; export type {
ClaudeFailureFields,
CodexAuthentication,
CommandResult,
} from "./auth.ts";
export { export {
collectOpenCodeOutput, collectOpenCodeOutput,
openCodeArgs, openCodeArgs,
+13 -6
View File
@@ -10,7 +10,9 @@ import {
} from "./opencode.ts"; } from "./opencode.ts";
test("builds a non-interactive auto-approved OpenCode command", () => { test("builds a non-interactive auto-approved OpenCode command", () => {
expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([ expect(
openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"),
).toStrictEqual([
"run", "run",
"--format", "--format",
"json", "json",
@@ -70,7 +72,10 @@ test("streams OpenCode output while collecting it", async () => {
}, },
); );
expect(result).toStrictEqual({ stdout: "first\nsecond\n", stderr: "warning\n" }); expect(result).toStrictEqual({
stdout: "first\nsecond\n",
stderr: "warning\n",
});
expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]); expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]);
}); });
@@ -94,12 +99,14 @@ test("reports a rejected OpenRouter key as a sign-in failure", () => {
assert.instanceOf(error, AgentAuthError); assert.instanceOf(error, AgentAuthError);
expect(error.message).toBe("opencode sign-in failed"); expect(error.message).toBe("opencode sign-in failed");
assert.instanceOf(error.cause, Error); assert.instanceOf(error.cause, Error);
expect(error.cause.message).toBe("opencode APIError (API error 401): User not found."); expect(error.cause.message).toBe(
"opencode APIError (API error 401): User not found.",
);
}); });
test("surfaces an OpenCode error event instead of empty output", () => { test("surfaces an OpenCode error event instead of empty output", () => {
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow( expect(() =>
"API error 429", parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded")),
); ).toThrow("API error 429");
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBeNull(); expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBeNull();
}); });
+3 -1
View File
@@ -96,7 +96,9 @@ export function parseOpenCodeEvents(output: string): string {
} }
} }
if (result === null) { if (result === null) {
throw openCodeError(output) ?? new Error("OpenCode produced no text output"); throw (
openCodeError(output) ?? new Error("OpenCode produced no text output")
);
} }
return result; return result;
} }
+3 -1
View File
@@ -3,7 +3,9 @@ import { expect, test } from "vite-plus/test";
import { field, isMapping, isObject } from "./json.ts"; import { field, isMapping, isObject } from "./json.ts";
test("reads a named field of a parsed object", () => { test("reads a named field of a parsed object", () => {
const value: unknown = JSON.parse('{"login":"bot","user":{"login":"nested"}}'); const value: unknown = JSON.parse(
'{"login":"bot","user":{"login":"nested"}}',
);
expect(field(value, "login")).toBe("bot"); expect(field(value, "login")).toBe("bot");
expect(field(field(value, "user"), "login")).toBe("nested"); expect(field(field(value, "user"), "login")).toBe("nested");
expect(field(value, "missing")).toBeUndefined(); expect(field(value, "missing")).toBeUndefined();
+39 -28
View File
@@ -145,7 +145,9 @@ test("posts a successful issue result and removes runtime credentials", async ()
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`",
}); });
@@ -157,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(
@@ -198,9 +202,9 @@ test("reports each tried profile and keeps the action failed", async () => {
}); });
const bodies: unknown[] = []; const bodies: unknown[] = [];
await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( await expect(
"agent phase failed", postRuntime(result.manifestPath, context, recordingFetch(bodies)),
); ).rejects.toThrow("agent phase failed");
expect(bodies).toStrictEqual([ expect(bodies).toStrictEqual([
{ {
@@ -229,9 +233,9 @@ test("drops failure entries that are not known labels", async () => {
); );
const bodies: unknown[] = []; const bodies: unknown[] = [];
await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( await expect(
"agent phase failed", postRuntime(result.manifestPath, context, recordingFetch(bodies)),
); ).rejects.toThrow("agent phase failed");
expect(bodies).toStrictEqual([ expect(bodies).toStrictEqual([
{ {
@@ -245,9 +249,9 @@ 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(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( await expect(
"agent phase failed", postRuntime(result.manifestPath, context, recordingFetch(bodies)),
); ).rejects.toThrow("agent phase failed");
expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]); expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]);
assert(await missing(result.runtimeDir)); assert(await missing(result.runtimeDir));
@@ -258,7 +262,9 @@ test("removes runtime credentials when posting fails", async () => {
await expect( await expect(
postRuntime(result.manifestPath, context, async () => { postRuntime(result.manifestPath, context, async () => {
const resolved = await Promise.resolve(new Response("failed", { status: 500 })); const resolved = await Promise.resolve(
new Response("failed", { status: 500 }),
);
return resolved; return resolved;
}), }),
).rejects.toThrow("500"); ).rejects.toThrow("500");
@@ -281,22 +287,27 @@ 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, async () => { postRuntime(result.manifestPath, context, async () => {
requests += 1; requests += 1;
const resolved = await Promise.resolve(new Response(null, { status: 200 })); const resolved = await Promise.resolve(
return resolved; 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" });
+80 -34
View File
@@ -1,10 +1,19 @@
import type { Stats } from "node:fs"; import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises"; import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path"; import nodePath from "node:path";
import { field, isNotFound, isObject } from "#common"; import { field, isNotFound, isObject } from "#common";
import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles"; import {
import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime"; failureFooter,
type FailureReason,
type ProfileFailure,
withProfile,
} from "#profiles";
import {
readRuntimeManifest,
type RuntimeManifest,
type RuntimeOutcome,
} from "#runtime";
export interface PostContext { export interface PostContext {
apiUrl: string; apiUrl: string;
@@ -27,14 +36,20 @@ const VERDICTS = new Map<string, [event: string, mark: string]>([
["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>([
"SIGN_IN_FAILED",
"USAGE_EXHAUSTED",
"FAILED",
]);
// Anchors written before the UPPER_SNAKE switch spell sides in lowercase. // Anchors written before the UPPER_SNAKE switch spell sides in lowercase.
const OLD_SIDES = new Map<unknown, Anchor["side"]>([ const OLD_SIDES = new Map<unknown, Anchor["side"]>([
["new", "NEW"], ["new", "NEW"],
["old", "OLD"], ["old", "OLD"],
]); ]);
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value); const isProfileType = (value: unknown): value is ProfileFailure["type"] =>
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value); 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)}`;
@@ -61,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" : (OLD_SIDES.get(givenSide) ?? 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`);
@@ -95,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 }]
: []; : [];
}), }),
@@ -127,7 +145,9 @@ 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`);
} }
@@ -177,13 +197,18 @@ 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 = async (body: string): Promise<void> => { const postComment = async (body: string): Promise<void> => {
await 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 | null = null; let outcome: RuntimeOutcome | null = null;
@@ -199,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");
@@ -210,21 +239,34 @@ 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,
outcome.anchorsPath,
"anchors.json",
);
const anchors =
anchorText === null
? []
: parseAnchors(anchorText, outcome.anchorsPath);
const postReview = async (entries: Anchor[]): Promise<void> => { const postReview = async (entries: Anchor[]): Promise<void> => {
await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { await request(
body: stripAnsi( `repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile), {
), body: stripAnsi(
event, withProfile(
comments: entries.map(({ path, line, side, body }) => ({ [`${mark} ${verdict.trim()}`, ...rest].join("\n"),
path, outcome.profile,
body: stripAnsi(withProfile(body, outcome.profile)), ),
new_position: side === "NEW" ? line : 0, ),
old_position: side === "OLD" ? line : 0, 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) {
@@ -239,14 +281,18 @@ export async function postRuntime(
if (error instanceof GiteaRequestError) { if (error instanceof GiteaRequestError) {
throw error; throw error;
} }
await postComment(failureComment([{ ...outcome.profile, reason: "FAILED" }])); await postComment(
failureComment([{ ...outcome.profile, reason: "FAILED" }]),
);
throw error; throw error;
} }
} finally { } finally {
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => { await rm(manifest.runtimeDir, { recursive: true }).catch(
if (!isNotFound(error)) { (error: unknown) => {
throw error; if (!isNotFound(error)) {
} throw error;
}); }
},
);
} }
} }
+9 -3
View File
@@ -6,8 +6,12 @@ import { isNotFound } from "#common";
import { exitCode } from "./child.ts"; import { exitCode } from "./child.ts";
test("reports the exit code", async () => { test("reports the exit code", async () => {
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }))).toBe(0); expect(
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" }))).toBe(3); await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" })),
).toBe(0);
expect(
await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" })),
).toBe(3);
}); });
test("reports a signal as 128 plus its number", async () => { test("reports a signal as 128 plus its number", async () => {
@@ -16,7 +20,9 @@ test("reports a signal as 128 plus its number", async () => {
}); });
test("resolves on exit while a grandchild still holds the output open", async () => { test("resolves on exit while a grandchild still holds the output open", async () => {
const child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], { stdio: ["ignore", "pipe", "pipe"] }); const child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], {
stdio: ["ignore", "pipe", "pipe"],
});
const started = Date.now(); const started = Date.now();
expect(await exitCode(child)).toBe(5); expect(await exitCode(child)).toBe(5);
expect(Date.now() - started).toBeLessThan(1500); expect(Date.now() - started).toBeLessThan(1500);
+4 -1
View File
@@ -23,7 +23,10 @@ export function exitCode(child: ChildProcess): Promise<number> {
} }
// Writes a chunk, waiting for the stream to drain when its buffer is full. // Writes a chunk, waiting for the stream to drain when its buffer is full.
export async function echo(stream: Writable, chunk: string | Uint8Array): Promise<void> { export async function echo(
stream: Writable,
chunk: string | Uint8Array,
): Promise<void> {
if (!stream.write(chunk)) { if (!stream.write(chunk)) {
await once(stream, "drain"); await once(stream, "drain");
} }
+103 -72
View File
@@ -1,5 +1,13 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process"; import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import {
chmod,
chown,
mkdir,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
@@ -7,7 +15,11 @@ 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,
@@ -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);
},
);
+16 -4
View File
@@ -18,20 +18,29 @@ async function profileProcessIds(uid: number): Promise<number[]> {
.map((entry) => entry.name); .map((entry) => entry.name);
const statuses = await Promise.all( const statuses = await Promise.all(
pids.map(async (pid) => { pids.map(async (pid) => {
const status = await readFile(`/proc/${pid}/status`, "utf8").catch(() => null); const status = await readFile(`/proc/${pid}/status`, "utf8").catch(
() => null,
);
return status; 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`);
} }
@@ -52,7 +61,10 @@ export async function killProfileProcesses(uid: number, attempt = 0): Promise<vo
await killProfileProcesses(uid, attempt + 1); await killProfileProcesses(uid, attempt + 1);
} }
export async function readProfileFile(profile: RuntimeProfile, path: string): Promise<string> { export async function readProfileFile(
profile: RuntimeProfile,
path: string,
): Promise<string> {
const stat = await lstat(path); const stat = await lstat(path);
if ( if (
!stat.isFile() || !stat.isFile() ||
+6 -1
View File
@@ -9,4 +9,9 @@ export {
sanitizedEnvironment, sanitizedEnvironment,
withProfile, withProfile,
} from "./profiles.ts"; } from "./profiles.ts";
export type { BotProfile, FailureReason, ProfileFailure, ProfileIdentity } from "./profiles.ts"; export type {
BotProfile,
FailureReason,
ProfileFailure,
ProfileIdentity,
} from "./profiles.ts";
+30 -22
View File
@@ -11,9 +11,9 @@ 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 () => {
@@ -57,22 +57,24 @@ 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(async (message) => { ["API Error: 402 Insufficient funds", "HTTP error: 429"].map(
const result = await 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" },
async (profile) => { ],
if (profile.type === "CLAUDE") { async (profile) => {
throw new Error(message); if (profile.type === "CLAUDE") {
} throw new Error(message);
const resolved = await Promise.resolve("fallback"); }
return resolved; const resolved = await Promise.resolve("fallback");
}, return resolved;
); },
return result; );
}), return result;
},
),
); );
expect(results).toStrictEqual(["fallback", "fallback"]); expect(results).toStrictEqual(["fallback", "fallback"]);
@@ -126,10 +128,14 @@ 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 () => {
@@ -237,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"],
+5 -2
View File
@@ -79,7 +79,8 @@ export function failureFooter(failures: ProfileFailure[]): string {
} }
return `> Tried: ${failures return `> Tried: ${failures
.map( .map(
({ type, model, reason }) => `\`${profileName(type)}\` · \`${model}\` (${toMessage(reason)})`, ({ type, model, reason }) =>
`\`${profileName(type)}\` · \`${model}\` (${toMessage(reason)})`,
) )
.join(", ")}`; .join(", ")}`;
} }
@@ -100,7 +101,9 @@ export async function runProfiles<T, P extends ProfileIdentity>(
if (reason === "FAILED" || index === profiles.length - 1) { if (reason === "FAILED" || index === profiles.length - 1) {
throw error; throw error;
} }
console.error(`${profile.type} ${toMessage(reason)}; trying next profile`); console.error(
`${profile.type} ${toMessage(reason)}; trying next profile`,
);
} }
} }
throw new Error("profiles must contain at least one profile"); throw new Error("profiles must contain at least one profile");
+90 -27
View File
@@ -25,7 +25,12 @@ import {
stageCodexAuth, stageCodexAuth,
} from "#agents"; } from "#agents";
import { field, isObject } from "#common"; import { field, isObject } from "#common";
import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes"; import {
echo,
exitCode,
killProfileProcesses,
readProfileFile,
} from "#processes";
import { import {
AgentAuthError, AgentAuthError,
agentEnvironment, agentEnvironment,
@@ -35,7 +40,11 @@ import {
sanitizedEnvironment, sanitizedEnvironment,
withProfile, withProfile,
} from "#profiles"; } from "#profiles";
import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "#runtime"; import {
readRuntimeManifest,
type RuntimeOutcome,
type RuntimeProfile,
} from "#runtime";
interface AttemptResult { interface AttemptResult {
body: string; body: string;
@@ -107,7 +116,10 @@ async function postAuthorComment(body: string): Promise<void> {
async function runRoot(command: string, args: string[]): Promise<void> { async function runRoot(command: string, args: string[]): Promise<void> {
const code = await exitCode( const code = await exitCode(
spawn(command, args, { env: Object.fromEntries(PROCESS_ENV), stdio: "ignore" }), spawn(command, args, {
env: Object.fromEntries(PROCESS_ENV),
stdio: "ignore",
}),
); );
if (code !== 0) { if (code !== 0) {
throw new Error(`${command} ${args[0]} exited with ${code}`); throw new Error(`${command} ${args[0]} exited with ${code}`);
@@ -126,8 +138,13 @@ async function configureGitAuthor(): Promise<void> {
await runRoot("git", ["config", "user.email", email]); await runRoot("git", ["config", "user.email", email]);
} }
async function readProfileToken(profile: RuntimeProfile): Promise<string | null> { async function readProfileToken(
const token = profile.tokenFile === null ? null : await readFile(profile.tokenFile, "utf8"); profile: RuntimeProfile,
): Promise<string | null> {
const token =
profile.tokenFile === null
? null
: await readFile(profile.tokenFile, "utf8");
return token; return token;
} }
@@ -191,7 +208,9 @@ 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");
@@ -200,7 +219,10 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
const codeRules = await rulesResponse.text(); 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")], [
"ISSUE_COMMENTS",
comments.map((comment: unknown) => formatComment(comment)).join("\n"),
],
["CODE_RULES", codeRules], ["CODE_RULES", codeRules],
["EVENT_NAME", EVENT], ["EVENT_NAME", EVENT],
["GITEA_API_URL", API], ["GITEA_API_URL", API],
@@ -209,19 +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(/\$\{(?<name>\w+)\}/gu, (match: string, ...args: unknown[]) => { prompt: template.replaceAll(
const groups = args.at(-1); /\$\{(?<name>\w+)\}/gu,
const name = isObject(groups) ? field(groups, "name") : undefined; (match: string, ...args: unknown[]) => {
return typeof name === "string" ? (values.get(name) ?? match) : match; 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",
[ [
@@ -236,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"],
@@ -253,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);
} }
@@ -275,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,
}; };
} }
} }
@@ -283,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}`);
@@ -320,7 +365,10 @@ 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);
while (shown.trim() === "") { while (shown.trim() === "") {
const exited = await Promise.race([status.then(() => true), sleep(100, false)]); const exited = await Promise.race([
status.then(() => true),
sleep(100, false),
]);
if (exited) { if (exited) {
throw new AgentAuthError("AI bot login produced no instructions"); throw new AgentAuthError("AI bot login produced no instructions");
} }
@@ -353,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);
@@ -366,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);
@@ -422,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 });
} }
@@ -457,14 +511,18 @@ async function runOpenCode(
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;
@@ -472,7 +530,12 @@ 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);
+23 -6
View File
@@ -1,6 +1,10 @@
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts"; import {
decryptCredential,
encryptCredential,
parseCredentials,
} from "./credentials.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"; const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
@@ -12,7 +16,9 @@ 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 () => {
@@ -21,7 +27,10 @@ test("rejects a credential encrypted by another master key", async () => {
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");
}); });
@@ -57,10 +66,15 @@ 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("reviewer-token"); await expect(
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");
@@ -74,5 +88,8 @@ test("reads lowercase profile types from older credentials", async () => {
}), }),
MASTER_KEY, MASTER_KEY,
); );
expect(credentials.profiles.map(({ type }) => type)).toStrictEqual(["CODEX", "CLAUDE"]); expect(credentials.profiles.map(({ type }) => type)).toStrictEqual([
"CODEX",
"CLAUDE",
]);
}); });
+45 -11
View File
@@ -23,15 +23,24 @@ 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;
async function importMasterKey( async function importMasterKey(
encoded: string, encoded: string,
@@ -41,13 +50,28 @@ async function importMasterKey(
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");
} }
const key = await 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; 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);
@@ -89,7 +113,11 @@ function currentSpelling(type: unknown): unknown {
return OLD_PROFILE_TYPES.get(type) ?? type; return OLD_PROFILE_TYPES.get(type) ?? type;
} }
async function parseProfile(entry: unknown, index: number, masterKey: string): Promise<BotProfile> { 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`);
@@ -123,7 +151,10 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
}; };
} }
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");
@@ -147,5 +178,8 @@ export async function parseCredentials(text: string, masterKey: string): Promise
return profile; return profile;
}), }),
); );
return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed }; return {
reviewerToken: await decryptCredential(reviewerToken, masterKey),
profiles: parsed,
};
} }
+103 -76
View File
@@ -1,5 +1,12 @@
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import {
chmod,
readdir,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
@@ -12,91 +19,111 @@ const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
// Runs `command` as the profile's user, from the root directory. // Runs `command` as the profile's user, from the root directory.
function runAs(profile: RuntimeProfile, command: string, path: string): boolean { function runAs(
const result = spawnSync(command, [path], { cwd: "/", uid: profile.uid, gid: profile.gid }); profile: RuntimeProfile,
command: string,
path: string,
): boolean {
const result = spawnSync(command, [path], {
cwd: "/",
uid: profile.uid,
gid: profile.gid,
});
return result.status === 0; return result.status === 0;
} }
async function expectPrivate(path: string, uid: number, mode: number): Promise<void> { async function expectPrivate(
path: string,
uid: number,
mode: number,
): Promise<void> {
const info = await stat(path); const info = await stat(path);
expect(info.uid).toStrictEqual(uid); expect(info.uid).toStrictEqual(uid);
expect(info.mode & 0o777).toBe(mode); expect(info.mode & 0o777).toBe(mode);
} }
test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credentials", async () => { test.skipIf(!IS_ROOT)(
const root = await makeTempDir(); "prepares root-only reviewer and isolated profile credentials",
await chmod(root, 0o711); async () => {
const action = await makeTempDir(); const root = await makeTempDir();
try { await chmod(root, 0o711);
await writeFile( const action = await makeTempDir();
`${action}/profiles.json`, try {
JSON.stringify({ await writeFile(
"reviewer-token": ENCRYPTED, `${action}/profiles.json`,
profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }], JSON.stringify({
}), "reviewer-token": ENCRYPTED,
); profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }],
}),
);
const manifest = await prepareRuntime(action, root, MASTER_KEY); const manifest = await prepareRuntime(action, root, MASTER_KEY);
const serialized = await readFile(manifest.manifestPath, "utf8"); const serialized = await readFile(manifest.manifestPath, "utf8");
assert(!serialized.includes("secret-token")); assert(!serialized.includes("secret-token"));
expect(await readFile(manifest.reviewerFile, "utf8")).toBe("secret-token"); expect(await readFile(manifest.reviewerFile, "utf8")).toBe(
await expectPrivate(manifest.reviewerFile, 0, 0o600); "secret-token",
);
await expectPrivate(manifest.reviewerFile, 0, 0o600);
const [claude, codex] = manifest.profiles; const [claude, codex] = manifest.profiles;
assert(claude !== undefined && codex !== undefined); assert(claude !== undefined && codex !== undefined);
expect(claude.uid).not.toStrictEqual(codex.uid); expect(claude.uid).not.toStrictEqual(codex.uid);
await expectPrivate(claude.home, claude.uid, 0o700); await expectPrivate(claude.home, claude.uid, 0o700);
assert(claude.tokenFile !== null); assert(claude.tokenFile !== null);
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token"); expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
await expectPrivate(claude.tokenFile, claude.uid, 0o600); await expectPrivate(claude.tokenFile, claude.uid, 0o600);
const codexHome = await stat(codex.home); const codexHome = await stat(codex.home);
expect(codexHome.uid).toStrictEqual(codex.uid); expect(codexHome.uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null); expect(codex.tokenFile).toBe(null);
const ownToken = spawnSync("/bin/cat", [claude.tokenFile], { const ownToken = spawnSync("/bin/cat", [claude.tokenFile], {
cwd: "/", cwd: "/",
uid: claude.uid, uid: claude.uid,
gid: claude.gid, gid: claude.gid,
encoding: "utf8", encoding: "utf8",
}); });
expect(ownToken.status).toBe(0); expect(ownToken.status).toBe(0);
expect(ownToken.stdout).toBe("secret-token"); expect(ownToken.stdout).toBe("secret-token");
expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false); expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
expect(runAs(claude, "/bin/ls", codex.home)).toBe(false); expect(runAs(claude, "/bin/ls", codex.home)).toBe(false);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
await rm(action, { recursive: true }); await rm(action, { recursive: true });
} }
}); },
);
test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fails", async () => { test.skipIf(!IS_ROOT)(
const root = await makeTempDir(); "removes prepared credentials when output publication fails",
const action = await makeTempDir(); async () => {
try { const root = await makeTempDir();
await writeFile( const action = await makeTempDir();
`${action}/profiles.json`, try {
JSON.stringify({ await writeFile(
"reviewer-token": ENCRYPTED, `${action}/profiles.json`,
profiles: [{ type: "CLAUDE", token: ENCRYPTED }], JSON.stringify({
}), "reviewer-token": ENCRYPTED,
); profiles: [{ type: "CLAUDE", token: ENCRYPTED }],
const result = spawnSync( }),
process.execPath, );
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))], const result = spawnSync(
{ process.execPath,
env: Object.fromEntries([ [fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
["ACTION_PATH", action], {
["RUNNER_TEMP", root], env: Object.fromEntries([
["BOT_AGENTS_MASTER_KEY", MASTER_KEY], ["ACTION_PATH", action],
["GITHUB_OUTPUT", action], ["RUNNER_TEMP", root],
]), ["BOT_AGENTS_MASTER_KEY", MASTER_KEY],
}, ["GITHUB_OUTPUT", action],
); ]),
},
);
expect(result.status).not.toBe(0); expect(result.status).not.toBe(0);
expect(await readdir(root)).toStrictEqual([]); expect(await readdir(root)).toStrictEqual([]);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
await rm(action, { recursive: true }); await rm(action, { recursive: true });
} }
}); },
);
+28 -11
View File
@@ -1,5 +1,13 @@
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import {
import { join } from "node:path"; chmod,
chown,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import nodePath from "node:path";
import { field, isMapping } from "#common"; import { field, isMapping } from "#common";
import type { ProfileFailure, ProfileIdentity } from "#profiles"; import type { ProfileFailure, ProfileIdentity } from "#profiles";
@@ -39,7 +47,12 @@ export interface RuntimeFailure {
export type RuntimeOutcome = RuntimeSuccess | RuntimeFailure; export type RuntimeOutcome = RuntimeSuccess | RuntimeFailure;
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> { async function writePrivate(
path: string,
value: string,
uid: number,
gid: number,
): Promise<void> {
await writeFile(path, value, { mode: 0o600 }); await writeFile(path, value, { mode: 0o600 });
await chmod(path, 0o600); await chmod(path, 0o600);
await chown(path, uid, gid); await chown(path, uid, gid);
@@ -51,25 +64,27 @@ 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: RuntimeProfile[] = []; const profiles: RuntimeProfile[] = [];
for (const [index, profile] of credentials.profiles.entries()) { for (const [index, profile] of credentials.profiles.entries()) {
const uid = FIRST_PROFILE_UID + index; const uid = FIRST_PROFILE_UID + index;
const home = join(runtimeDir, `profile-${index}`); const home = nodePath.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 === "" ? null : join(home, "token"); profile.token === undefined || profile.token === ""
? null
: nodePath.join(home, "token");
if (tokenFile !== null && profile.token !== undefined) { if (tokenFile !== null && profile.token !== undefined) {
await writePrivate(tokenFile, profile.token, uid, uid); await writePrivate(tokenFile, profile.token, uid, uid);
} }
@@ -84,12 +99,12 @@ export async function prepareRuntime(
}); });
} }
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);
@@ -111,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`);
+6 -3
View File
@@ -6,7 +6,7 @@ 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";
@@ -25,7 +25,7 @@ export const IS_ROOT: boolean = currentUid() === 0;
export const IS_WINDOWS: boolean = process.platform === "win32"; export const IS_WINDOWS: boolean = process.platform === "win32";
export async function makeTempDir(): Promise<string> { export async function makeTempDir(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), "bot-agents-test-")); const path = await mkdtemp(nodePath.join(tmpdir(), "bot-agents-test-"));
return path; return path;
} }
@@ -56,7 +56,10 @@ export async function runCommand(
env: Object.fromEntries(env), env: Object.fromEntries(env),
stdio: ["ignore", "ignore", "pipe"], stdio: ["ignore", "ignore", "pipe"],
}); });
const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]); const [stderr, closed] = await Promise.all([
text(child.stderr),
once(child, "close"),
]);
const exitCode: unknown = closed[0]; const exitCode: unknown = closed[0];
return { success: exitCode === 0, stderr }; return { success: exitCode === 0, stderr };
} }
+3 -1
View File
@@ -14,7 +14,9 @@ const PHASES: [phase: string, firstVariable: string][] = [
]; ];
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => { test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => {
const path = fileURLToPath(new URL(`../src/${phase}/index.ts`, import.meta.url)); const path = fileURLToPath(
new URL(`../src/${phase}/index.ts`, import.meta.url),
);
const result = await runCommand( const result = await runCommand(
process.execPath, process.execPath,
[path], [path],
+66 -22
View File
@@ -9,13 +9,18 @@ import { inheritedEnvironment, makeTempDir, runCommand } from "#testing";
type Mapping = Map<string, unknown>; type Mapping = Map<string, unknown>;
const mapping = (value: unknown, at: string): Mapping => { const mapping = (value: unknown, at: string): Mapping => {
assert(typeof value === "object" && value !== null && !Array.isArray(value), at); assert(
typeof value === "object" && value !== null && !Array.isArray(value),
at,
);
return new Map(Object.entries(value)); return new Map(Object.entries(value));
}; };
const sequence = (value: unknown, at: string): Mapping[] => { const sequence = (value: unknown, at: string): Mapping[] => {
assert(Array.isArray(value), at); assert(Array.isArray(value), at);
return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`)); return value.map((entry: unknown, index) =>
mapping(entry, `${at}[${index}]`),
);
}; };
async function yaml(path: string): Promise<Mapping> { async function yaml(path: string): Promise<Mapping> {
@@ -49,8 +54,13 @@ test("all committed YAML parses", async () => {
test("the action exposes only the master key and scopes it to preparation", async () => { test("the action exposes only the master key and scopes it to preparation", async () => {
const action = await yaml("action.yml"); const action = await yaml("action.yml");
expect([...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") {
@@ -62,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"));
@@ -83,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");
@@ -116,9 +138,9 @@ 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( const stats = await Promise.all(
assets.map(async (asset) => { assets.map(async (asset) => {
const info = await stat(asset); const info = await stat(asset);
@@ -132,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",
@@ -140,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");
@@ -169,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");
}); });
@@ -191,8 +230,13 @@ test("profile preparation does not receive provider credentials", async () => {
}); });
test("central profiles fall back from Codex to Claude to OpenCode", async () => { test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); const credentials: unknown = JSON.parse(
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", "CODEX",
"CLAUDE", "CLAUDE",
+5
View File
@@ -5,6 +5,11 @@ import { defineConfig } from "vite-plus";
import projectLint, { projectOverrides } from "./scripts/project-lint.ts"; import projectLint, { projectOverrides } from "./scripts/project-lint.ts";
export default defineConfig({ export default defineConfig({
fmt: {
printWidth: 80,
quoteProps: "preserve",
embeddedLanguageFormatting: "off",
},
lint: { lint: {
plugins: ["typescript", "unicorn", "oxc", "import"], plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: [ jsPlugins: [