Turn on upper-snake-string-unions and convert profile types, failure reasons and anchor sides at their boundaries

This commit is contained in:
Danny Kim
2026-10-19 19:46:31 +09:00
parent 71150900ff
commit c783935936
13 changed files with 144 additions and 113 deletions
+3 -3
View File
@@ -2,16 +2,16 @@
"reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY", "reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY",
"profiles": [ "profiles": [
{ {
"type": "codex", "type": "CODEX",
"model": "gpt-5.6-terra" "model": "gpt-5.6-terra"
}, },
{ {
"type": "claude", "type": "CLAUDE",
"model": "claude-sonnet-5", "model": "claude-sonnet-5",
"token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg" "token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg"
}, },
{ {
"type": "opencode", "type": "OPENCODE",
"model": "openrouter/qwen/qwen3.8-27b", "model": "openrouter/qwen/qwen3.8-27b",
"token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0" "token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0"
} }
+3 -3
View File
@@ -47,9 +47,9 @@ achieved.
Every finding that belongs to one line of the diff goes on that line instead of Every finding that belongs to one line of the diff goes on that line instead of
into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry
`{"path": "<path from the repository root>", "line": <number>, "side": "new" | `{"path": "<path from the repository root>", "line": <number>, "side": "NEW" |
"old", "body": "<the finding>"}`. "OLD", "body": "<the finding>"}`.
`side` is `new` for a line in the head file and `old` for one only in the base `side` is `NEW` for a line in the head file and `OLD` for one only in the base
file; `line` is that file's own line number, and it must be a line the diff file; `line` is that file's own line number, and it must be a line the diff
touches, or Gitea refuses the anchor. Write the file only when there is touches, or Gitea refuses the anchor. Write the file only when there is
something to anchor, and keep each body to the what, the why, and the how, with something to anchor, and keep each body to the what, the why, and the how, with
+2 -3
View File
@@ -257,9 +257,8 @@ const rules: DummyRuleMap = {
"consistent-return": "off", "consistent-return": "off",
// TypeScript's type checking covers exhaustive switches. // TypeScript's type checking covers exhaustive switches.
"typescript/switch-exhaustiveness-check": "off", "typescript/switch-exhaustiveness-check": "off",
// PENDING USER: union members are values in profiles.json, the Gitea API, // A `default` on a switch over a union would hide a new member from TS.
// and posted comments; renaming them changes behavior. "default-case": "off",
"explicit-types/upper-snake-string-unions": "off",
// Off, too much churn: // Off, too much churn:
// Literal numbers read better than a constant per use. // Literal numbers read better than a constant per use.
"no-magic-numbers": "off", "no-magic-numbers": "off",
+17 -17
View File
@@ -65,7 +65,7 @@ function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
profiles: [ profiles: [
{ {
index: 0, index: 0,
type: "claude", type: "CLAUDE",
model: "test", model: "test",
uid: currentUid(), uid: currentUid(),
gid: currentGid(), gid: currentGid(),
@@ -89,7 +89,7 @@ async function fixture(
const outcome: RuntimeOutcome = source.ok const outcome: RuntimeOutcome = source.ok
? { ? {
...source, ...source,
profile: { type: "codex", model: "gpt-test" }, profile: { type: "CODEX", model: "gpt-test" },
reviewPath: `${profileHome}/review.md`, reviewPath: `${profileHome}/review.md`,
anchorsPath: `${profileHome}/anchors.json`, anchorsPath: `${profileHome}/anchors.json`,
} }
@@ -147,7 +147,7 @@ test("posts a successful issue result and removes runtime credentials", async ()
const [request] = requests; const [request] = requests;
expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`); expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`);
expect(requestBody(request?.init)).toStrictEqual({ expect(requestBody(request?.init)).toStrictEqual({
body: "done\n\n> Profile: `codex` · `gpt-test`", body: "done\n\n> Profile: `CODEX` · `gpt-test`",
}); });
expect(request?.init?.headers).toStrictEqual({ expect(request?.init?.headers).toStrictEqual({
Authorization: "token reviewer-secret", Authorization: "token reviewer-secret",
@@ -158,7 +158,7 @@ test("posts a successful issue result and removes runtime credentials", async ()
test("retries a rejected inline review without anchors", async () => { test("retries a rejected inline review without anchors", async () => {
const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [ const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [
{ path: "run.ts", line: 1, side: "new", body: "Finding" }, { path: "run.ts", line: 1, side: "NEW", body: "Finding" },
]); ]);
const bodies: unknown[] = []; const bodies: unknown[] = [];
@@ -178,11 +178,11 @@ test("retries a rejected inline review without anchors", async () => {
); );
expect(bodies[0]).toMatchObject({ expect(bodies[0]).toMatchObject({
comments: [{ body: "Finding\n\n> Profile: `codex` · `gpt-test`" }], comments: [{ body: "Finding\n\n> Profile: `CODEX` · `gpt-test`" }],
}); });
expect(bodies[1]).toMatchObject({ expect(bodies[1]).toMatchObject({
comments: [], comments: [],
body: "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`", body: "✅ Approved\nReady\n\n> Profile: `CODEX` · `gpt-test`",
}); });
assert(await missing(result.runtimeDir)); assert(await missing(result.runtimeDir));
}); });
@@ -191,9 +191,9 @@ test("reports each tried profile and keeps the action failed", async () => {
const result = await fixture({ const result = await fixture({
ok: false, ok: false,
failures: [ failures: [
{ type: "codex", model: "gpt-test", reason: "sign-in failed" }, { type: "CODEX", model: "gpt-test", reason: "SIGN_IN_FAILED" },
{ type: "claude", model: "claude-test", reason: "usage exhausted" }, { type: "CLAUDE", model: "claude-test", reason: "USAGE_EXHAUSTED" },
{ type: "opencode", model: "openrouter/test", reason: "failed" }, { type: "OPENCODE", model: "openrouter/test", reason: "FAILED" },
], ],
}); });
const bodies: unknown[] = []; const bodies: unknown[] = [];
@@ -205,9 +205,9 @@ test("reports each tried profile and keeps the action failed", async () => {
expect(bodies).toStrictEqual([ expect(bodies).toStrictEqual([
{ {
body: body:
"AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " + "AI bot failed.\n\n> Tried: `CODEX` · `gpt-test` (sign-in failed), " +
"`claude` · `claude-test` (usage exhausted), " + "`CLAUDE` · `claude-test` (usage exhausted), " +
"`opencode` · `openrouter/test` (failed)", "`OPENCODE` · `openrouter/test` (failed)",
}, },
]); ]);
assert(!JSON.stringify(bodies).includes("reviewer-secret")); assert(!JSON.stringify(bodies).includes("reviewer-secret"));
@@ -221,9 +221,9 @@ test("drops failure entries that are not known labels", async () => {
JSON.stringify({ JSON.stringify({
ok: false, ok: false,
failures: [ failures: [
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" }, { type: "CODEX", model: "gpt-test", reason: "token sk-secret leaked" },
{ type: "shell", model: "x", reason: "failed" }, { type: "shell", model: "x", reason: "FAILED" },
{ type: "claude", model: "claude-test", reason: "sign-in failed" }, { type: "CLAUDE", model: "claude-test", reason: "SIGN_IN_FAILED" },
], ],
}), }),
); );
@@ -235,7 +235,7 @@ test("drops failure entries that are not known labels", async () => {
expect(bodies).toStrictEqual([ expect(bodies).toStrictEqual([
{ {
body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)", body: "AI bot failed.\n\n> Tried: `CLAUDE` · `claude-test` (sign-in failed)",
}, },
]); ]);
}); });
@@ -315,7 +315,7 @@ test("reports an attributed failure when a pull request review is missing", asyn
expect(bodies).toStrictEqual([ expect(bodies).toStrictEqual([
{ {
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)", body: "AI bot failed.\n\n> Tried: `CODEX` · `gpt-test` (failed)",
}, },
]); ]);
assert(await missing(result.runtimeDir)); assert(await missing(result.runtimeDir));
+9 -9
View File
@@ -16,7 +16,7 @@ export interface PostContext {
interface Anchor { interface Anchor {
path: string; path: string;
line: number; line: number;
side: "new" | "old"; side: "NEW" | "OLD";
body: string; body: string;
} }
@@ -26,8 +26,8 @@ const VERDICTS = new Map<string, [event: string, mark: string]>([
["Approved", ["APPROVED", "✅"]], ["Approved", ["APPROVED", "✅"]],
["Changes requested", ["REQUEST_CHANGES", "🛑"]], ["Changes requested", ["REQUEST_CHANGES", "🛑"]],
]); ]);
const PROFILE_TYPES = new Set<unknown>(["claude", "codex", "opencode"]); const PROFILE_TYPES = new Set<unknown>(["CLAUDE", "CODEX", "OPENCODE"]);
const FAILURE_REASONS = new Set<unknown>(["sign-in failed", "usage exhausted", "failed"]); const FAILURE_REASONS = new Set<unknown>(["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 =>
@@ -56,7 +56,7 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] {
const path = field(entry, "path"); const path = field(entry, "path");
const line = field(entry, "line"); const line = field(entry, "line");
const givenSide = field(entry, "side"); const givenSide = field(entry, "side");
const side = givenSide === undefined ? "new" : givenSide; const side = givenSide === undefined ? "NEW" : givenSide;
const body = field(entry, "body"); const body = field(entry, "body");
if (typeof path !== "string" || path === "") { if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`); throw new TypeError(`${at}.path: expected a repository path`);
@@ -64,8 +64,8 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] {
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) { if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new TypeError(`${at}.line: expected a line number`); throw new TypeError(`${at}.line: expected a line number`);
} }
if (side !== "new" && side !== "old") { if (side !== "NEW" && side !== "OLD") {
throw new Error(`${at}.side: expected "new" or "old"`); throw new Error(`${at}.side: expected "NEW" or "OLD"`);
} }
if (typeof body !== "string" || body.trim() === "") { if (typeof body !== "string" || body.trim() === "") {
throw new TypeError(`${at}.body: expected the finding`); throw new TypeError(`${at}.body: expected the finding`);
@@ -216,8 +216,8 @@ export async function postRuntime(
comments: entries.map(({ path, line, side, body }) => ({ comments: entries.map(({ path, line, side, body }) => ({
path, path,
body: stripAnsi(withProfile(body, outcome.profile)), body: stripAnsi(withProfile(body, outcome.profile)),
new_position: side === "new" ? line : 0, new_position: side === "NEW" ? line : 0,
old_position: side === "old" ? line : 0, old_position: side === "OLD" ? line : 0,
})), })),
}); });
}; };
@@ -234,7 +234,7 @@ export async function postRuntime(
if (error instanceof GiteaRequestError) { if (error instanceof GiteaRequestError) {
throw error; throw error;
} }
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); await postComment(failureComment([{ ...outcome.profile, reason: "FAILED" }]));
throw error; throw error;
} }
} finally { } finally {
+1 -1
View File
@@ -11,7 +11,7 @@ import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from
const profile = (uid: number, home: string): RuntimeProfile => ({ const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000, index: uid - 20_000,
type: "claude", type: "CLAUDE",
model: "test", model: "test",
uid, uid,
gid: uid, gid: uid,
+34 -34
View File
@@ -11,8 +11,8 @@ import {
} from "./profiles.ts"; } from "./profiles.ts";
test("formats profile attribution as a Markdown quote", () => { test("formats profile attribution as a Markdown quote", () => {
expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe( expect(profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`", "> Profile: `OPENCODE` · `openrouter/qwen/qwen3.8-27b`",
); );
}); });
@@ -20,12 +20,12 @@ test("falls through only after usage exhaustion", async () => {
const visited: string[] = []; const visited: string[] = [];
const result = await runProfiles( const result = await runProfiles(
[ [
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
], ],
async (profile) => { async (profile) => {
visited.push(profile.type); visited.push(profile.type);
if (profile.type === "claude") { if (profile.type === "CLAUDE") {
throw new Error("You've hit your usage limit"); throw new Error("You've hit your usage limit");
} }
const resolved = await Promise.resolve("done"); const resolved = await Promise.resolve("done");
@@ -34,17 +34,17 @@ test("falls through only after usage exhaustion", async () => {
); );
expect(result).toBe("done"); expect(result).toBe("done");
expect(visited).toStrictEqual(["claude", "codex"]); expect(visited).toStrictEqual(["CLAUDE", "CODEX"]);
}); });
test("recognizes Claude's weekly limit message", async () => { test("recognizes Claude's weekly limit message", async () => {
const result = await runProfiles( const result = await runProfiles(
[ [
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
], ],
async (profile) => { async (profile) => {
if (profile.type === "claude") { if (profile.type === "CLAUDE") {
throw new Error("You've hit your weekly limit · resets 12am (UTC)"); throw new Error("You've hit your weekly limit · resets 12am (UTC)");
} }
const resolved = await Promise.resolve("fallback"); const resolved = await Promise.resolve("fallback");
@@ -60,11 +60,11 @@ test("recognizes provider API exhaustion status", async () => {
["API Error: 402 Insufficient funds", "HTTP error: 429"].map(async (message) => { ["API Error: 402 Insufficient funds", "HTTP error: 429"].map(async (message) => {
const result = await runProfiles( const result = await runProfiles(
[ [
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
], ],
async (profile) => { async (profile) => {
if (profile.type === "claude") { if (profile.type === "CLAUDE") {
throw new Error(message); throw new Error(message);
} }
const resolved = await Promise.resolve("fallback"); const resolved = await Promise.resolve("fallback");
@@ -82,15 +82,15 @@ test("falls through after a sign-in failure and records each try", async () => {
const failures: ProfileFailure[] = []; const failures: ProfileFailure[] = [];
const result = await runProfiles( const result = await runProfiles(
[ [
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }, { type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" },
], ],
async (profile) => { async (profile) => {
if (profile.type === "codex") { if (profile.type === "CODEX") {
throw new AgentAuthError("codex sign-in failed"); throw new AgentAuthError("codex sign-in failed");
} }
if (profile.type === "claude") { if (profile.type === "CLAUDE") {
throw new Error("API Error: 429"); throw new Error("API Error: 429");
} }
const resolved = await Promise.resolve("fallback"); const resolved = await Promise.resolve("fallback");
@@ -101,12 +101,12 @@ test("falls through after a sign-in failure and records each try", async () => {
expect(result).toBe("fallback"); expect(result).toBe("fallback");
expect(failures).toStrictEqual([ expect(failures).toStrictEqual([
{ type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" }, { type: "CODEX", model: "gpt-5.6-terra", reason: "SIGN_IN_FAILED" },
{ type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" }, { type: "CLAUDE", model: "claude-sonnet-5", reason: "USAGE_EXHAUSTED" },
]); ]);
expect(failureFooter(failures)).toStrictEqual( expect(failureFooter(failures)).toStrictEqual(
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " + "> Tried: `CODEX` · `gpt-5.6-terra` (sign-in failed), " +
"`claude` · `claude-sonnet-5` (usage exhausted)", "`CLAUDE` · `claude-sonnet-5` (usage exhausted)",
); );
}); });
@@ -115,8 +115,8 @@ test("throws the last sign-in failure when every profile fails", async () => {
await expect( await expect(
runProfiles( runProfiles(
[ [
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
], ],
(profile) => { (profile) => {
throw new AgentAuthError(`${profile.type} sign-in failed`); throw new AgentAuthError(`${profile.type} sign-in failed`);
@@ -126,10 +126,10 @@ test("throws the last sign-in failure when every profile fails", async () => {
).rejects.toThrow( ).rejects.toThrow(
expect.toSatisfy( expect.toSatisfy(
(error: unknown) => (error: unknown) =>
error instanceof AgentAuthError && error.message.includes("claude sign-in failed"), error instanceof AgentAuthError && error.message.includes("CLAUDE sign-in failed"),
), ),
); );
expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]); expect(failures.map(({ reason }) => reason)).toStrictEqual(["SIGN_IN_FAILED", "SIGN_IN_FAILED"]);
}); });
test("preserves an ordinary agent failure", async () => { test("preserves an ordinary agent failure", async () => {
@@ -137,8 +137,8 @@ test("preserves an ordinary agent failure", async () => {
await expect( await expect(
runProfiles( runProfiles(
[ [
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
], ],
(profile) => { (profile) => {
visited.push(profile.type); visited.push(profile.type);
@@ -146,7 +146,7 @@ test("preserves an ordinary agent failure", async () => {
}, },
), ),
).rejects.toThrow("tests failed"); ).rejects.toThrow("tests failed");
expect(visited).toStrictEqual(["claude"]); expect(visited).toStrictEqual(["CLAUDE"]);
}); });
test("does not mistake incidental quota text for exhaustion", async () => { test("does not mistake incidental quota text for exhaustion", async () => {
@@ -154,8 +154,8 @@ test("does not mistake incidental quota text for exhaustion", async () => {
await expect( await expect(
runProfiles( runProfiles(
[ [
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" }, { type: "CODEX", model: "gpt-5.6-terra" },
], ],
(profile) => { (profile) => {
visited.push(profile.type); visited.push(profile.type);
@@ -163,7 +163,7 @@ test("does not mistake incidental quota text for exhaustion", async () => {
}, },
), ),
).rejects.toThrow("quota parser test failed"); ).rejects.toThrow("quota parser test failed");
expect(visited).toStrictEqual(["claude"]); expect(visited).toStrictEqual(["CLAUDE"]);
}); });
test("exposes only the selected profile credential to the agent", () => { test("exposes only the selected profile credential to the agent", () => {
@@ -183,7 +183,7 @@ test("exposes only the selected profile credential to the agent", () => {
]); ]);
const profile = { const profile = {
index: 0, index: 0,
type: "claude" as const, type: "CLAUDE" as const,
model: "claude-sonnet-5", model: "claude-sonnet-5",
uid: 20_000, uid: 20_000,
gid: 20_000, gid: 20_000,
@@ -209,7 +209,7 @@ test("exposes only the selected profile credential to the agent", () => {
test("uses a private Codex home without inventing an API credential", () => { test("uses a private Codex home without inventing an API credential", () => {
const profile = { const profile = {
index: 1, index: 1,
type: "codex" as const, type: "CODEX" as const,
model: "gpt-5.6-terra", model: "gpt-5.6-terra",
uid: 20_001, uid: 20_001,
gid: 20_001, gid: 20_001,
@@ -229,7 +229,7 @@ test("uses a private Codex home without inventing an API credential", () => {
test("exposes an OpenRouter key only to the OpenCode profile", () => { test("exposes an OpenRouter key only to the OpenCode profile", () => {
const profile = { const profile = {
index: 2, index: 2,
type: "opencode" as const, type: "OPENCODE" as const,
model: "openrouter/qwen/qwen3.8-27b", model: "openrouter/qwen/qwen3.8-27b",
uid: 20_002, uid: 20_002,
gid: 20_002, gid: 20_002,
+33 -13
View File
@@ -1,6 +1,6 @@
// The agent and model a profile runs, which every report names. // The agent and model a profile runs, which every report names.
export interface ProfileIdentity { export interface ProfileIdentity {
type: "claude" | "codex" | "opencode"; type: "CLAUDE" | "CODEX" | "OPENCODE";
model: string; model: string;
} }
@@ -16,7 +16,19 @@ export function withProfile(body: string, profile: ProfileIdentity): string {
return `${body}\n\n${profileFooter(profile)}`; return `${body}\n\n${profileFooter(profile)}`;
} }
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed"; export type FailureReason = "SIGN_IN_FAILED" | "USAGE_EXHAUSTED" | "FAILED";
// The words a failure reason reads as in logs and posted comments.
function toMessage(reason: FailureReason): string {
switch (reason) {
case "SIGN_IN_FAILED":
return "sign-in failed";
case "USAGE_EXHAUSTED":
return "usage exhausted";
case "FAILED":
return "failed";
}
}
export interface ProfileFailure extends ProfileIdentity { export interface ProfileFailure extends ProfileIdentity {
reason: FailureReason; reason: FailureReason;
@@ -44,9 +56,9 @@ function isUsageExhausted(error: unknown): boolean {
function failureReason(error: unknown): FailureReason { function failureReason(error: unknown): FailureReason {
if (error instanceof AgentAuthError) { if (error instanceof AgentAuthError) {
return "sign-in failed"; return "SIGN_IN_FAILED";
} }
return isUsageExhausted(error) ? "usage exhausted" : "failed"; return isUsageExhausted(error) ? "USAGE_EXHAUSTED" : "FAILED";
} }
export function failureFooter(failures: ProfileFailure[]): string { export function failureFooter(failures: ProfileFailure[]): string {
@@ -54,7 +66,7 @@ export function failureFooter(failures: ProfileFailure[]): string {
return "> Profile: none"; return "> Profile: none";
} }
return `> Tried: ${failures return `> Tried: ${failures
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`) .map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${toMessage(reason)})`)
.join(", ")}`; .join(", ")}`;
} }
@@ -71,10 +83,10 @@ export async function runProfiles<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) { if (reason === "FAILED" || index === profiles.length - 1) {
throw error; throw error;
} }
console.error(`${profile.type} ${reason}; trying next profile`); console.error(`${profile.type} ${toMessage(reason)}; trying next profile`);
} }
} }
throw new Error("profiles must contain at least one profile"); throw new Error("profiles must contain at least one profile");
@@ -120,6 +132,18 @@ export function sanitizedEnvironment(
return result; return result;
} }
// The environment variable each agent CLI reads its credential from.
function tokenVariable(type: ProfileIdentity["type"]): string {
switch (type) {
case "CLAUDE":
return "CLAUDE_CODE_OAUTH_TOKEN";
case "CODEX":
return "OPENAI_API_KEY";
case "OPENCODE":
return "OPENROUTER_API_KEY";
}
}
export function agentEnvironment( export function agentEnvironment(
environment: ReadonlyMap<string, string>, environment: ReadonlyMap<string, string>,
profile: ProfileHome, profile: ProfileHome,
@@ -127,15 +151,11 @@ export function agentEnvironment(
): Map<string, string> { ): Map<string, string> {
const result = sanitizedEnvironment(environment); const result = sanitizedEnvironment(environment);
result.set("HOME", profile.home); result.set("HOME", profile.home);
if (profile.type === "codex") { if (profile.type === "CODEX") {
result.set("CODEX_HOME", `${profile.home}/.codex`); result.set("CODEX_HOME", `${profile.home}/.codex`);
} }
if (token !== undefined && token !== "") { if (token !== undefined && token !== "") {
const tokenName = { const tokenName = tokenVariable(profile.type);
claude: "CLAUDE_CODE_OAUTH_TOKEN",
codex: "OPENAI_API_KEY",
opencode: "OPENROUTER_API_KEY",
}[profile.type];
result.set(tokenName, token); result.set(tokenName, token);
} }
return result; return result;
+16 -4
View File
@@ -150,6 +150,18 @@ async function runAgentCommand(
} }
} }
// The agent name the skills installer knows each profile type by.
function skillsAgent(type: RuntimeProfile["type"]): string {
switch (type) {
case "CLAUDE":
return "claude-code";
case "CODEX":
return "codex";
case "OPENCODE":
return "opencode";
}
}
async function prepareProfile(profile: RuntimeProfile): Promise<void> { async function prepareProfile(profile: RuntimeProfile): Promise<void> {
await runAgentCommand(profile, "git", [ await runAgentCommand(profile, "git", [
"config", "config",
@@ -163,7 +175,7 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
...SKILLS_INSTALL.split(" "), ...SKILLS_INSTALL.split(" "),
"--global", "--global",
"--agent", "--agent",
profile.type === "claude" ? "claude-code" : profile.type, skillsAgent(profile.type),
"--yes", "--yes",
]); ]);
} }
@@ -451,7 +463,7 @@ async function runOpenCode(
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> { async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile); const token = await readProfileToken(profile);
const authentication = profile.type === "codex" ? codexAuthentication(token ?? undefined) : null; const authentication = profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null;
let stagedCodexAuth: string | null = null; let stagedCodexAuth: string | null = null;
let body: string | null = null; let body: string | null = null;
let resultPath: string | null = null; let resultPath: string | null = null;
@@ -465,9 +477,9 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const rendered = await renderPrompt(profile); const rendered = await renderPrompt(profile);
reviewPath = rendered.reviewPath; reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath; anchorsPath = rendered.anchorsPath;
if (profile.type === "claude") { if (profile.type === "CLAUDE") {
body = await runClaude(profile, token ?? "", rendered.prompt); body = await runClaude(profile, token ?? "", rendered.prompt);
} else if (profile.type === "codex") { } else if (profile.type === "CODEX") {
resultPath = await runCodex( resultPath = await runCodex(
profile, profile,
rendered.prompt, rendered.prompt,
+7 -7
View File
@@ -31,9 +31,9 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
JSON.stringify({ JSON.stringify({
"reviewer-token": ENCRYPTED, "reviewer-token": ENCRYPTED,
profiles: [ profiles: [
{ type: "codex", model: "gpt-custom" }, { type: "CODEX", model: "gpt-custom" },
{ type: "claude", token: ENCRYPTED }, { type: "CLAUDE", token: ENCRYPTED },
{ type: "opencode", token: ENCRYPTED }, { type: "OPENCODE", token: ENCRYPTED },
], ],
}), }),
MASTER_KEY, MASTER_KEY,
@@ -41,14 +41,14 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
).toStrictEqual({ ).toStrictEqual({
reviewerToken: "secret-token", reviewerToken: "secret-token",
profiles: [ profiles: [
{ type: "codex", model: "gpt-custom" }, { type: "CODEX", model: "gpt-custom" },
{ {
type: "claude", type: "CLAUDE",
model: "claude-sonnet-5", model: "claude-sonnet-5",
token: "secret-token", token: "secret-token",
}, },
{ {
type: "opencode", type: "OPENCODE",
model: "openrouter/qwen/qwen3.8-27b", model: "openrouter/qwen/qwen3.8-27b",
token: "secret-token", token: "secret-token",
}, },
@@ -60,7 +60,7 @@ test("rejects malformed central credentials", async () => {
await expect(parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow("reviewer-token"); await expect(parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow("reviewer-token");
await expect( await expect(
parseCredentials( parseCredentials(
JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }), JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "CLAUDE" }] }),
MASTER_KEY, MASTER_KEY,
), ),
).rejects.toThrow("profiles[0].token"); ).rejects.toThrow("profiles[0].token");
+14 -14
View File
@@ -3,17 +3,17 @@ import { webcrypto } from "node:crypto";
import { field, isMapping } from "#common"; import { field, isMapping } from "#common";
import type { BotProfile } from "#profiles"; import type { BotProfile } from "#profiles";
interface DefaultModels { // The model a profile runs when profiles.json names none.
claude: string; function defaultModel(type: BotProfile["type"]): string {
codex: string; switch (type) {
opencode: string; case "CLAUDE":
return "claude-sonnet-5";
case "CODEX":
return "gpt-5.6-terra";
case "OPENCODE":
return "openrouter/qwen/qwen3.8-27b";
}
} }
const DEFAULT_MODELS: DefaultModels = {
claude: "claude-sonnet-5",
codex: "gpt-5.6-terra",
opencode: "openrouter/qwen/qwen3.8-27b",
};
export interface BotCredentials { export interface BotCredentials {
reviewerToken: string; reviewerToken: string;
@@ -91,13 +91,13 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
const type = field(entry, "type"); const type = field(entry, "type");
const model = field(entry, "model"); const model = field(entry, "model");
const token = field(entry, "token"); const token = field(entry, "token");
if (type !== "claude" && type !== "codex" && type !== "opencode") { if (type !== "CLAUDE" && type !== "CODEX" && type !== "OPENCODE") {
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`); throw new Error(`${at}.type: expected "CLAUDE", "CODEX", or "OPENCODE"`);
} }
if (model !== undefined && (typeof model !== "string" || model === "")) { if (model !== undefined && (typeof model !== "string" || model === "")) {
throw new Error(`${at}.model: expected a model name`); throw new Error(`${at}.model: expected a model name`);
} }
if ((type === "claude" || type === "opencode") && typeof token !== "string") { if ((type === "CLAUDE" || type === "OPENCODE") && typeof token !== "string") {
throw new Error(`${at}.token: expected an encrypted credential`); throw new Error(`${at}.token: expected an encrypted credential`);
} }
if (token !== undefined && typeof token !== "string") { if (token !== undefined && typeof token !== "string") {
@@ -105,7 +105,7 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
} }
return { return {
type, type,
model: model ?? DEFAULT_MODELS[type], model: model ?? defaultModel(type),
...(typeof token === "string" && { ...(typeof token === "string" && {
token: await decryptCredential(token, masterKey), token: await decryptCredential(token, masterKey),
}), }),
+2 -2
View File
@@ -32,7 +32,7 @@ test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credenti
`${action}/profiles.json`, `${action}/profiles.json`,
JSON.stringify({ JSON.stringify({
"reviewer-token": ENCRYPTED, "reviewer-token": ENCRYPTED,
profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }], profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }],
}), }),
); );
@@ -77,7 +77,7 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
`${action}/profiles.json`, `${action}/profiles.json`,
JSON.stringify({ JSON.stringify({
"reviewer-token": ENCRYPTED, "reviewer-token": ENCRYPTED,
profiles: [{ type: "claude", token: ENCRYPTED }], profiles: [{ type: "CLAUDE", token: ENCRYPTED }],
}), }),
); );
const result = spawnSync( const result = spawnSync(
+3 -3
View File
@@ -194,9 +194,9 @@ test("central profiles fall back from Codex to Claude to OpenCode", async () =>
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles"); const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles");
expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([ expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([
"codex", "CODEX",
"claude", "CLAUDE",
"opencode", "OPENCODE",
]); ]);
expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b"); expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.get("token")).startsWith("v1:")); assert(String(profiles[2]?.get("token")).startsWith("v1:"));