diff --git a/.agents/memory/lint-config.md b/.agents/memory/lint-config.md index 751ed86..3d6d40a 100644 --- a/.agents/memory/lint-config.md +++ b/.agents/memory/lint-config.md @@ -79,5 +79,24 @@ overrides: projectOverrides, `project/prefer-ascii` in `projectOverrides`: posted Gitea text shows emoji verdict marks and a middle dot. +## Rule decisions (2026-10-20, all repos) + +- `unicorn/number-literal-case` is off: Oxfmt lowercases hex digits back. +- `typescript/consistent-return` is off too: it misfires on complete + switches, like `consistent-return`. +- On with defaults, no offs: `prefer-object-spread`, `unicorn/prefer-spread`, + `unicorn/no-useless-spread`, `typescript/no-misused-spread`, `no-plusplus` + (`x += 1`), `unicorn/no-array-method-this-argument`, + `typescript/method-signature-style` ("property"), + `typescript/unified-signatures`. +- `unicorn/import-style` uses its default: `import nodePath from "node:path"` + and `nodePath.join(...)` (`nodePath`, since `path` is a common variable). +- `no-template-curly-in-string` is on; only `tests/workflow.test.ts`, which + checks literal Gitea `${{ }}` expressions, is exempt in `projectOverrides`. +- Rule defaults are preferred over option tweaks unless a tweak is needed. +- `fmt` matches `deno fmt`: `printWidth: 80`, `quoteProps: "preserve"`, + `embeddedLanguageFormatting: "off"`. `max-lines-per-function` is 124 after + the narrower width. + A new category hit is fixed in the code; an off or exception is added only for a real collision or churn, with its reason. Rules: [[project-lint-plugin]]. diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 270b3b7..af4f09a 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -62,13 +62,18 @@ const noDisableCommentsRule: ProjectRule = { type: "problem", docs: { description: "Disallow lint disable and enable comments" }, 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) { return { Program(_node: TSESTree.Program): void { 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" }); } } @@ -90,7 +95,8 @@ const preferAsciiRule: ProjectRule = { docs: { description: "Require the source to be ASCII" }, schema: [], messages: { - nonAscii: "Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.", + nonAscii: + "Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.", }, }, create(context) { @@ -107,7 +113,13 @@ const preferAsciiRule: ProjectRule = { end: sourceCode.getLocFromIndex(index + character.length), }, 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; @@ -123,7 +135,10 @@ const noDuplicateTypeofsRule: ProjectRule = { type: "problem", docs: { description: "Disallow `typeof` applied twice in a row" }, 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) { return { @@ -146,7 +161,9 @@ const noEmptyYieldRule: ProjectRule = { type: "problem", docs: { description: "Disallow `yield` without an expression" }, 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) { return { @@ -160,14 +177,21 @@ const noEmptyYieldRule: ProjectRule = { }; /** The globals `no-nan` sees through, as in `globalThis.Number.NaN`. */ -const GLOBAL_NAMES: ReadonlySet = new Set(["globalThis", "self", "window"]); +const GLOBAL_NAMES: ReadonlySet = new Set([ + "globalThis", + "self", + "window", +]); /** The name a member expression's property spells, computed or not. */ function propertyName(node: TSESTree.MemberExpression): string | null { if (node.property.type === AST_NODE_TYPES.Identifier && !node.computed) { return node.property.name; } - if (node.property.type === AST_NODE_TYPES.Literal && typeof node.property.value === "string") { + if ( + node.property.type === AST_NODE_TYPES.Literal && + typeof node.property.value === "string" + ) { return node.property.value; } return null; @@ -211,12 +235,17 @@ const noNanRule: ProjectRule = { type: "problem", docs: { description: "Disallow `NaN` as a value" }, 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) { return { 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" }); } }, @@ -233,7 +262,9 @@ const noNanRule: ProjectRule = { const noUnsafeNumberRule: ProjectRule = { meta: { type: "problem", - docs: { description: "Disallow number literals outside the safe integer range" }, + docs: { + description: "Disallow number literals outside the safe integer range", + }, schema: [], messages: { unsafe: @@ -245,7 +276,8 @@ const noUnsafeNumberRule: ProjectRule = { Literal(node: TSESTree.Literal): void { if ( 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" }); } @@ -275,11 +307,15 @@ interface TrailingContinue { function trailingContinue(node: LoopStatement): TrailingContinue | null { if (node.body.type === AST_NODE_TYPES.BlockStatement) { const last = node.body.body.at(-1); - return last?.type === AST_NODE_TYPES.ContinueStatement && last.label === null + return last?.type === AST_NODE_TYPES.ContinueStatement && + last.label === null ? { statement: last, removable: true } : null; } - if (node.body.type === AST_NODE_TYPES.ContinueStatement && node.body.label === null) { + if ( + node.body.type === AST_NODE_TYPES.ContinueStatement && + node.body.label === null + ) { return { statement: node.body, removable: false }; } return null; @@ -293,11 +329,14 @@ function trailingContinue(node: LoopStatement): TrailingContinue | null { const noUselessContinueRule: ProjectRule = { meta: { 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", schema: [], 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) { @@ -372,7 +411,8 @@ function serialize(value: unknown): string { if (typeof value !== "object" || value === null) { 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) .filter(([key]: [string, unknown]) => !POSITION_KEYS.has(key)) .map(([key, child]: [string, unknown]) => { @@ -396,7 +436,9 @@ function declarationsOf( const found: T[] = []; for (const statement of statements) { 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)) { found.push(declaration); } @@ -405,12 +447,16 @@ function declarationsOf( } /** 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; } /** 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; } @@ -442,21 +488,33 @@ function typeAliasShape(node: TSESTree.TSTypeAliasDeclaration): string { const noDuplicateInterfacesRule: ProjectRule = { meta: { 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: [], messages: { - sameShape: "Found multiple interfaces with the same shape, possibly mergeable.", - sameName: "Found multiple interfaces named '{{name}}', possibly not intended.", + sameShape: + "Found multiple interfaces with the same shape, possibly mergeable.", + sameName: + "Found multiple interfaces named '{{name}}', possibly not intended.", }, }, create(context) { function check(statements: TSESTree.Node[]): void { - const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf(statements, isInterface); + const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf( + statements, + isInterface, + ); for (const node of duplicateGroups(nodes, interfaceShape).flat()) { context.report({ node, messageId: "sameShape" }); } for (const node of duplicateGroups(nodes, interfaceName).flat()) { - context.report({ node, messageId: "sameName", data: { name: node.id.name } }); + context.report({ + node, + messageId: "sameName", + data: { name: node.id.name }, + }); } } return { @@ -474,13 +532,21 @@ const noDuplicateInterfacesRule: ProjectRule = { const noDuplicateTypesRule: ProjectRule = { meta: { 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: [], - messages: { sameShape: "Found multiple types with the same shape, possibly mergeable." }, + messages: { + sameShape: + "Found multiple types with the same shape, possibly mergeable.", + }, }, create(context) { function check(statements: TSESTree.Node[]): void { - const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf(statements, isTypeAlias); + const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf( + statements, + isTypeAlias, + ); for (const node of duplicateGroups(nodes, typeAliasShape).flat()) { context.report({ node, messageId: "sameShape" }); } @@ -516,12 +582,15 @@ const noModifierPrivateRule: ProjectRule = { type: "suggestion", docs: { description: "Disallow the private modifier" }, 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) { function check(node: AccessibilityCarrier): void { 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) { 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 * not among them. */ -const SCRIPT_EXTENSIONS: ReadonlySet = new Set([".ts", ".js", ".mts", ".mjs"]); +const SCRIPT_EXTENSIONS: ReadonlySet = new Set([ + ".ts", + ".js", + ".mts", + ".mjs", +]); /** * 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) ?? ""; - if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) { + if ( + lastSegment === "." || + lastSegment === ".." || + !lastSegment.includes(".") + ) { return false; } const extension: string = lastSegment.slice(lastSegment.lastIndexOf(".")); @@ -621,7 +699,10 @@ function isAssetSource(source: string): boolean { const noAssetImportsRule: ProjectRule = { meta: { 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: [], messages: { extension: "Only .ts, .js, .mts and .mjs modules may be imported.", @@ -653,7 +734,10 @@ const noAssetImportsRule: ProjectRule = { ExportNamedDeclaration: checkDeclaration, ExportAllDeclaration: checkDeclaration, 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); } if (node.options !== null) { @@ -676,7 +760,12 @@ const rules: DummyRuleMap = { "id-match": [ "error", "^_*[^_]*_*$|^[^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. "no-restricted-globals": [ @@ -694,12 +783,14 @@ const rules: DummyRuleMap = { "one-var": ["error", "never"], "no-use-before-define": ["error", { functions: false }], "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. complexity: ["error", 26], "max-depth": ["error", 6], - "max-lines": ["error", { max: 2048, skipBlankLines: false, skipComments: false }], - "max-lines-per-function": ["error", 98], + "max-lines": [ + "error", + { max: 2048, skipBlankLines: false, skipComments: false }, + ], + "max-lines-per-function": ["error", 124], "max-params": ["error", 4], "max-statements": ["error", 32], "import/max-dependencies": ["error", { max: 13 }], @@ -757,8 +848,8 @@ const rules: DummyRuleMap = { "import/no-named-export": "off", // Collides with `typescript/consistent-type-imports`. "import/consistent-type-specifier-style": "off", - // Collides with literal Gitea Actions `${{ }}` expressions in workflow strings. - "no-template-curly-in-string": "off", + // Collides with Oxfmt, which lowercases hex digits back. + "unicorn/number-literal-case": "off", // Node code, everywhere: this action runs on Node. // Node built-ins are the action's runtime. "import/no-nodejs-modules": "off", @@ -770,6 +861,8 @@ const rules: DummyRuleMap = { "no-await-in-loop": "off", // Not type-aware; TypeScript's `noImplicitReturns` covers it. "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/switch-exhaustiveness-check": "off", // TypeScript checks completeness; a forced `default:` hides missing cases. @@ -833,6 +926,11 @@ export const projectOverrides: OxlintOverride[] = [ ], 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" }, + }, ]; /** diff --git a/src/agents/auth.test.ts b/src/agents/auth.test.ts index 5188b4c..cf4bd86 100644 --- a/src/agents/auth.test.ts +++ b/src/agents/auth.test.ts @@ -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 { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing"; @@ -19,7 +26,10 @@ test("retries once after an invalid refresh token", async () => { async () => { const resolved = await Promise.resolve({ code: 1, - error: (attempts += 1) === 1 ? "ERROR: invalid_refresh_token" : "still failed", + error: + (attempts += 1) === 1 + ? "ERROR: invalid_refresh_token" + : "still failed", }); return resolved; }, @@ -111,15 +121,19 @@ test("recognizes a rejected Claude OAuth token", () => { expect( isClaudeAuthFailure({ 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); expect( 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); - 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", () => { @@ -138,47 +152,62 @@ const 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 () => { - const root = await makeTempDir(); - try { - const canonical = `${root}/persistent/auth.json`; - await mkdir(`${root}/persistent`); - await writeFile(canonical, AUTH, { mode: 0o600 }); - const home = `${root}/profile`; - await mkdir(home); +test.skipIf(IS_WINDOWS)( + "stages and persists only a regular private Codex login", + async () => { + const root = await makeTempDir(); + try { + const canonical = `${root}/persistent/auth.json`; + await mkdir(`${root}/persistent`); + await writeFile(canonical, AUTH, { mode: 0o600 }); + const home = `${root}/profile`; + await mkdir(home); - const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid()); - expect(await readFile(staged, "utf8")).toStrictEqual(AUTH); - const stagedStat = await stat(staged); - expect(stagedStat.mode & 0o777).toBe(0o600); + const staged = await stageCodexAuth( + canonical, + home, + currentUid(), + currentGid(), + ); + expect(await readFile(staged, "utf8")).toStrictEqual(AUTH); + const stagedStat = await stat(staged); + expect(stagedStat.mode & 0o777).toBe(0o600); - const refreshed = JSON.stringify({ - auth_mode: "chatgpt", - tokens: { ...AUTH_TOKENS, access_token: "refreshed" }, - }); - await writeFile(staged, refreshed); - await persistCodexAuth(staged, canonical); - expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed); - const canonicalStat = await stat(canonical); - expect(canonicalStat.mode & 0o777).toBe(0o600); - } finally { - await rm(root, { recursive: true }); - } -}); + const refreshed = JSON.stringify({ + auth_mode: "chatgpt", + tokens: { ...AUTH_TOKENS, access_token: "refreshed" }, + }); + await writeFile(staged, refreshed); + await persistCodexAuth(staged, canonical); + expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed); + const canonicalStat = await stat(canonical); + expect(canonicalStat.mode & 0o777).toBe(0o600); + } finally { + await rm(root, { recursive: true }); + } + }, +); -test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () => { - const root = await makeTempDir(); - try { - const canonical = `${root}/auth.json`; - const malformed = `${root}/malformed.json`; - const linked = `${root}/linked.json`; - await writeFile(canonical, AUTH, { mode: 0o600 }); - await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 }); - await symlink(canonical, linked); +test.skipIf(IS_WINDOWS)( + "rejects a symlink or malformed Codex login", + async () => { + const root = await makeTempDir(); + try { + const canonical = `${root}/auth.json`; + const malformed = `${root}/malformed.json`; + const linked = `${root}/linked.json`; + 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(malformed, canonical)).rejects.toThrow("invalid Codex login"); - } finally { - await rm(root, { recursive: true }); - } -}); + await expect(persistCodexAuth(linked, canonical)).rejects.toThrow( + "regular file", + ); + await expect(persistCodexAuth(malformed, canonical)).rejects.toThrow( + "invalid Codex login", + ); + } finally { + await rm(root, { recursive: true }); + } + }, +); diff --git a/src/agents/auth.ts b/src/agents/auth.ts index 22a2868..19c7d12 100644 --- a/src/agents/auth.ts +++ b/src/agents/auth.ts @@ -10,7 +10,7 @@ import { rm, writeFile, } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import nodePath from "node:path"; import { field, isNotFound, isObject } from "#common"; @@ -26,7 +26,9 @@ interface CodexPersistent { export type CodexAuthentication = CodexApiKey | CodexPersistent; 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 @@ -95,10 +97,12 @@ async function readCodexAuth(path: string): Promise { typeof authMode !== "string" || authMode === "" || !isObject(tokens) || - !["access_token", "refresh_token", "id_token", "account_id"].every((name) => { - const token = field(tokens, name); - return typeof token === "string" && token !== ""; - }) + !["access_token", "refresh_token", "id_token", "account_id"].every( + (name) => { + const token = field(tokens, name); + return typeof token === "string" && token !== ""; + }, + ) ) { throw new Error("invalid Codex login"); } @@ -111,13 +115,15 @@ export async function stageCodexAuth( uid: number, gid: number, ): Promise { - const codexHome = join(profileHome, ".codex"); + const codexHome = nodePath.join(profileHome, ".codex"); await mkdir(codexHome, { recursive: true, mode: 0o700 }); await chmod(codexHome, 0o700); await chown(codexHome, uid, gid); - const stagedPath = join(codexHome, "auth.json"); + const stagedPath = nodePath.join(codexHome, "auth.json"); try { - await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 }); + await writeFile(stagedPath, await readCodexAuth(canonicalPath), { + mode: 0o600, + }); await chmod(stagedPath, 0o600); await chown(stagedPath, uid, gid); } catch (error) { @@ -130,15 +136,18 @@ export async function stageCodexAuth( // Creates an empty private file under a fresh name. async function makeTempFile(directory: string): Promise { - const path = join(directory, `.tmp-${randomUUID()}`); + const path = nodePath.join(directory, `.tmp-${randomUUID()}`); const handle = await open(path, "wx", 0o600); await handle.close(); return path; } -export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise { +export async function persistCodexAuth( + stagedPath: string, + canonicalPath: string, +): Promise { const text = await readCodexAuth(stagedPath); - const directory = dirname(canonicalPath); + const directory = nodePath.dirname(canonicalPath); await mkdir(directory, { recursive: true, mode: 0o700 }); const temporary = await makeTempFile(directory); try { diff --git a/src/agents/index.ts b/src/agents/index.ts index 4a37ace..ce33c46 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -8,7 +8,11 @@ export { retryInvalidToken, stageCodexAuth, } from "./auth.ts"; -export type { ClaudeFailureFields, CodexAuthentication, CommandResult } from "./auth.ts"; +export type { + ClaudeFailureFields, + CodexAuthentication, + CommandResult, +} from "./auth.ts"; export { collectOpenCodeOutput, openCodeArgs, diff --git a/src/agents/opencode.test.ts b/src/agents/opencode.test.ts index 70781e7..4c58926 100644 --- a/src/agents/opencode.test.ts +++ b/src/agents/opencode.test.ts @@ -10,7 +10,9 @@ import { } from "./opencode.ts"; 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", "--format", "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"]); }); @@ -94,12 +99,14 @@ test("reports a rejected OpenRouter key as a sign-in failure", () => { assert.instanceOf(error, AgentAuthError); expect(error.message).toBe("opencode sign-in failed"); 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", () => { - expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow( - "API error 429", - ); + expect(() => + parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded")), + ).toThrow("API error 429"); expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBeNull(); }); diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts index a8bf54e..a8f02b6 100644 --- a/src/agents/opencode.ts +++ b/src/agents/opencode.ts @@ -96,7 +96,9 @@ export function parseOpenCodeEvents(output: string): string { } } 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; } diff --git a/src/common/json.test.ts b/src/common/json.test.ts index ed7880d..6987b34 100644 --- a/src/common/json.test.ts +++ b/src/common/json.test.ts @@ -3,7 +3,9 @@ import { expect, test } from "vite-plus/test"; import { field, isMapping, isObject } from "./json.ts"; 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(field(value, "user"), "login")).toBe("nested"); expect(field(value, "missing")).toBeUndefined(); diff --git a/src/post/post.test.ts b/src/post/post.test.ts index 74a92fd..8b657ee 100644 --- a/src/post/post.test.ts +++ b/src/post/post.test.ts @@ -145,7 +145,9 @@ test("posts a successful issue result and removes runtime credentials", async () expect(requests.length).toBe(1); 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({ 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 () => { - const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [ - { path: "run.ts", line: 1, side: "NEW", body: "Finding" }, - ]); + const result = await fixture( + { ok: true, body: "ignored final response" }, + "Approved\nReady", + [{ path: "run.ts", line: 1, side: "NEW", body: "Finding" }], + ); const bodies: unknown[] = []; await postRuntime( @@ -198,9 +202,9 @@ test("reports each tried profile and keeps the action failed", async () => { }); const bodies: unknown[] = []; - await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( - "agent phase failed", - ); + await expect( + postRuntime(result.manifestPath, context, recordingFetch(bodies)), + ).rejects.toThrow("agent phase failed"); expect(bodies).toStrictEqual([ { @@ -229,9 +233,9 @@ test("drops failure entries that are not known labels", async () => { ); const bodies: unknown[] = []; - await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( - "agent phase failed", - ); + await expect( + postRuntime(result.manifestPath, context, recordingFetch(bodies)), + ).rejects.toThrow("agent phase failed"); expect(bodies).toStrictEqual([ { @@ -245,9 +249,9 @@ test("reports a generic failure when the runner wrote no outcome", async () => { await rm(result.outcomeFile); const bodies: unknown[] = []; - await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( - "agent phase failed", - ); + await expect( + postRuntime(result.manifestPath, context, recordingFetch(bodies)), + ).rejects.toThrow("agent phase failed"); expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]); assert(await missing(result.runtimeDir)); @@ -258,7 +262,9 @@ test("removes runtime credentials when posting fails", async () => { await expect( 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; }), ).rejects.toThrow("500"); @@ -281,22 +287,27 @@ test("preserves a Gitea transport failure without retrying a comment", async () assert(await missing(result.runtimeDir)); }); -test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () => { - const result = await fixture({ ok: true, body: "done" }); - await symlink(result.reviewerFile, result.reviewPath); - let requests = 0; +test.skipIf(IS_WINDOWS)( + "refuses to follow an agent artifact symlink", + async () => { + const result = await fixture({ ok: true, body: "done" }); + await symlink(result.reviewerFile, result.reviewPath); + let requests = 0; - await expect( - postRuntime(result.manifestPath, context, async () => { - requests += 1; - const resolved = await Promise.resolve(new Response(null, { status: 200 })); - return resolved; - }), - ).rejects.toThrow("regular private file"); + await expect( + postRuntime(result.manifestPath, context, async () => { + requests += 1; + const resolved = await Promise.resolve( + new Response(null, { status: 200 }), + ); + return resolved; + }), + ).rejects.toThrow("regular private file"); - expect(requests).toBe(1); - assert(await missing(result.runtimeDir)); -}); + expect(requests).toBe(1); + assert(await missing(result.runtimeDir)); + }, +); test("reports an attributed failure when a pull request review is missing", async () => { const result = await fixture({ ok: true, body: "done" }); diff --git a/src/post/post.ts b/src/post/post.ts index 90ae232..a6f8a34 100644 --- a/src/post/post.ts +++ b/src/post/post.ts @@ -1,10 +1,19 @@ import type { Stats } from "node:fs"; 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 { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles"; -import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime"; +import { + failureFooter, + type FailureReason, + type ProfileFailure, + withProfile, +} from "#profiles"; +import { + readRuntimeManifest, + type RuntimeManifest, + type RuntimeOutcome, +} from "#runtime"; export interface PostContext { apiUrl: string; @@ -27,14 +36,20 @@ const VERDICTS = new Map([ ["Changes requested", ["REQUEST_CHANGES", "馃洃"]], ]); const PROFILE_TYPES = new Set(["CLAUDE", "CODEX", "OPENCODE"]); -const FAILURE_REASONS = new Set(["SIGN_IN_FAILED", "USAGE_EXHAUSTED", "FAILED"]); +const FAILURE_REASONS = new Set([ + "SIGN_IN_FAILED", + "USAGE_EXHAUSTED", + "FAILED", +]); // Anchors written before the UPPER_SNAKE switch spell sides in lowercase. const OLD_SIDES = new Map([ ["new", "NEW"], ["old", "OLD"], ]); -const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value); -const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value); +const isProfileType = (value: unknown): value is ProfileFailure["type"] => + PROFILE_TYPES.has(value); +const isFailureReason = (value: unknown): value is FailureReason => + FAILURE_REASONS.has(value); const failureComment = (failures: ProfileFailure[]): string => `AI bot failed.\n\n${failureFooter(failures)}`; @@ -61,7 +76,8 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] { const path = field(entry, "path"); const line = field(entry, "line"); const givenSide = field(entry, "side"); - const side = givenSide === undefined ? "NEW" : (OLD_SIDES.get(givenSide) ?? givenSide); + const side = + givenSide === undefined ? "NEW" : (OLD_SIDES.get(givenSide) ?? givenSide); const body = field(entry, "body"); if (typeof path !== "string" || path === "") { throw new TypeError(`${at}.path: expected a repository path`); @@ -95,7 +111,9 @@ async function readOutcome(path: string): Promise { const type = field(entry, "type"); const model = field(entry, "model"); const reason = field(entry, "reason"); - return isProfileType(type) && typeof model === "string" && isFailureReason(reason) + return isProfileType(type) && + typeof model === "string" && + isFailureReason(reason) ? [{ type, model, reason }] : []; }), @@ -127,7 +145,9 @@ async function readAgentArtifact( path: string, name: string, ): Promise { - 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) { throw new Error(`${name}: outside profile home`); } @@ -177,13 +197,18 @@ export async function postRuntime( throw new GiteaRequestError(`${path}: ${detail}`, { cause: error }); } 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 => { - await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, { - body: stripAnsi(body), - }); + await request( + `repos/${context.repository}/issues/${context.issueIndex}/comments`, + { + body: stripAnsi(body), + }, + ); }; let outcome: RuntimeOutcome | null = null; @@ -199,7 +224,11 @@ export async function postRuntime( } try { - const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md"); + const review = await readAgentArtifact( + manifest, + outcome.reviewPath, + "review.md", + ); if (review === null) { if (context.eventName === "pull_request") { 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 [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "馃挰"]; - const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); - const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); + const anchorText = await readAgentArtifact( + manifest, + outcome.anchorsPath, + "anchors.json", + ); + const anchors = + anchorText === null + ? [] + : parseAnchors(anchorText, outcome.anchorsPath); const postReview = async (entries: Anchor[]): Promise => { - await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { - body: stripAnsi( - withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile), - ), - event, - comments: entries.map(({ path, line, side, body }) => ({ - path, - body: stripAnsi(withProfile(body, outcome.profile)), - new_position: side === "NEW" ? line : 0, - old_position: side === "OLD" ? line : 0, - })), - }); + await request( + `repos/${context.repository}/pulls/${context.issueIndex}/reviews`, + { + body: stripAnsi( + withProfile( + [`${mark} ${verdict.trim()}`, ...rest].join("\n"), + outcome.profile, + ), + ), + event, + comments: entries.map(({ path, line, side, body }) => ({ + path, + body: stripAnsi(withProfile(body, outcome.profile)), + new_position: side === "NEW" ? line : 0, + old_position: side === "OLD" ? line : 0, + })), + }, + ); }; await postReview(anchors).catch(async (error: unknown) => { if (anchors.length === 0) { @@ -239,14 +281,18 @@ export async function postRuntime( if (error instanceof GiteaRequestError) { throw error; } - await postComment(failureComment([{ ...outcome.profile, reason: "FAILED" }])); + await postComment( + failureComment([{ ...outcome.profile, reason: "FAILED" }]), + ); throw error; } } finally { - await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => { - if (!isNotFound(error)) { - throw error; - } - }); + await rm(manifest.runtimeDir, { recursive: true }).catch( + (error: unknown) => { + if (!isNotFound(error)) { + throw error; + } + }, + ); } } diff --git a/src/processes/child.test.ts b/src/processes/child.test.ts index 98056c1..7e7d990 100644 --- a/src/processes/child.test.ts +++ b/src/processes/child.test.ts @@ -6,8 +6,12 @@ import { isNotFound } from "#common"; import { exitCode } from "./child.ts"; test("reports the exit code", async () => { - expect(await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }))).toBe(0); - expect(await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" }))).toBe(3); + expect( + await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" })), + ).toBe(0); + expect( + await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" })), + ).toBe(3); }); test("reports a signal as 128 plus its number", async () => { @@ -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 () => { - 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(); expect(await exitCode(child)).toBe(5); expect(Date.now() - started).toBeLessThan(1500); diff --git a/src/processes/child.ts b/src/processes/child.ts index 7a09a09..b8465a1 100644 --- a/src/processes/child.ts +++ b/src/processes/child.ts @@ -23,7 +23,10 @@ export function exitCode(child: ChildProcess): Promise { } // Writes a chunk, waiting for the stream to drain when its buffer is full. -export async function echo(stream: Writable, chunk: string | Uint8Array): Promise { +export async function echo( + stream: Writable, + chunk: string | Uint8Array, +): Promise { if (!stream.write(chunk)) { await once(stream, "drain"); } diff --git a/src/processes/isolation.test.ts b/src/processes/isolation.test.ts index b580a5b..6933661 100644 --- a/src/processes/isolation.test.ts +++ b/src/processes/isolation.test.ts @@ -1,5 +1,13 @@ 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 { expect, test } from "vite-plus/test"; @@ -7,7 +15,11 @@ import { isNotFound } from "#common"; import type { RuntimeProfile } from "#runtime"; 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 => ({ index: uid - 20_000, @@ -41,77 +53,96 @@ test("zombie process entries are already quiescent", () => { expect(isRunnableProfileProcess(status, 20_010)).toBe(false); }); -test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", async () => { - const root = await makeTempDir(); - try { - const secret = `${root}/reviewer-token`; - const result = `${root}/result.md`; - await writeFile(secret, "reviewer-secret", { mode: 0o600 }); - await symlink(secret, result); +test.skipIf(IS_WINDOWS)( + "refuses to read a profile result symlink as root", + async () => { + const root = await makeTempDir(); + try { + const secret = `${root}/reviewer-token`; + 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( - "regular private file", - ); - } finally { - 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; + await expect( + readProfileFile(profile(currentUid(), root), result), + ).rejects.toThrow("regular private file"); + } finally { + await rm(root, { recursive: true }); } - 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"]]), - ); +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 }); - 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); + }, +); diff --git a/src/processes/isolation.ts b/src/processes/isolation.ts index b63d8d3..2ffbb65 100644 --- a/src/processes/isolation.ts +++ b/src/processes/isolation.ts @@ -18,20 +18,29 @@ async function profileProcessIds(uid: number): Promise { .map((entry) => entry.name); const statuses = await Promise.all( 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 pids .filter((_pid, index) => { const status = statuses[index]; - return status !== null && status !== undefined && isRunnableProfileProcess(status, uid); + return ( + status !== null && + status !== undefined && + isRunnableProfileProcess(status, uid) + ); }) .map(Number); } // 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 { +export async function killProfileProcesses( + uid: number, + attempt = 0, +): Promise { if (attempt === 100) { throw new Error(`profile UID ${uid} still owns processes after cleanup`); } @@ -52,7 +61,10 @@ export async function killProfileProcesses(uid: number, attempt = 0): Promise { +export async function readProfileFile( + profile: RuntimeProfile, + path: string, +): Promise { const stat = await lstat(path); if ( !stat.isFile() || diff --git a/src/profiles/index.ts b/src/profiles/index.ts index 69ad6a9..71870da 100644 --- a/src/profiles/index.ts +++ b/src/profiles/index.ts @@ -9,4 +9,9 @@ export { sanitizedEnvironment, withProfile, } from "./profiles.ts"; -export type { BotProfile, FailureReason, ProfileFailure, ProfileIdentity } from "./profiles.ts"; +export type { + BotProfile, + FailureReason, + ProfileFailure, + ProfileIdentity, +} from "./profiles.ts"; diff --git a/src/profiles/profiles.test.ts b/src/profiles/profiles.test.ts index 6879598..63b8e0e 100644 --- a/src/profiles/profiles.test.ts +++ b/src/profiles/profiles.test.ts @@ -11,9 +11,9 @@ import { } from "./profiles.ts"; test("formats profile attribution as a Markdown quote", () => { - expect(profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" })).toBe( - "> Profile: `opencode` 路 `openrouter/qwen/qwen3.8-27b`", - ); + expect( + profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" }), + ).toBe("> Profile: `opencode` 路 `openrouter/qwen/qwen3.8-27b`"); }); 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 () => { const results = await Promise.all( - ["API Error: 402 Insufficient funds", "HTTP error: 429"].map(async (message) => { - const result = await runProfiles( - [ - { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, - { type: "CODEX", model: "gpt-5.6-terra" }, - ], - async (profile) => { - if (profile.type === "CLAUDE") { - throw new Error(message); - } - const resolved = await Promise.resolve("fallback"); - return resolved; - }, - ); - return result; - }), + ["API Error: 402 Insufficient funds", "HTTP error: 429"].map( + async (message) => { + const result = await runProfiles( + [ + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, + ], + async (profile) => { + if (profile.type === "CLAUDE") { + throw new Error(message); + } + const resolved = await Promise.resolve("fallback"); + return resolved; + }, + ); + return result; + }, + ), ); expect(results).toStrictEqual(["fallback", "fallback"]); @@ -126,10 +128,14 @@ test("throws the last sign-in failure when every profile fails", async () => { ).rejects.toThrow( expect.toSatisfy( (error: unknown) => - error instanceof AgentAuthError && error.message.includes("CLAUDE sign-in failed"), + error instanceof AgentAuthError && + error.message.includes("CLAUDE sign-in failed"), ), ); - expect(failures.map(({ reason }) => reason)).toStrictEqual(["SIGN_IN_FAILED", "SIGN_IN_FAILED"]); + expect(failures.map(({ reason }) => reason)).toStrictEqual([ + "SIGN_IN_FAILED", + "SIGN_IN_FAILED", + ]); }); test("preserves an ordinary agent failure", async () => { @@ -237,7 +243,9 @@ test("exposes an OpenRouter key only to the OpenCode profile", () => { 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([ ["PATH", "/bin"], ["HOME", "/runtime/profile-2"], diff --git a/src/profiles/profiles.ts b/src/profiles/profiles.ts index 67cb85e..bb8399c 100644 --- a/src/profiles/profiles.ts +++ b/src/profiles/profiles.ts @@ -79,7 +79,8 @@ export function failureFooter(failures: ProfileFailure[]): string { } return `> Tried: ${failures .map( - ({ type, model, reason }) => `\`${profileName(type)}\` 路 \`${model}\` (${toMessage(reason)})`, + ({ type, model, reason }) => + `\`${profileName(type)}\` 路 \`${model}\` (${toMessage(reason)})`, ) .join(", ")}`; } @@ -100,7 +101,9 @@ export async function runProfiles( if (reason === "FAILED" || index === profiles.length - 1) { 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"); diff --git a/src/run/index.ts b/src/run/index.ts index bb2a3c8..6282193 100644 --- a/src/run/index.ts +++ b/src/run/index.ts @@ -25,7 +25,12 @@ import { stageCodexAuth, } from "#agents"; import { field, isObject } from "#common"; -import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes"; +import { + echo, + exitCode, + killProfileProcesses, + readProfileFile, +} from "#processes"; import { AgentAuthError, agentEnvironment, @@ -35,7 +40,11 @@ import { sanitizedEnvironment, withProfile, } from "#profiles"; -import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "#runtime"; +import { + readRuntimeManifest, + type RuntimeOutcome, + type RuntimeProfile, +} from "#runtime"; interface AttemptResult { body: string; @@ -107,7 +116,10 @@ async function postAuthorComment(body: string): Promise { async function runRoot(command: string, args: string[]): Promise { 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) { throw new Error(`${command} ${args[0]} exited with ${code}`); @@ -126,8 +138,13 @@ async function configureGitAuthor(): Promise { await runRoot("git", ["config", "user.email", email]); } -async function readProfileToken(profile: RuntimeProfile): Promise { - const token = profile.tokenFile === null ? null : await readFile(profile.tokenFile, "utf8"); +async function readProfileToken( + profile: RuntimeProfile, +): Promise { + const token = + profile.tokenFile === null + ? null + : await readFile(profile.tokenFile, "utf8"); return token; } @@ -191,7 +208,9 @@ function formatComment(comment: unknown): string { async function renderPrompt(profile: RuntimeProfile): Promise { const reviewPath = await makeProfileFile(profile, "review.md"); 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(); if (!Array.isArray(comments)) { throw new TypeError("comments: unexpected Gitea response"); @@ -200,7 +219,10 @@ async function renderPrompt(profile: RuntimeProfile): Promise { const codeRules = await rulesResponse.text(); const values = new Map([ ["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], ["EVENT_NAME", EVENT], ["GITEA_API_URL", API], @@ -209,19 +231,29 @@ async function renderPrompt(profile: RuntimeProfile): Promise { ["REVIEW_PATH", reviewPath], ["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 { - prompt: template.replaceAll(/\$\{(?\w+)\}/gu, (match: string, ...args: unknown[]) => { - const groups = args.at(-1); - const name = isObject(groups) ? field(groups, "name") : undefined; - return typeof name === "string" ? (values.get(name) ?? match) : match; - }), + prompt: template.replaceAll( + /\$\{(?\w+)\}/gu, + (match: string, ...args: unknown[]) => { + const groups = args.at(-1); + const name = isObject(groups) ? field(groups, "name") : undefined; + return typeof name === "string" ? (values.get(name) ?? match) : match; + }, + ), reviewPath, anchorsPath, }; } -async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise { +async function runClaude( + profile: RuntimeProfile, + token: string, + prompt: string, +): Promise { const claude = spawn( "claude", [ @@ -236,7 +268,9 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string) ], { 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, gid: profile.gid, stdio: ["ignore", "pipe", "pipe"], @@ -253,14 +287,20 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string) 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 !== "") { const event: unknown = JSON.parse(line); if (isObject(event)) { const content = field(field(event, "message"), "content"); for (const part of Array.isArray(content) ? content : []) { 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 !== "") { console.log(text); } @@ -275,7 +315,8 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string) isError: typeof isError === "boolean" ? isError : undefined, result: typeof answer === "string" ? answer : undefined, 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]); 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)) { throw new AgentAuthError("claude sign-in failed", { cause: failure }); } throw failure; } 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) { 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)]); await sleep(3000); 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) { throw new AgentAuthError("AI bot login produced no instructions"); } @@ -353,7 +401,10 @@ async function codexApiLogin( } } -async function makeProfileFile(profile: RuntimeProfile, name: string): Promise { +async function makeProfileFile( + profile: RuntimeProfile, + name: string, +): Promise { const path = `${profile.home}/${name}`; await writeFile(path, "", { mode: 0o600 }); await chmod(path, 0o600); @@ -366,7 +417,8 @@ async function runCodex( prompt: string, authentication: CodexAuthentication, ): Promise { - 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); if (authentication.kind === "api-key") { await codexApiLogin(profile, environment, authentication.token); @@ -422,7 +474,9 @@ async function runCodex( await codexDeviceLogin(profile, environment); }); 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)) { 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 error = streams.stderr.trim(); 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); } async function runProfile(profile: RuntimeProfile): Promise { const token = await readProfileToken(profile); - const authentication = profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null; + const authentication = + profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null; let stagedCodexAuth: string | null = null; let body: string | null = null; let resultPath: string | null = null; @@ -472,7 +530,12 @@ async function runProfile(profile: RuntimeProfile): Promise { let anchorsPath: string | null = null; try { 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); const rendered = await renderPrompt(profile); diff --git a/src/runtime/credentials.test.ts b/src/runtime/credentials.test.ts index 2508e5d..ff8d382 100644 --- a/src/runtime/credentials.test.ts +++ b/src/runtime/credentials.test.ts @@ -1,6 +1,10 @@ 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 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 () => { - 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 () => { @@ -21,7 +27,10 @@ test("rejects a credential encrypted by another master key", async () => { test("rejects a credential with a nonstandard nonce", async () => { await expect( - decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY), + decryptCredential( + "v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", + MASTER_KEY, + ), ).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 () => { - await expect(parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow("reviewer-token"); + await expect( + parseCredentials('{"profiles": []}', MASTER_KEY), + ).rejects.toThrow("reviewer-token"); await expect( parseCredentials( - JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "CLAUDE" }] }), + JSON.stringify({ + "reviewer-token": ENCRYPTED, + profiles: [{ type: "CLAUDE" }], + }), MASTER_KEY, ), ).rejects.toThrow("profiles[0].token"); @@ -74,5 +88,8 @@ test("reads lowercase profile types from older credentials", async () => { }), MASTER_KEY, ); - expect(credentials.profiles.map(({ type }) => type)).toStrictEqual(["CODEX", "CLAUDE"]); + expect(credentials.profiles.map(({ type }) => type)).toStrictEqual([ + "CODEX", + "CLAUDE", + ]); }); diff --git a/src/runtime/credentials.ts b/src/runtime/credentials.ts index 3e9634a..3861918 100644 --- a/src/runtime/credentials.ts +++ b/src/runtime/credentials.ts @@ -23,15 +23,24 @@ export interface BotCredentials { function decodeBase64Url(value: string): Uint8Array { const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); 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 { - const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join(""); - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join( + "", + ); + 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( encoded: string, @@ -41,13 +50,28 @@ async function importMasterKey( if (bytes.length !== 32) { 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; } -export async function decryptCredential(encrypted: string, masterKey: string): Promise { - const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = encrypted.split(":"); - if (version !== "v1" || encodedNonce === "" || encodedCiphertext === "" || extra !== "") { +export async function decryptCredential( + encrypted: string, + masterKey: string, +): Promise { + const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = + encrypted.split(":"); + if ( + version !== "v1" || + encodedNonce === "" || + encodedCiphertext === "" || + extra !== "" + ) { throw new Error("credential must use v1::"); } const nonce = decodeBase64Url(encodedNonce); @@ -89,7 +113,11 @@ function currentSpelling(type: unknown): unknown { return OLD_PROFILE_TYPES.get(type) ?? type; } -async function parseProfile(entry: unknown, index: number, masterKey: string): Promise { +async function parseProfile( + entry: unknown, + index: number, + masterKey: string, +): Promise { const at = `profiles[${index}]`; if (!isMapping(entry)) { 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 { +export async function parseCredentials( + text: string, + masterKey: string, +): Promise { const value: unknown = JSON.parse(text); if (!isMapping(value)) { throw new TypeError("credentials must be a mapping"); @@ -147,5 +178,8 @@ export async function parseCredentials(text: string, masterKey: string): Promise return profile; }), ); - return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed }; + return { + reviewerToken: await decryptCredential(reviewerToken, masterKey), + profiles: parsed, + }; } diff --git a/src/runtime/runtime.test.ts b/src/runtime/runtime.test.ts index 55b6e6c..7e97b09 100644 --- a/src/runtime/runtime.test.ts +++ b/src/runtime/runtime.test.ts @@ -1,5 +1,12 @@ 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 { fileURLToPath } from "node:url"; import { assert, expect, test } from "vite-plus/test"; @@ -12,91 +19,111 @@ const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; // Runs `command` as the profile's user, from the root directory. -function runAs(profile: RuntimeProfile, command: string, path: string): boolean { - const result = spawnSync(command, [path], { cwd: "/", uid: profile.uid, gid: profile.gid }); +function runAs( + profile: RuntimeProfile, + command: string, + path: string, +): boolean { + const result = spawnSync(command, [path], { + cwd: "/", + uid: profile.uid, + gid: profile.gid, + }); return result.status === 0; } -async function expectPrivate(path: string, uid: number, mode: number): Promise { +async function expectPrivate( + path: string, + uid: number, + mode: number, +): Promise { const info = await stat(path); expect(info.uid).toStrictEqual(uid); expect(info.mode & 0o777).toBe(mode); } -test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credentials", async () => { - const root = await makeTempDir(); - await chmod(root, 0o711); - const action = await makeTempDir(); - try { - await writeFile( - `${action}/profiles.json`, - JSON.stringify({ - "reviewer-token": ENCRYPTED, - profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }], - }), - ); +test.skipIf(!IS_ROOT)( + "prepares root-only reviewer and isolated profile credentials", + async () => { + const root = await makeTempDir(); + await chmod(root, 0o711); + const action = await makeTempDir(); + try { + await writeFile( + `${action}/profiles.json`, + JSON.stringify({ + "reviewer-token": ENCRYPTED, + profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }], + }), + ); - const manifest = await prepareRuntime(action, root, MASTER_KEY); - const serialized = await readFile(manifest.manifestPath, "utf8"); - assert(!serialized.includes("secret-token")); - expect(await readFile(manifest.reviewerFile, "utf8")).toBe("secret-token"); - await expectPrivate(manifest.reviewerFile, 0, 0o600); + const manifest = await prepareRuntime(action, root, MASTER_KEY); + const serialized = await readFile(manifest.manifestPath, "utf8"); + assert(!serialized.includes("secret-token")); + expect(await readFile(manifest.reviewerFile, "utf8")).toBe( + "secret-token", + ); + await expectPrivate(manifest.reviewerFile, 0, 0o600); - const [claude, codex] = manifest.profiles; - assert(claude !== undefined && codex !== undefined); - expect(claude.uid).not.toStrictEqual(codex.uid); - await expectPrivate(claude.home, claude.uid, 0o700); - assert(claude.tokenFile !== null); - expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token"); - await expectPrivate(claude.tokenFile, claude.uid, 0o600); - const codexHome = await stat(codex.home); - expect(codexHome.uid).toStrictEqual(codex.uid); - expect(codex.tokenFile).toBe(null); + const [claude, codex] = manifest.profiles; + assert(claude !== undefined && codex !== undefined); + expect(claude.uid).not.toStrictEqual(codex.uid); + await expectPrivate(claude.home, claude.uid, 0o700); + assert(claude.tokenFile !== null); + expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token"); + await expectPrivate(claude.tokenFile, claude.uid, 0o600); + const codexHome = await stat(codex.home); + expect(codexHome.uid).toStrictEqual(codex.uid); + expect(codex.tokenFile).toBe(null); - const ownToken = spawnSync("/bin/cat", [claude.tokenFile], { - cwd: "/", - uid: claude.uid, - gid: claude.gid, - encoding: "utf8", - }); - expect(ownToken.status).toBe(0); - expect(ownToken.stdout).toBe("secret-token"); - expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false); - expect(runAs(claude, "/bin/ls", codex.home)).toBe(false); - } finally { - await rm(root, { recursive: true }); - await rm(action, { recursive: true }); - } -}); + const ownToken = spawnSync("/bin/cat", [claude.tokenFile], { + cwd: "/", + uid: claude.uid, + gid: claude.gid, + encoding: "utf8", + }); + expect(ownToken.status).toBe(0); + expect(ownToken.stdout).toBe("secret-token"); + expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false); + expect(runAs(claude, "/bin/ls", codex.home)).toBe(false); + } finally { + await rm(root, { recursive: true }); + await rm(action, { recursive: true }); + } + }, +); -test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fails", async () => { - const root = await makeTempDir(); - const action = await makeTempDir(); - try { - await writeFile( - `${action}/profiles.json`, - JSON.stringify({ - "reviewer-token": ENCRYPTED, - profiles: [{ type: "CLAUDE", token: ENCRYPTED }], - }), - ); - const result = spawnSync( - process.execPath, - [fileURLToPath(new URL("../prepare/index.ts", import.meta.url))], - { - env: Object.fromEntries([ - ["ACTION_PATH", action], - ["RUNNER_TEMP", root], - ["BOT_AGENTS_MASTER_KEY", MASTER_KEY], - ["GITHUB_OUTPUT", action], - ]), - }, - ); +test.skipIf(!IS_ROOT)( + "removes prepared credentials when output publication fails", + async () => { + const root = await makeTempDir(); + const action = await makeTempDir(); + try { + await writeFile( + `${action}/profiles.json`, + JSON.stringify({ + "reviewer-token": ENCRYPTED, + profiles: [{ type: "CLAUDE", token: ENCRYPTED }], + }), + ); + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL("../prepare/index.ts", import.meta.url))], + { + env: Object.fromEntries([ + ["ACTION_PATH", action], + ["RUNNER_TEMP", root], + ["BOT_AGENTS_MASTER_KEY", MASTER_KEY], + ["GITHUB_OUTPUT", action], + ]), + }, + ); - expect(result.status).not.toBe(0); - expect(await readdir(root)).toStrictEqual([]); - } finally { - await rm(root, { recursive: true }); - await rm(action, { recursive: true }); - } -}); + expect(result.status).not.toBe(0); + expect(await readdir(root)).toStrictEqual([]); + } finally { + await rm(root, { recursive: true }); + await rm(action, { recursive: true }); + } + }, +); diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index cf09236..bab5d32 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -1,5 +1,13 @@ -import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { + chmod, + chown, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import nodePath from "node:path"; import { field, isMapping } from "#common"; import type { ProfileFailure, ProfileIdentity } from "#profiles"; @@ -39,7 +47,12 @@ export interface RuntimeFailure { export type RuntimeOutcome = RuntimeSuccess | RuntimeFailure; -async function writePrivate(path: string, value: string, uid: number, gid: number): Promise { +async function writePrivate( + path: string, + value: string, + uid: number, + gid: number, +): Promise { await writeFile(path, value, { mode: 0o600 }); await chmod(path, 0o600); await chown(path, uid, gid); @@ -51,25 +64,27 @@ export async function prepareRuntime( masterKey: string, ): Promise { const credentials = await parseCredentials( - await readFile(join(actionPath, "profiles.json"), "utf8"), + await readFile(nodePath.join(actionPath, "profiles.json"), "utf8"), masterKey, ); - const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-")); + const runtimeDir = await mkdtemp(nodePath.join(runtimeRoot, "bot-agents-")); try { await chmod(runtimeDir, 0o711); - const reviewerFile = join(runtimeDir, "reviewer-token"); + const reviewerFile = nodePath.join(runtimeDir, "reviewer-token"); await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0); // One profile at a time, in order, as each claims the next UID. const profiles: RuntimeProfile[] = []; for (const [index, profile] of credentials.profiles.entries()) { 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 chmod(home, 0o700); await chown(home, uid, uid); 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) { 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 = { runtimeDir, manifestPath, reviewerFile, - outcomeFile: join(runtimeDir, "outcome.json"), + outcomeFile: nodePath.join(runtimeDir, "outcome.json"), profiles, }; 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 { +export async function readRuntimeManifest( + path: string, +): Promise { const value: unknown = JSON.parse(await readFile(path, "utf8")); if (!isRuntimeManifest(value)) { throw new Error(`${path}: invalid runtime manifest`); diff --git a/src/testing/index.ts b/src/testing/index.ts index 66752c8..20db225 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -6,7 +6,7 @@ import { spawn } from "node:child_process"; import { once } from "node:events"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import nodePath from "node:path"; import process from "node:process"; 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 async function makeTempDir(): Promise { - const path = await mkdtemp(join(tmpdir(), "bot-agents-test-")); + const path = await mkdtemp(nodePath.join(tmpdir(), "bot-agents-test-")); return path; } @@ -56,7 +56,10 @@ export async function runCommand( env: Object.fromEntries(env), 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]; return { success: exitCode === 0, stderr }; } diff --git a/tests/entrypoints.test.ts b/tests/entrypoints.test.ts index ed7af66..c7fb75d 100644 --- a/tests/entrypoints.test.ts +++ b/tests/entrypoints.test.ts @@ -14,7 +14,9 @@ const PHASES: [phase: string, firstVariable: string][] = [ ]; 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( process.execPath, [path], diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 9564744..45c9afc 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -9,13 +9,18 @@ import { inheritedEnvironment, makeTempDir, runCommand } from "#testing"; type Mapping = Map; 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)); }; const sequence = (value: unknown, at: string): Mapping[] => { 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 { @@ -49,8 +54,13 @@ test("all committed YAML parses", async () => { test("the action exposes only the master key and scopes it to preparation", async () => { const action = await yaml("action.yml"); - expect([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual(["master-key"]); - const steps = sequence(mapping(action.get("runs"), "runs").get("steps"), "runs.steps"); + expect([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual([ + "master-key", + ]); + const steps = sequence( + mapping(action.get("runs"), "runs").get("steps"), + "runs.steps", + ); for (const step of steps) { const run = step.get("run"); 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 post = findStep(steps, "id", "post"); findStep(steps, "id", "source"); - expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe("${{ gitea.token }}"); - expect(mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY")).toBe( - "${{ inputs.master-key }}", + expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe( + "${{ gitea.token }}", + ); + 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()")); for (const step of [prepare, agent, post]) { 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("GITHUB_OUTPUT", outputFile); 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); 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 () => { const module = new URL("../src/run/index.ts", import.meta.url); const source = await readFile(module, "utf8"); - const assets = [...source.matchAll(/new URL\("(?[^"]+)", import\.meta\.url\)/gu)].map( - ([, asset = ""]) => new URL(asset, module), - ); + const assets = [ + ...source.matchAll(/new URL\("(?[^"]+)", import\.meta\.url\)/gu), + ].map(([, asset = ""]) => new URL(asset, module)); const stats = await Promise.all( assets.map(async (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 () => { 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"); expect(Object.fromEntries(permissions)).toStrictEqual({ code: "write", @@ -140,17 +165,30 @@ test("the repository uses its trusted main action", async () => { "pull-requests": "write", }); const steps = sequence(job.get("steps"), "jobs.agent.steps"); - expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual(-1); - const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main"); + expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual( + -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( "${{ 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 () => { 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") .map((step) => { 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")); const removed = await stat("superpowers.ts").then( () => 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"); }); @@ -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 () => { - const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); - const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles"); + const credentials: unknown = JSON.parse( + await readFile("profiles.json", "utf8"), + ); + const profiles = sequence( + mapping(credentials, "profiles.json").get("profiles"), + "profiles", + ); expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([ "CODEX", "CLAUDE", diff --git a/vite.config.ts b/vite.config.ts index c6a5295..5ed8611 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,11 @@ import { defineConfig } from "vite-plus"; import projectLint, { projectOverrides } from "./scripts/project-lint.ts"; export default defineConfig({ + fmt: { + printWidth: 80, + quoteProps: "preserve", + embeddedLanguageFormatting: "off", + }, lint: { plugins: ["typescript", "unicorn", "oxc", "import"], jsPlugins: [