Lint with every Oxlint category and list only the offs and options
Check / check (pull_request) Successful in 51s
Check / check (pull_request) Successful in 51s
This commit is contained in:
+116
-70
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* `project` — lint rules this repository holds its own source to, on top of
|
* `project` — this repository's changes to the Oxlint categories in
|
||||||
* the Oxlint categories in `vite.config.ts`. Its rules of its own are
|
* `vite.config.ts`. Its rules of its own are `no-disable-comments` and
|
||||||
* `no-disable-comments` and `no-asset-imports`.
|
* `no-asset-imports`.
|
||||||
*
|
*
|
||||||
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
|
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
|
||||||
*
|
*
|
||||||
@@ -14,7 +14,7 @@ import type { DummyRuleMap } from "vite-plus/lint";
|
|||||||
|
|
||||||
/** The source code both hosts hand a rule, with the comment list. */
|
/** The source code both hosts hand a rule, with the comment list. */
|
||||||
interface ProjectSourceCode extends TextSourceCode {
|
interface ProjectSourceCode extends TextSourceCode {
|
||||||
getAllComments(): TSESTree.Comment[];
|
getAllComments: () => TSESTree.Comment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */
|
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */
|
||||||
@@ -161,11 +161,16 @@ const noAssetImportsRule: ProjectRule = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This repository's own rule list, spread by `vite.config.ts`: rules that
|
* This repository's changes to the Oxlint categories in `vite.config.ts`,
|
||||||
* sit outside the Oxlint categories, plus the template's extras. Built-in
|
* which turn every non-nursery category on: its own rules, the category
|
||||||
* Oxlint rule ids sit here too, so `vite.config.ts` lists no rule by name.
|
* rules it turns off, and options that differ from a rule's defaults.
|
||||||
*/
|
*/
|
||||||
const rules: DummyRuleMap = {
|
const rules: DummyRuleMap = {
|
||||||
|
// Its own rules.
|
||||||
|
"project/no-disable-comments": "error",
|
||||||
|
"project/no-asset-imports": "error",
|
||||||
|
|
||||||
|
// Options.
|
||||||
// The explicit-types template's `camelcase` stand-in: no `_` inside a name
|
// The explicit-types template's `camelcase` stand-in: no `_` inside a name
|
||||||
// once leading and trailing ones are trimmed, unless it has no lowercase
|
// once leading and trailing ones are trimmed, unless it has no lowercase
|
||||||
// ASCII letter (UPPER_SNAKE).
|
// ASCII letter (UPPER_SNAKE).
|
||||||
@@ -174,31 +179,6 @@ const rules: DummyRuleMap = {
|
|||||||
"^_*[^_]*_*$|^[^a-z]*$",
|
"^_*[^_]*_*$|^[^a-z]*$",
|
||||||
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true },
|
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true },
|
||||||
],
|
],
|
||||||
// Rules outside the categories.
|
|
||||||
"no-array-constructor": "error",
|
|
||||||
"no-case-declarations": "error",
|
|
||||||
"no-empty": "error",
|
|
||||||
"no-fallthrough": "error",
|
|
||||||
"no-inner-declarations": "error",
|
|
||||||
"no-prototype-builtins": "error",
|
|
||||||
"no-redeclare": "error",
|
|
||||||
"no-regex-spaces": "error",
|
|
||||||
"no-var": "error",
|
|
||||||
"prefer-const": "error",
|
|
||||||
"require-await": "error",
|
|
||||||
"require-yield": "error",
|
|
||||||
"typescript/adjacent-overload-signatures": "error",
|
|
||||||
"typescript/ban-ts-comment": "error",
|
|
||||||
"typescript/no-empty-interface": "error",
|
|
||||||
"typescript/no-empty-object-type": "error",
|
|
||||||
"typescript/no-explicit-any": "error",
|
|
||||||
"typescript/no-namespace": "error",
|
|
||||||
"typescript/no-non-null-asserted-optional-chain": "error",
|
|
||||||
"typescript/no-unsafe-function-type": "error",
|
|
||||||
"typescript/no-wrapper-object-types": "error",
|
|
||||||
"typescript/prefer-as-const": "error",
|
|
||||||
"typescript/prefer-namespace-keyword": "error",
|
|
||||||
"typescript/triple-slash-reference": "error",
|
|
||||||
// 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": [
|
||||||
"error",
|
"error",
|
||||||
@@ -209,45 +189,111 @@ const rules: DummyRuleMap = {
|
|||||||
"setImmediate",
|
"setImmediate",
|
||||||
"clearImmediate",
|
"clearImmediate",
|
||||||
],
|
],
|
||||||
// The explicit-types template's extras.
|
// Single capital type parameters, which explicit-types requires, may be one letter.
|
||||||
"import/no-cycle": "error",
|
"id-length": ["error", { exceptionPatterns: ["^[A-Z]$"] }],
|
||||||
// `../` is banned in every file; anything above a file needs a `#` alias.
|
"one-var": ["error", "never"],
|
||||||
"import/no-relative-parent-imports": "error",
|
"no-use-before-define": ["error", { functions: false }],
|
||||||
"typescript/consistent-type-imports": "error",
|
"func-style": ["error", "declaration", { allowArrowFunctions: true }],
|
||||||
"typescript/no-import-type-side-effects": "error",
|
"unicorn/import-style": ["error", { styles: { "node:path": { named: true } } }],
|
||||||
"typescript/no-non-null-assertion": "error",
|
// Each limit is the smallest value the current code passes.
|
||||||
// No disable comments at all.
|
complexity: ["error", 26],
|
||||||
"project/no-disable-comments": "error",
|
"max-depth": ["error", 6],
|
||||||
"project/no-asset-imports": "error",
|
"max-lines": ["error", { max: 2048, skipBlankLines: false, skipComments: false }],
|
||||||
"unicorn/no-abusive-eslint-disable": "error",
|
"max-lines-per-function": ["error", 96],
|
||||||
// Size and complexity limits match Deno's: this repo's `deno lint` ran the
|
"max-params": ["error", 4],
|
||||||
// `recommended` set with no config file, so no size or complexity rule
|
"max-statements": ["error", 32],
|
||||||
// applied and none applies here. Each Oxlint limit rule, from a category or
|
"import/max-dependencies": ["error", { max: 13 }],
|
||||||
// not, is off:
|
"unicorn/max-nested-calls": ["error", { max: 4 }],
|
||||||
// - `complexity`: Deno had no `max-complexity`.
|
|
||||||
// - `max-lines`: Deno had no `max-file-lines`.
|
// Collides with another rule, a plugin config, or Oxfmt.
|
||||||
// - `max-params`: Deno had no `max-params`.
|
// Collides with `#private`: members are public unless marked so.
|
||||||
// - `id-length`: Deno had no `max-identifier-length`.
|
"typescript/explicit-member-accessibility": "off",
|
||||||
// - `no-nested-ternary`: Deno had no `max-nest-ternaries`.
|
// Collides with `typescript/prefer-readonly-parameter-types` being off.
|
||||||
// - `max-lines-per-function`, `max-classes-per-file`, `max-depth`,
|
"typescript/prefer-readonly": "off",
|
||||||
// `max-nested-callbacks`, `max-statements`, `import/max-dependencies`,
|
// Collides with Node's and the web's mutable types (`Response`, `Stats`).
|
||||||
// `unicorn/max-nested-calls`: Deno had none of these.
|
|
||||||
complexity: "off",
|
|
||||||
"max-lines": "off",
|
|
||||||
"max-params": "off",
|
|
||||||
"id-length": "off",
|
|
||||||
"no-nested-ternary": "off",
|
|
||||||
"max-lines-per-function": "off",
|
|
||||||
"max-classes-per-file": "off",
|
|
||||||
"max-depth": "off",
|
|
||||||
"max-nested-callbacks": "off",
|
|
||||||
"max-statements": "off",
|
|
||||||
"import/max-dependencies": "off",
|
|
||||||
"unicorn/max-nested-calls": "off",
|
|
||||||
// Pedantic, but Node's and the web's own types (`Response`, `Stats`,
|
|
||||||
// `RequestInit`) are mutable, so no parameter of theirs can pass; the
|
|
||||||
// explicit-types template and the app repos turn it off too.
|
|
||||||
"typescript/prefer-readonly-parameter-types": "off",
|
"typescript/prefer-readonly-parameter-types": "off",
|
||||||
|
// Collides with explicit-types `no-utility-types`, which bans `Record`.
|
||||||
|
"typescript/consistent-indexed-object-style": "off",
|
||||||
|
// Collides with explicitly annotated declarations.
|
||||||
|
"typescript/no-inferrable-types": "off",
|
||||||
|
// Collides with import-structure `enforce-import-order`.
|
||||||
|
"sort-imports": "off",
|
||||||
|
// Collides with import-structure `enforce-mod-file` barrels.
|
||||||
|
"oxc/no-barrel-file": "off",
|
||||||
|
// Collides with exporting each declaration where it stands.
|
||||||
|
"import/group-exports": "off",
|
||||||
|
// Collides with exporting each declaration where it stands.
|
||||||
|
"import/exports-last": "off",
|
||||||
|
// Collides with `import/no-default-export`.
|
||||||
|
"import/prefer-default-export": "off",
|
||||||
|
// Collides with `unicorn/prefer-ternary`.
|
||||||
|
"no-ternary": "off",
|
||||||
|
// Collides with `require-await` and `promise-function-async`.
|
||||||
|
"oxc/no-async-await": "off",
|
||||||
|
// Collides with `typescript/prefer-optional-chain`.
|
||||||
|
"oxc/no-optional-chaining": "off",
|
||||||
|
// Collides with file mode checks such as `mode & 0o077`.
|
||||||
|
"no-bitwise": "off",
|
||||||
|
// Collides with explicit-types `no-optional-null`, which makes `undefined` the absent value.
|
||||||
|
"no-undefined": "off",
|
||||||
|
// Collides with `typescript/no-floating-promises`, which `void` satisfies.
|
||||||
|
"no-void": "off",
|
||||||
|
// Collides with JSON and Node APIs, which return `null`.
|
||||||
|
"unicorn/no-null": "off",
|
||||||
|
// Collides with `import/no-default-export`.
|
||||||
|
"import/no-named-export": "off",
|
||||||
|
// Collides with `typescript/consistent-type-imports`.
|
||||||
|
"import/consistent-type-specifier-style": "off",
|
||||||
|
// Collides with `require-await`, which fails an `async` wrapper with no `await`.
|
||||||
|
"typescript/promise-function-async": "off",
|
||||||
|
// Collides with explicit-types `no-undefined-type`: a `let` that `try`
|
||||||
|
// assigns would need `T | undefined`.
|
||||||
|
"init-declarations": "off",
|
||||||
|
// Collides with `no-await-in-loop`: sequential steps chain through `reduce`.
|
||||||
|
"unicorn/no-array-reduce": "off",
|
||||||
|
// Collides with literal Gitea Actions `${{ }}` expressions in workflow strings.
|
||||||
|
"no-template-curly-in-string": "off",
|
||||||
|
|
||||||
|
// Node code: this action runs on Node.
|
||||||
|
// Node built-ins are the action's runtime.
|
||||||
|
"import/no-nodejs-modules": "off",
|
||||||
|
// Command-line entry points set their exit status.
|
||||||
|
"unicorn/no-process-exit": "off",
|
||||||
|
// The action reports to the job log through the console.
|
||||||
|
"no-console": "off",
|
||||||
|
|
||||||
|
// PENDING USER: union members are values in profiles.json, the Gitea API,
|
||||||
|
// and posted comments; renaming them changes behavior.
|
||||||
|
"explicit-types/upper-snake-string-unions": "off",
|
||||||
|
// PENDING USER: a fitting `name` needs a constructor and changes logged
|
||||||
|
// error names.
|
||||||
|
"unicorn/custom-error-definition": "off",
|
||||||
|
|
||||||
|
// Too much churn.
|
||||||
|
// Literal numbers read better than a constant per use.
|
||||||
|
"no-magic-numbers": "off",
|
||||||
|
// Keys follow meaning, not the alphabet.
|
||||||
|
"sort-keys": "off",
|
||||||
|
// Comments may start with code or lowercase identifiers.
|
||||||
|
"capitalized-comments": "off",
|
||||||
|
// explicit-types `require-return-type` covers return types.
|
||||||
|
"typescript/explicit-function-return-type": "off",
|
||||||
|
// explicit-types `require-return-type` covers return types.
|
||||||
|
"typescript/explicit-module-boundary-types": "off",
|
||||||
|
// Empty callbacks are legitimate no-ops.
|
||||||
|
"no-empty-function": "off",
|
||||||
|
// Methods may not need `this`.
|
||||||
|
"class-methods-use-this": "off",
|
||||||
|
// Braces only where a case declares.
|
||||||
|
"unicorn/switch-case-braces": "off",
|
||||||
|
// Plain property reads are fine.
|
||||||
|
"prefer-destructuring": "off",
|
||||||
|
// Object rest and spread are idiomatic.
|
||||||
|
"oxc/no-rest-spread-properties": "off",
|
||||||
|
// Short numbers need no separators.
|
||||||
|
"unicorn/numeric-separators-style": "off",
|
||||||
|
// `continue` keeps loop bodies flat.
|
||||||
|
"no-continue": "off",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+10
-8
@@ -19,10 +19,10 @@ test("retries once after an invalid refresh token", async () => {
|
|||||||
() =>
|
() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
code: 1,
|
code: 1,
|
||||||
error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed",
|
error: (attempts += 1) === 1 ? "ERROR: invalid_refresh_token" : "still failed",
|
||||||
}),
|
}),
|
||||||
() => {
|
() => {
|
||||||
relogins++;
|
relogins += 1;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -39,14 +39,14 @@ test("does not retry an unrelated failure", async () => {
|
|||||||
let relogins = 0;
|
let relogins = 0;
|
||||||
await retryInvalidToken(
|
await retryInvalidToken(
|
||||||
() => {
|
() => {
|
||||||
attempts++;
|
attempts += 1;
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
code: 1,
|
code: 1,
|
||||||
error: "rate limited",
|
error: "rate limited",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
relogins++;
|
relogins += 1;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -87,11 +87,11 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
|
|||||||
const result = await retryInvalidToken(
|
const result = await retryInvalidToken(
|
||||||
() =>
|
() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
code: attempts++ > 0 ? 0 : 1,
|
code: (attempts += 1) > 1 ? 0 : 1,
|
||||||
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
|
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
|
||||||
}),
|
}),
|
||||||
() => {
|
() => {
|
||||||
relogins++;
|
relogins += 1;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -144,7 +144,8 @@ test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login"
|
|||||||
|
|
||||||
const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid());
|
const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid());
|
||||||
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH);
|
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH);
|
||||||
expect((await stat(staged)).mode & 0o777).toBe(0o600);
|
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",
|
||||||
@@ -153,7 +154,8 @@ test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login"
|
|||||||
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);
|
||||||
expect((await stat(canonical)).mode & 0o777).toBe(0o600);
|
const canonicalStat = await stat(canonical);
|
||||||
|
expect(canonicalStat.mode & 0o777).toBe(0o600);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(root, { recursive: true });
|
await rm(root, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -121,7 +121,9 @@ export async function stageCodexAuth(
|
|||||||
await chmod(stagedPath, 0o600);
|
await chmod(stagedPath, 0o600);
|
||||||
await chown(stagedPath, uid, gid);
|
await chown(stagedPath, uid, gid);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isNotFound(error)) throw error;
|
if (!isNotFound(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return stagedPath;
|
return stagedPath;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-4
@@ -49,7 +49,9 @@ export async function collectOpenCodeOutput(
|
|||||||
export function openCodeError(output: string): Error | null {
|
export function openCodeError(output: string): Error | null {
|
||||||
let failure: Error | null = null;
|
let failure: Error | null = null;
|
||||||
for (const line of output.split("\n")) {
|
for (const line of output.split("\n")) {
|
||||||
if (line.trim() === "") continue;
|
if (line.trim() === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let event: unknown;
|
let event: unknown;
|
||||||
try {
|
try {
|
||||||
event = JSON.parse(line);
|
event = JSON.parse(line);
|
||||||
@@ -80,12 +82,18 @@ export function openCodeError(output: string): Error | null {
|
|||||||
export function parseOpenCodeEvents(output: string): string {
|
export function parseOpenCodeEvents(output: string): string {
|
||||||
let result: string | null = null;
|
let result: string | null = null;
|
||||||
for (const line of output.split("\n")) {
|
for (const line of output.split("\n")) {
|
||||||
if (line.trim() === "") continue;
|
if (line.trim() === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const event: unknown = JSON.parse(line);
|
const event: unknown = JSON.parse(line);
|
||||||
const part = field(event, "part");
|
const part = field(event, "part");
|
||||||
if (field(event, "type") !== "text" || !isObject(part)) continue;
|
if (field(event, "type") !== "text" || !isObject(part)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const text = field(part, "text");
|
const text = field(part, "text");
|
||||||
if (typeof text === "string" && text.trim() !== "") result = text;
|
if (typeof text === "string" && text.trim() !== "") {
|
||||||
|
result = text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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");
|
||||||
|
|||||||
+3
-1
@@ -8,7 +8,9 @@ export { type Fetcher, type PostContext, postRuntime } from "./post.ts";
|
|||||||
|
|
||||||
function required(name: string): string {
|
function required(name: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
if (value === undefined || value === "") throw new Error(`${name} is not set`);
|
if (value === undefined || value === "") {
|
||||||
|
throw new Error(`${name} is not set`);
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -45,9 +45,15 @@ async function writeArtifacts(
|
|||||||
review?: string,
|
review?: string,
|
||||||
anchors?: unknown,
|
anchors?: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!outcome.ok) return;
|
if (!outcome.ok) {
|
||||||
if (review !== undefined) await writePrivate(outcome.reviewPath, review);
|
return;
|
||||||
if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
|
}
|
||||||
|
if (review !== undefined) {
|
||||||
|
await writePrivate(outcome.reviewPath, review);
|
||||||
|
}
|
||||||
|
if (anchors !== undefined) {
|
||||||
|
await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
|
function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
|
||||||
@@ -106,7 +112,9 @@ const missing = async (path: string): Promise<boolean> => {
|
|||||||
await stat(path);
|
await stat(path);
|
||||||
return false;
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isNotFound(error)) return true;
|
if (isNotFound(error)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+23
-10
@@ -22,12 +22,12 @@ interface Anchor {
|
|||||||
|
|
||||||
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
|
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
|
||||||
const stripAnsi = (text: string): string => text.replace(ANSI, "");
|
const stripAnsi = (text: string): string => text.replace(ANSI, "");
|
||||||
const VERDICTS: Map<string, [event: string, mark: string]> = new Map([
|
const VERDICTS = new Map<string, [event: string, mark: string]>([
|
||||||
["Approved", ["APPROVED", "✅"]],
|
["Approved", ["APPROVED", "✅"]],
|
||||||
["Changes requested", ["REQUEST_CHANGES", "🛑"]],
|
["Changes requested", ["REQUEST_CHANGES", "🛑"]],
|
||||||
]);
|
]);
|
||||||
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
|
const PROFILE_TYPES = new Set<unknown>(["claude", "codex", "opencode"]);
|
||||||
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
|
const FAILURE_REASONS = new Set<unknown>(["sign-in failed", "usage exhausted", "failed"]);
|
||||||
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value);
|
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value);
|
||||||
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
|
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
|
||||||
const failureComment = (failures: ProfileFailure[]): string =>
|
const failureComment = (failures: ProfileFailure[]): string =>
|
||||||
@@ -40,7 +40,9 @@ class GiteaRequestError extends Error {}
|
|||||||
|
|
||||||
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||||
const entries: unknown = JSON.parse(text);
|
const entries: unknown = JSON.parse(text);
|
||||||
if (!Array.isArray(entries)) throw new TypeError(`${sourcePath}: not an array`);
|
if (!Array.isArray(entries)) {
|
||||||
|
throw new TypeError(`${sourcePath}: not an array`);
|
||||||
|
}
|
||||||
return entries.map((entry: unknown, index) => {
|
return entries.map((entry: unknown, index) => {
|
||||||
const at = `${sourcePath}[${index}]`;
|
const at = `${sourcePath}[${index}]`;
|
||||||
if (!isObject(entry)) {
|
if (!isObject(entry)) {
|
||||||
@@ -116,12 +118,16 @@ async function readAgentArtifact(
|
|||||||
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) => join(entry.home, name) === path);
|
||||||
if (profile === undefined) throw new Error(`${name}: outside profile home`);
|
if (profile === undefined) {
|
||||||
|
throw new Error(`${name}: outside profile home`);
|
||||||
|
}
|
||||||
let stat: Stats;
|
let stat: Stats;
|
||||||
try {
|
try {
|
||||||
stat = await lstat(path);
|
stat = await lstat(path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isNotFound(error)) return null;
|
if (isNotFound(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -143,7 +149,8 @@ export async function postRuntime(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const manifest = await readRuntimeManifest(manifestPath);
|
const manifest = await readRuntimeManifest(manifestPath);
|
||||||
try {
|
try {
|
||||||
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
|
const reviewerText = await readFile(manifest.reviewerFile, "utf8");
|
||||||
|
const reviewerToken = reviewerText.trim();
|
||||||
const request = async (path: string, body: unknown): Promise<void> => {
|
const request = async (path: string, body: unknown): Promise<void> => {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
@@ -208,20 +215,26 @@ export async function postRuntime(
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
await postReview(anchors).catch(async (error: unknown) => {
|
await postReview(anchors).catch(async (error: unknown) => {
|
||||||
if (anchors.length === 0) throw error;
|
if (anchors.length === 0) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
console.error(
|
console.error(
|
||||||
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
|
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
await postReview([]);
|
await postReview([]);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof GiteaRequestError) throw error;
|
if (error instanceof GiteaRequestError) {
|
||||||
|
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((error: unknown) => {
|
||||||
if (!isNotFound(error)) throw error;
|
if (!isNotFound(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { prepareRuntime } from "#runtime";
|
|||||||
|
|
||||||
const env = (name: string): string => {
|
const env = (name: string): string => {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
if (value === undefined || value === "") throw new Error(`${name} is not set`);
|
if (value === undefined || value === "") {
|
||||||
|
throw new Error(`${name} is not set`);
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,9 @@ test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async ()
|
|||||||
await killProfileProcesses(selected.uid);
|
await killProfileProcesses(selected.uid);
|
||||||
|
|
||||||
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
|
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
|
||||||
if (isNotFound(error)) return null;
|
if (isNotFound(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
|
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { isNotFound } from "#common";
|
|||||||
import type { RuntimeProfile } from "#runtime";
|
import type { RuntimeProfile } from "#runtime";
|
||||||
|
|
||||||
export function isRunnableProfileProcess(status: string, uid: number): boolean {
|
export function isRunnableProfileProcess(status: string, uid: number): boolean {
|
||||||
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
|
const owner = /^Uid:\s+(?<uid>\d+)/mu.exec(status)?.[1];
|
||||||
const state = /^State:\s+(\w)/mu.exec(status)?.[1];
|
const state = /^State:\s+(?<state>\w)/mu.exec(status)?.[1];
|
||||||
return owner !== undefined && Number(owner) === uid && state !== "Z";
|
return owner !== undefined && Number(owner) === uid && state !== "Z";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,12 +33,16 @@ export async function killProfileProcesses(uid: number, attempt = 0): Promise<vo
|
|||||||
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
|
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
|
||||||
}
|
}
|
||||||
const ids = await profileProcessIds(uid);
|
const ids = await profileProcessIds(uid);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const pid of ids) {
|
for (const pid of ids) {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, "SIGKILL");
|
process.kill(pid, "SIGKILL");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isNotFound(error)) throw error;
|
if (!isNotFound(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await sleep(10);
|
await sleep(10);
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ test("recognizes provider API exhaustion status", async () => {
|
|||||||
{ type: "codex", model: "gpt-5.6-terra" },
|
{ type: "codex", model: "gpt-5.6-terra" },
|
||||||
],
|
],
|
||||||
(profile) => {
|
(profile) => {
|
||||||
if (profile.type === "claude") throw new Error(message);
|
if (profile.type === "claude") {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
return Promise.resolve("fallback");
|
return Promise.resolve("fallback");
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -84,7 +86,9 @@ test("falls through after a sign-in failure and records each try", async () => {
|
|||||||
if (profile.type === "codex") {
|
if (profile.type === "codex") {
|
||||||
throw new AgentAuthError("codex sign-in failed");
|
throw new AgentAuthError("codex sign-in failed");
|
||||||
}
|
}
|
||||||
if (profile.type === "claude") throw new Error("API Error: 429");
|
if (profile.type === "claude") {
|
||||||
|
throw new Error("API Error: 429");
|
||||||
|
}
|
||||||
return Promise.resolve("fallback");
|
return Promise.resolve("fallback");
|
||||||
},
|
},
|
||||||
failures,
|
failures,
|
||||||
@@ -116,7 +120,8 @@ test("throws the last sign-in failure when every profile fails", async () => {
|
|||||||
),
|
),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
expect.toSatisfy(
|
expect.toSatisfy(
|
||||||
(e: unknown) => e instanceof AgentAuthError && e.message.includes("claude sign-in failed"),
|
(error: unknown) =>
|
||||||
|
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"]);
|
||||||
@@ -157,22 +162,20 @@ test("does not mistake incidental quota text for exhaustion", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("exposes only the selected profile credential to the agent", () => {
|
test("exposes only the selected profile credential to the agent", () => {
|
||||||
const base = new Map(
|
const base = new Map([
|
||||||
Object.entries({
|
["PATH", "/usr/bin"],
|
||||||
PATH: "/usr/bin",
|
["HOME", "/home/bot"],
|
||||||
HOME: "/home/bot",
|
["UNRELATED_SECRET", "must-not-reach-agent"],
|
||||||
UNRELATED_SECRET: "must-not-reach-agent",
|
["BOT_AGENTS_MASTER_KEY", "master"],
|
||||||
BOT_AGENTS_MASTER_KEY: "master",
|
["PROFILES", "contains every secret"],
|
||||||
PROFILES: "contains every secret",
|
["REVIEWER_TOKEN", "reviewer"],
|
||||||
REVIEWER_TOKEN: "reviewer",
|
["CLAUDE_CODE_OAUTH_TOKEN", "old-claude"],
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: "old-claude",
|
["ANTHROPIC_API_KEY", "old-anthropic-key"],
|
||||||
ANTHROPIC_API_KEY: "old-anthropic-key",
|
["ANTHROPIC_AUTH_TOKEN", "old-anthropic-token"],
|
||||||
ANTHROPIC_AUTH_TOKEN: "old-anthropic-token",
|
["OPENAI_API_KEY", "old-openai"],
|
||||||
OPENAI_API_KEY: "old-openai",
|
["CODEX_API_KEY", "old-codex-key"],
|
||||||
CODEX_API_KEY: "old-codex-key",
|
["CODEX_ACCESS_TOKEN", "old-codex-token"],
|
||||||
CODEX_ACCESS_TOKEN: "old-codex-token",
|
]);
|
||||||
}),
|
|
||||||
);
|
|
||||||
const profile = {
|
const profile = {
|
||||||
index: 0,
|
index: 0,
|
||||||
type: "claude" as const,
|
type: "claude" as const,
|
||||||
|
|||||||
@@ -38,12 +38,16 @@ function isUsageExhausted(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function failureReason(error: unknown): FailureReason {
|
function failureReason(error: unknown): FailureReason {
|
||||||
if (error instanceof AgentAuthError) return "sign-in failed";
|
if (error instanceof AgentAuthError) {
|
||||||
|
return "sign-in failed";
|
||||||
|
}
|
||||||
return isUsageExhausted(error) ? "usage exhausted" : "failed";
|
return isUsageExhausted(error) ? "usage exhausted" : "failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function failureFooter(failures: ProfileFailure[]): string {
|
export function failureFooter(failures: ProfileFailure[]): string {
|
||||||
if (failures.length === 0) return "> Profile: none";
|
if (failures.length === 0) {
|
||||||
|
return "> Profile: none";
|
||||||
|
}
|
||||||
return `> Tried: ${failures
|
return `> Tried: ${failures
|
||||||
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
|
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
|
||||||
.join(", ")}`;
|
.join(", ")}`;
|
||||||
@@ -66,7 +70,9 @@ async function attemptFrom<T, P extends ProfileIdentity>(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = failureReason(error);
|
const reason = failureReason(error);
|
||||||
failures.push({ type: profile.type, model: profile.model, reason });
|
failures.push({ type: profile.type, model: profile.model, reason });
|
||||||
if (reason === "failed" || index === profiles.length - 1) throw error;
|
if (reason === "failed" || index === profiles.length - 1) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
console.error(`${profile.type} ${reason}; trying next profile`);
|
console.error(`${profile.type} ${reason}; trying next profile`);
|
||||||
return attemptFrom(profiles, attempt, failures, index + 1);
|
return attemptFrom(profiles, attempt, failures, index + 1);
|
||||||
}
|
}
|
||||||
@@ -85,7 +91,7 @@ export function runProfiles<T, P extends ProfileIdentity>(
|
|||||||
export function sanitizedEnvironment(
|
export function sanitizedEnvironment(
|
||||||
environment: ReadonlyMap<string, string>,
|
environment: ReadonlyMap<string, string>,
|
||||||
): Map<string, string> {
|
): Map<string, string> {
|
||||||
const result: Map<string, string> = new Map();
|
const result = new Map<string, string>();
|
||||||
for (const name of [
|
for (const name of [
|
||||||
"PATH",
|
"PATH",
|
||||||
"HOME",
|
"HOME",
|
||||||
@@ -113,7 +119,9 @@ export function sanitizedEnvironment(
|
|||||||
"NODE_EXTRA_CA_CERTS",
|
"NODE_EXTRA_CA_CERTS",
|
||||||
]) {
|
]) {
|
||||||
const value = environment.get(name);
|
const value = environment.get(name);
|
||||||
if (value !== undefined) result.set(name, value);
|
if (value !== undefined) {
|
||||||
|
result.set(name, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -125,7 +133,9 @@ export function agentEnvironment(
|
|||||||
): Map<string, string> {
|
): Map<string, string> {
|
||||||
const result = sanitizedEnvironment(environment);
|
const result = sanitizedEnvironment(environment);
|
||||||
result.set("HOME", profile.home);
|
result.set("HOME", profile.home);
|
||||||
if (profile.type === "codex") result.set("CODEX_HOME", `${profile.home}/.codex`);
|
if (profile.type === "codex") {
|
||||||
|
result.set("CODEX_HOME", `${profile.home}/.codex`);
|
||||||
|
}
|
||||||
if (token !== undefined && token !== "") {
|
if (token !== undefined && token !== "") {
|
||||||
const tokenName = {
|
const tokenName = {
|
||||||
claude: "CLAUDE_CODE_OAUTH_TOKEN",
|
claude: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||||
|
|||||||
+23
-13
@@ -58,12 +58,14 @@ interface RenderedPrompt {
|
|||||||
|
|
||||||
const env = (name: string): string => {
|
const env = (name: string): string => {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
if (value === undefined) throw new Error(`${name} is not set`);
|
if (value === undefined) {
|
||||||
|
throw new Error(`${name} is not set`);
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
function definedEnvironment(): Map<string, string> {
|
function definedEnvironment(): Map<string, string> {
|
||||||
const result: Map<string, string> = new Map();
|
const result = new Map<string, string>();
|
||||||
for (const [name, value] of Object.entries(process.env)) {
|
for (const [name, value] of Object.entries(process.env)) {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
result.set(name, value);
|
result.set(name, value);
|
||||||
@@ -107,11 +109,14 @@ 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) throw new Error(`${command} ${args[0]} exited with ${code}`);
|
if (code !== 0) {
|
||||||
|
throw new Error(`${command} ${args[0]} exited with ${code}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function configureGitAuthor(): Promise<void> {
|
async function configureGitAuthor(): Promise<void> {
|
||||||
const user: unknown = await (await gitea("user")).json();
|
const userResponse = await gitea("user");
|
||||||
|
const user: unknown = await userResponse.json();
|
||||||
const login = field(user, "login");
|
const login = field(user, "login");
|
||||||
const email = field(user, "email");
|
const email = field(user, "email");
|
||||||
if (typeof login !== "string" || typeof email !== "string") {
|
if (typeof login !== "string" || typeof email !== "string") {
|
||||||
@@ -140,7 +145,9 @@ async function runAgentCommand(
|
|||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
});
|
});
|
||||||
const code = await exitCode(child);
|
const code = await exitCode(child);
|
||||||
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
|
if (code !== 0) {
|
||||||
|
throw new Error(`${command} ${args[0]} exited with ${code}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
|
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
|
||||||
@@ -172,16 +179,15 @@ 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 comments: unknown = await (
|
const commentsResponse = await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`);
|
||||||
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
|
const comments: unknown = await commentsResponse.json();
|
||||||
).json();
|
|
||||||
if (!Array.isArray(comments)) {
|
if (!Array.isArray(comments)) {
|
||||||
throw new TypeError("comments: unexpected Gitea response");
|
throw new TypeError("comments: unexpected Gitea response");
|
||||||
}
|
}
|
||||||
const values: Map<string, string> = new Map([
|
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", await (await gitea(RULES_PATH)).text()],
|
["CODE_RULES", await gitea(RULES_PATH).then((response) => response.text())],
|
||||||
["EVENT_NAME", EVENT],
|
["EVENT_NAME", EVENT],
|
||||||
["GITEA_API_URL", API],
|
["GITEA_API_URL", API],
|
||||||
["GITEA_REPOSITORY", REPO],
|
["GITEA_REPOSITORY", REPO],
|
||||||
@@ -192,7 +198,7 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
|
|||||||
const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8");
|
const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8");
|
||||||
return {
|
return {
|
||||||
prompt: template.replaceAll(
|
prompt: template.replaceAll(
|
||||||
/\$\{(\w+)\}/gu,
|
/\$\{(?<name>\w+)\}/gu,
|
||||||
(match: string, name: string) => values.get(name) ?? match,
|
(match: string, name: string) => values.get(name) ?? match,
|
||||||
),
|
),
|
||||||
reviewPath,
|
reviewPath,
|
||||||
@@ -367,7 +373,9 @@ async function runCodex(
|
|||||||
gid: profile.gid,
|
gid: profile.gid,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
});
|
});
|
||||||
if ((await exitCode(check)) !== 0) await codexDeviceLogin(profile, environment);
|
if ((await exitCode(check)) !== 0) {
|
||||||
|
await codexDeviceLogin(profile, environment);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const outputPath = await makeProfileFile(profile, "result.md");
|
const outputPath = await makeProfileFile(profile, "result.md");
|
||||||
const attempt = async (): Promise<CommandResult> => {
|
const attempt = async (): Promise<CommandResult> => {
|
||||||
@@ -478,7 +486,9 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
|||||||
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
|
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (resultPath !== null) body = await readProfileFile(profile, resultPath);
|
if (resultPath !== null) {
|
||||||
|
body = await readProfileFile(profile, resultPath);
|
||||||
|
}
|
||||||
if (body === null || reviewPath === null || anchorsPath === null) {
|
if (body === null || reviewPath === null || anchorsPath === null) {
|
||||||
throw new Error("agent attempt produced no result");
|
throw new Error("agent attempt produced no result");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { decryptCredential, encryptCredential, parseCredentials } from "./creden
|
|||||||
|
|
||||||
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
||||||
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
|
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
|
||||||
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32);
|
const NONCE = Uint8Array.from({ length: 12 }, (_value, index) => index + 32);
|
||||||
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
||||||
|
|
||||||
test("decrypts a known AES-256-GCM credential", async () => {
|
test("decrypts a known AES-256-GCM credential", async () => {
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export async function decryptCredential(encrypted: string, masterKey: string): P
|
|||||||
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);
|
||||||
if (nonce.length !== 12) throw new Error("credential nonce must be 12 bytes");
|
if (nonce.length !== 12) {
|
||||||
|
throw new Error("credential nonce must be 12 bytes");
|
||||||
|
}
|
||||||
const plaintext = await webcrypto.subtle.decrypt(
|
const plaintext = await webcrypto.subtle.decrypt(
|
||||||
{ name: "AES-GCM", iv: buffer(nonce) },
|
{ name: "AES-GCM", iv: buffer(nonce) },
|
||||||
await importMasterKey(masterKey, "decrypt"),
|
await importMasterKey(masterKey, "decrypt"),
|
||||||
@@ -61,7 +63,9 @@ export async function encryptCredential(
|
|||||||
masterKey: string,
|
masterKey: string,
|
||||||
nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)),
|
nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)),
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (nonce.length !== 12) throw new Error("AES-GCM nonce must be 12 bytes");
|
if (nonce.length !== 12) {
|
||||||
|
throw new Error("AES-GCM nonce must be 12 bytes");
|
||||||
|
}
|
||||||
const ciphertext = await webcrypto.subtle.encrypt(
|
const ciphertext = await webcrypto.subtle.encrypt(
|
||||||
{ name: "AES-GCM", iv: buffer(nonce) },
|
{ name: "AES-GCM", iv: buffer(nonce) },
|
||||||
await importMasterKey(masterKey, "encrypt"),
|
await importMasterKey(masterKey, "encrypt"),
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credenti
|
|||||||
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);
|
||||||
expect((await stat(codex.home)).uid).toStrictEqual(codex.uid);
|
const codexHome = await stat(codex.home);
|
||||||
|
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], {
|
||||||
@@ -83,12 +84,12 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
|
|||||||
process.execPath,
|
process.execPath,
|
||||||
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
|
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
|
||||||
{
|
{
|
||||||
env: {
|
env: Object.fromEntries([
|
||||||
ACTION_PATH: action,
|
["ACTION_PATH", action],
|
||||||
RUNNER_TEMP: root,
|
["RUNNER_TEMP", root],
|
||||||
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
|
["BOT_AGENTS_MASTER_KEY", MASTER_KEY],
|
||||||
GITHUB_OUTPUT: action,
|
["GITHUB_OUTPUT", action],
|
||||||
},
|
]),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ export interface CommandResult {
|
|||||||
|
|
||||||
// This process's environment as a map from variable name to value.
|
// This process's environment as a map from variable name to value.
|
||||||
export function inheritedEnvironment(): Map<string, string> {
|
export function inheritedEnvironment(): Map<string, string> {
|
||||||
const result: Map<string, string> = new Map();
|
const result = new Map<string, string>();
|
||||||
for (const [name, value] of Object.entries(process.env)) {
|
for (const [name, value] of Object.entries(process.env)) {
|
||||||
if (value !== undefined) result.set(name, value);
|
if (value !== undefined) {
|
||||||
|
result.set(name, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ test("the source protection script stages immutable action files", async () => {
|
|||||||
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 path = (await readFile(outputFile, "utf8")).trim().slice(5);
|
const output = await readFile(outputFile, "utf8");
|
||||||
|
const path = output.trim().slice(5);
|
||||||
const files = [
|
const files = [
|
||||||
"src/prepare/index.ts",
|
"src/prepare/index.ts",
|
||||||
"src/run/index.ts",
|
"src/run/index.ts",
|
||||||
@@ -106,7 +107,7 @@ 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\("([^"]+)", import\.meta\.url\)/gu)].map(
|
const assets = [...source.matchAll(/new URL\("(?<asset>[^"]+)", import\.meta\.url\)/gu)].map(
|
||||||
([, asset = ""]) => new URL(asset, module),
|
([, asset = ""]) => new URL(asset, module),
|
||||||
);
|
);
|
||||||
const stats = await Promise.all(assets.map((asset) => stat(asset)));
|
const stats = await Promise.all(assets.map((asset) => stat(asset)));
|
||||||
|
|||||||
+17
-1
@@ -12,13 +12,29 @@ export default defineConfig({
|
|||||||
"eslint-plugin-import-structure",
|
"eslint-plugin-import-structure",
|
||||||
"./scripts/project-lint.ts",
|
"./scripts/project-lint.ts",
|
||||||
],
|
],
|
||||||
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
|
categories: {
|
||||||
|
correctness: "error",
|
||||||
|
suspicious: "error",
|
||||||
|
pedantic: "error",
|
||||||
|
perf: "error",
|
||||||
|
style: "error",
|
||||||
|
restriction: "error",
|
||||||
|
nursery: "off",
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...explicitTypes.configs.concrete.rules,
|
...explicitTypes.configs.concrete.rules,
|
||||||
|
...explicitTypes.configs.naming.rules,
|
||||||
...explicitTypes.configs.absence.rules,
|
...explicitTypes.configs.absence.rules,
|
||||||
...importStructure.configs.modules.rules,
|
...importStructure.configs.modules.rules,
|
||||||
...projectLint.configs.project.rules,
|
...projectLint.configs.project.rules,
|
||||||
},
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
// Vite+ and Oxlint load these through their default export.
|
||||||
|
files: ["vite.config.ts", "scripts/project-lint.ts"],
|
||||||
|
rules: { "import/no-default-export": "off" },
|
||||||
|
},
|
||||||
|
],
|
||||||
options: {
|
options: {
|
||||||
typeAware: true,
|
typeAware: true,
|
||||||
typeCheck: true,
|
typeCheck: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user