1 Commits

Author SHA1 Message Date
Danny Kim e106d08bed Add ordered bot profiles
Check / deno (pull_request) Successful in 49s
2026-09-23 20:48:49 +09:00
4 changed files with 32 additions and 185 deletions
+7 -8
View File
@@ -2,14 +2,13 @@ name: Bot agents
description: Run a coding agent for approved Gitea issue/PR automation
inputs:
profiles-file:
profiles:
description: >-
Repository-relative path to an ordered YAML sequence of bot profiles.
Each profile has `type` (`claude` or `codex`), an optional `model`, and
`token-env` naming a `BOT_PROFILE_...` environment variable containing
its credential. Codex may omit `token-env` to use its persisted device
login. A profile is tried only after the previous one exhausts its usage
allowance.
Ordered YAML sequence of bot profiles. Each profile has `type` (`claude`
or `codex`), an optional `model`, and a `token` containing a Claude setup
token or Codex API key. Codex may omit `token` to use its persisted
device login. A profile is tried only after the previous one exhausts
its usage allowance.
required: true
author-token:
description: >-
@@ -40,7 +39,7 @@ runs:
run: deno run -A "${ACTION_PATH}/run.ts"
env:
ACTION_PATH: ${{ gitea.action_path }}
PROFILES_FILE: ${{ inputs.profiles-file }}
PROFILES: ${{ inputs.profiles }}
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ inputs.author-token }}
+7 -74
View File
@@ -1,11 +1,9 @@
import { parse } from "jsr:@std/yaml@1";
import { isAbsolute, relative, resolve } from "jsr:@std/path@1";
export type BotProfile = {
type: "claude" | "codex";
model: string;
token?: string;
tokenEnv?: string;
};
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
@@ -13,48 +11,7 @@ const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
codex: "gpt-5.6-terra",
};
export type ProfileFileSystem = {
realPath(path: string): Promise<string>;
stat(path: string): Promise<{ isFile: boolean }>;
readTextFile(path: string): Promise<string>;
};
const systemFileSystem: ProfileFileSystem = {
realPath: Deno.realPath,
async stat(path) {
return { isFile: (await Deno.stat(path)).isFile };
},
readTextFile: Deno.readTextFile,
};
export async function readProfilesFile(
file: string,
workspace: string,
fileSystem: ProfileFileSystem = systemFileSystem,
): Promise<string> {
if (isAbsolute(file)) {
throw new Error("profiles-file must be repository-relative");
}
const root = await fileSystem.realPath(workspace);
const target = await fileSystem.realPath(resolve(root, file));
const pathFromRoot = relative(root, target);
if (
pathFromRoot === "" || pathFromRoot === ".." ||
pathFromRoot.startsWith("../") || pathFromRoot.startsWith("..\\") ||
isAbsolute(pathFromRoot)
) {
throw new Error("profiles-file must stay inside the repository");
}
if (!(await fileSystem.stat(target)).isFile) {
throw new Error("profiles-file must name a file");
}
return await fileSystem.readTextFile(target);
}
export function parseProfiles(
text: string,
environment: Record<string, string>,
): BotProfile[] {
export function parseProfiles(text: string): BotProfile[] {
const value: unknown = parse(text);
if (!Array.isArray(value) || value.length === 0) {
throw new Error("profiles must contain at least one profile");
@@ -64,39 +21,23 @@ export function parseProfiles(
if (typeof entry !== "object" || entry === null) {
throw new Error(`${at}: expected a mapping`);
}
const allowed = new Set(["type", "model", "token-env"]);
for (const key of Object.keys(entry)) {
if (!allowed.has(key)) throw new Error(`${at}.${key}: unknown field`);
}
const { type, model, "token-env": tokenEnv } = entry as Record<
string,
unknown
>;
const { type, model, token } = entry as Record<string, unknown>;
if (type !== "claude" && type !== "codex") {
throw new Error(`${at}.type: expected "claude" or "codex"`);
}
if (model !== undefined && (typeof model !== "string" || model === "")) {
throw new Error(`${at}.model: expected a model name`);
}
if (
tokenEnv !== undefined &&
(typeof tokenEnv !== "string" ||
!/^BOT_PROFILE_[A-Z0-9_]+$/.test(tokenEnv))
) {
throw new Error(`${at}.token-env: must start with BOT_PROFILE_`);
if (token !== undefined && (typeof token !== "string" || token === "")) {
throw new Error(`${at}.token: expected a token`);
}
if (type === "claude" && tokenEnv === undefined) {
throw new Error(`${at}.token-env: required for Claude`);
}
const token = tokenEnv === undefined ? undefined : environment[tokenEnv];
if (tokenEnv !== undefined && !token) {
throw new Error(`${at}.token-env: ${tokenEnv} is not set`);
if (type === "claude" && token === undefined) {
throw new Error(`${at}.token: required for Claude`);
}
return {
type,
model: model ?? DEFAULT_MODELS[type],
...(token && { token }),
...(tokenEnv && { tokenEnv }),
};
});
}
@@ -125,7 +66,6 @@ export async function runProfiles<T>(
export function sanitizedEnvironment(
environment: Record<string, string>,
profiles: BotProfile[],
): Record<string, string> {
const result = { ...environment };
for (
@@ -143,21 +83,14 @@ export function sanitizedEnvironment(
) {
delete result[name];
}
for (const name of Object.keys(result)) {
if (name.startsWith("BOT_PROFILE_")) delete result[name];
}
for (const profile of profiles) {
if (profile.tokenEnv) delete result[profile.tokenEnv];
}
return result;
}
export function agentEnvironment(
environment: Record<string, string>,
profile: BotProfile,
profiles: BotProfile[],
): Record<string, string> {
const result = sanitizedEnvironment(environment, profiles);
const result = sanitizedEnvironment(environment);
if (profile.token) {
result[
profile.type === "claude" ? "CLAUDE_CODE_OAUTH_TOKEN" : "OPENAI_API_KEY"
+14 -94
View File
@@ -2,101 +2,34 @@ import { assertEquals, assertRejects, assertThrows } from "jsr:@std/assert@1";
import {
agentEnvironment,
parseProfiles,
readProfilesFile,
runProfiles,
sanitizedEnvironment,
} from "./profiles.ts";
Deno.test("parses profiles in fallback order with model defaults", () => {
assertEquals(
parseProfiles(
`
parseProfiles(`
- type: claude
token-env: BOT_PROFILE_CLAUDE_PRIMARY
token: claude-token
- type: codex
model: gpt-custom
`,
{ BOT_PROFILE_CLAUDE_PRIMARY: "claude-token" },
),
`),
[
{
type: "claude",
model: "claude-sonnet-5",
token: "claude-token",
tokenEnv: "BOT_PROFILE_CLAUDE_PRIMARY",
},
{ type: "claude", model: "claude-sonnet-5", token: "claude-token" },
{ type: "codex", model: "gpt-custom" },
],
);
});
Deno.test("rejects an empty profile list", () => {
assertThrows(() => parseProfiles("[]", {}), Error, "at least one profile");
assertThrows(() => parseProfiles("[]"), Error, "at least one profile");
});
Deno.test("rejects a Claude profile without its token environment variable", () => {
Deno.test("rejects a Claude profile without its setup token", () => {
assertThrows(
() => parseProfiles("- type: claude", {}),
() => parseProfiles("- type: claude"),
Error,
"profiles[0].token-env",
);
assertThrows(
() =>
parseProfiles(
"- type: claude\n token-env: BOT_PROFILE_CLAUDE_PRIMARY",
{},
),
Error,
"BOT_PROFILE_CLAUDE_PRIMARY is not set",
);
});
Deno.test("rejects non-profile credentials and unknown fields", () => {
assertThrows(
() =>
parseProfiles(
"- type: claude\n token-env: REVIEWER_TOKEN",
{ REVIEWER_TOKEN: "secret" },
),
Error,
"must start with BOT_PROFILE_",
);
assertThrows(
() =>
parseProfiles(
"- type: codex\n token: committed-secret",
{},
),
Error,
"profiles[0].token: unknown field",
);
});
Deno.test("reads only a profile file contained by the workspace", async () => {
const fileSystem = {
realPath(path: string) {
return Promise.resolve(path === "/repo/escaped.yml" ? "/outside" : path);
},
stat(_path: string) {
return Promise.resolve({ isFile: true });
},
readTextFile(_path: string) {
return Promise.resolve("- type: codex\n");
},
};
assertEquals(
await readProfilesFile("profiles.yml", "/repo", fileSystem),
"- type: codex\n",
);
await assertRejects(
() => readProfilesFile("/outside", "/repo", fileSystem),
Error,
"repository-relative",
);
await assertRejects(
() => readProfilesFile("escaped.yml", "/repo", fileSystem),
Error,
"must stay inside",
"profiles[0].token",
);
});
@@ -209,28 +142,15 @@ Deno.test("exposes only the selected profile credential to the agent", () => {
OPENAI_API_KEY: "old-openai",
CODEX_API_KEY: "old-codex-key",
CODEX_ACCESS_TOKEN: "old-codex-token",
BOT_PROFILE_CLAUDE_PRIMARY: "selected",
BOT_PROFILE_CLAUDE_FALLBACK: "fallback",
BOT_PROFILE_UNUSED: "unused",
};
const profiles = [
{
type: "claude" as const,
model: "claude-sonnet-5",
token: "selected",
tokenEnv: "BOT_PROFILE_CLAUDE_PRIMARY",
},
{
type: "claude" as const,
model: "claude-sonnet-5",
token: "fallback",
tokenEnv: "BOT_PROFILE_CLAUDE_FALLBACK",
},
];
assertEquals(
agentEnvironment(base, profiles[0], profiles),
agentEnvironment(base, {
type: "claude",
model: "claude-sonnet-5",
token: "selected",
}),
{ HOME: "/home/bot", CLAUDE_CODE_OAUTH_TOKEN: "selected" },
);
assertEquals(sanitizedEnvironment(base, profiles), { HOME: "/home/bot" });
assertEquals(sanitizedEnvironment(base), { HOME: "/home/bot" });
});
+4 -9
View File
@@ -8,7 +8,6 @@ import {
agentEnvironment,
type BotProfile,
parseProfiles,
readProfilesFile,
runProfiles,
sanitizedEnvironment,
} from "./profiles.ts";
@@ -33,15 +32,11 @@ const API = env("GITEA_API_URL");
const REPO = env("GITEA_REPOSITORY");
const INDEX = env("ISSUE_INDEX");
const EVENT = env("EVENT_NAME");
const PROCESS_ENV = Deno.env.toObject();
const PROFILES = parseProfiles(
await readProfilesFile(env("PROFILES_FILE"), env("GITHUB_WORKSPACE")),
PROCESS_ENV,
);
const PROFILES = parseProfiles(env("PROFILES"));
const AUTHOR_TOKEN = env("GITEA_TOKEN");
const REVIEWER_TOKEN = env("REVIEWER_TOKEN");
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
const HELPER_ENV = sanitizedEnvironment(PROCESS_ENV, PROFILES);
const HELPER_ENV = sanitizedEnvironment(Deno.env.toObject());
async function gitea(
token: string,
@@ -235,7 +230,7 @@ async function runClaude(profile: BotProfile): Promise<string> {
],
// Claude refuses --dangerously-skip-permissions as root outside a sandbox.
env: {
...agentEnvironment(PROCESS_ENV, profile, PROFILES),
...agentEnvironment(Deno.env.toObject(), profile),
IS_SANDBOX: "1",
},
clearEnv: true,
@@ -327,7 +322,7 @@ async function codexApiLogin(token: string, codexHome: string): Promise<void> {
}
async function runCodex(profile: BotProfile): Promise<string> {
const currentEnv = agentEnvironment(PROCESS_ENV, profile, PROFILES);
const currentEnv = agentEnvironment(Deno.env.toObject(), profile);
const temporaryHome = profile.token ? await Deno.makeTempDir() : null;
if (temporaryHome !== null) currentEnv.CODEX_HOME = temporaryHome;
try {