Split tokens #1

Merged
temeddix merged 2 commits from split-tokens into main 2026-09-13 14:15:24 +00:00
6 changed files with 260 additions and 114 deletions
+17
View File
@@ -0,0 +1,17 @@
name: Check
on:
pull_request:
branches: [main]
jobs:
deno:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- run: apt-get update && apt-get install -y unzip
- uses: denoland/setup-deno@v2
- run: deno fmt --check . && deno lint . && deno check run.ts
+17 -9
View File
@@ -5,8 +5,17 @@ inputs:
bot-type:
description: Which bot to run, either `codex` or `claude`
required: true
gitea-token:
description: Gitea access token for API calls and pushes
author-token:
description: >-
Gitea token of the account that commits, pushes, and opens pull requests,
with the `read:user`, `write:issue`, and `write:repository` scopes. The
agent sees it as `GITEA_TOKEN`.
required: true
reviewer-token:
description: >-
Gitea token of the bot account that posts comments and pull request
reviews, with the `write:issue` and `write:repository` scopes. Never
exposed to the agent, so it must differ from the author.
required: true
bot-token:
description: API key or token for the selected bot
@@ -18,19 +27,18 @@ runs:
# This step assumes this is `node:24-bookworm` container.
- name: Install dependencies
shell: bash
run: |
apt-get update
apt-get install -y --no-install-recommends gettext-base jq ansifilter
npm install -g @openai/codex @anthropic-ai/claude-code
run: npm install -g @openai/codex @anthropic-ai/claude-code deno
- name: Run bot
shell: bash
run: bash "${ACTION_PATH}/scripts/run-${{ inputs.bot-type }}.sh"
run: deno run -A "${ACTION_PATH}/run.ts"
env:
ACTION_PATH: ${{ gitea.action_path }}
BOT_TYPE: ${{ inputs.bot-type }}
BOT_TOKEN: ${{ inputs.bot-token }}
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ inputs.gitea-token }}
BOT_TOKEN: ${{ inputs.bot-token }}
GITEA_TOKEN: ${{ inputs.author-token }}
REVIEWER_TOKEN: ${{ inputs.reviewer-token }}
EVENT_NAME: ${{ gitea.event_name }}
ISSUE_INDEX: ${{ gitea.event.issue.number || gitea.event.pull_request.number }}
COMMENT: ${{ toJSON(gitea.event.comment || gitea.event.review) }}
+24 -13
View File
@@ -13,11 +13,15 @@ response ends, so background monitors, scheduled wake-ups, and queued tasks
never resume. Never promise future action and never claim to be waiting on a
notification.
For a `pull_request` event, review the newly opened PR without changing code. If
changes are needed, include `@bot` in the final response with instructions to
fix the findings. Otherwise, do not mention `@bot`. For UI changes, check that
the result is aligned, clean, and pixel-perfect, and that included screenshots
prove the intended result was achieved.
For a `pull_request` event, review the PR without changing code. Your final
response is posted as a pull request review from the bot account: it requests
changes when it mentions `@bot` and approves otherwise. So include `@bot` with
instructions to fix the findings exactly when changes are needed, and never
mention `@bot` when the PR is ready. For UI changes, check that the result is
aligned, clean, and pixel-perfect, and that included screenshots prove the
intended result was achieved. Also check the whole repository, not only the
diff, against the code rules at the end of this prompt, and request changes for
every violation you find even when the diff did not cause it.
For an `issue_comment` or `pull_request_review_comment` event, treat the `body`
in the triggering comment payload below as the user's exact instruction.
@@ -36,13 +40,14 @@ another run; always wait it out and complete the task before responding.
Afterwards, pull the latest branch state before pushing. Skip this check when no
code changes are needed.
Prefer minimal, correct changes. Run relevant checks or tests if practical.
Treat code changes as a request to create a pull request. If a prior bot pull
request already exists for this issue, update that same pull request instead of
creating a new one. When you create a pull request, include `@bot` in its body
and ask it to review the pull request. This is required for every pull request
you create. Finish by briefly reporting what changed and what tests ran. Wait
for Gitea Actions CI to complete, and fix any failures.
Prefer minimal, correct changes that follow the code rules at the end of this
prompt. Run relevant checks or tests if practical. Treat code changes as a
request to create a pull request. If a prior bot pull request already exists for
this issue, update that same pull request instead of creating a new one. When
you create a pull request, include `@bot` in its body and ask it to review the
pull request. This is required for every pull request you create. Finish by
briefly reporting what changed and what tests ran. Wait for Gitea Actions CI to
complete, and fix any failures.
If you modify UI code, include Playwright screenshots in your PR or issue
comments. If you need UI clarification, ask with screenshots when helpful.
@@ -65,4 +70,10 @@ ${ISSUE_COMMENTS}
- API URL: `${GITEA_API_URL}`
- Repository: `${GITEA_REPOSITORY}`
- Issue index: `${ISSUE_INDEX}`
- Use the `GITEA_TOKEN` environment variable for authenticated Gitea API calls.
- Use the `GITEA_TOKEN` environment variable for authenticated Gitea API calls
and pushes. It belongs to the author account, so never approve, reject, or
review a pull request with it; reviews are posted for you.
# Code rules
${CODE_RULES}
+202
View File
@@ -0,0 +1,202 @@
// Runs a coding agent for one Gitea issue or pull request event and posts its
// final response back. The agent works as the author account through
// GITEA_TOKEN; everything this script posts goes through the reviewer token,
// so it appears as the bot account.
import { TextLineStream } from "jsr:@std/streams@1/text-line-stream";
type GiteaUser = { login: string; email: string };
type GiteaComment = { user: GiteaUser; created_at: string; body: string };
const env = (name: string): string => {
const value = Deno.env.get(name);
if (value === undefined) throw new Error(`${name} is not set`);
return value;
};
const API = env("GITEA_API_URL");
const REPO = env("GITEA_REPOSITORY");
const INDEX = env("ISSUE_INDEX");
const EVENT = env("EVENT_NAME");
const AUTHOR_TOKEN = env("GITEA_TOKEN");
const REVIEWER_TOKEN = env("REVIEWER_TOKEN");
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
// The reviewer token is withheld so the agent cannot approve as the bot.
const { REVIEWER_TOKEN: _, ...agentEnv } = Deno.env.toObject();
async function gitea(
token: string,
path: string,
body?: unknown,
): Promise<Response> {
const response = await fetch(`${API}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `token ${token}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response;
}
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
const stripAnsi = (text: string): string => text.replace(ANSI, "");
async function postComment(body: string): Promise<void> {
await gitea(REVIEWER_TOKEN, `repos/${REPO}/issues/${INDEX}/comments`, {
body: stripAnsi(body),
});
}
// A pull request event is a review request, so the response becomes a review:
// changes are requested when the agent asked @bot to fix something, a failed
// run only comments, and anything else approves.
async function postResult(body: string): Promise<void> {
if (EVENT !== "pull_request") return postComment(body);
const event = body.includes("@bot")
? "REQUEST_CHANGES"
: body.startsWith("Bot failed:")
? "COMMENT"
: "APPROVED";
await gitea(REVIEWER_TOKEN, `repos/${REPO}/pulls/${INDEX}/reviews`, {
body: stripAnsi(body),
event,
});
}
// Commits belong to the same account as the pull request they end up in.
async function configureGitAuthor(): Promise<void> {
const user: GiteaUser = await (await gitea(AUTHOR_TOKEN, "user")).json();
for (const [key, value] of [["name", user.login], ["email", user.email]]) {
await new Deno.Command("git", {
args: ["config", "--global", `user.${key}`, value],
}).output();
}
}
async function renderPrompt(): Promise<string> {
const comments: GiteaComment[] = await (await gitea(
REVIEWER_TOKEN,
`repos/${REPO}/issues/${INDEX}/comments?limit=100`,
)).json();
const values: Record<string, string> = {
COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments
.map((c) => `## ${c.user.login} at ${c.created_at}\n\n${c.body}\n`)
.join("\n"),
CODE_RULES: await (await gitea(REVIEWER_TOKEN, RULES_PATH)).text(),
EVENT_NAME: EVENT,
GITEA_API_URL: API,
GITEA_REPOSITORY: REPO,
ISSUE_INDEX: INDEX,
};
const template = await Deno.readTextFile(
new URL("prompt.md", import.meta.url),
);
return template.replace(
/\$\{(\w+)\}/g,
(match, name) => values[name] ?? match,
);
}
async function runClaude(prompt: string): Promise<string> {
const token = Deno.env.get("BOT_TOKEN");
if (!token) {
await postComment(
"Run `claude setup-token` locally and set the `bot-token` action input.",
);
Deno.exit(1);
}
const claude = new Deno.Command("claude", {
args: [
"--print",
"--dangerously-skip-permissions",
"--model",
"claude-fable-5",
"--output-format",
"stream-json",
"--verbose",
prompt,
],
// Claude refuses --dangerously-skip-permissions as root outside a sandbox.
env: { ...agentEnv, CLAUDE_CODE_OAUTH_TOKEN: token, IS_SANDBOX: "1" },
clearEnv: true,
stdout: "piped",
}).spawn();
let result = "Bot failed: no result";
// Print events as they stream so the runner does not kill the job as a zombie.
const lines = claude.stdout
.pipeThrough(new TextDecoderStream())
.pipeThrough(new TextLineStream());
for await (const line of lines) {
if (!line) continue;
const event = JSON.parse(line);
for (const part of event.message?.content ?? []) {
const text = part.thinking ?? part.text ?? part.name;
if (text) console.log(text);
}
if (event.type === "result") {
result = event.result ?? `Bot failed: ${event.subtype}`;
}
}
await claude.status;
return result;
}
// Posts the device code so a human can finish the login on the persisted home.
async function codexDeviceLogin(): Promise<void> {
const login = new Deno.Command("codex", {
args: ["login", "--device-auth"],
stdout: "piped",
stderr: "piped",
}).spawn();
let shown = "";
const collect = (stream: ReadableStream<Uint8Array>): Promise<void> =>
stream.pipeThrough(new TextDecoderStream()).pipeTo(
new WritableStream({ write: (chunk) => void (shown += chunk) }),
);
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
await new Promise((resolve) => setTimeout(resolve, 3000));
await postComment(shown);
await Promise.all([login.status, drained]);
const status = await new Deno.Command("codex", { args: ["login", "status"] })
.output();
await postComment(new TextDecoder().decode(status.stdout));
}
async function runCodex(prompt: string): Promise<string> {
const loggedIn = await new Deno.Command("codex", {
args: ["login", "status"],
})
.output();
if (!loggedIn.success) await codexDeviceLogin();
const file = await Deno.makeTempFile();
const status = await new Deno.Command("codex", {
args: [
"exec",
"--model",
"gpt-5.5",
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
file,
prompt,
],
env: agentEnv,
clearEnv: true,
stdout: "inherit",
stderr: "inherit",
}).spawn().status;
if (!status.success) throw new Error(`codex exited with ${status.code}`);
return await Deno.readTextFile(file);
}
await configureGitAuthor();
const prompt = await renderPrompt();
const result = env("BOT_TYPE") === "claude"
? await runClaude(prompt)
: await runCodex(prompt);
await postResult(result);
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
git config --global user.name bot
git config --global user.email noreply@capsulizers.com
COMMENT_FILE="$(mktemp)"
post_comment() {
COMMENT_JSON="$(jq -n --rawfile body "${COMMENT_FILE}" '{body: $body}')"
curl --fail-with-body --silent --show-error \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
--data "${COMMENT_JSON}" \
"${GITEA_API_URL}/repos/${GITEA_REPOSITORY}/issues/${ISSUE_INDEX}/comments"
}
if [ -z "${BOT_TOKEN:-}" ]; then
echo 'Run `claude setup-token` locally and set the `bot-token` action input.' > "${COMMENT_FILE}"
post_comment
exit 1
fi
export CLAUDE_CODE_OAUTH_TOKEN="${BOT_TOKEN}"
export ISSUE_COMMENTS="$(
curl --fail-with-body --silent --show-error \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API_URL}/repos/${GITEA_REPOSITORY}/issues/${ISSUE_INDEX}/comments?limit=100" \
| jq -r '.[] | "## " + .user.login + " at " + .created_at + "\n\n" + .body + "\n"'
)"
FINAL_PROMPT="$(envsubst < "${ACTION_PATH}/scripts/prompt.md")"
# Claude refuses --dangerously-skip-permissions as root outside a sandbox.
export IS_SANDBOX=1
# Stream events so the runner sees output and does not kill the job as a zombie.
STREAM_FILE="$(mktemp)"
claude --print --dangerously-skip-permissions --model claude-fable-5 \
--output-format stream-json --verbose "${FINAL_PROMPT}" \
| tee "${STREAM_FILE}" \
| jq -r --unbuffered '.message.content[]? | .thinking // .text // .name // empty'
jq -r 'select(.type == "result") | .result // ("Bot failed: " + .subtype)' "${STREAM_FILE}" \
| ansifilter > "${COMMENT_FILE}"
post_comment
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
git config --global user.name bot
git config --global user.email noreply@capsulizers.com
COMMENT_FILE="$(mktemp)"
post_comment() {
COMMENT_JSON="$(jq -n --rawfile body "${COMMENT_FILE}" '{body: $body}')"
curl --fail-with-body --silent --show-error \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
--data "${COMMENT_JSON}" \
"${GITEA_API_URL}/repos/${GITEA_REPOSITORY}/issues/${ISSUE_INDEX}/comments"
}
if ! codex login status > /dev/null 2>&1; then
codex login --device-auth 2>&1 | ansifilter > "${COMMENT_FILE}" &
LOGIN_PID="${!}"
sleep 3
post_comment
wait "${LOGIN_PID}"
codex login status 2>&1 | ansifilter > "${COMMENT_FILE}"
post_comment
fi
export ISSUE_COMMENTS="$(
curl --fail-with-body --silent --show-error \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API_URL}/repos/${GITEA_REPOSITORY}/issues/${ISSUE_INDEX}/comments?limit=100" \
| jq -r '.[] | "## " + .user.login + " at " + .created_at + "\n\n" + .body + "\n"'
)"
FINAL_PROMPT="$(envsubst < "${ACTION_PATH}/scripts/prompt.md")"
codex exec --model gpt-5.5 \
--dangerously-bypass-approvals-and-sandbox \
--output-last-message "${COMMENT_FILE}" \
"${FINAL_PROMPT}"
post_comment