Move the action from Deno to Node.js and Vite+
This commit is contained in:
@@ -5,17 +5,18 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deno:
|
check:
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- run: apt-get update && apt-get install -y unzip
|
# The action runs on this same image; src/post.ts needs Node 24.2+.
|
||||||
|
- run: node --version
|
||||||
|
|
||||||
- uses: denoland/setup-deno@v2
|
- run: npm ci
|
||||||
|
|
||||||
- run: >-
|
# Formatting, lint, and type checks, then every test. The job runs as
|
||||||
deno fmt --check . &&
|
# root, so the isolation tests run rather than skip.
|
||||||
deno lint . &&
|
- run: npx vp check
|
||||||
deno check src/prepare.ts src/run.ts src/post.ts &&
|
|
||||||
deno test --allow-all
|
- run: npx vp test
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
@@ -10,6 +10,21 @@ comment `@bot` to request an AI-bot review. Wait for green CI and the bot's
|
|||||||
approval on the current head before merging. Never merge without the user's
|
approval on the current head before merging. Never merge without the user's
|
||||||
explicit permission.
|
explicit permission.
|
||||||
|
|
||||||
Repository TypeScript and checks use Deno. The composite action uses npm only to
|
# Runtime and checks
|
||||||
install the externally distributed Claude Code and Codex command-line tools; do
|
|
||||||
not treat those vendor executables as repository TypeScript dependencies.
|
The action's TypeScript runs directly on the job image's Node.js (24.2 or
|
||||||
|
newer), which strips the types itself: there is no build step and no runtime
|
||||||
|
dependency. Keep the sources to erasable syntax and `.ts` imports, which
|
||||||
|
`tsconfig.json` enforces. Checks use Vite+: `vp check` formats, lints, and
|
||||||
|
type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as
|
||||||
|
`src/*.test.ts`; repository-level tests live in `tests/`.
|
||||||
|
|
||||||
|
The only config files are `package.json`, `tsconfig.json`, and
|
||||||
|
`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule
|
||||||
|
list live in `scripts/project-lint.ts`. Lint disable comments are not allowed;
|
||||||
|
fix the source instead.
|
||||||
|
|
||||||
|
The composite action uses npm only to install the externally distributed
|
||||||
|
Claude Code, Codex, and OpenCode command-line tools, plus Deno for agents that
|
||||||
|
check Deno-based repositories; do not treat those vendor executables as
|
||||||
|
repository TypeScript dependencies.
|
||||||
|
|||||||
+6
-4
@@ -15,7 +15,9 @@ runs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ gitea.token }}
|
token: ${{ gitea.token }}
|
||||||
# This step assumes this is `node:24-bookworm` container.
|
# This step assumes this is `node:24-bookworm` container. The action runs
|
||||||
|
# its TypeScript on that Node directly; Deno stays for the agents, which
|
||||||
|
# still check Deno-based repositories with it.
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno
|
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno
|
||||||
@@ -34,7 +36,7 @@ runs:
|
|||||||
- name: Prepare credentials
|
- name: Prepare credentials
|
||||||
id: prepare
|
id: prepare
|
||||||
shell: bash
|
shell: bash
|
||||||
run: deno run -A "${{ steps.source.outputs.path }}/src/prepare.ts"
|
run: node "${{ steps.source.outputs.path }}/src/prepare.ts"
|
||||||
env:
|
env:
|
||||||
ACTION_PATH: ${{ steps.source.outputs.path }}
|
ACTION_PATH: ${{ steps.source.outputs.path }}
|
||||||
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
|
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
|
||||||
@@ -42,7 +44,7 @@ runs:
|
|||||||
- name: Run bot
|
- name: Run bot
|
||||||
id: agent
|
id: agent
|
||||||
shell: bash
|
shell: bash
|
||||||
run: deno run -A "${{ steps.source.outputs.path }}/src/run.ts"
|
run: node "${{ steps.source.outputs.path }}/src/run.ts"
|
||||||
env:
|
env:
|
||||||
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
|
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
|
||||||
GITEA_TOKEN: ${{ gitea.token }}
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
@@ -55,7 +57,7 @@ runs:
|
|||||||
id: post
|
id: post
|
||||||
if: always() && steps.prepare.outputs.manifest != ''
|
if: always() && steps.prepare.outputs.manifest != ''
|
||||||
shell: bash
|
shell: bash
|
||||||
run: deno run -A "${{ steps.source.outputs.path }}/src/post.ts"
|
run: node "${{ steps.source.outputs.path }}/src/post.ts"
|
||||||
env:
|
env:
|
||||||
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
|
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
|
||||||
GITEA_API_URL: ${{ gitea.api_url }}
|
GITEA_API_URL: ${{ gitea.api_url }}
|
||||||
|
|||||||
Generated
+3083
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "bot-agents",
|
||||||
|
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/core": "^0.17.0",
|
||||||
|
"@types/node": "^24.19.2",
|
||||||
|
"@typescript-eslint/types": "^8.71.2",
|
||||||
|
"vite-plus": "^1.2.0",
|
||||||
|
"yaml": "^2.9.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"type": "codex",
|
||||||
|
"model": "gpt-5.6-terra"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "claude",
|
||||||
|
"model": "claude-sonnet-5",
|
||||||
|
"token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencode",
|
||||||
|
"model": "openrouter/qwen/qwen3.8-27b",
|
||||||
|
"token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
reviewer-token: v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY
|
|
||||||
profiles:
|
|
||||||
- type: codex
|
|
||||||
model: gpt-5.6-terra
|
|
||||||
- type: claude
|
|
||||||
model: claude-sonnet-5
|
|
||||||
token: v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg
|
|
||||||
- type: opencode
|
|
||||||
model: openrouter/qwen/qwen3.8-27b
|
|
||||||
token: v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0
|
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* `project` — lint rules this repository holds its own source to. Deno lint
|
||||||
|
* ran here with its `recommended` rules and no plugins; the Oxlint categories
|
||||||
|
* in `vite.config.ts` plus the rule list below keep at least that coverage.
|
||||||
|
* Its rules of its own are `no-disable-comments` and `no-non-ts-imports`.
|
||||||
|
*
|
||||||
|
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
|
||||||
|
*
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core";
|
||||||
|
import type { TSESTree } from "@typescript-eslint/types";
|
||||||
|
import type { DummyRuleMap } from "vite-plus/lint";
|
||||||
|
|
||||||
|
/** The source code both hosts hand a rule, with the comment list. */
|
||||||
|
interface ProjectSourceCode extends TextSourceCode {
|
||||||
|
getAllComments(): TSESTree.Comment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */
|
||||||
|
interface ProjectRuleTypes {
|
||||||
|
LangOptions: LanguageOptions;
|
||||||
|
Code: ProjectSourceCode;
|
||||||
|
RuleOptions: [];
|
||||||
|
Visitor: RuleVisitor;
|
||||||
|
Node: TSESTree.Node;
|
||||||
|
MessageIds: string;
|
||||||
|
ExtRuleDocs: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A repo-local rule, in ESLint's standard `create` form. */
|
||||||
|
type ProjectRule = RuleDefinition<ProjectRuleTypes>;
|
||||||
|
|
||||||
|
/** A comment that turns lint rules off or back on, in ESLint or Oxlint form. */
|
||||||
|
const DIRECTIVE_PATTERN: RegExp = /^\s*(?:eslint|oxlint)-(?:disable|enable)/u;
|
||||||
|
|
||||||
|
/** This rule's own name, which a directive may not use to silence it. */
|
||||||
|
const RULE_NAME: string = "no-disable-comments";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `no-disable-comments` — rejects every `eslint-disable*`, `eslint-enable*`,
|
||||||
|
* `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has
|
||||||
|
* no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them
|
||||||
|
* itself: when a rule fires, fix the source.
|
||||||
|
*
|
||||||
|
* A directive can silence the report on itself, so two built-in checks close
|
||||||
|
* the gaps: `unicorn/no-abusive-eslint-disable` fails one that names no rule,
|
||||||
|
* and this rule skips one that names it, which then silences nothing and fails
|
||||||
|
* `reportUnusedDisableDirectives` in `vite.config.ts`.
|
||||||
|
*/
|
||||||
|
const noDisableCommentsRule: ProjectRule = {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: { description: "Disallow lint disable and enable comments" },
|
||||||
|
schema: [],
|
||||||
|
messages: { directive: "Lint disable comments are not allowed; fix the source." },
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
return {
|
||||||
|
Program(_node: TSESTree.Program): void {
|
||||||
|
for (const comment of context.sourceCode.getAllComments()) {
|
||||||
|
if (DIRECTIVE_PATTERN.test(comment.value) && !comment.value.includes(RULE_NAME)) {
|
||||||
|
context.report({ loc: comment.loc, messageId: "directive" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A module specifier read as a path: relative, absolute, or a `#` alias. */
|
||||||
|
const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `source` names something other than a `.ts` module: a query string
|
||||||
|
* such as `?raw`, or a final path segment with any other extension. Bare
|
||||||
|
* package names are left alone, dots and all, as are extensionless paths.
|
||||||
|
*/
|
||||||
|
function isNonTsSource(source: string): boolean {
|
||||||
|
if (source.includes("?")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const segments: string[] = source.split("/");
|
||||||
|
if (!PATH_SPECIFIER_PATTERN.test(source)) {
|
||||||
|
const nameLength: number = source.startsWith("@") ? 2 : 1;
|
||||||
|
if (segments.length <= nameLength) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lastSegment: string = segments.at(-1) ?? "";
|
||||||
|
if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !lastSegment.endsWith(".ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `no-non-ts-imports` — rejects importing anything but a `.ts` module: a
|
||||||
|
* source path, static or a dynamic `import()` with a string literal, ending in
|
||||||
|
* another extension (`.css`, `.json`, `.svg`, `.js`, `.tsx`) or carrying a
|
||||||
|
* query string (`?raw`, `?url`), and any import or export with import
|
||||||
|
* attributes. Bare package names, `#` aliases, and `node:` builtins without an
|
||||||
|
* extension pass; package subpaths are checked like paths.
|
||||||
|
*/
|
||||||
|
const noNonTsImportsRule: ProjectRule = {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: { description: "Disallow importing modules other than .ts files" },
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
extension: "Only .ts modules may be imported.",
|
||||||
|
attributes: "Import attributes are not allowed.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
function checkSource(source: TSESTree.StringLiteral): void {
|
||||||
|
if (isNonTsSource(source.value)) {
|
||||||
|
context.report({ node: source, messageId: "extension" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function checkDeclaration(
|
||||||
|
node:
|
||||||
|
| TSESTree.ImportDeclaration
|
||||||
|
| TSESTree.ExportNamedDeclaration
|
||||||
|
| TSESTree.ExportAllDeclaration,
|
||||||
|
): void {
|
||||||
|
if (node.source === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkSource(node.source);
|
||||||
|
if (node.attributes.length > 0) {
|
||||||
|
context.report({ node, messageId: "attributes" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ImportDeclaration: checkDeclaration,
|
||||||
|
ExportNamedDeclaration: checkDeclaration,
|
||||||
|
ExportAllDeclaration: checkDeclaration,
|
||||||
|
ImportExpression(node: TSESTree.ImportExpression): void {
|
||||||
|
if (node.source.type === "Literal" && typeof node.source.value === "string") {
|
||||||
|
checkSource(node.source);
|
||||||
|
}
|
||||||
|
if (node.options !== null) {
|
||||||
|
context.report({ node, messageId: "attributes" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This repository's own rule list, spread by `vite.config.ts`: Deno's
|
||||||
|
* `recommended` rules that sit outside the Oxlint categories, plus the
|
||||||
|
* template's extras. Built-in Oxlint rule ids sit here too, so
|
||||||
|
* `vite.config.ts` lists no rule by name.
|
||||||
|
*/
|
||||||
|
const rules: DummyRuleMap = {
|
||||||
|
// The explicit-types template's `camelcase` stand-in: no `_` inside a name
|
||||||
|
// once leading and trailing ones are trimmed, unless it has no lowercase
|
||||||
|
// ASCII letter (UPPER_SNAKE).
|
||||||
|
"id-match": [
|
||||||
|
"error",
|
||||||
|
"^_*[^_]*_*$|^[^a-z]*$",
|
||||||
|
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true },
|
||||||
|
],
|
||||||
|
// Deno's `recommended` 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",
|
||||||
|
// Deno's `no-window`, `no-process-global`, and `no-node-globals`: on Node,
|
||||||
|
// these come from an explicit `node:` import instead of the global scope.
|
||||||
|
"no-restricted-globals": [
|
||||||
|
"error",
|
||||||
|
"window",
|
||||||
|
"process",
|
||||||
|
"Buffer",
|
||||||
|
"global",
|
||||||
|
"setImmediate",
|
||||||
|
"clearImmediate",
|
||||||
|
],
|
||||||
|
// The explicit-types template's extras.
|
||||||
|
"import/no-cycle": "error",
|
||||||
|
"typescript/consistent-type-imports": "error",
|
||||||
|
"typescript/no-import-type-side-effects": "error",
|
||||||
|
"typescript/no-non-null-assertion": "error",
|
||||||
|
// Deno's `ban-untagged-ignore` and `ban-unused-ignore`, made stricter: no
|
||||||
|
// disable comments at all.
|
||||||
|
"project/no-disable-comments": "error",
|
||||||
|
"project/no-non-ts-imports": "error",
|
||||||
|
"unicorn/no-abusive-eslint-disable": "error",
|
||||||
|
// 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",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repo-local plugin, namespace `project`: its own rules, enabled as
|
||||||
|
* `project/<rule>`, and `configs.project`, the rule list above.
|
||||||
|
*/
|
||||||
|
const plugin = {
|
||||||
|
meta: { name: "project" },
|
||||||
|
rules: {
|
||||||
|
"no-disable-comments": noDisableCommentsRule,
|
||||||
|
"no-non-ts-imports": noNonTsImportsRule,
|
||||||
|
},
|
||||||
|
configs: { project: { rules } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
|||||||
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
|
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
|
||||||
mkdir "${path}/src"
|
mkdir "${path}/src"
|
||||||
cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/"
|
cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/"
|
||||||
cp -a "${ACTION_PATH}/profiles.yml" "${ACTION_PATH}/prompt.md" "${path}/"
|
# package.json makes Node load the TypeScript sources as ES modules.
|
||||||
|
cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/"
|
||||||
chmod -R go-w "${path}"
|
chmod -R go-w "${path}"
|
||||||
printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}"
|
printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Runs Claude Code for one profile and returns its final result text.
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
|
import { isClaudeAuthFailure } from "./auth.ts";
|
||||||
|
import type { ClaudeFailureFields } from "./auth.ts";
|
||||||
|
import { chunks } from "./command.ts";
|
||||||
|
import { agentCommand } from "./isolation.ts";
|
||||||
|
import { isObject } from "./json.ts";
|
||||||
|
import type { JsonObject } from "./json.ts";
|
||||||
|
import { AgentAuthError, agentEnvironment } from "./profiles.ts";
|
||||||
|
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
|
||||||
|
import type { RuntimeProfile } from "./runtime.ts";
|
||||||
|
|
||||||
|
// Claude Code's stream-json `result` event, in its own field names.
|
||||||
|
type ClaudeResult = ClaudeFailureFields &
|
||||||
|
Partial<Record<"is_error", boolean>> & {
|
||||||
|
subtype: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Echoes stderr as it arrives and resolves with all of it.
|
||||||
|
async function copyError(stream: Readable): Promise<string> {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let error = "";
|
||||||
|
for await (const chunk of chunks(stream)) {
|
||||||
|
const text = decoder.decode(chunk, { stream: true });
|
||||||
|
error += text;
|
||||||
|
console.error(text);
|
||||||
|
}
|
||||||
|
return error + decoder.decode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prints the visible text of each streamed message part.
|
||||||
|
function printParts(event: JsonObject): void {
|
||||||
|
const content = isObject(event.message) ? event.message.content : undefined;
|
||||||
|
for (const part of Array.isArray(content) ? content : []) {
|
||||||
|
if (isObject(part)) {
|
||||||
|
const text = part.thinking ?? part.text ?? part.name;
|
||||||
|
if (typeof text === "string" && text !== "") {
|
||||||
|
console.log(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResult(event: JsonObject): ClaudeResult {
|
||||||
|
const { is_error: isError, result, subtype, api_error_status: apiErrorStatus } = event;
|
||||||
|
return {
|
||||||
|
...(typeof isError === "boolean" && { is_error: isError }),
|
||||||
|
...(typeof result === "string" && { result }),
|
||||||
|
subtype: String(subtype),
|
||||||
|
...((typeof apiErrorStatus === "number" || apiErrorStatus === null) && {
|
||||||
|
api_error_status: apiErrorStatus,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prints each streamed message part and returns the final `result` event.
|
||||||
|
async function readEvents(stream: Readable): Promise<ClaudeResult | undefined> {
|
||||||
|
let result: ClaudeResult | undefined;
|
||||||
|
for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) {
|
||||||
|
if (line !== "") {
|
||||||
|
const event: unknown = JSON.parse(line);
|
||||||
|
if (isObject(event)) {
|
||||||
|
printParts(event);
|
||||||
|
if (event.type === "result") {
|
||||||
|
result = toResult(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkResult(result: ClaudeResult | undefined, error: string): string {
|
||||||
|
if (result?.is_error === true) {
|
||||||
|
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
|
||||||
|
if (isClaudeAuthFailure(result)) {
|
||||||
|
throw new AgentAuthError("claude sign-in failed", { cause: failure });
|
||||||
|
}
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
if (result?.result === undefined) {
|
||||||
|
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
|
||||||
|
}
|
||||||
|
return result.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runClaude(
|
||||||
|
profile: RuntimeProfile,
|
||||||
|
token: string,
|
||||||
|
prompt: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const args = [
|
||||||
|
"--print",
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"--model",
|
||||||
|
profile.model,
|
||||||
|
"--output-format",
|
||||||
|
"stream-json",
|
||||||
|
"--verbose",
|
||||||
|
prompt,
|
||||||
|
];
|
||||||
|
const claude = agentCommand(profile, "claude", args, {
|
||||||
|
cwd: WORKSPACE,
|
||||||
|
env: { ...agentEnvironment(PROCESS_ENV, profile, token), IS_SANDBOX: "1" },
|
||||||
|
clearEnv: true,
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
}).spawn();
|
||||||
|
const errorText = copyError(claude.stderr);
|
||||||
|
const result = await readEvents(claude.stdout);
|
||||||
|
const [status, error] = await Promise.all([claude.status, errorText]);
|
||||||
|
const body = checkResult(result, error);
|
||||||
|
if (!status.success) {
|
||||||
|
throw new Error(error.trim() || `claude exited with ${status.code}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Signs Codex in for one profile, runs it, and returns its result file path.
|
||||||
|
import process from "node:process";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
import { text } from "node:stream/consumers";
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises";
|
||||||
|
|
||||||
|
import { isCodexAuthFailure, retryInvalidToken } from "./auth.ts";
|
||||||
|
import type { CodexAuthentication } from "./auth.ts";
|
||||||
|
import { chunks, endWith, writeChunk } from "./command.ts";
|
||||||
|
import type { CommandStatus } from "./command.ts";
|
||||||
|
import { agentCommand } from "./isolation.ts";
|
||||||
|
import { AgentAuthError, agentEnvironment, withProfile } from "./profiles.ts";
|
||||||
|
import {
|
||||||
|
makeProfileFile,
|
||||||
|
postAuthorComment,
|
||||||
|
PROCESS_ENV,
|
||||||
|
runAgentCommand,
|
||||||
|
WORKSPACE,
|
||||||
|
} from "./run-context.ts";
|
||||||
|
import type { RuntimeProfile } from "./runtime.ts";
|
||||||
|
|
||||||
|
interface CodexAttempt {
|
||||||
|
status: CommandStatus;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectText(stream: Readable, append: (chunk: string) => void): Promise<void> {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
for await (const chunk of chunks(stream)) {
|
||||||
|
append(decoder.decode(chunk, { stream: true }));
|
||||||
|
}
|
||||||
|
append(decoder.decode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polls every 100 ms until the login prints something, failing if it exits first.
|
||||||
|
async function pollForInstructions(
|
||||||
|
shownText: () => string,
|
||||||
|
status: Promise<CommandStatus>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (shownText().trim() !== "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exited = await Promise.race([status.then(() => true), sleep(100, false)]);
|
||||||
|
if (exited) {
|
||||||
|
throw new AgentAuthError("AI bot login produced no instructions");
|
||||||
|
}
|
||||||
|
await pollForInstructions(shownText, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function codexDeviceLogin(
|
||||||
|
profile: RuntimeProfile,
|
||||||
|
environment: Record<string, string>,
|
||||||
|
): Promise<void> {
|
||||||
|
const login = agentCommand(profile, "codex", ["login", "--device-auth"], {
|
||||||
|
cwd: WORKSPACE,
|
||||||
|
env: environment,
|
||||||
|
clearEnv: true,
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
}).spawn();
|
||||||
|
let shown = "";
|
||||||
|
const append = (chunk: string): void => {
|
||||||
|
shown += chunk;
|
||||||
|
};
|
||||||
|
const drained = Promise.all([
|
||||||
|
collectText(login.stdout, append),
|
||||||
|
collectText(login.stderr, append),
|
||||||
|
]);
|
||||||
|
await sleep(3000);
|
||||||
|
await pollForInstructions(() => shown, login.status);
|
||||||
|
await postAuthorComment(withProfile(shown, profile));
|
||||||
|
const [result] = await Promise.all([login.status, drained]);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new AgentAuthError(`codex login exited with ${result.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function codexApiLogin(
|
||||||
|
profile: RuntimeProfile,
|
||||||
|
environment: Record<string, string>,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const login = agentCommand(profile, "codex", ["login", "--with-api-key"], {
|
||||||
|
cwd: WORKSPACE,
|
||||||
|
env: environment,
|
||||||
|
clearEnv: true,
|
||||||
|
stdin: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
}).spawn();
|
||||||
|
await endWith(login.stdin, `${token}\n`);
|
||||||
|
const [status, error] = await Promise.all([login.status, text(login.stderr)]);
|
||||||
|
if (!status.success) {
|
||||||
|
throw new Error(error.trim() || `codex login exited with ${status.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(
|
||||||
|
profile: RuntimeProfile,
|
||||||
|
environment: Record<string, string>,
|
||||||
|
authentication: CodexAuthentication,
|
||||||
|
): Promise<void> {
|
||||||
|
if (authentication.kind === "api-key") {
|
||||||
|
await codexApiLogin(profile, environment, authentication.token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loggedIn = await agentCommand(profile, "codex", ["login", "status"], {
|
||||||
|
cwd: WORKSPACE,
|
||||||
|
env: environment,
|
||||||
|
clearEnv: true,
|
||||||
|
}).output();
|
||||||
|
if (!loggedIn.success) {
|
||||||
|
await codexDeviceLogin(profile, environment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execCodex(
|
||||||
|
profile: RuntimeProfile,
|
||||||
|
environment: Record<string, string>,
|
||||||
|
prompt: string,
|
||||||
|
outputPath: string,
|
||||||
|
): Promise<CodexAttempt> {
|
||||||
|
const args = [
|
||||||
|
"exec",
|
||||||
|
"--model",
|
||||||
|
profile.model,
|
||||||
|
"--dangerously-bypass-approvals-and-sandbox",
|
||||||
|
"--output-last-message",
|
||||||
|
outputPath,
|
||||||
|
prompt,
|
||||||
|
];
|
||||||
|
const codex = agentCommand(profile, "codex", args, {
|
||||||
|
cwd: WORKSPACE,
|
||||||
|
env: environment,
|
||||||
|
clearEnv: true,
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "piped",
|
||||||
|
}).spawn();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let error = "";
|
||||||
|
for await (const chunk of chunks(codex.stderr)) {
|
||||||
|
await writeChunk(process.stderr, chunk);
|
||||||
|
error += decoder.decode(chunk, { stream: true });
|
||||||
|
}
|
||||||
|
error += decoder.decode();
|
||||||
|
// Codex echoes the prompt, whose comments may quote auth errors.
|
||||||
|
return { status: await codex.status, error: error.replaceAll(prompt, "") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCodex(
|
||||||
|
profile: RuntimeProfile,
|
||||||
|
prompt: string,
|
||||||
|
authentication: CodexAuthentication,
|
||||||
|
): Promise<string> {
|
||||||
|
const token = authentication.kind === "api-key" ? authentication.token : undefined;
|
||||||
|
const environment = agentEnvironment(PROCESS_ENV, profile, token);
|
||||||
|
await signIn(profile, environment, authentication);
|
||||||
|
const outputPath = await makeProfileFile(profile, "result.md");
|
||||||
|
const attempt = (): Promise<CodexAttempt> => execCodex(profile, environment, prompt, outputPath);
|
||||||
|
const result =
|
||||||
|
authentication.kind === "api-key"
|
||||||
|
? await attempt()
|
||||||
|
: await retryInvalidToken(attempt, async () => {
|
||||||
|
await runAgentCommand(profile, "codex", ["logout"]);
|
||||||
|
await codexDeviceLogin(profile, environment);
|
||||||
|
});
|
||||||
|
if (!result.status.success) {
|
||||||
|
const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`);
|
||||||
|
if (isCodexAuthFailure(result.error)) {
|
||||||
|
throw new AgentAuthError("codex sign-in failed", { cause: failure });
|
||||||
|
}
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Runs OpenCode for one profile and returns its final visible text.
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
import { chunks, writeChunk } from "./command.ts";
|
||||||
|
import { agentCommand } from "./isolation.ts";
|
||||||
|
import {
|
||||||
|
collectOpenCodeOutput,
|
||||||
|
openCodeArgs,
|
||||||
|
openCodeError,
|
||||||
|
parseOpenCodeEvents,
|
||||||
|
} from "./opencode.ts";
|
||||||
|
import { agentEnvironment } from "./profiles.ts";
|
||||||
|
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
|
||||||
|
import type { RuntimeProfile } from "./runtime.ts";
|
||||||
|
|
||||||
|
export async function runOpenCode(
|
||||||
|
profile: RuntimeProfile,
|
||||||
|
token: string,
|
||||||
|
prompt: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const child = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), {
|
||||||
|
cwd: WORKSPACE,
|
||||||
|
env: agentEnvironment(PROCESS_ENV, profile, token),
|
||||||
|
clearEnv: true,
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
}).spawn();
|
||||||
|
const collected = collectOpenCodeOutput(
|
||||||
|
chunks(child.stdout),
|
||||||
|
chunks(child.stderr),
|
||||||
|
async (chunk) => {
|
||||||
|
await writeChunk(process.stdout, chunk);
|
||||||
|
},
|
||||||
|
async (chunk) => {
|
||||||
|
await writeChunk(process.stderr, chunk);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [status, streams] = await Promise.all([child.status, collected]);
|
||||||
|
const error = streams.stderr.trim();
|
||||||
|
if (!status.success) {
|
||||||
|
throw (
|
||||||
|
openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parseOpenCodeEvents(streams.stdout);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Dispatches one attempt to the agent CLI its profile names.
|
||||||
|
import { runClaude } from "./agent-claude.ts";
|
||||||
|
import { runCodex } from "./agent-codex.ts";
|
||||||
|
import { runOpenCode } from "./agent-opencode.ts";
|
||||||
|
import { codexAuthentication } from "./auth.ts";
|
||||||
|
import type { CodexAuthentication } from "./auth.ts";
|
||||||
|
import type { RuntimeProfile } from "./runtime.ts";
|
||||||
|
|
||||||
|
export interface AgentAnswer {
|
||||||
|
body?: string;
|
||||||
|
resultPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs the selected agent; Codex leaves its answer in a file, the others return it.
|
||||||
|
export async function runAgent(
|
||||||
|
profile: RuntimeProfile,
|
||||||
|
token: string | undefined,
|
||||||
|
prompt: string,
|
||||||
|
authentication: CodexAuthentication | null,
|
||||||
|
): Promise<AgentAnswer> {
|
||||||
|
if (profile.type === "claude") {
|
||||||
|
return { body: await runClaude(profile, token ?? "", prompt) };
|
||||||
|
}
|
||||||
|
if (profile.type === "codex") {
|
||||||
|
return {
|
||||||
|
resultPath: await runCodex(profile, prompt, authentication ?? codexAuthentication(token)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { body: await runOpenCode(profile, token ?? "", prompt) };
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
import { expect, test } from "vite-plus/test";
|
||||||
|
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
codexAuthentication,
|
codexAuthentication,
|
||||||
isClaudeAuthFailure,
|
isClaudeAuthFailure,
|
||||||
@@ -7,8 +9,9 @@ import {
|
|||||||
retryInvalidToken,
|
retryInvalidToken,
|
||||||
stageCodexAuth,
|
stageCodexAuth,
|
||||||
} from "./auth.ts";
|
} from "./auth.ts";
|
||||||
|
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
|
||||||
|
|
||||||
Deno.test("retries once after an invalid refresh token", async () => {
|
test("retries once after an invalid refresh token", async () => {
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
let relogins = 0;
|
let relogins = 0;
|
||||||
const result = await retryInvalidToken(
|
const result = await retryInvalidToken(
|
||||||
@@ -23,17 +26,14 @@ Deno.test("retries once after an invalid refresh token", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(
|
expect({ attempts, relogins, error: result.error }).toStrictEqual({
|
||||||
{ attempts, relogins, error: result.error },
|
attempts: 2,
|
||||||
{
|
relogins: 1,
|
||||||
attempts: 2,
|
error: "still failed",
|
||||||
relogins: 1,
|
});
|
||||||
error: "still failed",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("does not retry an unrelated failure", async () => {
|
test("does not retry an unrelated failure", async () => {
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
let relogins = 0;
|
let relogins = 0;
|
||||||
await retryInvalidToken(
|
await retryInvalidToken(
|
||||||
@@ -50,7 +50,7 @@ Deno.test("does not retry an unrelated failure", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals({ attempts, relogins }, { attempts: 1, relogins: 0 });
|
expect({ attempts, relogins }).toStrictEqual({ attempts: 1, relogins: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verbatim stderr lines from the revoked-login job on lead/memona-home#36.
|
// Verbatim stderr lines from the revoked-login job on lead/memona-home#36.
|
||||||
@@ -61,27 +61,26 @@ const REVOKED_CODEX = [
|
|||||||
"ERROR: workspace routing discovery unauthorized (401)",
|
"ERROR: workspace routing discovery unauthorized (401)",
|
||||||
];
|
];
|
||||||
|
|
||||||
Deno.test("recognizes each Codex sign-in failure line", () => {
|
test("recognizes each Codex sign-in failure line", () => {
|
||||||
for (const line of [
|
for (const line of [
|
||||||
...REVOKED_CODEX,
|
...REVOKED_CODEX,
|
||||||
`2026-10-12T02:28:05Z ERROR codex_login::auth::manager: Failed to refresh token status=401 Unauthorized detail=TokenErrorDetail { error_code: Some("refresh_token_expired"), .. }`,
|
`2026-10-12T02:28:05Z ERROR codex_login::auth::manager: Failed to refresh token status=401 Unauthorized detail=TokenErrorDetail { error_code: Some("refresh_token_expired"), .. }`,
|
||||||
`ERROR: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again. (refresh_token_reused)`,
|
`ERROR: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again. (refresh_token_reused)`,
|
||||||
"ERROR: unexpected status 401 Unauthorized: Incorrect API key provided: sk-inval*d123., auth error: 401, auth error code: invalid_api_key",
|
"ERROR: unexpected status 401 Unauthorized: Incorrect API key provided: sk-inval*d123., auth error: 401, auth error code: invalid_api_key",
|
||||||
]) {
|
]) {
|
||||||
assertEquals(isCodexAuthFailure(`exec\n${line}\n`), true, line);
|
expect(isCodexAuthFailure(`exec\n${line}\n`), line).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("ignores auth words outside Codex error lines", () => {
|
test("ignores auth words outside Codex error lines", () => {
|
||||||
assertEquals(
|
expect(
|
||||||
isCodexAuthFailure(
|
isCodexAuthFailure(
|
||||||
"exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\n" + "ERROR: tests failed\n",
|
"exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\nERROR: tests failed\n",
|
||||||
),
|
),
|
||||||
false,
|
).toBe(false);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("re-logs in after the job 9837 revoked-session stderr", async () => {
|
test("re-logs in after the job 9837 revoked-session stderr", async () => {
|
||||||
let relogins = 0;
|
let relogins = 0;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const result = await retryInvalidToken(
|
const result = await retryInvalidToken(
|
||||||
@@ -96,91 +95,84 @@ Deno.test("re-logs in after the job 9837 revoked-session stderr", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(
|
expect({ relogins, success: result.status.success }).toStrictEqual({
|
||||||
{ relogins, success: result.status.success },
|
relogins: 1,
|
||||||
{
|
success: true,
|
||||||
relogins: 1,
|
});
|
||||||
success: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("recognizes a rejected Claude OAuth token", () => {
|
test("recognizes a rejected Claude OAuth token", () => {
|
||||||
assertEquals(
|
expect(
|
||||||
isClaudeAuthFailure({
|
isClaudeAuthFailure({
|
||||||
api_error_status: 401,
|
api_error_status: 401,
|
||||||
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.",
|
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.",
|
||||||
}),
|
}),
|
||||||
true,
|
).toBe(true);
|
||||||
);
|
expect(
|
||||||
assertEquals(
|
|
||||||
isClaudeAuthFailure({
|
isClaudeAuthFailure({
|
||||||
result: "OAuth token has expired. Please obtain a new token or refresh your existing token.",
|
result: "OAuth token has expired. Please obtain a new token or refresh your existing token.",
|
||||||
}),
|
}),
|
||||||
true,
|
).toBe(true);
|
||||||
);
|
expect(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" })).toBe(false);
|
||||||
assertEquals(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" }), false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("selects API-key login only for a configured Codex token", () => {
|
test("selects API-key login only for a configured Codex token", () => {
|
||||||
assertEquals(codexAuthentication("selected-key"), {
|
expect(codexAuthentication("selected-key")).toStrictEqual({
|
||||||
kind: "api-key",
|
kind: "api-key",
|
||||||
token: "selected-key",
|
token: "selected-key",
|
||||||
});
|
});
|
||||||
assertEquals(codexAuthentication(undefined), { kind: "persistent" });
|
expect(codexAuthentication()).toStrictEqual({ kind: "persistent" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const AUTH = JSON.stringify({
|
const AUTH_TOKENS = {
|
||||||
auth_mode: "chatgpt",
|
access_token: "access",
|
||||||
tokens: {
|
refresh_token: "refresh",
|
||||||
access_token: "access",
|
id_token: "id",
|
||||||
refresh_token: "refresh",
|
account_id: "account",
|
||||||
id_token: "id",
|
};
|
||||||
account_id: "account",
|
const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS });
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("stages and persists only a regular private Codex login", async () => {
|
test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login", async () => {
|
||||||
if (Deno.build.os === "windows") return;
|
const root = await makeTempDir();
|
||||||
const root = await Deno.makeTempDir();
|
|
||||||
try {
|
try {
|
||||||
const canonical = `${root}/persistent/auth.json`;
|
const canonical = `${root}/persistent/auth.json`;
|
||||||
await Deno.mkdir(`${root}/persistent`);
|
await mkdir(`${root}/persistent`);
|
||||||
await Deno.writeTextFile(canonical, AUTH, { mode: 0o600 });
|
await writeFile(canonical, AUTH, { mode: 0o600 });
|
||||||
const home = `${root}/profile`;
|
const home = `${root}/profile`;
|
||||||
await Deno.mkdir(home);
|
await mkdir(home);
|
||||||
|
|
||||||
const staged = await stageCodexAuth(canonical, home, Deno.uid()!, Deno.gid()!);
|
const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid());
|
||||||
assertEquals(await Deno.readTextFile(staged), AUTH);
|
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH);
|
||||||
assertEquals((await Deno.stat(staged)).mode! & 0o777, 0o600);
|
expect((await stat(staged)).mode & 0o777).toBe(0o600);
|
||||||
|
|
||||||
const refreshed = JSON.stringify({
|
const refreshed = JSON.stringify({
|
||||||
...JSON.parse(AUTH),
|
auth_mode: "chatgpt",
|
||||||
tokens: { ...JSON.parse(AUTH).tokens, access_token: "refreshed" },
|
tokens: { ...AUTH_TOKENS, access_token: "refreshed" },
|
||||||
});
|
});
|
||||||
await Deno.writeTextFile(staged, refreshed);
|
await writeFile(staged, refreshed);
|
||||||
await persistCodexAuth(staged, canonical);
|
await persistCodexAuth(staged, canonical);
|
||||||
assertEquals(await Deno.readTextFile(canonical), refreshed);
|
expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed);
|
||||||
assertEquals((await Deno.stat(canonical)).mode! & 0o777, 0o600);
|
expect((await stat(canonical)).mode & 0o777).toBe(0o600);
|
||||||
} finally {
|
} finally {
|
||||||
await Deno.remove(root, { recursive: true });
|
await rm(root, { recursive: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("rejects a symlink or malformed Codex login", async () => {
|
test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () => {
|
||||||
if (Deno.build.os === "windows") return;
|
const root = await makeTempDir();
|
||||||
const root = await Deno.makeTempDir();
|
|
||||||
try {
|
try {
|
||||||
const canonical = `${root}/auth.json`;
|
const canonical = `${root}/auth.json`;
|
||||||
const malformed = `${root}/malformed.json`;
|
const malformed = `${root}/malformed.json`;
|
||||||
const linked = `${root}/linked.json`;
|
const linked = `${root}/linked.json`;
|
||||||
await Deno.writeTextFile(canonical, AUTH, { mode: 0o600 });
|
await writeFile(canonical, AUTH, { mode: 0o600 });
|
||||||
await Deno.writeTextFile(malformed, '{"tokens":{}}', { mode: 0o600 });
|
await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 });
|
||||||
await Deno.symlink(canonical, linked);
|
await symlink(canonical, linked);
|
||||||
|
|
||||||
await assertRejects(() => persistCodexAuth(linked, canonical), Error, "regular file");
|
await expect(() => persistCodexAuth(linked, canonical)).rejects.toThrow("regular file");
|
||||||
await assertRejects(() => persistCodexAuth(malformed, canonical), Error, "invalid Codex login");
|
await expect(() => persistCodexAuth(malformed, canonical)).rejects.toThrow(
|
||||||
|
"invalid Codex login",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await Deno.remove(root, { recursive: true });
|
await rm(root, { recursive: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
+62
-36
@@ -1,4 +1,19 @@
|
|||||||
import { dirname, join } from "jsr:@std/path@1";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import {
|
||||||
|
chmod,
|
||||||
|
chown,
|
||||||
|
lstat,
|
||||||
|
mkdir,
|
||||||
|
open,
|
||||||
|
readFile,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
import { isNotFound } from "./errors.ts";
|
||||||
|
import { isObject } from "./json.ts";
|
||||||
|
|
||||||
export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" };
|
export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" };
|
||||||
|
|
||||||
@@ -9,7 +24,7 @@ export function codexAuthentication(token?: string): CodexAuthentication {
|
|||||||
// Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys
|
// Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys
|
||||||
// on its own ERROR lines; tool output and the echoed prompt must not count.
|
// on its own ERROR lines; tool output and the echoed prompt must not count.
|
||||||
const CODEX_AUTH_FAILURE =
|
const CODEX_AUTH_FAILURE =
|
||||||
/^(?:\S+\s+)?ERROR\b.*(?:\b401\b|unauthorized|invalid_refresh_token|refresh_token_(?:expired|reused|invalidated)|token_(?:revoked|expired)|invalid_api_key|(?:log|sign) in again)/im;
|
/^(?:\S+\s+)?ERROR\b.*(?:\b401\b|unauthorized|invalid_refresh_token|refresh_token_(?:expired|reused|invalidated)|token_(?:revoked|expired)|invalid_api_key|(?:log|sign) in again)/imu;
|
||||||
|
|
||||||
export function isCodexAuthFailure(error: string): boolean {
|
export function isCodexAuthFailure(error: string): boolean {
|
||||||
return CODEX_AUTH_FAILURE.test(error);
|
return CODEX_AUTH_FAILURE.test(error);
|
||||||
@@ -18,13 +33,16 @@ export function isCodexAuthFailure(error: string): boolean {
|
|||||||
// Claude Code reports a rejected CLAUDE_CODE_OAUTH_TOKEN as an is_error
|
// Claude Code reports a rejected CLAUDE_CODE_OAUTH_TOKEN as an is_error
|
||||||
// stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth
|
// stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth
|
||||||
// access token is invalid." with api_error_status 401.
|
// access token is invalid." with api_error_status 401.
|
||||||
export function isClaudeAuthFailure(result: {
|
// The fields of Claude Code's stream-json `result` event that tell a sign-in
|
||||||
api_error_status?: number | null;
|
// failure apart, under their own names.
|
||||||
|
export type ClaudeFailureFields = Partial<Record<"api_error_status", number | null>> & {
|
||||||
result?: string;
|
result?: string;
|
||||||
}): boolean {
|
};
|
||||||
|
|
||||||
|
export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
|
||||||
return (
|
return (
|
||||||
result.api_error_status === 401 ||
|
result.api_error_status === 401 ||
|
||||||
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/i.test(
|
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test(
|
||||||
result.result ?? "",
|
result.result ?? "",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -42,33 +60,35 @@ export async function retryInvalidToken<T extends { status: { success: boolean }
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasCodexTokens(tokens: unknown): boolean {
|
||||||
|
return (
|
||||||
|
isObject(tokens) &&
|
||||||
|
["access_token", "refresh_token", "id_token", "account_id"].every(
|
||||||
|
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function readCodexAuth(path: string): Promise<string> {
|
async function readCodexAuth(path: string): Promise<string> {
|
||||||
const stat = await Deno.lstat(path);
|
const stat = await lstat(path);
|
||||||
if (!stat.isFile || stat.isSymlink) {
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||||
throw new Error("Codex login must be a regular file");
|
throw new Error("Codex login must be a regular file");
|
||||||
}
|
}
|
||||||
if ((stat.mode! & 0o077) !== 0) {
|
if ((stat.mode & 0o077) !== 0) {
|
||||||
throw new Error("Codex login must be private");
|
throw new Error("Codex login must be private");
|
||||||
}
|
}
|
||||||
const text = await Deno.readTextFile(path);
|
const text = await readFile(path, "utf8");
|
||||||
let value: unknown;
|
let value: unknown;
|
||||||
try {
|
try {
|
||||||
value = JSON.parse(text);
|
value = JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("invalid Codex login");
|
throw new Error("invalid Codex login");
|
||||||
}
|
}
|
||||||
const record = value as Record<string, unknown> | null;
|
|
||||||
const tokens = record?.tokens as Record<string, unknown> | null;
|
|
||||||
if (
|
if (
|
||||||
record === null ||
|
!isObject(value) ||
|
||||||
typeof record !== "object" ||
|
typeof value.auth_mode !== "string" ||
|
||||||
typeof record.auth_mode !== "string" ||
|
value.auth_mode === "" ||
|
||||||
record.auth_mode === "" ||
|
!hasCodexTokens(value.tokens)
|
||||||
tokens === null ||
|
|
||||||
typeof tokens !== "object" ||
|
|
||||||
!["access_token", "refresh_token", "id_token", "account_id"].every(
|
|
||||||
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
throw new Error("invalid Codex login");
|
throw new Error("invalid Codex login");
|
||||||
}
|
}
|
||||||
@@ -82,33 +102,39 @@ export async function stageCodexAuth(
|
|||||||
gid: number,
|
gid: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const codexHome = join(profileHome, ".codex");
|
const codexHome = join(profileHome, ".codex");
|
||||||
await Deno.mkdir(codexHome, { recursive: true, mode: 0o700 });
|
await mkdir(codexHome, { recursive: true, mode: 0o700 });
|
||||||
await Deno.chmod(codexHome, 0o700);
|
await chmod(codexHome, 0o700);
|
||||||
await Deno.chown(codexHome, uid, gid);
|
await chown(codexHome, uid, gid);
|
||||||
const stagedPath = join(codexHome, "auth.json");
|
const stagedPath = join(codexHome, "auth.json");
|
||||||
try {
|
try {
|
||||||
await Deno.writeTextFile(stagedPath, await readCodexAuth(canonicalPath), {
|
await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 });
|
||||||
mode: 0o600,
|
await chmod(stagedPath, 0o600);
|
||||||
});
|
await chown(stagedPath, uid, gid);
|
||||||
await Deno.chmod(stagedPath, 0o600);
|
|
||||||
await Deno.chown(stagedPath, uid, gid);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
if (!isNotFound(error)) throw error;
|
||||||
}
|
}
|
||||||
return stagedPath;
|
return stagedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Creates an empty private file under a fresh name, as Deno.makeTempFile did.
|
||||||
|
async function makeTempFile(directory: string): Promise<string> {
|
||||||
|
const path = join(directory, `.tmp-${randomUUID()}`);
|
||||||
|
const handle = await open(path, "wx", 0o600);
|
||||||
|
await handle.close();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise<void> {
|
export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise<void> {
|
||||||
const text = await readCodexAuth(stagedPath);
|
const text = await readCodexAuth(stagedPath);
|
||||||
const directory = dirname(canonicalPath);
|
const directory = dirname(canonicalPath);
|
||||||
await Deno.mkdir(directory, { recursive: true, mode: 0o700 });
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
const temporary = await Deno.makeTempFile({ dir: directory });
|
const temporary = await makeTempFile(directory);
|
||||||
try {
|
try {
|
||||||
await Deno.writeTextFile(temporary, text, { mode: 0o600 });
|
await writeFile(temporary, text, { mode: 0o600 });
|
||||||
await Deno.chmod(temporary, 0o600);
|
await chmod(temporary, 0o600);
|
||||||
await Deno.rename(temporary, canonicalPath);
|
await rename(temporary, canonicalPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Deno.remove(temporary).catch(() => undefined);
|
await rm(temporary).catch(() => {});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
// A child process with the call shapes the runner was reviewed with: `output()`
|
||||||
|
// collects both streams, `spawn()` hands them out, and `clearEnv` passes only
|
||||||
|
// the given environment. Streams default as Deno's did: stdin is inherited,
|
||||||
|
// stdout and stderr are piped for `output()` and inherited for `spawn()`.
|
||||||
|
import { spawn as spawnProcess } from "node:child_process";
|
||||||
|
import type { ChildProcess, StdioOptions } from "node:child_process";
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import { constants } from "node:os";
|
||||||
|
import process from "node:process";
|
||||||
|
import type { Readable, Writable } from "node:stream";
|
||||||
|
import { buffer } from "node:stream/consumers";
|
||||||
|
import { finished } from "node:stream/promises";
|
||||||
|
|
||||||
|
export type StdioMode = "inherit" | "piped" | "null";
|
||||||
|
|
||||||
|
export interface CommandOptions {
|
||||||
|
args?: string[];
|
||||||
|
cwd?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
clearEnv?: boolean;
|
||||||
|
uid?: number;
|
||||||
|
gid?: number;
|
||||||
|
stdin?: StdioMode;
|
||||||
|
stdout?: StdioMode;
|
||||||
|
stderr?: StdioMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandStatus {
|
||||||
|
success: boolean;
|
||||||
|
code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandOutput extends CommandStatus {
|
||||||
|
stdout: Uint8Array;
|
||||||
|
stderr: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandChild {
|
||||||
|
status: Promise<CommandStatus>;
|
||||||
|
stdin: Writable;
|
||||||
|
stdout: Readable;
|
||||||
|
stderr: Readable;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STDIO: Record<StdioMode, "inherit" | "pipe" | "ignore"> = {
|
||||||
|
inherit: "inherit",
|
||||||
|
piped: "pipe",
|
||||||
|
null: "ignore",
|
||||||
|
};
|
||||||
|
|
||||||
|
function signalNumber(signal: NodeJS.Signals): number {
|
||||||
|
return constants.signals[signal];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProcess(
|
||||||
|
command: string,
|
||||||
|
options: CommandOptions,
|
||||||
|
outputDefault: StdioMode,
|
||||||
|
): ChildProcess {
|
||||||
|
const stdio: StdioOptions = [
|
||||||
|
STDIO[options.stdin ?? "inherit"],
|
||||||
|
STDIO[options.stdout ?? outputDefault],
|
||||||
|
STDIO[options.stderr ?? outputDefault],
|
||||||
|
];
|
||||||
|
const environment = options.clearEnv === true ? options.env : { ...process.env, ...options.env };
|
||||||
|
return spawnProcess(command, options.args ?? [], {
|
||||||
|
stdio,
|
||||||
|
...(options.cwd !== undefined && { cwd: options.cwd }),
|
||||||
|
...(environment !== undefined && { env: environment }),
|
||||||
|
...(options.uid !== undefined && { uid: options.uid }),
|
||||||
|
...(options.gid !== undefined && { gid: options.gid }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settles when the process exits, before its streams close, as Deno's did. A
|
||||||
|
// signal-killed process reports 128 plus the signal number, as a shell does.
|
||||||
|
function waitForStatus(child: ChildProcess): Promise<CommandStatus> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("exit", (code, signal) => {
|
||||||
|
const exitCode = code ?? (signal === null ? 1 : 128 + signalNumber(signal));
|
||||||
|
resolve({ success: exitCode === 0, code: exitCode });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function piped<T>(stream: T | null, name: string): T {
|
||||||
|
if (stream === null) {
|
||||||
|
throw new TypeError(`${name} is not piped`);
|
||||||
|
}
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(stream: Readable | null): Promise<Uint8Array> {
|
||||||
|
return stream === null ? Promise.resolve(new Uint8Array()) : buffer(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stream's chunks as bytes, whether or not it has an encoding set.
|
||||||
|
export async function* chunks(stream: Readable): AsyncGenerator<Uint8Array> {
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const value: unknown = chunk;
|
||||||
|
if (value instanceof Uint8Array) {
|
||||||
|
yield value;
|
||||||
|
} else if (typeof value === "string") {
|
||||||
|
yield Buffer.from(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawn(command: string, options: CommandOptions = {}): CommandChild {
|
||||||
|
const child = startProcess(command, options, "inherit");
|
||||||
|
const status = waitForStatus(child);
|
||||||
|
// A failed start rejects `status`; the caller sees it once it awaits that.
|
||||||
|
status.catch(() => {});
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
get stdin(): Writable {
|
||||||
|
return piped(child.stdin, "stdin");
|
||||||
|
},
|
||||||
|
get stdout(): Readable {
|
||||||
|
return piped(child.stdout, "stdout");
|
||||||
|
},
|
||||||
|
get stderr(): Readable {
|
||||||
|
return piped(child.stderr, "stderr");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function output(
|
||||||
|
command: string,
|
||||||
|
options: CommandOptions = {},
|
||||||
|
): Promise<CommandOutput> {
|
||||||
|
const child = startProcess(command, options, "piped");
|
||||||
|
const [status, stdout, stderr] = await Promise.all([
|
||||||
|
waitForStatus(child),
|
||||||
|
collect(child.stdout),
|
||||||
|
collect(child.stderr),
|
||||||
|
]);
|
||||||
|
return { ...status, stdout, stderr };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes one chunk and settles once the stream has taken it, as Deno's
|
||||||
|
// `Deno.stdout.write` did.
|
||||||
|
export async function writeChunk(stream: Writable, chunk: Uint8Array): Promise<void> {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stream.write(chunk, (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes the last chunk and settles once the stream has flushed and closed.
|
||||||
|
export async function endWith(stream: Writable, chunk: string): Promise<void> {
|
||||||
|
stream.end(chunk);
|
||||||
|
await finished(stream);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { expect, test } from "vite-plus/test";
|
||||||
|
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts";
|
||||||
|
|
||||||
|
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
||||||
|
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
|
||||||
|
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32);
|
||||||
|
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
||||||
|
|
||||||
|
test("decrypts a known AES-256-GCM credential", async () => {
|
||||||
|
expect(await decryptCredential(ENCRYPTED, MASTER_KEY)).toBe("secret-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("encrypts a credential with a fresh supplied nonce", async () => {
|
||||||
|
expect(await encryptCredential("secret-token", MASTER_KEY, NONCE)).toStrictEqual(ENCRYPTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a credential encrypted by another master key", async () => {
|
||||||
|
await expect(() => decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a credential with a nonstandard nonce", async () => {
|
||||||
|
await expect(() =>
|
||||||
|
decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY),
|
||||||
|
).rejects.toThrow("nonce must be 12 bytes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decrypts the central reviewer token and ordered profiles", async () => {
|
||||||
|
expect(
|
||||||
|
await parseCredentials(
|
||||||
|
JSON.stringify({
|
||||||
|
"reviewer-token": ENCRYPTED,
|
||||||
|
profiles: [
|
||||||
|
{ type: "codex", model: "gpt-custom" },
|
||||||
|
{ type: "claude", token: ENCRYPTED },
|
||||||
|
{ type: "opencode", token: ENCRYPTED },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
MASTER_KEY,
|
||||||
|
),
|
||||||
|
).toStrictEqual({
|
||||||
|
reviewerToken: "secret-token",
|
||||||
|
profiles: [
|
||||||
|
{ type: "codex", model: "gpt-custom" },
|
||||||
|
{
|
||||||
|
type: "claude",
|
||||||
|
model: "claude-sonnet-5",
|
||||||
|
token: "secret-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "opencode",
|
||||||
|
model: "openrouter/qwen/qwen3.8-27b",
|
||||||
|
token: "secret-token",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects malformed central credentials", async () => {
|
||||||
|
await expect(() => parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow(
|
||||||
|
"reviewer-token",
|
||||||
|
);
|
||||||
|
await expect(() =>
|
||||||
|
parseCredentials(
|
||||||
|
JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }),
|
||||||
|
MASTER_KEY,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("profiles[0].token");
|
||||||
|
});
|
||||||
+58
-58
@@ -1,4 +1,6 @@
|
|||||||
import { parse } from "jsr:@std/yaml@1";
|
import { webcrypto } from "node:crypto";
|
||||||
|
|
||||||
|
import { isMapping } from "./json.ts";
|
||||||
import type { BotProfile } from "./profiles.ts";
|
import type { BotProfile } from "./profiles.ts";
|
||||||
|
|
||||||
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
|
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
|
||||||
@@ -7,40 +9,40 @@ const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
|
|||||||
opencode: "openrouter/qwen/qwen3.8-27b",
|
opencode: "openrouter/qwen/qwen3.8-27b",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BotCredentials = {
|
export interface BotCredentials {
|
||||||
reviewerToken: string;
|
reviewerToken: string;
|
||||||
profiles: BotProfile[];
|
profiles: BotProfile[];
|
||||||
};
|
}
|
||||||
|
|
||||||
function decodeBase64Url(value: string): Uint8Array {
|
function decodeBase64Url(value: string): Uint8Array {
|
||||||
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
|
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
|
||||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
||||||
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
return Uint8Array.from(atob(padded), (character) => character.codePointAt(0) ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeBase64Url(value: Uint8Array): string {
|
function encodeBase64Url(value: Uint8Array): string {
|
||||||
const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join("");
|
const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join("");
|
||||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer;
|
const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer;
|
||||||
|
|
||||||
async function importMasterKey(encoded: string, usage: KeyUsage): Promise<CryptoKey> {
|
function importMasterKey(encoded: string, usage: webcrypto.KeyUsage): Promise<webcrypto.CryptoKey> {
|
||||||
const bytes = decodeBase64Url(encoded);
|
const bytes = decodeBase64Url(encoded);
|
||||||
if (bytes.length !== 32) {
|
if (bytes.length !== 32) {
|
||||||
throw new Error("master-key must be 32 bytes encoded as base64url");
|
throw new Error("master-key must be 32 bytes encoded as base64url");
|
||||||
}
|
}
|
||||||
return await crypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]);
|
return webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptCredential(encrypted: string, masterKey: string): Promise<string> {
|
export async function decryptCredential(encrypted: string, masterKey: string): Promise<string> {
|
||||||
const [version, encodedNonce, encodedCiphertext, extra] = encrypted.split(":");
|
const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = encrypted.split(":");
|
||||||
if (version !== "v1" || !encodedNonce || !encodedCiphertext || extra) {
|
if (version !== "v1" || encodedNonce === "" || encodedCiphertext === "" || extra !== "") {
|
||||||
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 crypto.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"),
|
||||||
buffer(decodeBase64Url(encodedCiphertext)),
|
buffer(decodeBase64Url(encodedCiphertext)),
|
||||||
@@ -51,10 +53,10 @@ export async function decryptCredential(encrypted: string, masterKey: string): P
|
|||||||
export async function encryptCredential(
|
export async function encryptCredential(
|
||||||
plaintext: string,
|
plaintext: string,
|
||||||
masterKey: string,
|
masterKey: string,
|
||||||
nonce: Uint8Array = crypto.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 crypto.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"),
|
||||||
new TextEncoder().encode(plaintext),
|
new TextEncoder().encode(plaintext),
|
||||||
@@ -62,59 +64,57 @@ export async function encryptCredential(
|
|||||||
return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`;
|
return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> {
|
async function parseProfile(entry: unknown, index: number, masterKey: string): Promise<BotProfile> {
|
||||||
const value: unknown = parse(text);
|
const at = `profiles[${index}]`;
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
if (!isMapping(entry)) {
|
||||||
throw new Error("credentials must be a mapping");
|
throw new TypeError(`${at}: expected a mapping`);
|
||||||
}
|
}
|
||||||
const record = value as Record<string, unknown>;
|
for (const key of Object.keys(entry)) {
|
||||||
for (const key of Object.keys(record)) {
|
if (key !== "type" && key !== "model" && key !== "token") {
|
||||||
|
throw new Error(`${at}.${key}: unknown field`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { type, model, token } = entry;
|
||||||
|
if (type !== "claude" && type !== "codex" && type !== "opencode") {
|
||||||
|
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`);
|
||||||
|
}
|
||||||
|
if (model !== undefined && (typeof model !== "string" || model === "")) {
|
||||||
|
throw new Error(`${at}.model: expected a model name`);
|
||||||
|
}
|
||||||
|
if ((type === "claude" || type === "opencode") && typeof token !== "string") {
|
||||||
|
throw new Error(`${at}.token: expected an encrypted credential`);
|
||||||
|
}
|
||||||
|
if (token !== undefined && typeof token !== "string") {
|
||||||
|
throw new Error(`${at}.token: expected an encrypted credential`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
model: model ?? DEFAULT_MODELS[type],
|
||||||
|
...(typeof token === "string" && {
|
||||||
|
token: await decryptCredential(token, masterKey),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> {
|
||||||
|
const value: unknown = JSON.parse(text);
|
||||||
|
if (!isMapping(value)) {
|
||||||
|
throw new TypeError("credentials must be a mapping");
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
if (key !== "reviewer-token" && key !== "profiles") {
|
if (key !== "reviewer-token" && key !== "profiles") {
|
||||||
throw new Error(`${key}: unknown field`);
|
throw new Error(`${key}: unknown field`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof record["reviewer-token"] !== "string") {
|
const { "reviewer-token": reviewerToken, profiles } = value;
|
||||||
throw new Error("reviewer-token: expected an encrypted credential");
|
if (typeof reviewerToken !== "string") {
|
||||||
|
throw new TypeError("reviewer-token: expected an encrypted credential");
|
||||||
}
|
}
|
||||||
if (!Array.isArray(record.profiles) || record.profiles.length === 0) {
|
if (!Array.isArray(profiles) || profiles.length === 0) {
|
||||||
throw new Error("profiles must contain at least one profile");
|
throw new Error("profiles must contain at least one profile");
|
||||||
}
|
}
|
||||||
const profiles = await Promise.all(
|
const parsed = await Promise.all(
|
||||||
record.profiles.map(async (entry: unknown, index): Promise<BotProfile> => {
|
profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)),
|
||||||
const at = `profiles[${index}]`;
|
|
||||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
||||||
throw new Error(`${at}: expected a mapping`);
|
|
||||||
}
|
|
||||||
const profile = entry as Record<string, unknown>;
|
|
||||||
for (const key of Object.keys(profile)) {
|
|
||||||
if (key !== "type" && key !== "model" && key !== "token") {
|
|
||||||
throw new Error(`${at}.${key}: unknown field`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const { type, model, token } = profile;
|
|
||||||
if (type !== "claude" && type !== "codex" && type !== "opencode") {
|
|
||||||
throw new Error(`${at}.type: expected \"claude\", \"codex\", or \"opencode\"`);
|
|
||||||
}
|
|
||||||
if (model !== undefined && (typeof model !== "string" || model === "")) {
|
|
||||||
throw new Error(`${at}.model: expected a model name`);
|
|
||||||
}
|
|
||||||
if ((type === "claude" || type === "opencode") && typeof token !== "string") {
|
|
||||||
throw new Error(`${at}.token: expected an encrypted credential`);
|
|
||||||
}
|
|
||||||
if (token !== undefined && typeof token !== "string") {
|
|
||||||
throw new Error(`${at}.token: expected an encrypted credential`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
model: model ?? DEFAULT_MODELS[type],
|
|
||||||
...(typeof token === "string" && {
|
|
||||||
token: await decryptCredential(token, masterKey),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
return {
|
return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed };
|
||||||
reviewerToken: await decryptCredential(record["reviewer-token"], masterKey),
|
|
||||||
profiles,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
|
||||||
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts";
|
|
||||||
|
|
||||||
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
|
||||||
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
|
|
||||||
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32);
|
|
||||||
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
|
||||||
|
|
||||||
Deno.test("decrypts a known AES-256-GCM credential", async () => {
|
|
||||||
assertEquals(await decryptCredential(ENCRYPTED, MASTER_KEY), "secret-token");
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("encrypts a credential with a fresh supplied nonce", async () => {
|
|
||||||
assertEquals(await encryptCredential("secret-token", MASTER_KEY, NONCE), ENCRYPTED);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("rejects a credential encrypted by another master key", async () => {
|
|
||||||
await assertRejects(() => decryptCredential(ENCRYPTED, OTHER_KEY));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("rejects a credential with a nonstandard nonce", async () => {
|
|
||||||
await assertRejects(
|
|
||||||
() => decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY),
|
|
||||||
Error,
|
|
||||||
"nonce must be 12 bytes",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("decrypts the central reviewer token and ordered profiles", async () => {
|
|
||||||
assertEquals(
|
|
||||||
await parseCredentials(
|
|
||||||
`
|
|
||||||
reviewer-token: ${ENCRYPTED}
|
|
||||||
profiles:
|
|
||||||
- type: codex
|
|
||||||
model: gpt-custom
|
|
||||||
- type: claude
|
|
||||||
token: ${ENCRYPTED}
|
|
||||||
- type: opencode
|
|
||||||
token: ${ENCRYPTED}
|
|
||||||
`,
|
|
||||||
MASTER_KEY,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
reviewerToken: "secret-token",
|
|
||||||
profiles: [
|
|
||||||
{ type: "codex", model: "gpt-custom" },
|
|
||||||
{
|
|
||||||
type: "claude",
|
|
||||||
model: "claude-sonnet-5",
|
|
||||||
token: "secret-token",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "opencode",
|
|
||||||
model: "openrouter/qwen/qwen3.8-27b",
|
|
||||||
token: "secret-token",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("rejects malformed central credentials", async () => {
|
|
||||||
await assertRejects(() => parseCredentials("profiles: []", MASTER_KEY), Error, "reviewer-token");
|
|
||||||
await assertRejects(
|
|
||||||
() => parseCredentials(`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude`, MASTER_KEY),
|
|
||||||
Error,
|
|
||||||
"profiles[0].token",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Node reports a missing file as ENOENT and a missing process as ESRCH, where
|
||||||
|
// Deno threw `Deno.errors.NotFound` for both.
|
||||||
|
export function isNotFound(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error) || !("code" in error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return error.code === "ENOENT" || error.code === "ESRCH";
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { expect, test } from "vite-plus/test";
|
||||||
|
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
import { isNotFound } from "./errors.ts";
|
||||||
|
import {
|
||||||
|
agentCommand,
|
||||||
|
isRunnableProfileProcess,
|
||||||
|
killProfileProcesses,
|
||||||
|
readProfileFile,
|
||||||
|
} from "./isolation.ts";
|
||||||
|
import type { RuntimeProfile } from "./runtime.ts";
|
||||||
|
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "./testing.ts";
|
||||||
|
|
||||||
|
const profile = (uid: number, home: string): RuntimeProfile => ({
|
||||||
|
index: uid - 20_000,
|
||||||
|
type: "claude",
|
||||||
|
model: "test",
|
||||||
|
uid,
|
||||||
|
gid: uid,
|
||||||
|
home,
|
||||||
|
tokenFile: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zombie process entries are already quiescent", () => {
|
||||||
|
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
|
||||||
|
expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", async () => {
|
||||||
|
const root = await makeTempDir();
|
||||||
|
try {
|
||||||
|
const secret = `${root}/reviewer-token`;
|
||||||
|
const result = `${root}/result.md`;
|
||||||
|
await writeFile(secret, "reviewer-secret", { mode: 0o600 });
|
||||||
|
await symlink(secret, result);
|
||||||
|
|
||||||
|
await expect(() => readProfileFile(profile(currentUid(), root), result)).rejects.toThrow(
|
||||||
|
"regular private file",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skipIf(!IS_ROOT)("a profile process can read only its own private files", async () => {
|
||||||
|
const root = await makeTempDir();
|
||||||
|
await chmod(root, 0o711);
|
||||||
|
try {
|
||||||
|
const homeA = `${root}/a`;
|
||||||
|
const homeB = `${root}/b`;
|
||||||
|
await mkdir(homeA, { mode: 0o700 });
|
||||||
|
await mkdir(homeB, { mode: 0o700 });
|
||||||
|
await chown(homeA, 20_000, 20_000);
|
||||||
|
await chown(homeB, 20_001, 20_001);
|
||||||
|
await writeFile(`${homeA}/token`, "own", { mode: 0o600 });
|
||||||
|
await chown(`${homeA}/token`, 20_000, 20_000);
|
||||||
|
await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 });
|
||||||
|
await chown(`${homeB}/token`, 20_001, 20_001);
|
||||||
|
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
|
||||||
|
|
||||||
|
const selected = profile(20_000, homeA);
|
||||||
|
const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], {
|
||||||
|
cwd: "/",
|
||||||
|
}).output();
|
||||||
|
const sibling = await agentCommand(selected, "/bin/cat", [`${homeB}/token`], {
|
||||||
|
cwd: "/",
|
||||||
|
}).output();
|
||||||
|
const reviewer = await agentCommand(selected, "/bin/cat", [`${root}/reviewer`], {
|
||||||
|
cwd: "/",
|
||||||
|
}).output();
|
||||||
|
|
||||||
|
expect(new TextDecoder().decode(own.stdout)).toBe("own");
|
||||||
|
expect(own.success).toBe(true);
|
||||||
|
expect(sibling.success).toBe(false);
|
||||||
|
expect(reviewer.success).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => {
|
||||||
|
const selected = profile(20_010, "/tmp");
|
||||||
|
const launched = await agentCommand(
|
||||||
|
selected,
|
||||||
|
"/bin/sh",
|
||||||
|
["-c", "sleep 60 >/dev/null 2>&1 & echo $!"],
|
||||||
|
{ cwd: "/" },
|
||||||
|
).output();
|
||||||
|
const pid = Number(new TextDecoder().decode(launched.stdout).trim());
|
||||||
|
expect(Number.isInteger(pid)).toBe(true);
|
||||||
|
|
||||||
|
await killProfileProcesses(selected.uid);
|
||||||
|
|
||||||
|
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
|
||||||
|
if (isNotFound(error)) return null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", async () => {
|
||||||
|
const selected = profile(20_020, "/tmp");
|
||||||
|
const result = await agentCommand(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
|
||||||
|
cwd: "/",
|
||||||
|
env: { PARENT_SECRET: "must-not-cross-boundary" },
|
||||||
|
clearEnv: true,
|
||||||
|
stderr: "piped",
|
||||||
|
}).output();
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
+66
-34
@@ -1,57 +1,89 @@
|
|||||||
|
import { lstat, readdir, readFile } from "node:fs/promises";
|
||||||
|
import process from "node:process";
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises";
|
||||||
|
|
||||||
|
import { output, spawn } from "./command.ts";
|
||||||
|
import type { CommandChild, CommandOptions, CommandOutput } from "./command.ts";
|
||||||
|
import { isNotFound } from "./errors.ts";
|
||||||
import type { RuntimeProfile } from "./runtime.ts";
|
import type { RuntimeProfile } from "./runtime.ts";
|
||||||
|
|
||||||
|
export type AgentCommandOptions = Omit<CommandOptions, "args" | "uid" | "gid">;
|
||||||
|
|
||||||
|
export interface AgentCommand {
|
||||||
|
output(): Promise<CommandOutput>;
|
||||||
|
spawn(): CommandChild;
|
||||||
|
}
|
||||||
|
|
||||||
export function agentCommand(
|
export function agentCommand(
|
||||||
profile: RuntimeProfile,
|
profile: RuntimeProfile,
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
options: Omit<Deno.CommandOptions, "args" | "uid" | "gid"> = {},
|
options: AgentCommandOptions = {},
|
||||||
): Deno.Command {
|
): AgentCommand {
|
||||||
return new Deno.Command(command, {
|
const full: CommandOptions = { ...options, args, uid: profile.uid, gid: profile.gid };
|
||||||
...options,
|
return {
|
||||||
args,
|
output: () => output(command, full),
|
||||||
uid: profile.uid,
|
spawn: () => spawn(command, full),
|
||||||
gid: profile.gid,
|
};
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRunnableProfileProcess(status: string, uid: number): boolean {
|
export function isRunnableProfileProcess(status: string, uid: number): boolean {
|
||||||
const owner = status.match(/^Uid:\s+(\d+)/m)?.[1];
|
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
|
||||||
const state = status.match(/^State:\s+(\w)/m)?.[1];
|
const state = /^State:\s+(\w)/mu.exec(status)?.[1];
|
||||||
return owner !== undefined && Number(owner) === uid && state !== "Z";
|
return owner !== undefined && Number(owner) === uid && state !== "Z";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function profileProcessIds(uid: number): Promise<number[]> {
|
async function profileProcessIds(uid: number): Promise<number[]> {
|
||||||
const ids: number[] = [];
|
const entries = await readdir("/proc", { withFileTypes: true });
|
||||||
for await (const entry of Deno.readDir("/proc")) {
|
const pids = entries
|
||||||
if (!entry.isDirectory || !/^\d+$/.test(entry.name)) continue;
|
.filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name))
|
||||||
const status = await Deno.readTextFile(`/proc/${entry.name}/status`).catch(() => null);
|
.map((entry) => entry.name);
|
||||||
if (status !== null && isRunnableProfileProcess(status, uid)) {
|
const statuses = await Promise.all(
|
||||||
ids.push(Number(entry.name));
|
pids.map((pid) => readFile(`/proc/${pid}/status`, "utf8").catch(() => null)),
|
||||||
}
|
);
|
||||||
}
|
return pids
|
||||||
return ids;
|
.filter((_pid, index) => {
|
||||||
|
const status = statuses[index];
|
||||||
|
return status !== null && status !== undefined && isRunnableProfileProcess(status, uid);
|
||||||
|
})
|
||||||
|
.map(Number);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function killProfileProcesses(uid: number): Promise<void> {
|
function killProcess(pid: number): void {
|
||||||
for (let attempt = 0; attempt < 100; attempt++) {
|
try {
|
||||||
const ids = await profileProcessIds(uid);
|
process.kill(pid, "SIGKILL");
|
||||||
if (ids.length === 0) return;
|
} catch (error) {
|
||||||
for (const pid of ids) {
|
if (!isNotFound(error)) {
|
||||||
try {
|
throw error;
|
||||||
Deno.kill(pid, "SIGKILL");
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
}
|
}
|
||||||
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
|
}
|
||||||
|
|
||||||
|
// Kills the UID's processes every 10 ms until none is left, for up to 100 rounds.
|
||||||
|
export async function killProfileProcesses(uid: number, attempt = 0): Promise<void> {
|
||||||
|
if (attempt === 100) {
|
||||||
|
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
|
||||||
|
}
|
||||||
|
const ids = await profileProcessIds(uid);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const pid of ids) {
|
||||||
|
killProcess(pid);
|
||||||
|
}
|
||||||
|
await sleep(10);
|
||||||
|
await killProfileProcesses(uid, attempt + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readProfileFile(profile: RuntimeProfile, path: string): Promise<string> {
|
export async function readProfileFile(profile: RuntimeProfile, path: string): Promise<string> {
|
||||||
const stat = await Deno.lstat(path);
|
const stat = await lstat(path);
|
||||||
if (!stat.isFile || stat.isSymlink || stat.uid !== profile.uid || (stat.mode! & 0o077) !== 0) {
|
if (
|
||||||
|
!stat.isFile() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.uid !== profile.uid ||
|
||||||
|
(stat.mode & 0o077) !== 0
|
||||||
|
) {
|
||||||
throw new Error(`${path}: expected a profile-owned regular private file`);
|
throw new Error(`${path}: expected a profile-owned regular private file`);
|
||||||
}
|
}
|
||||||
return await Deno.readTextFile(path);
|
return readFile(path, "utf8");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
|
||||||
import {
|
|
||||||
agentCommand,
|
|
||||||
isRunnableProfileProcess,
|
|
||||||
killProfileProcesses,
|
|
||||||
readProfileFile,
|
|
||||||
} from "./isolation.ts";
|
|
||||||
import type { RuntimeProfile } from "./runtime.ts";
|
|
||||||
|
|
||||||
const profile = (uid: number, home: string): RuntimeProfile => ({
|
|
||||||
index: uid - 20_000,
|
|
||||||
type: "claude",
|
|
||||||
model: "test",
|
|
||||||
uid,
|
|
||||||
gid: uid,
|
|
||||||
home,
|
|
||||||
tokenFile: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("zombie process entries are already quiescent", () => {
|
|
||||||
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
|
|
||||||
assertEquals(isRunnableProfileProcess(status, 20_010), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test({
|
|
||||||
name: "refuses to read a profile result symlink as root",
|
|
||||||
permissions: { read: true, write: true, sys: ["uid"] },
|
|
||||||
async fn() {
|
|
||||||
if (Deno.build.os === "windows") return;
|
|
||||||
const root = await Deno.makeTempDir();
|
|
||||||
try {
|
|
||||||
const secret = `${root}/reviewer-token`;
|
|
||||||
const result = `${root}/result.md`;
|
|
||||||
await Deno.writeTextFile(secret, "reviewer-secret", { mode: 0o600 });
|
|
||||||
await Deno.symlink(secret, result);
|
|
||||||
|
|
||||||
await assertRejects(
|
|
||||||
() => readProfileFile(profile(Deno.uid()!, root), result),
|
|
||||||
Error,
|
|
||||||
"regular private file",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await Deno.remove(root, { recursive: true });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test({
|
|
||||||
name: "a profile process can read only its own private files",
|
|
||||||
permissions: { read: true, write: true, run: true, sys: ["uid"] },
|
|
||||||
async fn() {
|
|
||||||
if (Deno.uid() !== 0) return;
|
|
||||||
const root = await Deno.makeTempDir();
|
|
||||||
await Deno.chmod(root, 0o711);
|
|
||||||
try {
|
|
||||||
const homeA = `${root}/a`;
|
|
||||||
const homeB = `${root}/b`;
|
|
||||||
await Deno.mkdir(homeA, { mode: 0o700 });
|
|
||||||
await Deno.mkdir(homeB, { mode: 0o700 });
|
|
||||||
await Deno.chown(homeA, 20_000, 20_000);
|
|
||||||
await Deno.chown(homeB, 20_001, 20_001);
|
|
||||||
await Deno.writeTextFile(`${homeA}/token`, "own", { mode: 0o600 });
|
|
||||||
await Deno.chown(`${homeA}/token`, 20_000, 20_000);
|
|
||||||
await Deno.writeTextFile(`${homeB}/token`, "sibling", { mode: 0o600 });
|
|
||||||
await Deno.chown(`${homeB}/token`, 20_001, 20_001);
|
|
||||||
await Deno.writeTextFile(`${root}/reviewer`, "root", { mode: 0o600 });
|
|
||||||
|
|
||||||
const selected = profile(20_000, homeA);
|
|
||||||
const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], {
|
|
||||||
cwd: "/",
|
|
||||||
}).output();
|
|
||||||
const sibling = await agentCommand(selected, "/bin/cat", [`${homeB}/token`], {
|
|
||||||
cwd: "/",
|
|
||||||
}).output();
|
|
||||||
const reviewer = await agentCommand(selected, "/bin/cat", [`${root}/reviewer`], {
|
|
||||||
cwd: "/",
|
|
||||||
}).output();
|
|
||||||
|
|
||||||
assertEquals(new TextDecoder().decode(own.stdout), "own");
|
|
||||||
assertEquals(own.success, true);
|
|
||||||
assertEquals(sibling.success, false);
|
|
||||||
assertEquals(reviewer.success, false);
|
|
||||||
} finally {
|
|
||||||
await Deno.remove(root, { recursive: true });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test({
|
|
||||||
name: "cleanup leaves no runnable daemonized process",
|
|
||||||
permissions: "inherit",
|
|
||||||
async fn() {
|
|
||||||
if (Deno.uid() !== 0) return;
|
|
||||||
const selected = profile(20_010, "/tmp");
|
|
||||||
const launched = await agentCommand(
|
|
||||||
selected,
|
|
||||||
"/bin/sh",
|
|
||||||
["-c", "sleep 60 >/dev/null 2>&1 & echo $!"],
|
|
||||||
{ cwd: "/" },
|
|
||||||
).output();
|
|
||||||
const pid = Number(new TextDecoder().decode(launched.stdout).trim());
|
|
||||||
assertEquals(Number.isInteger(pid), true);
|
|
||||||
|
|
||||||
await killProfileProcesses(selected.uid);
|
|
||||||
|
|
||||||
const status = await Deno.readTextFile(`/proc/${pid}/status`).catch((error) => {
|
|
||||||
if (error instanceof Deno.errors.NotFound) return null;
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
assertEquals(status === null || !isRunnableProfileProcess(status, selected.uid), true);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test({
|
|
||||||
name: "the agent process cannot read its coordinator environment",
|
|
||||||
permissions: { run: true, sys: ["uid"] },
|
|
||||||
async fn() {
|
|
||||||
if (Deno.uid() !== 0) return;
|
|
||||||
const selected = profile(20_020, "/tmp");
|
|
||||||
const result = await agentCommand(selected, "/usr/bin/cat", [`/proc/${Deno.pid}/environ`], {
|
|
||||||
cwd: "/",
|
|
||||||
env: { PARENT_SECRET: "must-not-cross-boundary" },
|
|
||||||
clearEnv: true,
|
|
||||||
stderr: "piped",
|
|
||||||
}).output();
|
|
||||||
|
|
||||||
assertEquals(result.success, false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
// Type guards for values read from JSON, which arrive as `unknown`.
|
||||||
|
export type JsonObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
// Any non-null object, arrays included: reading a missing key yields undefined.
|
||||||
|
export function isObject(value: unknown): value is JsonObject {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain mapping, which an array is not.
|
||||||
|
export function isMapping(value: unknown): value is JsonObject {
|
||||||
|
return isObject(value) && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { assertEquals, assertInstanceOf, assertRejects, assertThrows } from "jsr:@std/assert@1";
|
import { assert, expect, test } from "vite-plus/test";
|
||||||
import {
|
import {
|
||||||
collectOpenCodeOutput,
|
collectOpenCodeOutput,
|
||||||
openCodeArgs,
|
openCodeArgs,
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
} from "./opencode.ts";
|
} from "./opencode.ts";
|
||||||
import { AgentAuthError } from "./profiles.ts";
|
import { AgentAuthError } from "./profiles.ts";
|
||||||
|
|
||||||
Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
|
test("builds a non-interactive auto-approved OpenCode command", () => {
|
||||||
assertEquals(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"), [
|
expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([
|
||||||
"run",
|
"run",
|
||||||
"--format",
|
"--format",
|
||||||
"json",
|
"json",
|
||||||
@@ -19,8 +19,8 @@ Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("returns the final visible OpenCode text event", () => {
|
test("returns the final visible OpenCode text event", () => {
|
||||||
assertEquals(
|
expect(
|
||||||
parseOpenCodeEvents(
|
parseOpenCodeEvents(
|
||||||
[
|
[
|
||||||
JSON.stringify({ type: "step_start", part: { type: "step-start" } }),
|
JSON.stringify({ type: "step_start", part: { type: "step-start" } }),
|
||||||
@@ -29,23 +29,19 @@ Deno.test("returns the final visible OpenCode text event", () => {
|
|||||||
JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }),
|
JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }),
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
),
|
),
|
||||||
"done",
|
).toBe("done");
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("rejects a successful OpenCode run without visible text", async () => {
|
test("rejects a successful OpenCode run without visible text", async () => {
|
||||||
await assertRejects(
|
await expect(async () => {
|
||||||
async () => {
|
parseOpenCodeEvents('{"type":"step_start"}');
|
||||||
parseOpenCodeEvents('{"type":"step_start"}');
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
}).rejects.toThrow("no text output");
|
||||||
},
|
|
||||||
Error,
|
|
||||||
"no text output",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("streams OpenCode output while collecting it", async () => {
|
const encode = (value: string): Uint8Array => new TextEncoder().encode(value);
|
||||||
const encode = (value: string) => new TextEncoder().encode(value);
|
|
||||||
|
test("streams OpenCode output while collecting it", async () => {
|
||||||
const stdout = new ReadableStream<Uint8Array>({
|
const stdout = new ReadableStream<Uint8Array>({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
controller.enqueue(encode("first\n"));
|
controller.enqueue(encode("first\n"));
|
||||||
@@ -72,8 +68,8 @@ Deno.test("streams OpenCode output while collecting it", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(result, { stdout: "first\nsecond\n", stderr: "warning\n" });
|
expect(result).toStrictEqual({ stdout: "first\nsecond\n", stderr: "warning\n" });
|
||||||
assertEquals(shown, ["out:first\n", "err:warning\n", "out:second\n"]);
|
expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const openRouterError = (statusCode: number, message: string): string =>
|
const openRouterError = (statusCode: number, message: string): string =>
|
||||||
@@ -91,21 +87,17 @@ const openRouterError = (statusCode: number, message: string): string =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("reports a rejected OpenRouter key as a sign-in failure", () => {
|
test("reports a rejected OpenRouter key as a sign-in failure", () => {
|
||||||
const error = openCodeError(openRouterError(401, "User not found."));
|
const error = openCodeError(openRouterError(401, "User not found."));
|
||||||
assertInstanceOf(error, AgentAuthError);
|
assert.instanceOf(error, AgentAuthError);
|
||||||
assertEquals(error.message, "opencode sign-in failed");
|
expect(error.message).toBe("opencode sign-in failed");
|
||||||
assertEquals(
|
assert.instanceOf(error.cause, Error);
|
||||||
(error.cause as Error).message,
|
expect(error.cause.message).toBe("opencode APIError (API error 401): User not found.");
|
||||||
"opencode APIError (API error 401): User not found.",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("surfaces an OpenCode error event instead of empty output", () => {
|
test("surfaces an OpenCode error event instead of empty output", () => {
|
||||||
assertThrows(
|
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow(
|
||||||
() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded")),
|
|
||||||
Error,
|
|
||||||
"API error 429",
|
"API error 429",
|
||||||
);
|
);
|
||||||
assertEquals(openCodeError('{"type":"text","part":{"text":"ok"}}'), undefined);
|
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBe(undefined);
|
||||||
});
|
});
|
||||||
+13
-12
@@ -1,3 +1,4 @@
|
|||||||
|
import { isObject } from "./json.ts";
|
||||||
import { AgentAuthError } from "./profiles.ts";
|
import { AgentAuthError } from "./profiles.ts";
|
||||||
|
|
||||||
export function openCodeArgs(model: string, prompt: string): string[] {
|
export function openCodeArgs(model: string, prompt: string): string[] {
|
||||||
@@ -7,12 +8,12 @@ export function openCodeArgs(model: string, prompt: string): string[] {
|
|||||||
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
|
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
|
||||||
|
|
||||||
export async function collectOpenCodeOutput(
|
export async function collectOpenCodeOutput(
|
||||||
stdout: ReadableStream<Uint8Array>,
|
stdout: AsyncIterable<Uint8Array>,
|
||||||
stderr: ReadableStream<Uint8Array>,
|
stderr: AsyncIterable<Uint8Array>,
|
||||||
writeStdout: ChunkWriter,
|
writeStdout: ChunkWriter,
|
||||||
writeStderr: ChunkWriter,
|
writeStderr: ChunkWriter,
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
const collect = async (stream: ReadableStream<Uint8Array>, write: ChunkWriter) => {
|
const collect = async (stream: AsyncIterable<Uint8Array>, write: ChunkWriter) => {
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
@@ -47,13 +48,13 @@ export function openCodeError(output: string): Error | undefined {
|
|||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (typeof event !== "object" || event === null) continue;
|
if (!isObject(event)) continue;
|
||||||
const { type, error } = event as Record<string, unknown>;
|
const { type, error } = event;
|
||||||
if (type !== "error" || typeof error !== "object" || error === null) {
|
if (type !== "error" || !isObject(error)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const { name, data } = error as Record<string, unknown>;
|
const { name, data } = error;
|
||||||
const { message, statusCode } = (data ?? {}) as Record<string, unknown>;
|
const { message, statusCode } = isObject(data) ? data : {};
|
||||||
const detail = `opencode ${typeof name === "string" ? name : "error"}${
|
const detail = `opencode ${typeof name === "string" ? name : "error"}${
|
||||||
typeof statusCode === "number" ? ` (API error ${statusCode})` : ""
|
typeof statusCode === "number" ? ` (API error ${statusCode})` : ""
|
||||||
}: ${typeof message === "string" ? message : "unknown error"}`;
|
}: ${typeof message === "string" ? message : "unknown error"}`;
|
||||||
@@ -72,10 +73,10 @@ export function parseOpenCodeEvents(output: string): string {
|
|||||||
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);
|
||||||
if (typeof event !== "object" || event === null) continue;
|
if (!isObject(event)) continue;
|
||||||
const { type, part } = event as Record<string, unknown>;
|
const { type, part } = event;
|
||||||
if (type !== "text" || typeof part !== "object" || part === null) continue;
|
if (type !== "text" || !isObject(part)) continue;
|
||||||
const text = (part as Record<string, unknown>).text;
|
const { text } = part;
|
||||||
if (typeof text === "string" && text.trim() !== "") result = text;
|
if (typeof text === "string" && text.trim() !== "") result = text;
|
||||||
}
|
}
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Reads what the agent phase left behind: its outcome, and the review and
|
||||||
|
// anchors files it wrote into a profile home.
|
||||||
|
import type { Stats } from "node:fs";
|
||||||
|
import { lstat, readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { isNotFound } from "./errors.ts";
|
||||||
|
import { isObject } from "./json.ts";
|
||||||
|
import { isFailureReason, isProfileType } from "./profiles.ts";
|
||||||
|
import type { ProfileFailure } from "./profiles.ts";
|
||||||
|
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
||||||
|
|
||||||
|
export interface Anchor {
|
||||||
|
path: string;
|
||||||
|
line: number;
|
||||||
|
side: "new" | "old";
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnchor(entry: unknown, at: string): Anchor {
|
||||||
|
if (!isObject(entry)) {
|
||||||
|
throw new TypeError(`${at}: not an object`);
|
||||||
|
}
|
||||||
|
const { path, line, side = "new", body } = entry;
|
||||||
|
if (typeof path !== "string" || path === "") {
|
||||||
|
throw new TypeError(`${at}.path: expected a repository path`);
|
||||||
|
}
|
||||||
|
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
|
||||||
|
throw new TypeError(`${at}.line: expected a line number`);
|
||||||
|
}
|
||||||
|
if (side !== "new" && side !== "old") {
|
||||||
|
throw new Error(`${at}.side: expected "new" or "old"`);
|
||||||
|
}
|
||||||
|
if (typeof body !== "string" || body.trim() === "") {
|
||||||
|
throw new TypeError(`${at}.body: expected the finding`);
|
||||||
|
}
|
||||||
|
return { path, line, side, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||||
|
const entries: unknown = JSON.parse(text);
|
||||||
|
if (!Array.isArray(entries)) {
|
||||||
|
throw new TypeError(`${sourcePath}: not an array`);
|
||||||
|
}
|
||||||
|
return entries.map((entry: unknown, index) => parseAnchor(entry, `${sourcePath}[${index}]`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only known labels reach the comment, never raw agent output.
|
||||||
|
function knownFailures(failures: unknown): ProfileFailure[] {
|
||||||
|
return (Array.isArray(failures) ? failures : []).flatMap((entry: unknown) => {
|
||||||
|
if (!isObject(entry)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { type, model, reason } = entry;
|
||||||
|
return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
|
||||||
|
? [{ type, model, reason }]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readOutcome(path: string): Promise<RuntimeOutcome> {
|
||||||
|
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||||
|
if (!isObject(value) || !("ok" in value)) {
|
||||||
|
throw new Error("invalid agent outcome");
|
||||||
|
}
|
||||||
|
if (value.ok === false) {
|
||||||
|
return { ok: false, failures: knownFailures(value.failures) };
|
||||||
|
}
|
||||||
|
const { ok, body, reviewPath, anchorsPath, profile } = value;
|
||||||
|
if (
|
||||||
|
ok !== true ||
|
||||||
|
typeof body !== "string" ||
|
||||||
|
typeof reviewPath !== "string" ||
|
||||||
|
typeof anchorsPath !== "string" ||
|
||||||
|
!isObject(profile) ||
|
||||||
|
!isProfileType(profile.type) ||
|
||||||
|
typeof profile.model !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error("invalid agent outcome");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
body,
|
||||||
|
reviewPath,
|
||||||
|
anchorsPath,
|
||||||
|
profile: { type: profile.type, model: profile.model },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readAgentArtifact(
|
||||||
|
manifest: RuntimeManifest,
|
||||||
|
path: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
|
||||||
|
if (profile === undefined) throw new Error(`${name}: outside profile home`);
|
||||||
|
let stat: Stats;
|
||||||
|
try {
|
||||||
|
stat = await lstat(path);
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotFound(error)) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!stat.isFile() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.uid !== profile.uid ||
|
||||||
|
(stat.mode & 0o077) !== 0
|
||||||
|
) {
|
||||||
|
throw new Error(`${name}: expected a profile-owned regular private file`);
|
||||||
|
}
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
return text === "" ? null : text;
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { assert, expect, test } from "vite-plus/test";
|
||||||
|
|
||||||
|
import { isNotFound } from "./errors.ts";
|
||||||
|
import { postRuntime } from "./post.ts";
|
||||||
|
import type { PostContext } from "./post.ts";
|
||||||
|
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
||||||
|
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
|
||||||
|
|
||||||
|
const context: PostContext = {
|
||||||
|
apiUrl: "https://gitea.example/api/v1",
|
||||||
|
repository: "owner/repo",
|
||||||
|
issueIndex: "7",
|
||||||
|
eventName: "issue_comment",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Fixture {
|
||||||
|
manifestPath: string;
|
||||||
|
runtimeDir: string;
|
||||||
|
reviewerFile: string;
|
||||||
|
reviewPath: string;
|
||||||
|
outcomeFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FixtureSource = Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string };
|
||||||
|
|
||||||
|
async function writePrivate(path: string, text: string): Promise<void> {
|
||||||
|
await writeFile(path, text, { mode: 0o600 });
|
||||||
|
await chmod(path, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeArtifacts(
|
||||||
|
outcome: RuntimeOutcome,
|
||||||
|
review: string | undefined,
|
||||||
|
anchors: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!outcome.ok) return;
|
||||||
|
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 {
|
||||||
|
return {
|
||||||
|
runtimeDir,
|
||||||
|
manifestPath: `${runtimeDir}/manifest.json`,
|
||||||
|
reviewerFile: `${runtimeDir}/reviewer-token`,
|
||||||
|
outcomeFile: `${runtimeDir}/outcome.json`,
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
type: "claude",
|
||||||
|
model: "test",
|
||||||
|
uid: currentUid(),
|
||||||
|
gid: currentGid(),
|
||||||
|
home: profileHome,
|
||||||
|
tokenFile: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture(
|
||||||
|
source: FixtureSource,
|
||||||
|
review?: string,
|
||||||
|
anchors?: unknown,
|
||||||
|
): Promise<Fixture> {
|
||||||
|
const runtimeDir = await makeTempDir();
|
||||||
|
const profileHome = `${runtimeDir}/profile-0`;
|
||||||
|
const manifest = manifestFor(runtimeDir, profileHome);
|
||||||
|
await mkdir(profileHome);
|
||||||
|
await writeFile(manifest.reviewerFile, "reviewer-secret");
|
||||||
|
const outcome: RuntimeOutcome = source.ok
|
||||||
|
? {
|
||||||
|
...source,
|
||||||
|
profile: { type: "codex", model: "gpt-test" },
|
||||||
|
reviewPath: `${profileHome}/review.md`,
|
||||||
|
anchorsPath: `${profileHome}/anchors.json`,
|
||||||
|
}
|
||||||
|
: source;
|
||||||
|
await writeFile(manifest.outcomeFile, JSON.stringify(outcome));
|
||||||
|
await writeArtifacts(outcome, review, anchors);
|
||||||
|
await writeFile(manifest.manifestPath, JSON.stringify(manifest));
|
||||||
|
const { manifestPath, reviewerFile, outcomeFile } = manifest;
|
||||||
|
return {
|
||||||
|
manifestPath,
|
||||||
|
runtimeDir,
|
||||||
|
reviewerFile,
|
||||||
|
reviewPath: `${profileHome}/review.md`,
|
||||||
|
outcomeFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = async (path: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotFound(error)) return true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The JSON body of one request the fake Gitea received.
|
||||||
|
function requestBody(init?: RequestInit): unknown {
|
||||||
|
return typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fake Gitea that records each request body and answers with `status`.
|
||||||
|
function recordingFetch(bodies: unknown[], status = 201): typeof fetch {
|
||||||
|
return (_url, init) => {
|
||||||
|
bodies.push(requestBody(init));
|
||||||
|
return Promise.resolve(new Response(null, { status }));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("posts a successful issue result and removes runtime credentials", async () => {
|
||||||
|
const result = await fixture({ ok: true, body: "done" });
|
||||||
|
const requests: { url: unknown; init: RequestInit | undefined }[] = [];
|
||||||
|
|
||||||
|
await postRuntime(result.manifestPath, context, (url, init) => {
|
||||||
|
requests.push({ url, init });
|
||||||
|
return Promise.resolve(new Response(null, { status: 201 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requests.length).toBe(1);
|
||||||
|
const [request] = requests;
|
||||||
|
expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`);
|
||||||
|
expect(requestBody(request?.init)).toStrictEqual({
|
||||||
|
body: "done\n\n> Profile: `codex` · `gpt-test`",
|
||||||
|
});
|
||||||
|
expect(request?.init?.headers).toStrictEqual({
|
||||||
|
Authorization: "token reviewer-secret",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
assert(await missing(result.runtimeDir));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retries a rejected inline review without anchors", async () => {
|
||||||
|
const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [
|
||||||
|
{ path: "run.ts", line: 1, side: "new", body: "Finding" },
|
||||||
|
]);
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
|
||||||
|
await postRuntime(
|
||||||
|
result.manifestPath,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
eventName: "pull_request",
|
||||||
|
},
|
||||||
|
(_url, init) => {
|
||||||
|
bodies.push(requestBody(init));
|
||||||
|
return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bodies[0]).toMatchObject({
|
||||||
|
comments: [{ body: "Finding\n\n> Profile: `codex` · `gpt-test`" }],
|
||||||
|
});
|
||||||
|
expect(bodies[1]).toMatchObject({
|
||||||
|
comments: [],
|
||||||
|
body: "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
|
||||||
|
});
|
||||||
|
assert(await missing(result.runtimeDir));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports each tried profile and keeps the action failed", async () => {
|
||||||
|
const result = await fixture({
|
||||||
|
ok: false,
|
||||||
|
failures: [
|
||||||
|
{ type: "codex", model: "gpt-test", reason: "sign-in failed" },
|
||||||
|
{ type: "claude", model: "claude-test", reason: "usage exhausted" },
|
||||||
|
{ type: "opencode", model: "openrouter/test", reason: "failed" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
|
||||||
|
await expect(() =>
|
||||||
|
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
|
||||||
|
).rejects.toThrow("agent phase failed");
|
||||||
|
|
||||||
|
expect(bodies).toStrictEqual([
|
||||||
|
{
|
||||||
|
body:
|
||||||
|
"AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " +
|
||||||
|
"`claude` · `claude-test` (usage exhausted), " +
|
||||||
|
"`opencode` · `openrouter/test` (failed)",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
|
||||||
|
assert(await missing(result.runtimeDir));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops failure entries that are not known labels", async () => {
|
||||||
|
const result = await fixture({ ok: false, failures: [] });
|
||||||
|
await writeFile(
|
||||||
|
result.outcomeFile,
|
||||||
|
JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
failures: [
|
||||||
|
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" },
|
||||||
|
{ type: "shell", model: "x", reason: "failed" },
|
||||||
|
{ type: "claude", model: "claude-test", reason: "sign-in failed" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
|
||||||
|
await expect(() =>
|
||||||
|
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
|
||||||
|
).rejects.toThrow("agent phase failed");
|
||||||
|
|
||||||
|
expect(bodies).toStrictEqual([
|
||||||
|
{
|
||||||
|
body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports a generic failure when the runner wrote no outcome", async () => {
|
||||||
|
const result = await fixture({ ok: false, failures: [] });
|
||||||
|
await rm(result.outcomeFile);
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
|
||||||
|
await expect(() =>
|
||||||
|
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
|
||||||
|
).rejects.toThrow("agent phase failed");
|
||||||
|
|
||||||
|
expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]);
|
||||||
|
assert(await missing(result.runtimeDir));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes runtime credentials when posting fails", async () => {
|
||||||
|
const result = await fixture({ ok: true, body: "done" });
|
||||||
|
|
||||||
|
await expect(() =>
|
||||||
|
postRuntime(result.manifestPath, context, () =>
|
||||||
|
Promise.resolve(new Response("failed", { status: 500 })),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("500");
|
||||||
|
assert(await missing(result.runtimeDir));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves a Gitea transport failure without retrying a comment", async () => {
|
||||||
|
const result = await fixture({ ok: true, body: "done" });
|
||||||
|
let requests = 0;
|
||||||
|
|
||||||
|
await expect(() =>
|
||||||
|
postRuntime(result.manifestPath, context, () => {
|
||||||
|
requests += 1;
|
||||||
|
return Promise.reject(new Error(`network failure ${requests}`));
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("network failure 1");
|
||||||
|
|
||||||
|
expect(requests).toBe(1);
|
||||||
|
assert(await missing(result.runtimeDir));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () => {
|
||||||
|
const result = await fixture({ ok: true, body: "done" });
|
||||||
|
await symlink(result.reviewerFile, result.reviewPath);
|
||||||
|
let requests = 0;
|
||||||
|
|
||||||
|
await expect(() =>
|
||||||
|
postRuntime(result.manifestPath, context, () => {
|
||||||
|
requests += 1;
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("regular private file");
|
||||||
|
|
||||||
|
expect(requests).toBe(1);
|
||||||
|
assert(await missing(result.runtimeDir));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports an attributed failure when a pull request review is missing", async () => {
|
||||||
|
const result = await fixture({ ok: true, body: "done" });
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
|
||||||
|
await expect(() =>
|
||||||
|
postRuntime(
|
||||||
|
result.manifestPath,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
eventName: "pull_request",
|
||||||
|
},
|
||||||
|
recordingFetch(bodies),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("did not write");
|
||||||
|
|
||||||
|
expect(bodies).toStrictEqual([
|
||||||
|
{
|
||||||
|
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert(await missing(result.runtimeDir));
|
||||||
|
});
|
||||||
+136
-184
@@ -1,133 +1,157 @@
|
|||||||
import { join } from "jsr:@std/path@1";
|
import { readFile, rm } from "node:fs/promises";
|
||||||
import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "./runtime.ts";
|
import process from "node:process";
|
||||||
import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "./profiles.ts";
|
|
||||||
|
|
||||||
export type PostContext = {
|
import { isNotFound } from "./errors.ts";
|
||||||
|
import { parseAnchors, readAgentArtifact, readOutcome } from "./post-artifacts.ts";
|
||||||
|
import type { Anchor } from "./post-artifacts.ts";
|
||||||
|
import { failureFooter, withProfile } from "./profiles.ts";
|
||||||
|
import type { ProfileFailure } from "./profiles.ts";
|
||||||
|
import { readRuntimeManifest } from "./runtime.ts";
|
||||||
|
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
||||||
|
|
||||||
|
export interface PostContext {
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
repository: string;
|
repository: string;
|
||||||
issueIndex: string;
|
issueIndex: string;
|
||||||
eventName: string;
|
eventName: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
type Anchor = {
|
type SuccessOutcome = Extract<RuntimeOutcome, { ok: true }>;
|
||||||
path: string;
|
|
||||||
line: number;
|
|
||||||
side: "new" | "old";
|
|
||||||
body: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
|
type GiteaRequest = (path: string, body: unknown) => Promise<void>;
|
||||||
|
|
||||||
|
// The reviewer's view of one issue or pull request.
|
||||||
|
interface Publisher {
|
||||||
|
context: PostContext;
|
||||||
|
request: GiteaRequest;
|
||||||
|
postComment(body: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: Record<string, [event: string, mark: string]> = {
|
const VERDICTS: Record<string, [event: string, mark: string]> = {
|
||||||
Approved: ["APPROVED", "✅"],
|
Approved: ["APPROVED", "✅"],
|
||||||
"Changes requested": ["REQUEST_CHANGES", "🛑"],
|
"Changes requested": ["REQUEST_CHANGES", "🛑"],
|
||||||
};
|
};
|
||||||
const PROFILE_TYPES = ["claude", "codex", "opencode"];
|
|
||||||
const FAILURE_REASONS = ["sign-in failed", "usage exhausted", "failed"];
|
|
||||||
const failureComment = (failures: ProfileFailure[]): string =>
|
const failureComment = (failures: ProfileFailure[]): string =>
|
||||||
`AI bot failed.\n\n${failureFooter(failures)}`;
|
`AI bot failed.\n\n${failureFooter(failures)}`;
|
||||||
|
|
||||||
class GiteaRequestError extends Error {}
|
class GiteaRequestError extends Error {}
|
||||||
|
|
||||||
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest {
|
||||||
const entries: unknown = JSON.parse(text);
|
return async (path, body) => {
|
||||||
if (!Array.isArray(entries)) throw new Error(`${sourcePath}: not an array`);
|
let response: Response;
|
||||||
return entries.map((entry: unknown, index) => {
|
try {
|
||||||
const at = `${sourcePath}[${index}]`;
|
response = await fetcher(`${apiUrl}/${path}`, {
|
||||||
if (typeof entry !== "object" || entry === null) {
|
method: "POST",
|
||||||
throw new Error(`${at}: not an object`);
|
headers: {
|
||||||
|
Authorization: `token ${reviewerToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
|
||||||
}
|
}
|
||||||
const { path, line, side = "new", body } = entry as Record<string, unknown>;
|
if (!response.ok) {
|
||||||
if (typeof path !== "string" || path === "") {
|
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
|
||||||
throw new Error(`${at}.path: expected a repository path`);
|
|
||||||
}
|
}
|
||||||
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
|
|
||||||
throw new Error(`${at}.line: expected a line number`);
|
|
||||||
}
|
|
||||||
if (side !== "new" && side !== "old") {
|
|
||||||
throw new Error(`${at}.side: expected \"new\" or \"old\"`);
|
|
||||||
}
|
|
||||||
if (typeof body !== "string" || body.trim() === "") {
|
|
||||||
throw new Error(`${at}.body: expected the finding`);
|
|
||||||
}
|
|
||||||
return { path, line, side, body };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readOutcome(path: string): Promise<RuntimeOutcome> {
|
|
||||||
const value: unknown = JSON.parse(await Deno.readTextFile(path));
|
|
||||||
if (typeof value !== "object" || value === null || !("ok" in value)) {
|
|
||||||
throw new Error("invalid agent outcome");
|
|
||||||
}
|
|
||||||
const outcome = value as Record<string, unknown>;
|
|
||||||
if (outcome.ok === false) {
|
|
||||||
// Only known labels reach the comment, never raw agent output.
|
|
||||||
const failures = Array.isArray(outcome.failures) ? outcome.failures : [];
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
failures: failures.flatMap((entry: unknown) => {
|
|
||||||
const { type, model, reason } = (entry ?? {}) as Record<string, unknown>;
|
|
||||||
return typeof type === "string" &&
|
|
||||||
PROFILE_TYPES.includes(type) &&
|
|
||||||
typeof model === "string" &&
|
|
||||||
typeof reason === "string" &&
|
|
||||||
FAILURE_REASONS.includes(reason)
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: type as ProfileFailure["type"],
|
|
||||||
model,
|
|
||||||
reason: reason as FailureReason,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
outcome.ok !== true ||
|
|
||||||
typeof outcome.body !== "string" ||
|
|
||||||
typeof outcome.reviewPath !== "string" ||
|
|
||||||
typeof outcome.anchorsPath !== "string" ||
|
|
||||||
typeof outcome.profile !== "object" ||
|
|
||||||
outcome.profile === null
|
|
||||||
) {
|
|
||||||
throw new Error("invalid agent outcome");
|
|
||||||
}
|
|
||||||
const profile = outcome.profile as Record<string, unknown>;
|
|
||||||
if (typeof profile.type !== "string" || typeof profile.model !== "string") {
|
|
||||||
throw new Error("invalid agent outcome");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
body: outcome.body,
|
|
||||||
reviewPath: outcome.reviewPath,
|
|
||||||
anchorsPath: outcome.anchorsPath,
|
|
||||||
profile: {
|
|
||||||
type: profile.type as "claude" | "codex" | "opencode",
|
|
||||||
model: profile.model,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readAgentArtifact(
|
async function readSuccess(
|
||||||
manifest: RuntimeManifest,
|
manifest: RuntimeManifest,
|
||||||
path: string,
|
publisher: Publisher,
|
||||||
name: string,
|
): Promise<SuccessOutcome> {
|
||||||
): Promise<string | null> {
|
let outcome: RuntimeOutcome;
|
||||||
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
|
|
||||||
if (profile === undefined) throw new Error(`${name}: outside profile home`);
|
|
||||||
let stat: Deno.FileInfo;
|
|
||||||
try {
|
try {
|
||||||
stat = await Deno.lstat(path);
|
outcome = await readOutcome(manifest.outcomeFile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Deno.errors.NotFound) return null;
|
await publisher.postComment(failureComment([]));
|
||||||
|
throw new Error("agent phase failed", { cause: error });
|
||||||
|
}
|
||||||
|
if (!outcome.ok) {
|
||||||
|
await publisher.postComment(failureComment(outcome.failures));
|
||||||
|
throw new Error("agent phase failed");
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postReview(
|
||||||
|
publisher: Publisher,
|
||||||
|
outcome: SuccessOutcome,
|
||||||
|
review: string,
|
||||||
|
anchors: Anchor[],
|
||||||
|
): Promise<void> {
|
||||||
|
const { context, request } = publisher;
|
||||||
|
const [verdict = "", ...rest] = review.split("\n");
|
||||||
|
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
|
||||||
|
const send = async (entries: Anchor[]): Promise<void> => {
|
||||||
|
await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
|
||||||
|
body: stripAnsi(
|
||||||
|
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
|
||||||
|
),
|
||||||
|
event,
|
||||||
|
comments: entries.map(({ path, line, side, body }) => ({
|
||||||
|
path,
|
||||||
|
body: stripAnsi(withProfile(body, outcome.profile)),
|
||||||
|
new_position: side === "new" ? line : 0,
|
||||||
|
old_position: side === "old" ? line : 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await send(anchors).catch(async (error: unknown) => {
|
||||||
|
if (anchors.length === 0) throw error;
|
||||||
|
console.error(
|
||||||
|
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
await send([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishResult(
|
||||||
|
manifest: RuntimeManifest,
|
||||||
|
outcome: SuccessOutcome,
|
||||||
|
publisher: Publisher,
|
||||||
|
): Promise<void> {
|
||||||
|
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md");
|
||||||
|
if (review === null) {
|
||||||
|
if (publisher.context.eventName === "pull_request") {
|
||||||
|
throw new Error("agent did not write a pull request review");
|
||||||
|
}
|
||||||
|
await publisher.postComment(withProfile(outcome.body, outcome.profile));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
|
||||||
|
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
|
||||||
|
await postReview(publisher, outcome, review, anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish(
|
||||||
|
manifest: RuntimeManifest,
|
||||||
|
context: PostContext,
|
||||||
|
fetcher: typeof fetch,
|
||||||
|
): Promise<void> {
|
||||||
|
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
|
||||||
|
const request = giteaRequest(context.apiUrl, reviewerToken, fetcher);
|
||||||
|
const publisher: Publisher = {
|
||||||
|
context,
|
||||||
|
request,
|
||||||
|
postComment: async (body) => {
|
||||||
|
await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
|
||||||
|
body: stripAnsi(body),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const outcome = await readSuccess(manifest, publisher);
|
||||||
|
try {
|
||||||
|
await publishResult(manifest, outcome, publisher);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GiteaRequestError) throw error;
|
||||||
|
await publisher.postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (!stat.isFile || stat.isSymlink || stat.uid !== profile.uid || (stat.mode! & 0o077) !== 0) {
|
|
||||||
throw new Error(`${name}: expected a profile-owned regular private file`);
|
|
||||||
}
|
|
||||||
const text = await Deno.readTextFile(path);
|
|
||||||
return text === "" ? null : text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postRuntime(
|
export async function postRuntime(
|
||||||
@@ -137,93 +161,21 @@ export async function postRuntime(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const manifest = await readRuntimeManifest(manifestPath);
|
const manifest = await readRuntimeManifest(manifestPath);
|
||||||
try {
|
try {
|
||||||
const reviewerToken = (await Deno.readTextFile(manifest.reviewerFile)).trim();
|
await publish(manifest, context, fetcher);
|
||||||
const request = async (path: string, body: unknown): Promise<void> => {
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetcher(`${context.apiUrl}/${path}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${reviewerToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const postComment = (body: string) =>
|
|
||||||
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
|
|
||||||
body: stripAnsi(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
let outcome: RuntimeOutcome;
|
|
||||||
try {
|
|
||||||
outcome = await readOutcome(manifest.outcomeFile);
|
|
||||||
} catch (error) {
|
|
||||||
await postComment(failureComment([]));
|
|
||||||
throw new Error("agent phase failed", { cause: error });
|
|
||||||
}
|
|
||||||
if (!outcome.ok) {
|
|
||||||
await postComment(failureComment(outcome.failures));
|
|
||||||
throw new Error("agent phase failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md");
|
|
||||||
if (review === null) {
|
|
||||||
if (context.eventName === "pull_request") {
|
|
||||||
throw new Error("agent did not write a pull request review");
|
|
||||||
}
|
|
||||||
await postComment(withProfile(outcome.body, outcome.profile));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [verdict, ...rest] = review.split("\n");
|
|
||||||
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
|
|
||||||
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
|
|
||||||
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
|
|
||||||
const postReview = (entries: Anchor[]) =>
|
|
||||||
request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
|
|
||||||
body: stripAnsi(
|
|
||||||
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
|
|
||||||
),
|
|
||||||
event,
|
|
||||||
comments: entries.map(({ path, line, side, body }) => ({
|
|
||||||
path,
|
|
||||||
body: stripAnsi(withProfile(body, outcome.profile)),
|
|
||||||
new_position: side === "new" ? line : 0,
|
|
||||||
old_position: side === "old" ? line : 0,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
await postReview(anchors).catch(async (error: Error) => {
|
|
||||||
if (anchors.length === 0) throw error;
|
|
||||||
console.error(`inline comments rejected: ${error.message}`);
|
|
||||||
await postReview([]);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof GiteaRequestError) throw error;
|
|
||||||
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch((error) => {
|
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
|
||||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
if (!isNotFound(error)) throw error;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function required(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value === undefined || value === "") throw new Error(`${name} is not set`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
const required = (name: string): string => {
|
|
||||||
const value = Deno.env.get(name);
|
|
||||||
if (!value) throw new Error(`${name} is not set`);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
|
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
|
||||||
apiUrl: required("GITEA_API_URL"),
|
apiUrl: required("GITEA_API_URL"),
|
||||||
repository: required("GITEA_REPOSITORY"),
|
repository: required("GITEA_REPOSITORY"),
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
import { assert, assertEquals, assertRejects } from "jsr:@std/assert@1";
|
|
||||||
import { type PostContext, postRuntime } from "./post.ts";
|
|
||||||
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
|
||||||
|
|
||||||
const context: PostContext = {
|
|
||||||
apiUrl: "https://gitea.example/api/v1",
|
|
||||||
repository: "owner/repo",
|
|
||||||
issueIndex: "7",
|
|
||||||
eventName: "issue_comment",
|
|
||||||
};
|
|
||||||
|
|
||||||
async function fixture(
|
|
||||||
source: Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string },
|
|
||||||
review?: string,
|
|
||||||
anchors?: unknown,
|
|
||||||
): Promise<{
|
|
||||||
manifestPath: string;
|
|
||||||
runtimeDir: string;
|
|
||||||
reviewerFile: string;
|
|
||||||
reviewPath: string;
|
|
||||||
outcomeFile: string;
|
|
||||||
}> {
|
|
||||||
const runtimeDir = await Deno.makeTempDir();
|
|
||||||
const reviewerFile = `${runtimeDir}/reviewer-token`;
|
|
||||||
const outcomeFile = `${runtimeDir}/outcome.json`;
|
|
||||||
const profileHome = `${runtimeDir}/profile-0`;
|
|
||||||
await Deno.mkdir(profileHome);
|
|
||||||
await Deno.writeTextFile(reviewerFile, "reviewer-secret");
|
|
||||||
const outcome: RuntimeOutcome = source.ok
|
|
||||||
? {
|
|
||||||
...source,
|
|
||||||
profile: { type: "codex", model: "gpt-test" },
|
|
||||||
reviewPath: `${profileHome}/review.md`,
|
|
||||||
anchorsPath: `${profileHome}/anchors.json`,
|
|
||||||
}
|
|
||||||
: source;
|
|
||||||
await Deno.writeTextFile(outcomeFile, JSON.stringify(outcome));
|
|
||||||
if (outcome.ok) {
|
|
||||||
if (review !== undefined) {
|
|
||||||
await Deno.writeTextFile(outcome.reviewPath, review, { mode: 0o600 });
|
|
||||||
await Deno.chmod(outcome.reviewPath, 0o600);
|
|
||||||
}
|
|
||||||
if (anchors !== undefined) {
|
|
||||||
await Deno.writeTextFile(outcome.anchorsPath, JSON.stringify(anchors), {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
await Deno.chmod(outcome.anchorsPath, 0o600);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const manifestPath = `${runtimeDir}/manifest.json`;
|
|
||||||
const manifest: RuntimeManifest = {
|
|
||||||
runtimeDir,
|
|
||||||
manifestPath,
|
|
||||||
reviewerFile,
|
|
||||||
outcomeFile,
|
|
||||||
profiles: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
type: "claude",
|
|
||||||
model: "test",
|
|
||||||
uid: Deno.uid()!,
|
|
||||||
gid: Deno.gid()!,
|
|
||||||
home: profileHome,
|
|
||||||
tokenFile: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
await Deno.writeTextFile(manifestPath, JSON.stringify(manifest));
|
|
||||||
return {
|
|
||||||
manifestPath,
|
|
||||||
runtimeDir,
|
|
||||||
reviewerFile,
|
|
||||||
reviewPath: `${profileHome}/review.md`,
|
|
||||||
outcomeFile,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const missing = async (path: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
await Deno.stat(path);
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Deno.errors.NotFound) return true;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Deno.test("posts a successful issue result and removes runtime credentials", async () => {
|
|
||||||
const result = await fixture({ ok: true, body: "done" });
|
|
||||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
|
||||||
|
|
||||||
await postRuntime(result.manifestPath, context, (url, init) => {
|
|
||||||
requests.push({ url: String(url), init });
|
|
||||||
return Promise.resolve(new Response(null, { status: 201 }));
|
|
||||||
});
|
|
||||||
|
|
||||||
assertEquals(requests.length, 1);
|
|
||||||
assertEquals(requests[0].url, `${context.apiUrl}/repos/owner/repo/issues/7/comments`);
|
|
||||||
assertEquals(JSON.parse(String(requests[0].init?.body)), {
|
|
||||||
body: "done\n\n> Profile: `codex` · `gpt-test`",
|
|
||||||
});
|
|
||||||
assertEquals(requests[0].init?.headers, {
|
|
||||||
Authorization: "token reviewer-secret",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
});
|
|
||||||
assert(await missing(result.runtimeDir));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("retries a rejected inline review without anchors", async () => {
|
|
||||||
const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [
|
|
||||||
{ path: "run.ts", line: 1, side: "new", body: "Finding" },
|
|
||||||
]);
|
|
||||||
const bodies: unknown[] = [];
|
|
||||||
|
|
||||||
await postRuntime(
|
|
||||||
result.manifestPath,
|
|
||||||
{
|
|
||||||
...context,
|
|
||||||
eventName: "pull_request",
|
|
||||||
},
|
|
||||||
(_url, init) => {
|
|
||||||
bodies.push(JSON.parse(String(init?.body)));
|
|
||||||
return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 }));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals((bodies[0] as { comments: unknown[] }).comments.length, 1);
|
|
||||||
assertEquals(
|
|
||||||
(bodies[0] as { comments: Array<{ body: string }> }).comments[0].body,
|
|
||||||
"Finding\n\n> Profile: `codex` · `gpt-test`",
|
|
||||||
);
|
|
||||||
assertEquals((bodies[1] as { comments: unknown[] }).comments, []);
|
|
||||||
assertEquals(
|
|
||||||
(bodies[1] as { body: string }).body,
|
|
||||||
"✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
|
|
||||||
);
|
|
||||||
assert(await missing(result.runtimeDir));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("reports each tried profile and keeps the action failed", async () => {
|
|
||||||
const result = await fixture({
|
|
||||||
ok: false,
|
|
||||||
failures: [
|
|
||||||
{ type: "codex", model: "gpt-test", reason: "sign-in failed" },
|
|
||||||
{ type: "claude", model: "claude-test", reason: "usage exhausted" },
|
|
||||||
{ type: "opencode", model: "openrouter/test", reason: "failed" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const bodies: unknown[] = [];
|
|
||||||
|
|
||||||
await assertRejects(
|
|
||||||
() =>
|
|
||||||
postRuntime(result.manifestPath, context, (_url, init) => {
|
|
||||||
bodies.push(JSON.parse(String(init?.body)));
|
|
||||||
return Promise.resolve(new Response(null, { status: 201 }));
|
|
||||||
}),
|
|
||||||
Error,
|
|
||||||
"agent phase failed",
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(bodies, [
|
|
||||||
{
|
|
||||||
body:
|
|
||||||
"AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " +
|
|
||||||
"`claude` · `claude-test` (usage exhausted), " +
|
|
||||||
"`opencode` · `openrouter/test` (failed)",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
|
|
||||||
assert(await missing(result.runtimeDir));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("drops failure entries that are not known labels", async () => {
|
|
||||||
const result = await fixture({ ok: false, failures: [] });
|
|
||||||
await Deno.writeTextFile(
|
|
||||||
result.outcomeFile,
|
|
||||||
JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
failures: [
|
|
||||||
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" },
|
|
||||||
{ type: "shell", model: "x", reason: "failed" },
|
|
||||||
{ type: "claude", model: "claude-test", reason: "sign-in failed" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const bodies: unknown[] = [];
|
|
||||||
|
|
||||||
await assertRejects(
|
|
||||||
() =>
|
|
||||||
postRuntime(result.manifestPath, context, (_url, init) => {
|
|
||||||
bodies.push(JSON.parse(String(init?.body)));
|
|
||||||
return Promise.resolve(new Response(null, { status: 201 }));
|
|
||||||
}),
|
|
||||||
Error,
|
|
||||||
"agent phase failed",
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(bodies, [
|
|
||||||
{
|
|
||||||
body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("reports a generic failure when the runner wrote no outcome", async () => {
|
|
||||||
const result = await fixture({ ok: false, failures: [] });
|
|
||||||
await Deno.remove(result.outcomeFile);
|
|
||||||
const bodies: unknown[] = [];
|
|
||||||
|
|
||||||
await assertRejects(
|
|
||||||
() =>
|
|
||||||
postRuntime(result.manifestPath, context, (_url, init) => {
|
|
||||||
bodies.push(JSON.parse(String(init?.body)));
|
|
||||||
return Promise.resolve(new Response(null, { status: 201 }));
|
|
||||||
}),
|
|
||||||
Error,
|
|
||||||
"agent phase failed",
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(bodies, [{ body: "AI bot failed.\n\n> Profile: none" }]);
|
|
||||||
assert(await missing(result.runtimeDir));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("removes runtime credentials when posting fails", async () => {
|
|
||||||
const result = await fixture({ ok: true, body: "done" });
|
|
||||||
|
|
||||||
await assertRejects(
|
|
||||||
() =>
|
|
||||||
postRuntime(result.manifestPath, context, () =>
|
|
||||||
Promise.resolve(new Response("failed", { status: 500 })),
|
|
||||||
),
|
|
||||||
Error,
|
|
||||||
"500",
|
|
||||||
);
|
|
||||||
assert(await missing(result.runtimeDir));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("preserves a Gitea transport failure without retrying a comment", async () => {
|
|
||||||
const result = await fixture({ ok: true, body: "done" });
|
|
||||||
let requests = 0;
|
|
||||||
|
|
||||||
await assertRejects(
|
|
||||||
() =>
|
|
||||||
postRuntime(result.manifestPath, context, () => {
|
|
||||||
requests++;
|
|
||||||
return Promise.reject(new Error(`network failure ${requests}`));
|
|
||||||
}),
|
|
||||||
Error,
|
|
||||||
"network failure 1",
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(requests, 1);
|
|
||||||
assert(await missing(result.runtimeDir));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("refuses to follow an agent artifact symlink", async () => {
|
|
||||||
if (Deno.build.os === "windows") return;
|
|
||||||
const result = await fixture({ ok: true, body: "done" });
|
|
||||||
await Deno.symlink(result.reviewerFile, result.reviewPath);
|
|
||||||
let requests = 0;
|
|
||||||
|
|
||||||
await assertRejects(
|
|
||||||
() =>
|
|
||||||
postRuntime(result.manifestPath, context, () => {
|
|
||||||
requests++;
|
|
||||||
return Promise.resolve(new Response(null, { status: 200 }));
|
|
||||||
}),
|
|
||||||
Error,
|
|
||||||
"regular private file",
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(requests, 1);
|
|
||||||
assert(await missing(result.runtimeDir));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("reports an attributed failure when a pull request review is missing", async () => {
|
|
||||||
const result = await fixture({ ok: true, body: "done" });
|
|
||||||
const bodies: unknown[] = [];
|
|
||||||
|
|
||||||
await assertRejects(
|
|
||||||
() =>
|
|
||||||
postRuntime(
|
|
||||||
result.manifestPath,
|
|
||||||
{
|
|
||||||
...context,
|
|
||||||
eventName: "pull_request",
|
|
||||||
},
|
|
||||||
(_url, init) => {
|
|
||||||
bodies.push(JSON.parse(String(init?.body)));
|
|
||||||
return Promise.resolve(new Response(null, { status: 201 }));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Error,
|
|
||||||
"did not write",
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(bodies, [
|
|
||||||
{
|
|
||||||
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
assert(await missing(result.runtimeDir));
|
|
||||||
});
|
|
||||||
+9
-6
@@ -1,8 +1,13 @@
|
|||||||
|
import { appendFile, rm } from "node:fs/promises";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
import { prepareRuntime } from "./runtime.ts";
|
import { prepareRuntime } from "./runtime.ts";
|
||||||
|
|
||||||
const env = (name: string): string => {
|
const env = (name: string): string => {
|
||||||
const value = Deno.env.get(name);
|
const value = process.env[name];
|
||||||
if (!value) throw new Error(`${name} is not set`);
|
if (value === undefined || value === "") {
|
||||||
|
throw new Error(`${name} is not set`);
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -12,10 +17,8 @@ const manifest = await prepareRuntime(
|
|||||||
env("BOT_AGENTS_MASTER_KEY"),
|
env("BOT_AGENTS_MASTER_KEY"),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await Deno.writeTextFile(env("GITHUB_OUTPUT"), `manifest=${manifest.manifestPath}\n`, {
|
await appendFile(env("GITHUB_OUTPUT"), `manifest=${manifest.manifestPath}\n`);
|
||||||
append: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(() => undefined);
|
await rm(manifest.runtimeDir, { recursive: true }).catch(() => {});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
import { expect, test } from "vite-plus/test";
|
||||||
import {
|
import {
|
||||||
AgentAuthError,
|
AgentAuthError,
|
||||||
agentEnvironment,
|
agentEnvironment,
|
||||||
@@ -9,14 +9,13 @@ import {
|
|||||||
sanitizedEnvironment,
|
sanitizedEnvironment,
|
||||||
} from "./profiles.ts";
|
} from "./profiles.ts";
|
||||||
|
|
||||||
Deno.test("formats profile attribution as a Markdown quote", () => {
|
test("formats profile attribution as a Markdown quote", () => {
|
||||||
assertEquals(
|
expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
|
||||||
profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }),
|
|
||||||
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
|
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("falls through only after usage exhaustion", async () => {
|
test("falls through only after usage exhaustion", async () => {
|
||||||
const visited: string[] = [];
|
const visited: string[] = [];
|
||||||
const result = await runProfiles(
|
const result = await runProfiles(
|
||||||
[
|
[
|
||||||
@@ -32,11 +31,11 @@ Deno.test("falls through only after usage exhaustion", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(result, "done");
|
expect(result).toBe("done");
|
||||||
assertEquals(visited, ["claude", "codex"]);
|
expect(visited).toStrictEqual(["claude", "codex"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.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" },
|
||||||
@@ -50,10 +49,10 @@ Deno.test("recognizes Claude's weekly limit message", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(result, "fallback");
|
expect(result).toBe("fallback");
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("recognizes provider API exhaustion status", async () => {
|
test("recognizes provider API exhaustion status", async () => {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) =>
|
["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) =>
|
||||||
runProfiles(
|
runProfiles(
|
||||||
@@ -69,10 +68,10 @@ Deno.test("recognizes provider API exhaustion status", async () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(results, ["fallback", "fallback"]);
|
expect(results).toStrictEqual(["fallback", "fallback"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("falls through after a sign-in failure and records each try", async () => {
|
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(
|
||||||
[
|
[
|
||||||
@@ -90,82 +89,73 @@ Deno.test("falls through after a sign-in failure and records each try", async ()
|
|||||||
failures,
|
failures,
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(result, "fallback");
|
expect(result).toBe("fallback");
|
||||||
assertEquals(failures, [
|
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" },
|
||||||
]);
|
]);
|
||||||
assertEquals(
|
expect(failureFooter(failures)).toStrictEqual(
|
||||||
failureFooter(failures),
|
|
||||||
"> 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)",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("throws the last sign-in failure when every profile fails", async () => {
|
test("throws the last sign-in failure when every profile fails", async () => {
|
||||||
const failures: ProfileFailure[] = [];
|
const failures: ProfileFailure[] = [];
|
||||||
await assertRejects(
|
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`);
|
},
|
||||||
},
|
failures,
|
||||||
failures,
|
),
|
||||||
),
|
).rejects.toThrow(
|
||||||
AgentAuthError,
|
expect.toSatisfy(
|
||||||
"claude sign-in failed",
|
(e: unknown) => e instanceof AgentAuthError && e.message.includes("claude sign-in failed"),
|
||||||
);
|
),
|
||||||
assertEquals(
|
|
||||||
failures.map(({ reason }) => reason),
|
|
||||||
["sign-in failed", "sign-in failed"],
|
|
||||||
);
|
);
|
||||||
|
expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("preserves an ordinary agent failure", async () => {
|
test("preserves an ordinary agent failure", async () => {
|
||||||
const visited: string[] = [];
|
const visited: string[] = [];
|
||||||
await assertRejects(
|
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);
|
throw new Error("tests failed");
|
||||||
throw new Error("tests failed");
|
},
|
||||||
},
|
),
|
||||||
),
|
).rejects.toThrow("tests failed");
|
||||||
Error,
|
expect(visited).toStrictEqual(["claude"]);
|
||||||
"tests failed",
|
|
||||||
);
|
|
||||||
assertEquals(visited, ["claude"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("does not mistake incidental quota text for exhaustion", async () => {
|
test("does not mistake incidental quota text for exhaustion", async () => {
|
||||||
const visited: string[] = [];
|
const visited: string[] = [];
|
||||||
await assertRejects(
|
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);
|
throw new Error("quota parser test failed at line 429");
|
||||||
throw new Error("quota parser test failed at line 429");
|
},
|
||||||
},
|
),
|
||||||
),
|
).rejects.toThrow("quota parser test failed");
|
||||||
Error,
|
expect(visited).toStrictEqual(["claude"]);
|
||||||
"quota parser test failed",
|
|
||||||
);
|
|
||||||
assertEquals(visited, ["claude"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("exposes only the selected profile credential to the agent", () => {
|
test("exposes only the selected profile credential to the agent", () => {
|
||||||
const base = {
|
const base = {
|
||||||
PATH: "/usr/bin",
|
PATH: "/usr/bin",
|
||||||
HOME: "/home/bot",
|
HOME: "/home/bot",
|
||||||
@@ -190,18 +180,18 @@ Deno.test("exposes only the selected profile credential to the agent", () => {
|
|||||||
tokenFile: "/runtime/profile-0/token",
|
tokenFile: "/runtime/profile-0/token",
|
||||||
};
|
};
|
||||||
|
|
||||||
assertEquals(agentEnvironment(base, profile, "selected"), {
|
expect(agentEnvironment(base, profile, "selected")).toStrictEqual({
|
||||||
PATH: "/usr/bin",
|
PATH: "/usr/bin",
|
||||||
HOME: "/runtime/profile-0",
|
HOME: "/runtime/profile-0",
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: "selected",
|
CLAUDE_CODE_OAUTH_TOKEN: "selected",
|
||||||
});
|
});
|
||||||
assertEquals(sanitizedEnvironment(base), {
|
expect(sanitizedEnvironment(base)).toStrictEqual({
|
||||||
PATH: "/usr/bin",
|
PATH: "/usr/bin",
|
||||||
HOME: "/home/bot",
|
HOME: "/home/bot",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.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,
|
||||||
@@ -212,14 +202,14 @@ Deno.test("uses a private Codex home without inventing an API credential", () =>
|
|||||||
tokenFile: null,
|
tokenFile: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
assertEquals(agentEnvironment({ PATH: "/bin" }, profile), {
|
expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({
|
||||||
PATH: "/bin",
|
PATH: "/bin",
|
||||||
HOME: "/runtime/profile-1",
|
HOME: "/runtime/profile-1",
|
||||||
CODEX_HOME: "/runtime/profile-1/.codex",
|
CODEX_HOME: "/runtime/profile-1/.codex",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.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,
|
||||||
@@ -230,7 +220,7 @@ Deno.test("exposes an OpenRouter key only to the OpenCode profile", () => {
|
|||||||
tokenFile: "/runtime/profile-2/token",
|
tokenFile: "/runtime/profile-2/token",
|
||||||
};
|
};
|
||||||
|
|
||||||
assertEquals(agentEnvironment({ PATH: "/bin" }, profile, "selected"), {
|
expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({
|
||||||
PATH: "/bin",
|
PATH: "/bin",
|
||||||
HOME: "/runtime/profile-2",
|
HOME: "/runtime/profile-2",
|
||||||
OPENROUTER_API_KEY: "selected",
|
OPENROUTER_API_KEY: "selected",
|
||||||
+45
-18
@@ -1,8 +1,17 @@
|
|||||||
export type BotProfile = {
|
export type ProfileType = "claude" | "codex" | "opencode";
|
||||||
type: "claude" | "codex" | "opencode";
|
|
||||||
|
export interface BotProfile {
|
||||||
|
type: ProfileType;
|
||||||
model: string;
|
model: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
|
||||||
|
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
|
||||||
|
|
||||||
|
export function isProfileType(value: unknown): value is ProfileType {
|
||||||
|
return PROFILE_TYPES.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
|
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
|
||||||
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
|
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
|
||||||
@@ -14,6 +23,10 @@ export function withProfile(body: string, profile: Pick<BotProfile, "type" | "mo
|
|||||||
|
|
||||||
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
|
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
|
||||||
|
|
||||||
|
export function isFailureReason(value: unknown): value is FailureReason {
|
||||||
|
return FAILURE_REASONS.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
|
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
|
||||||
reason: FailureReason;
|
reason: FailureReason;
|
||||||
};
|
};
|
||||||
@@ -22,7 +35,7 @@ export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
|
|||||||
export class AgentAuthError extends Error {}
|
export class AgentAuthError extends Error {}
|
||||||
|
|
||||||
function isUsageExhausted(error: unknown): boolean {
|
function isUsageExhausted(error: unknown): boolean {
|
||||||
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/i.test(
|
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test(
|
||||||
String(error),
|
String(error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -39,22 +52,35 @@ export function failureFooter(failures: ProfileFailure[]): string {
|
|||||||
.join(", ")}`;
|
.join(", ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
|
// Tries the profiles from `index` on, moving on only after a sign-in failure
|
||||||
|
// or exhausted usage, and records every profile it tried.
|
||||||
|
async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||||
|
profiles: P[],
|
||||||
|
attempt: (profile: P) => Promise<T>,
|
||||||
|
failures: ProfileFailure[],
|
||||||
|
index: number,
|
||||||
|
): Promise<T> {
|
||||||
|
const profile = profiles[index];
|
||||||
|
if (profile === undefined) {
|
||||||
|
throw new Error("profiles must contain at least one profile");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await attempt(profile);
|
||||||
|
} catch (error) {
|
||||||
|
const reason = failureReason(error);
|
||||||
|
failures.push({ type: profile.type, model: profile.model, reason });
|
||||||
|
if (reason === "failed" || index === profiles.length - 1) throw error;
|
||||||
|
console.error(`${profile.type} ${reason}; trying next profile`);
|
||||||
|
return attemptFrom(profiles, attempt, failures, index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||||
profiles: P[],
|
profiles: P[],
|
||||||
attempt: (profile: P) => Promise<T>,
|
attempt: (profile: P) => Promise<T>,
|
||||||
failures: ProfileFailure[] = [],
|
failures: ProfileFailure[] = [],
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
for (const [index, profile] of profiles.entries()) {
|
return attemptFrom(profiles, attempt, failures, 0);
|
||||||
try {
|
|
||||||
return await attempt(profile);
|
|
||||||
} catch (error) {
|
|
||||||
const reason = failureReason(error);
|
|
||||||
failures.push({ type: profile.type, model: profile.model, reason });
|
|
||||||
if (reason === "failed" || index === profiles.length - 1) throw error;
|
|
||||||
console.error(`${profile.type} ${reason}; trying next profile`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("profiles must contain at least one profile");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
|
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
|
||||||
@@ -85,7 +111,8 @@ export function sanitizedEnvironment(environment: Record<string, string>): Recor
|
|||||||
"SSL_CERT_DIR",
|
"SSL_CERT_DIR",
|
||||||
"NODE_EXTRA_CA_CERTS",
|
"NODE_EXTRA_CA_CERTS",
|
||||||
]) {
|
]) {
|
||||||
if (environment[name] !== undefined) result[name] = environment[name];
|
const value = environment[name];
|
||||||
|
if (value !== undefined) result[name] = value;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -98,7 +125,7 @@ export function agentEnvironment(
|
|||||||
const result = sanitizedEnvironment(environment);
|
const result = sanitizedEnvironment(environment);
|
||||||
result.HOME = profile.home;
|
result.HOME = profile.home;
|
||||||
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
|
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
|
||||||
if (token) {
|
if (token !== undefined && token !== "") {
|
||||||
const tokenName = {
|
const tokenName = {
|
||||||
claude: "CLAUDE_CODE_OAUTH_TOKEN",
|
claude: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||||
codex: "OPENAI_API_KEY",
|
codex: "OPENAI_API_KEY",
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// The agent phase's environment and the helpers every agent runner shares. It
|
||||||
|
// is read once, when the runner starts, as the single-file runner did.
|
||||||
|
import { chmod, chown, writeFile } from "node:fs/promises";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
import { agentCommand } from "./isolation.ts";
|
||||||
|
import { agentEnvironment, sanitizedEnvironment } from "./profiles.ts";
|
||||||
|
import { readRuntimeManifest } from "./runtime.ts";
|
||||||
|
import type { RuntimeProfile } from "./runtime.ts";
|
||||||
|
|
||||||
|
export const env = (name: string): string => {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error(`${name} is not set`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
function definedEnvironment(): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [name, value] of Object.entries(process.env)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
result[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API = env("GITEA_API_URL");
|
||||||
|
export const REPO = env("GITEA_REPOSITORY");
|
||||||
|
export const INDEX = env("ISSUE_INDEX");
|
||||||
|
export const EVENT = env("EVENT_NAME");
|
||||||
|
const AUTHOR_TOKEN = env("GITEA_TOKEN");
|
||||||
|
export const WORKSPACE = env("GITHUB_WORKSPACE");
|
||||||
|
export const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
|
||||||
|
export const PROCESS_ENV = sanitizedEnvironment(definedEnvironment());
|
||||||
|
|
||||||
|
export async function gitea(path: string, body?: unknown): Promise<Response> {
|
||||||
|
const response = await fetch(`${API}/${path}`, {
|
||||||
|
method: body === undefined ? "GET" : "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${AUTHOR_TOKEN}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: body === undefined ? null : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${path}: ${response.status} ${await response.text()}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postAuthorComment(body: string): Promise<void> {
|
||||||
|
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAgentCommand(
|
||||||
|
profile: RuntimeProfile,
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
token?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { success, code } = await agentCommand(profile, command, args, {
|
||||||
|
cwd: WORKSPACE,
|
||||||
|
env: agentEnvironment(PROCESS_ENV, profile, token),
|
||||||
|
clearEnv: true,
|
||||||
|
}).output();
|
||||||
|
if (!success) {
|
||||||
|
throw new Error(`${command} ${args[0]} exited with ${code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
|
||||||
|
const path = `${profile.home}/${name}`;
|
||||||
|
await writeFile(path, "", { mode: 0o600 });
|
||||||
|
await chmod(path, 0o600);
|
||||||
|
await chown(path, profile.uid, profile.gid);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
+84
-364
@@ -1,113 +1,66 @@
|
|||||||
// Runs one ordered agent profile. Credential decryption and reviewer posting
|
// Runs one ordered agent profile. Credential decryption and reviewer posting
|
||||||
// live in separate processes so neither privileged secret reaches this runner.
|
// live in separate processes so neither privileged secret reaches this runner.
|
||||||
import { TextLineStream } from "jsr:@std/streams@1/text-line-stream";
|
import { chmod, readFile, writeFile } from "node:fs/promises";
|
||||||
import {
|
|
||||||
type CodexAuthentication,
|
|
||||||
codexAuthentication,
|
|
||||||
isClaudeAuthFailure,
|
|
||||||
isCodexAuthFailure,
|
|
||||||
persistCodexAuth,
|
|
||||||
retryInvalidToken,
|
|
||||||
stageCodexAuth,
|
|
||||||
} from "./auth.ts";
|
|
||||||
import { agentCommand, killProfileProcesses, readProfileFile } from "./isolation.ts";
|
|
||||||
import {
|
|
||||||
AgentAuthError,
|
|
||||||
agentEnvironment,
|
|
||||||
type ProfileFailure,
|
|
||||||
runProfiles,
|
|
||||||
sanitizedEnvironment,
|
|
||||||
withProfile,
|
|
||||||
} from "./profiles.ts";
|
|
||||||
import {
|
|
||||||
collectOpenCodeOutput,
|
|
||||||
openCodeArgs,
|
|
||||||
openCodeError,
|
|
||||||
parseOpenCodeEvents,
|
|
||||||
} from "./opencode.ts";
|
|
||||||
import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "./runtime.ts";
|
|
||||||
|
|
||||||
type GiteaUser = { login: string; email: string };
|
import { codexAuthentication, persistCodexAuth, stageCodexAuth } from "./auth.ts";
|
||||||
type GiteaComment = { user: GiteaUser; created_at: string; body: string };
|
import { runAgent } from "./agents.ts";
|
||||||
type AttemptResult = {
|
import type { AgentAnswer } from "./agents.ts";
|
||||||
|
import { output } from "./command.ts";
|
||||||
|
import { killProfileProcesses, readProfileFile } from "./isolation.ts";
|
||||||
|
import { isObject } from "./json.ts";
|
||||||
|
import { runProfiles } from "./profiles.ts";
|
||||||
|
import type { ProfileFailure } from "./profiles.ts";
|
||||||
|
import {
|
||||||
|
API,
|
||||||
|
env,
|
||||||
|
EVENT,
|
||||||
|
gitea,
|
||||||
|
INDEX,
|
||||||
|
makeProfileFile,
|
||||||
|
MANIFEST,
|
||||||
|
PROCESS_ENV,
|
||||||
|
REPO,
|
||||||
|
runAgentCommand,
|
||||||
|
WORKSPACE,
|
||||||
|
} from "./run-context.ts";
|
||||||
|
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
|
||||||
|
|
||||||
|
interface AttemptResult {
|
||||||
body: string;
|
body: string;
|
||||||
reviewPath: string;
|
reviewPath: string;
|
||||||
anchorsPath: string;
|
anchorsPath: string;
|
||||||
profile: Pick<RuntimeProfile, "type" | "model">;
|
profile: Pick<RuntimeProfile, "type" | "model">;
|
||||||
};
|
}
|
||||||
|
|
||||||
const env = (name: string): string => {
|
interface RenderedPrompt {
|
||||||
const value = Deno.env.get(name);
|
prompt: string;
|
||||||
if (value === undefined) throw new Error(`${name} is not set`);
|
reviewPath: string;
|
||||||
return value;
|
anchorsPath: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
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 WORKSPACE = env("GITHUB_WORKSPACE");
|
|
||||||
const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
|
|
||||||
const PROCESS_ENV = sanitizedEnvironment(Deno.env.toObject());
|
|
||||||
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
|
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
|
||||||
const CODEX_AUTH = "/root/.codex/auth.json";
|
const CODEX_AUTH = "/root/.codex/auth.json";
|
||||||
const SKILLS_INSTALL = "skills add obra/superpowers";
|
const SKILLS_INSTALL = "skills add obra/superpowers";
|
||||||
|
|
||||||
async function gitea(path: string, body?: unknown): Promise<Response> {
|
|
||||||
const response = await fetch(`${API}/${path}`, {
|
|
||||||
method: body === undefined ? "GET" : "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${AUTHOR_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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postAuthorComment(body: string): Promise<void> {
|
|
||||||
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runRoot(command: string, args: string[]): Promise<void> {
|
async function runRoot(command: string, args: string[]): Promise<void> {
|
||||||
const { success, code } = await new Deno.Command(command, {
|
const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true });
|
||||||
args,
|
if (!success) {
|
||||||
env: PROCESS_ENV,
|
throw new Error(`${command} ${args[0]} exited with ${code}`);
|
||||||
clearEnv: true,
|
}
|
||||||
}).output();
|
|
||||||
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function configureGitAuthor(): Promise<void> {
|
async function configureGitAuthor(): Promise<void> {
|
||||||
const user: GiteaUser = await (await gitea("user")).json();
|
const user: unknown = await (await gitea("user")).json();
|
||||||
for (const [key, value] of [
|
if (!isObject(user) || typeof user.login !== "string" || typeof user.email !== "string") {
|
||||||
["name", user.login],
|
throw new Error("user: unexpected Gitea response");
|
||||||
["email", user.email],
|
|
||||||
]) {
|
|
||||||
await runRoot("git", ["config", `user.${key}`, value]);
|
|
||||||
}
|
}
|
||||||
|
await runRoot("git", ["config", "user.name", user.login]);
|
||||||
|
await runRoot("git", ["config", "user.email", user.email]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
|
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
|
||||||
return profile.tokenFile === null ? undefined : await Deno.readTextFile(profile.tokenFile);
|
const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8");
|
||||||
}
|
return token;
|
||||||
|
|
||||||
async function runAgentCommand(
|
|
||||||
profile: RuntimeProfile,
|
|
||||||
command: string,
|
|
||||||
args: string[],
|
|
||||||
token?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const { success, code } = await agentCommand(profile, command, args, {
|
|
||||||
cwd: WORKSPACE,
|
|
||||||
env: agentEnvironment(PROCESS_ENV, profile, token),
|
|
||||||
clearEnv: true,
|
|
||||||
}).output();
|
|
||||||
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
|
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
|
||||||
@@ -128,19 +81,26 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderPrompt(
|
// One issue comment as the prompt quotes it.
|
||||||
profile: RuntimeProfile,
|
function formatComment(comment: unknown): string {
|
||||||
): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> {
|
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {};
|
||||||
const reviewPath = await makeProfileFile(profile, "review.md");
|
const login = isObject(user) ? user.login : undefined;
|
||||||
const anchorsPath = await makeProfileFile(profile, "anchors.json");
|
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
|
||||||
const comments: GiteaComment[] = await (
|
}
|
||||||
|
|
||||||
|
async function promptValues(
|
||||||
|
reviewPath: string,
|
||||||
|
anchorsPath: string,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const comments: unknown = await (
|
||||||
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
|
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
|
||||||
).json();
|
).json();
|
||||||
const values: Record<string, string> = {
|
if (!Array.isArray(comments)) {
|
||||||
|
throw new TypeError("comments: unexpected Gitea response");
|
||||||
|
}
|
||||||
|
return {
|
||||||
COMMENT: env("COMMENT"),
|
COMMENT: env("COMMENT"),
|
||||||
ISSUE_COMMENTS: comments
|
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
|
||||||
.map((comment) => `## ${comment.user.login} at ${comment.created_at}\n\n${comment.body}\n`)
|
|
||||||
.join("\n"),
|
|
||||||
CODE_RULES: await (await gitea(RULES_PATH)).text(),
|
CODE_RULES: await (await gitea(RULES_PATH)).text(),
|
||||||
EVENT_NAME: EVENT,
|
EVENT_NAME: EVENT,
|
||||||
GITEA_API_URL: API,
|
GITEA_API_URL: API,
|
||||||
@@ -149,302 +109,62 @@ async function renderPrompt(
|
|||||||
REVIEW_PATH: reviewPath,
|
REVIEW_PATH: reviewPath,
|
||||||
ANCHORS_PATH: anchorsPath,
|
ANCHORS_PATH: anchorsPath,
|
||||||
};
|
};
|
||||||
const template = await Deno.readTextFile(new URL("../prompt.md", import.meta.url));
|
}
|
||||||
|
|
||||||
|
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
|
||||||
|
const reviewPath = await makeProfileFile(profile, "review.md");
|
||||||
|
const anchorsPath = await makeProfileFile(profile, "anchors.json");
|
||||||
|
const values = await promptValues(reviewPath, anchorsPath);
|
||||||
|
const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8");
|
||||||
return {
|
return {
|
||||||
prompt: template.replace(/\$\{(\w+)\}/g, (match, name) => values[name] ?? match),
|
prompt: template.replaceAll(
|
||||||
|
/\$\{(\w+)\}/gu,
|
||||||
|
(match: string, name: string) => values[name] ?? match,
|
||||||
|
),
|
||||||
reviewPath,
|
reviewPath,
|
||||||
anchorsPath,
|
anchorsPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise<string> {
|
|
||||||
const claude = agentCommand(
|
|
||||||
profile,
|
|
||||||
"claude",
|
|
||||||
[
|
|
||||||
"--print",
|
|
||||||
"--dangerously-skip-permissions",
|
|
||||||
"--model",
|
|
||||||
profile.model,
|
|
||||||
"--output-format",
|
|
||||||
"stream-json",
|
|
||||||
"--verbose",
|
|
||||||
prompt,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: WORKSPACE,
|
|
||||||
env: {
|
|
||||||
...agentEnvironment(PROCESS_ENV, profile, token),
|
|
||||||
IS_SANDBOX: "1",
|
|
||||||
},
|
|
||||||
clearEnv: true,
|
|
||||||
stdout: "piped",
|
|
||||||
stderr: "piped",
|
|
||||||
},
|
|
||||||
).spawn();
|
|
||||||
let result:
|
|
||||||
| {
|
|
||||||
is_error?: boolean;
|
|
||||||
result?: string;
|
|
||||||
subtype: string;
|
|
||||||
api_error_status?: number | null;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
let error = "";
|
|
||||||
const copyError = claude.stderr.pipeThrough(new TextDecoderStream()).pipeTo(
|
|
||||||
new WritableStream({
|
|
||||||
write(chunk) {
|
|
||||||
error += chunk;
|
|
||||||
console.error(chunk);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
const [status] = await Promise.all([claude.status, copyError]);
|
|
||||||
if (result?.is_error) {
|
|
||||||
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
|
|
||||||
if (isClaudeAuthFailure(result)) {
|
|
||||||
throw new AgentAuthError("claude sign-in failed", { cause: failure });
|
|
||||||
}
|
|
||||||
throw failure;
|
|
||||||
}
|
|
||||||
if (result?.result === undefined) {
|
|
||||||
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
|
|
||||||
}
|
|
||||||
if (!status.success) {
|
|
||||||
throw new Error(error.trim() || `claude exited with ${status.code}`);
|
|
||||||
}
|
|
||||||
return result.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function codexDeviceLogin(
|
|
||||||
profile: RuntimeProfile,
|
|
||||||
environment: Record<string, string>,
|
|
||||||
): Promise<void> {
|
|
||||||
const login = agentCommand(profile, "codex", ["login", "--device-auth"], {
|
|
||||||
cwd: WORKSPACE,
|
|
||||||
env: environment,
|
|
||||||
clearEnv: true,
|
|
||||||
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)]);
|
|
||||||
const status = login.status;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
while (shown.trim() === "") {
|
|
||||||
const exited = await Promise.race([
|
|
||||||
status.then(() => true),
|
|
||||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 100)),
|
|
||||||
]);
|
|
||||||
if (exited) {
|
|
||||||
throw new AgentAuthError("AI bot login produced no instructions");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await postAuthorComment(withProfile(shown, profile));
|
|
||||||
const [result] = await Promise.all([status, drained]);
|
|
||||||
if (!result.success) {
|
|
||||||
throw new AgentAuthError(`codex login exited with ${result.code}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function codexApiLogin(
|
|
||||||
profile: RuntimeProfile,
|
|
||||||
environment: Record<string, string>,
|
|
||||||
token: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const login = agentCommand(profile, "codex", ["login", "--with-api-key"], {
|
|
||||||
cwd: WORKSPACE,
|
|
||||||
env: environment,
|
|
||||||
clearEnv: true,
|
|
||||||
stdin: "piped",
|
|
||||||
stderr: "piped",
|
|
||||||
}).spawn();
|
|
||||||
const writer = login.stdin.getWriter();
|
|
||||||
await writer.write(new TextEncoder().encode(`${token}\n`));
|
|
||||||
await writer.close();
|
|
||||||
const [status, error] = await Promise.all([login.status, new Response(login.stderr).text()]);
|
|
||||||
if (!status.success) {
|
|
||||||
throw new Error(error.trim() || `codex login exited with ${status.code}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
|
|
||||||
const path = `${profile.home}/${name}`;
|
|
||||||
await Deno.writeTextFile(path, "", { mode: 0o600 });
|
|
||||||
await Deno.chmod(path, 0o600);
|
|
||||||
await Deno.chown(path, profile.uid, profile.gid);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCodex(
|
|
||||||
profile: RuntimeProfile,
|
|
||||||
prompt: string,
|
|
||||||
authentication: CodexAuthentication,
|
|
||||||
): Promise<string> {
|
|
||||||
const token = authentication.kind === "api-key" ? authentication.token : undefined;
|
|
||||||
const environment = agentEnvironment(PROCESS_ENV, profile, token);
|
|
||||||
if (authentication.kind === "api-key") {
|
|
||||||
await codexApiLogin(profile, environment, authentication.token);
|
|
||||||
} else {
|
|
||||||
const loggedIn = await agentCommand(profile, "codex", ["login", "status"], {
|
|
||||||
cwd: WORKSPACE,
|
|
||||||
env: environment,
|
|
||||||
clearEnv: true,
|
|
||||||
}).output();
|
|
||||||
if (!loggedIn.success) await codexDeviceLogin(profile, environment);
|
|
||||||
}
|
|
||||||
const outputPath = await makeProfileFile(profile, "result.md");
|
|
||||||
const attempt = async () => {
|
|
||||||
const codex = agentCommand(
|
|
||||||
profile,
|
|
||||||
"codex",
|
|
||||||
[
|
|
||||||
"exec",
|
|
||||||
"--model",
|
|
||||||
profile.model,
|
|
||||||
"--dangerously-bypass-approvals-and-sandbox",
|
|
||||||
"--output-last-message",
|
|
||||||
outputPath,
|
|
||||||
prompt,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: WORKSPACE,
|
|
||||||
env: environment,
|
|
||||||
clearEnv: true,
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "piped",
|
|
||||||
},
|
|
||||||
).spawn();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let error = "";
|
|
||||||
for await (const chunk of codex.stderr) {
|
|
||||||
await Deno.stderr.write(chunk);
|
|
||||||
error += decoder.decode(chunk, { stream: true });
|
|
||||||
}
|
|
||||||
error += decoder.decode();
|
|
||||||
// Codex echoes the prompt, whose comments may quote auth errors.
|
|
||||||
return { status: await codex.status, error: error.replaceAll(prompt, "") };
|
|
||||||
};
|
|
||||||
const result =
|
|
||||||
authentication.kind === "api-key"
|
|
||||||
? await attempt()
|
|
||||||
: await retryInvalidToken(attempt, async () => {
|
|
||||||
await runAgentCommand(profile, "codex", ["logout"]);
|
|
||||||
await codexDeviceLogin(profile, environment);
|
|
||||||
});
|
|
||||||
if (!result.status.success) {
|
|
||||||
const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`);
|
|
||||||
if (isCodexAuthFailure(result.error)) {
|
|
||||||
throw new AgentAuthError("codex sign-in failed", { cause: failure });
|
|
||||||
}
|
|
||||||
throw failure;
|
|
||||||
}
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runOpenCode(
|
|
||||||
profile: RuntimeProfile,
|
|
||||||
token: string,
|
|
||||||
prompt: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const process = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), {
|
|
||||||
cwd: WORKSPACE,
|
|
||||||
env: agentEnvironment(PROCESS_ENV, profile, token),
|
|
||||||
clearEnv: true,
|
|
||||||
stdout: "piped",
|
|
||||||
stderr: "piped",
|
|
||||||
}).spawn();
|
|
||||||
const output = collectOpenCodeOutput(
|
|
||||||
process.stdout,
|
|
||||||
process.stderr,
|
|
||||||
async (chunk) => {
|
|
||||||
await Deno.stdout.write(chunk);
|
|
||||||
},
|
|
||||||
async (chunk) => {
|
|
||||||
await Deno.stderr.write(chunk);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const [status, streams] = await Promise.all([process.status, output]);
|
|
||||||
const error = streams.stderr.trim();
|
|
||||||
if (!status.success) {
|
|
||||||
throw (
|
|
||||||
openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return parseOpenCodeEvents(streams.stdout);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) : null;
|
const authentication = profile.type === "codex" ? codexAuthentication(token) : null;
|
||||||
let stagedCodexAuth: string | null = null;
|
let stagedCodexAuth: string | null = null;
|
||||||
let body: string | undefined;
|
let answer: AgentAnswer = {};
|
||||||
let resultPath: string | null = null;
|
let rendered: RenderedPrompt | undefined;
|
||||||
let reviewPath: string | undefined;
|
|
||||||
let anchorsPath: string | undefined;
|
|
||||||
try {
|
try {
|
||||||
if (authentication?.kind === "persistent") {
|
if (authentication?.kind === "persistent") {
|
||||||
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
|
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
|
||||||
}
|
}
|
||||||
await prepareProfile(profile);
|
await prepareProfile(profile);
|
||||||
const rendered = await renderPrompt(profile);
|
rendered = await renderPrompt(profile);
|
||||||
reviewPath = rendered.reviewPath;
|
answer = await runAgent(profile, token, rendered.prompt, authentication);
|
||||||
anchorsPath = rendered.anchorsPath;
|
|
||||||
if (profile.type === "claude") {
|
|
||||||
body = await runClaude(profile, token ?? "", rendered.prompt);
|
|
||||||
} else if (profile.type === "codex") {
|
|
||||||
resultPath = await runCodex(profile, rendered.prompt, authentication!);
|
|
||||||
} else {
|
|
||||||
body = await runOpenCode(profile, token ?? "", rendered.prompt);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await killProfileProcesses(profile.uid);
|
await killProfileProcesses(profile.uid);
|
||||||
if (stagedCodexAuth !== null) {
|
if (stagedCodexAuth !== null) {
|
||||||
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
|
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (resultPath !== null) body = await readProfileFile(profile, resultPath);
|
const body =
|
||||||
if (body === undefined || reviewPath === undefined || anchorsPath === undefined) {
|
answer.resultPath === undefined
|
||||||
|
? answer.body
|
||||||
|
: await readProfileFile(profile, answer.resultPath);
|
||||||
|
if (body === undefined || rendered === undefined) {
|
||||||
throw new Error("agent attempt produced no result");
|
throw new Error("agent attempt produced no result");
|
||||||
}
|
}
|
||||||
return {
|
const { reviewPath, anchorsPath } = rendered;
|
||||||
body,
|
return { body, reviewPath, anchorsPath, profile: { type: profile.type, model: profile.model } };
|
||||||
reviewPath,
|
|
||||||
anchorsPath,
|
|
||||||
profile: { type: profile.type, model: profile.model },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
|
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
|
||||||
await Deno.writeTextFile(MANIFEST.outcomeFile, JSON.stringify(outcome), {
|
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { mode: 0o600 });
|
||||||
mode: 0o600,
|
await chmod(MANIFEST.outcomeFile, 0o600);
|
||||||
});
|
|
||||||
await Deno.chmod(MANIFEST.outcomeFile, 0o600);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const failures: ProfileFailure[] = [];
|
const failures: ProfileFailure[] = [];
|
||||||
try {
|
try {
|
||||||
await configureGitAuthor();
|
await configureGitAuthor();
|
||||||
await writeOutcome({
|
await writeOutcome({ ok: true, ...(await runProfiles(MANIFEST.profiles, runProfile, failures)) });
|
||||||
ok: true,
|
|
||||||
...(await runProfiles(MANIFEST.profiles, runProfile, failures)),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
await writeOutcome({ ok: false, failures });
|
await writeOutcome({ ok: false, failures });
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import process from "node:process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { assert, expect, test } from "vite-plus/test";
|
||||||
|
|
||||||
|
import { output } from "./command.ts";
|
||||||
|
import { prepareRuntime } from "./runtime.ts";
|
||||||
|
import type { RuntimeProfile } from "./runtime.ts";
|
||||||
|
import { IS_ROOT, makeTempDir } from "./testing.ts";
|
||||||
|
|
||||||
|
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
||||||
|
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
||||||
|
|
||||||
|
// Runs `command` as the profile's user, from the root directory.
|
||||||
|
async function runAs(profile: RuntimeProfile, command: string, path: string): Promise<boolean> {
|
||||||
|
const result = await output(command, {
|
||||||
|
args: [path],
|
||||||
|
cwd: "/",
|
||||||
|
uid: profile.uid,
|
||||||
|
gid: profile.gid,
|
||||||
|
});
|
||||||
|
return result.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPrivate(path: string, uid: number, mode: number): Promise<void> {
|
||||||
|
const info = await stat(path);
|
||||||
|
expect(info.uid).toStrictEqual(uid);
|
||||||
|
expect(info.mode & 0o777).toBe(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credentials", async () => {
|
||||||
|
const root = await makeTempDir();
|
||||||
|
await chmod(root, 0o711);
|
||||||
|
const action = await makeTempDir();
|
||||||
|
try {
|
||||||
|
await writeFile(
|
||||||
|
`${action}/profiles.json`,
|
||||||
|
JSON.stringify({
|
||||||
|
"reviewer-token": ENCRYPTED,
|
||||||
|
profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const manifest = await prepareRuntime(action, root, MASTER_KEY);
|
||||||
|
const serialized = await readFile(manifest.manifestPath, "utf8");
|
||||||
|
assert(!serialized.includes("secret-token"));
|
||||||
|
expect(await readFile(manifest.reviewerFile, "utf8")).toBe("secret-token");
|
||||||
|
await expectPrivate(manifest.reviewerFile, 0, 0o600);
|
||||||
|
|
||||||
|
const [claude, codex] = manifest.profiles;
|
||||||
|
assert(claude !== undefined && codex !== undefined);
|
||||||
|
expect(claude.uid).not.toStrictEqual(codex.uid);
|
||||||
|
await expectPrivate(claude.home, claude.uid, 0o700);
|
||||||
|
assert(claude.tokenFile !== null);
|
||||||
|
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
|
||||||
|
await expectPrivate(claude.tokenFile, claude.uid, 0o600);
|
||||||
|
expect((await stat(codex.home)).uid).toStrictEqual(codex.uid);
|
||||||
|
expect(codex.tokenFile).toBe(null);
|
||||||
|
|
||||||
|
const ownToken = await output("/bin/cat", {
|
||||||
|
args: [claude.tokenFile],
|
||||||
|
cwd: "/",
|
||||||
|
uid: claude.uid,
|
||||||
|
gid: claude.gid,
|
||||||
|
});
|
||||||
|
expect(ownToken.success).toBe(true);
|
||||||
|
expect(new TextDecoder().decode(ownToken.stdout)).toBe("secret-token");
|
||||||
|
expect(await runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
|
||||||
|
expect(await runAs(claude, "/bin/ls", codex.home)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true });
|
||||||
|
await rm(action, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fails", async () => {
|
||||||
|
const root = await makeTempDir();
|
||||||
|
const action = await makeTempDir();
|
||||||
|
try {
|
||||||
|
await writeFile(
|
||||||
|
`${action}/profiles.json`,
|
||||||
|
JSON.stringify({
|
||||||
|
"reviewer-token": ENCRYPTED,
|
||||||
|
profiles: [{ type: "claude", token: ENCRYPTED }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = await output(process.execPath, {
|
||||||
|
args: [fileURLToPath(new URL("prepare.ts", import.meta.url))],
|
||||||
|
env: {
|
||||||
|
ACTION_PATH: action,
|
||||||
|
RUNNER_TEMP: root,
|
||||||
|
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
|
||||||
|
GITHUB_OUTPUT: action,
|
||||||
|
},
|
||||||
|
clearEnv: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(await readdir(root)).toStrictEqual([]);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true });
|
||||||
|
await rm(action, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
+56
-36
@@ -1,5 +1,8 @@
|
|||||||
import { join } from "jsr:@std/path@1";
|
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { parseCredentials } from "./credentials.ts";
|
import { parseCredentials } from "./credentials.ts";
|
||||||
|
import { isMapping } from "./json.ts";
|
||||||
import type { BotProfile, ProfileFailure } from "./profiles.ts";
|
import type { BotProfile, ProfileFailure } from "./profiles.ts";
|
||||||
|
|
||||||
const FIRST_PROFILE_UID = 20_000;
|
const FIRST_PROFILE_UID = 20_000;
|
||||||
@@ -12,13 +15,13 @@ export type RuntimeProfile = Pick<BotProfile, "type" | "model"> & {
|
|||||||
tokenFile: string | null;
|
tokenFile: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RuntimeManifest = {
|
export interface RuntimeManifest {
|
||||||
runtimeDir: string;
|
runtimeDir: string;
|
||||||
manifestPath: string;
|
manifestPath: string;
|
||||||
reviewerFile: string;
|
reviewerFile: string;
|
||||||
outcomeFile: string;
|
outcomeFile: string;
|
||||||
profiles: RuntimeProfile[];
|
profiles: RuntimeProfile[];
|
||||||
};
|
}
|
||||||
|
|
||||||
export type RuntimeOutcome =
|
export type RuntimeOutcome =
|
||||||
| {
|
| {
|
||||||
@@ -31,9 +34,27 @@ export type RuntimeOutcome =
|
|||||||
| { ok: false; failures: ProfileFailure[] };
|
| { ok: false; failures: ProfileFailure[] };
|
||||||
|
|
||||||
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
|
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
|
||||||
await Deno.writeTextFile(path, value, { mode: 0o600 });
|
await writeFile(path, value, { mode: 0o600 });
|
||||||
await Deno.chmod(path, 0o600);
|
await chmod(path, 0o600);
|
||||||
await Deno.chown(path, uid, gid);
|
await chown(path, uid, gid);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareProfile(
|
||||||
|
runtimeDir: string,
|
||||||
|
profile: BotProfile,
|
||||||
|
index: number,
|
||||||
|
): Promise<RuntimeProfile> {
|
||||||
|
const uid = FIRST_PROFILE_UID + index;
|
||||||
|
const home = join(runtimeDir, `profile-${index}`);
|
||||||
|
await mkdir(home, { mode: 0o700 });
|
||||||
|
await chmod(home, 0o700);
|
||||||
|
await chown(home, uid, uid);
|
||||||
|
const tokenFile =
|
||||||
|
profile.token === undefined || profile.token === "" ? null : join(home, "token");
|
||||||
|
if (tokenFile !== null && profile.token !== undefined) {
|
||||||
|
await writePrivate(tokenFile, profile.token, uid, uid);
|
||||||
|
}
|
||||||
|
return { index, type: profile.type, model: profile.model, uid, gid: uid, home, tokenFile };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prepareRuntime(
|
export async function prepareRuntime(
|
||||||
@@ -42,39 +63,23 @@ export async function prepareRuntime(
|
|||||||
masterKey: string,
|
masterKey: string,
|
||||||
): Promise<RuntimeManifest> {
|
): Promise<RuntimeManifest> {
|
||||||
const credentials = await parseCredentials(
|
const credentials = await parseCredentials(
|
||||||
await Deno.readTextFile(join(actionPath, "profiles.yml")),
|
await readFile(join(actionPath, "profiles.json"), "utf8"),
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
const runtimeDir = await Deno.makeTempDir({
|
const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-"));
|
||||||
dir: runtimeRoot,
|
|
||||||
prefix: "bot-agents-",
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
await Deno.chmod(runtimeDir, 0o711);
|
await chmod(runtimeDir, 0o711);
|
||||||
const reviewerFile = join(runtimeDir, "reviewer-token");
|
const reviewerFile = join(runtimeDir, "reviewer-token");
|
||||||
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
|
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
|
||||||
|
|
||||||
const profiles: RuntimeProfile[] = [];
|
// One profile at a time, in order, as each claims the next UID.
|
||||||
for (const [index, profile] of credentials.profiles.entries()) {
|
const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>(
|
||||||
const uid = FIRST_PROFILE_UID + index;
|
async (previous, profile, index) => [
|
||||||
const home = join(runtimeDir, `profile-${index}`);
|
...(await previous),
|
||||||
await Deno.mkdir(home, { mode: 0o700 });
|
await prepareProfile(runtimeDir, profile, index),
|
||||||
await Deno.chmod(home, 0o700);
|
],
|
||||||
await Deno.chown(home, uid, uid);
|
Promise.resolve([]),
|
||||||
const tokenFile = profile.token ? join(home, "token") : null;
|
);
|
||||||
if (tokenFile !== null) {
|
|
||||||
await writePrivate(tokenFile, profile.token!, uid, uid);
|
|
||||||
}
|
|
||||||
profiles.push({
|
|
||||||
index,
|
|
||||||
type: profile.type,
|
|
||||||
model: profile.model,
|
|
||||||
uid,
|
|
||||||
gid: uid,
|
|
||||||
home,
|
|
||||||
tokenFile,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifestPath = join(runtimeDir, "manifest.json");
|
const manifestPath = join(runtimeDir, "manifest.json");
|
||||||
const manifest: RuntimeManifest = {
|
const manifest: RuntimeManifest = {
|
||||||
@@ -87,11 +92,26 @@ export async function prepareRuntime(
|
|||||||
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
|
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
|
||||||
return manifest;
|
return manifest;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Deno.remove(runtimeDir, { recursive: true }).catch(() => undefined);
|
await rm(runtimeDir, { recursive: true }).catch(() => {});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readRuntimeManifest(path: string): Promise<RuntimeManifest> {
|
function isRuntimeManifest(value: unknown): value is RuntimeManifest {
|
||||||
return JSON.parse(await Deno.readTextFile(path));
|
return (
|
||||||
|
isMapping(value) &&
|
||||||
|
typeof value.runtimeDir === "string" &&
|
||||||
|
typeof value.manifestPath === "string" &&
|
||||||
|
typeof value.reviewerFile === "string" &&
|
||||||
|
typeof value.outcomeFile === "string" &&
|
||||||
|
Array.isArray(value.profiles)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readRuntimeManifest(path: string): Promise<RuntimeManifest> {
|
||||||
|
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||||
|
if (!isRuntimeManifest(value)) {
|
||||||
|
throw new Error(`${path}: invalid runtime manifest`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { assert, assertEquals, assertNotEquals } from "jsr:@std/assert@1";
|
|
||||||
import { prepareRuntime } from "./runtime.ts";
|
|
||||||
|
|
||||||
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
|
||||||
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
|
||||||
|
|
||||||
Deno.test({
|
|
||||||
name: "prepares root-only reviewer and isolated profile credentials",
|
|
||||||
permissions: { read: true, write: true, run: true, sys: ["uid"] },
|
|
||||||
async fn() {
|
|
||||||
if (Deno.uid() !== 0) return;
|
|
||||||
const root = await Deno.makeTempDir();
|
|
||||||
await Deno.chmod(root, 0o711);
|
|
||||||
const action = await Deno.makeTempDir();
|
|
||||||
try {
|
|
||||||
await Deno.writeTextFile(
|
|
||||||
`${action}/profiles.yml`,
|
|
||||||
`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude\n token: ${ENCRYPTED}\n - type: codex\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const manifest = await prepareRuntime(action, root, MASTER_KEY);
|
|
||||||
const serialized = await Deno.readTextFile(manifest.manifestPath);
|
|
||||||
assert(!serialized.includes("secret-token"));
|
|
||||||
assertEquals(await Deno.readTextFile(manifest.reviewerFile), "secret-token");
|
|
||||||
assertEquals((await Deno.stat(manifest.reviewerFile)).mode! & 0o777, 0o600);
|
|
||||||
assertEquals((await Deno.stat(manifest.reviewerFile)).uid, 0);
|
|
||||||
|
|
||||||
const [claude, codex] = manifest.profiles;
|
|
||||||
assertNotEquals(claude.uid, codex.uid);
|
|
||||||
assertEquals((await Deno.stat(claude.home)).uid, claude.uid);
|
|
||||||
assertEquals((await Deno.stat(claude.home)).mode! & 0o777, 0o700);
|
|
||||||
assert(claude.tokenFile !== null);
|
|
||||||
assertEquals(await Deno.readTextFile(claude.tokenFile), "secret-token");
|
|
||||||
assertEquals((await Deno.stat(claude.tokenFile)).uid, claude.uid);
|
|
||||||
assertEquals((await Deno.stat(claude.tokenFile)).mode! & 0o777, 0o600);
|
|
||||||
assertEquals((await Deno.stat(codex.home)).uid, codex.uid);
|
|
||||||
assertEquals(codex.tokenFile, null);
|
|
||||||
|
|
||||||
const ownToken = await new Deno.Command("/bin/cat", {
|
|
||||||
args: [claude.tokenFile],
|
|
||||||
cwd: "/",
|
|
||||||
uid: claude.uid,
|
|
||||||
gid: claude.gid,
|
|
||||||
}).output();
|
|
||||||
assertEquals(ownToken.success, true);
|
|
||||||
assertEquals(new TextDecoder().decode(ownToken.stdout), "secret-token");
|
|
||||||
|
|
||||||
const reviewerToken = await new Deno.Command("/bin/cat", {
|
|
||||||
args: [manifest.reviewerFile],
|
|
||||||
cwd: "/",
|
|
||||||
uid: claude.uid,
|
|
||||||
gid: claude.gid,
|
|
||||||
}).output();
|
|
||||||
assertEquals(reviewerToken.success, false);
|
|
||||||
|
|
||||||
const siblingHome = await new Deno.Command("/bin/ls", {
|
|
||||||
args: [codex.home],
|
|
||||||
cwd: "/",
|
|
||||||
uid: claude.uid,
|
|
||||||
gid: claude.gid,
|
|
||||||
}).output();
|
|
||||||
assertEquals(siblingHome.success, false);
|
|
||||||
} finally {
|
|
||||||
await Deno.remove(root, { recursive: true });
|
|
||||||
await Deno.remove(action, { recursive: true });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test({
|
|
||||||
name: "removes prepared credentials when output publication fails",
|
|
||||||
permissions: "inherit",
|
|
||||||
async fn() {
|
|
||||||
if (Deno.uid() !== 0) return;
|
|
||||||
const root = await Deno.makeTempDir();
|
|
||||||
const action = await Deno.makeTempDir();
|
|
||||||
try {
|
|
||||||
await Deno.writeTextFile(
|
|
||||||
`${action}/profiles.yml`,
|
|
||||||
`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude\n token: ${ENCRYPTED}\n`,
|
|
||||||
);
|
|
||||||
const result = await new Deno.Command(Deno.execPath(), {
|
|
||||||
args: ["run", "-A", new URL("./prepare.ts", import.meta.url).pathname],
|
|
||||||
env: {
|
|
||||||
ACTION_PATH: action,
|
|
||||||
RUNNER_TEMP: root,
|
|
||||||
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
|
|
||||||
GITHUB_OUTPUT: action,
|
|
||||||
},
|
|
||||||
clearEnv: true,
|
|
||||||
}).output();
|
|
||||||
|
|
||||||
assertEquals(result.success, false);
|
|
||||||
const entries = [];
|
|
||||||
for await (const entry of Deno.readDir(root)) entries.push(entry.name);
|
|
||||||
assertEquals(entries, []);
|
|
||||||
} finally {
|
|
||||||
await Deno.remove(root, { recursive: true });
|
|
||||||
await Deno.remove(action, { recursive: true });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Test support: the current user's IDs and a fresh temporary directory.
|
||||||
|
import { mkdtemp } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
export function currentUid(): number {
|
||||||
|
return process.getuid?.() ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentGid(): number {
|
||||||
|
return process.getgid?.() ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The isolation tests switch UIDs, which only root may do.
|
||||||
|
export const IS_ROOT: boolean = currentUid() === 0;
|
||||||
|
|
||||||
|
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
|
||||||
|
export const IS_WINDOWS: boolean = process.platform === "win32";
|
||||||
|
|
||||||
|
export function makeTempDir(): Promise<string> {
|
||||||
|
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
|
||||||
|
}
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import { assert, assertEquals } from "jsr:@std/assert@1";
|
|
||||||
import { parse } from "jsr:@std/yaml@1";
|
|
||||||
|
|
||||||
type Mapping = Record<string, unknown>;
|
|
||||||
|
|
||||||
const mapping = (value: unknown, at: string): Mapping => {
|
|
||||||
assert(typeof value === "object" && value !== null && !Array.isArray(value), at);
|
|
||||||
return value as Mapping;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sequence = (value: unknown, at: string): Mapping[] => {
|
|
||||||
assert(Array.isArray(value), at);
|
|
||||||
return value.map((entry, index) => mapping(entry, `${at}[${index}]`));
|
|
||||||
};
|
|
||||||
|
|
||||||
async function yaml(path: string): Promise<Mapping> {
|
|
||||||
return mapping(parse(await Deno.readTextFile(path)), path);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function yamlFiles(directory = "."): Promise<string[]> {
|
|
||||||
const result: string[] = [];
|
|
||||||
for await (const entry of Deno.readDir(directory)) {
|
|
||||||
if (entry.name === ".git" || entry.name === ".superpowers") continue;
|
|
||||||
const path = directory === "." ? entry.name : `${directory}/${entry.name}`;
|
|
||||||
if (entry.isDirectory) result.push(...(await yamlFiles(path)));
|
|
||||||
if (entry.isFile && /\.ya?ml$/.test(entry.name)) result.push(path);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.test("all committed YAML parses", async () => {
|
|
||||||
const files = await yamlFiles();
|
|
||||||
assert(files.length > 0);
|
|
||||||
for (const file of files) await yaml(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("the action exposes only the master key and scopes it to preparation", async () => {
|
|
||||||
const action = await yaml("action.yml");
|
|
||||||
assertEquals(Object.keys(mapping(action.inputs, "inputs")), ["master-key"]);
|
|
||||||
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps");
|
|
||||||
for (const step of steps) {
|
|
||||||
if (typeof step.run === "string") {
|
|
||||||
assertEquals(step.run.includes("\n"), false, String(step.name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const checkout = steps.find((step) => step.uses === "actions/checkout@v4");
|
|
||||||
const prepare = steps.find((step) => step.id === "prepare");
|
|
||||||
const agent = steps.find((step) => step.id === "agent");
|
|
||||||
const post = steps.find((step) => step.id === "post");
|
|
||||||
const source = steps.find((step) => step.id === "source");
|
|
||||||
assert(checkout && source && prepare && agent && post);
|
|
||||||
assertEquals(mapping(checkout.with, "checkout.with").token, "${{ gitea.token }}");
|
|
||||||
assertEquals(
|
|
||||||
mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY,
|
|
||||||
"${{ inputs.master-key }}",
|
|
||||||
);
|
|
||||||
assertEquals(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY"), false);
|
|
||||||
assertEquals(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY"), false);
|
|
||||||
assertEquals(mapping(agent.env, "agent.env").GITEA_TOKEN, "${{ gitea.token }}");
|
|
||||||
assert(String(post.if).includes("always()"));
|
|
||||||
for (const step of [prepare, agent, post]) {
|
|
||||||
assert(String(step.run).includes("steps.source.outputs.path"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("the source protection script stages immutable action files", async () => {
|
|
||||||
const temp = await Deno.makeTempDir();
|
|
||||||
const outputFile = `${temp}/output`;
|
|
||||||
try {
|
|
||||||
const output = await new Deno.Command("/bin/bash", {
|
|
||||||
args: ["scripts/protect-source.sh"],
|
|
||||||
env: {
|
|
||||||
ACTION_PATH: Deno.cwd(),
|
|
||||||
GITHUB_OUTPUT: outputFile,
|
|
||||||
RUNNER_TEMP: temp,
|
|
||||||
},
|
|
||||||
stderr: "piped",
|
|
||||||
}).output();
|
|
||||||
assert(output.success, new TextDecoder().decode(output.stderr));
|
|
||||||
|
|
||||||
const path = (await Deno.readTextFile(outputFile)).trim().slice(5);
|
|
||||||
for (const file of [
|
|
||||||
"src/prepare.ts",
|
|
||||||
"src/run.ts",
|
|
||||||
"src/post.ts",
|
|
||||||
"profiles.yml",
|
|
||||||
"prompt.md",
|
|
||||||
]) {
|
|
||||||
const stat = await Deno.stat(`${path}/${file}`);
|
|
||||||
assert(stat.isFile, file);
|
|
||||||
assertEquals(stat.mode! & 0o022, 0, file);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await Deno.remove(temp, { recursive: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("module-relative runtime assets exist", async () => {
|
|
||||||
const module = new URL("run.ts", import.meta.url);
|
|
||||||
const source = await Deno.readTextFile(module);
|
|
||||||
for (const match of source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/g)) {
|
|
||||||
const asset = new URL(match[1], module);
|
|
||||||
const stat = await Deno.stat(asset);
|
|
||||||
assert(stat.isFile, asset.pathname);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("the repository uses its trusted main action", async () => {
|
|
||||||
const workflow = await yaml(".gitea/workflows/agents.yml");
|
|
||||||
const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent");
|
|
||||||
const permissions = mapping(job.permissions, "jobs.agent.permissions");
|
|
||||||
assertEquals(permissions, {
|
|
||||||
code: "write",
|
|
||||||
issues: "write",
|
|
||||||
"pull-requests": "write",
|
|
||||||
});
|
|
||||||
const steps = sequence(job.steps, "jobs.agent.steps");
|
|
||||||
const local = steps.findIndex((step) => step.uses === "./");
|
|
||||||
assertEquals(local, -1);
|
|
||||||
const trusted = steps.findIndex(
|
|
||||||
(step) => step.uses === "https://git.capsulizers.com/commons/bot-agents@main",
|
|
||||||
);
|
|
||||||
assert(trusted >= 0);
|
|
||||||
assertEquals(
|
|
||||||
mapping(steps[trusted].with, "trusted.with")["master-key"],
|
|
||||||
"${{ secrets.BOT_AGENTS_MASTER_KEY }}",
|
|
||||||
);
|
|
||||||
assertEquals(JSON.stringify(workflow).includes("gitea.actor != 'bot'"), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("CI checks every phase and the root isolation suite", async () => {
|
|
||||||
const workflow = await yaml(".gitea/workflows/check.yml");
|
|
||||||
const job = mapping(mapping(workflow.jobs, "jobs").deno, "jobs.deno");
|
|
||||||
const commands = sequence(job.steps, "jobs.deno.steps")
|
|
||||||
.map((step) => (typeof step.run === "string" ? step.run : ""))
|
|
||||||
.join("\n");
|
|
||||||
for (const entrypoint of ["src/prepare.ts", "src/run.ts", "src/post.ts"]) {
|
|
||||||
assert(commands.includes(entrypoint), entrypoint);
|
|
||||||
}
|
|
||||||
assert(commands.includes("deno test --allow-all"));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("uses the standard Skills installer without custom discovery", async () => {
|
|
||||||
const runner = await Deno.readTextFile("src/run.ts");
|
|
||||||
assert(runner.includes("skills add obra/superpowers"));
|
|
||||||
try {
|
|
||||||
await Deno.stat("superpowers.ts");
|
|
||||||
assert(false, "superpowers.ts should be removed");
|
|
||||||
} catch (error) {
|
|
||||||
assert(error instanceof Deno.errors.NotFound);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("installs the OpenCode CLI", async () => {
|
|
||||||
const action = await Deno.readTextFile("action.yml");
|
|
||||||
assert(action.includes("opencode-ai"));
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("profile preparation does not receive provider credentials", async () => {
|
|
||||||
const runner = await Deno.readTextFile("src/run.ts");
|
|
||||||
assert(runner.includes("await prepareProfile(profile);"));
|
|
||||||
assertEquals(runner.includes("prepareProfile(profile, token)"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("central profiles fall back from Codex to Claude to OpenCode", async () => {
|
|
||||||
const credentials = await yaml("profiles.yml");
|
|
||||||
const profiles = sequence(credentials.profiles, "profiles");
|
|
||||||
assertEquals(
|
|
||||||
profiles.map((profile) => profile.type),
|
|
||||||
["codex", "claude", "opencode"],
|
|
||||||
);
|
|
||||||
assertEquals(profiles[2].model, "openrouter/qwen/qwen3.8-27b");
|
|
||||||
assert(String(profiles[2].token).startsWith("v1:"));
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import process from "node:process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { expect, test } from "vite-plus/test";
|
||||||
|
|
||||||
|
import { output } from "../src/command.ts";
|
||||||
|
|
||||||
|
// Each phase runs as `node src/<phase>.ts` with no build step. Node loads the
|
||||||
|
// whole import graph, stripping types, before the phase reads its first
|
||||||
|
// variable, so failing on that variable proves every module loads.
|
||||||
|
const PHASES: [phase: string, firstVariable: string][] = [
|
||||||
|
["prepare", "ACTION_PATH"],
|
||||||
|
["run", "GITEA_API_URL"],
|
||||||
|
["post", "BOT_AGENTS_MANIFEST"],
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => {
|
||||||
|
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url));
|
||||||
|
const result = await output(process.execPath, {
|
||||||
|
args: [path],
|
||||||
|
env: { PATH: process.env.PATH ?? "" },
|
||||||
|
clearEnv: true,
|
||||||
|
stdin: "null",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(new TextDecoder().decode(result.stderr)).toContain(`${firstVariable} is not set`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { readdir, readFile, rm, stat } from "node:fs/promises";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
import { assert, expect, test } from "vite-plus/test";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
import { output } from "../src/command.ts";
|
||||||
|
import { isNotFound } from "../src/errors.ts";
|
||||||
|
import { isMapping } from "../src/json.ts";
|
||||||
|
import type { JsonObject } from "../src/json.ts";
|
||||||
|
import { makeTempDir } from "../src/testing.ts";
|
||||||
|
|
||||||
|
const mapping = (value: unknown, at: string): JsonObject => {
|
||||||
|
assert(isMapping(value), at);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sequence = (value: unknown, at: string): JsonObject[] => {
|
||||||
|
assert(Array.isArray(value), at);
|
||||||
|
return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`));
|
||||||
|
};
|
||||||
|
|
||||||
|
async function yaml(path: string): Promise<JsonObject> {
|
||||||
|
const value: unknown = parse(await readFile(path, "utf8"));
|
||||||
|
return mapping(value, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function yamlFiles(): Promise<string[]> {
|
||||||
|
const entries = await readdir(".", { recursive: true, withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && /\.ya?ml$/u.test(entry.name))
|
||||||
|
.map((entry) => `${entry.parentPath}/${entry.name}`.replace(/^\.\//u, ""))
|
||||||
|
.filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => {
|
||||||
|
const step = steps.find((entry) => entry[key] === value);
|
||||||
|
assert(step !== undefined, `${key}: ${value}`);
|
||||||
|
return step;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("all committed YAML parses", async () => {
|
||||||
|
const files = await yamlFiles();
|
||||||
|
assert(files.length > 0);
|
||||||
|
await Promise.all(files.map((file) => yaml(file)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the action exposes only the master key and scopes it to preparation", async () => {
|
||||||
|
const action = await yaml("action.yml");
|
||||||
|
expect(Object.keys(mapping(action.inputs, "inputs"))).toStrictEqual(["master-key"]);
|
||||||
|
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps");
|
||||||
|
for (const step of steps) {
|
||||||
|
if (typeof step.run === "string") {
|
||||||
|
expect(step.run.includes("\n"), String(step.name)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const checkout = findStep(steps, "uses", "actions/checkout@v4");
|
||||||
|
const prepare = findStep(steps, "id", "prepare");
|
||||||
|
const agent = findStep(steps, "id", "agent");
|
||||||
|
const post = findStep(steps, "id", "post");
|
||||||
|
findStep(steps, "id", "source");
|
||||||
|
expect(mapping(checkout.with, "checkout.with").token).toBe("${{ gitea.token }}");
|
||||||
|
expect(mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY).toBe(
|
||||||
|
"${{ inputs.master-key }}",
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
|
||||||
|
expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
|
||||||
|
expect(mapping(agent.env, "agent.env").GITEA_TOKEN).toBe("${{ gitea.token }}");
|
||||||
|
assert(String(post.if).includes("always()"));
|
||||||
|
for (const step of [prepare, agent, post]) {
|
||||||
|
assert(String(step.run).includes("steps.source.outputs.path"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the source protection script stages immutable action files", async () => {
|
||||||
|
const temp = await makeTempDir();
|
||||||
|
const outputFile = `${temp}/output`;
|
||||||
|
try {
|
||||||
|
const result = await output("/bin/bash", {
|
||||||
|
args: ["scripts/protect-source.sh"],
|
||||||
|
env: { ACTION_PATH: process.cwd(), GITHUB_OUTPUT: outputFile, RUNNER_TEMP: temp },
|
||||||
|
stderr: "piped",
|
||||||
|
});
|
||||||
|
assert(result.success, new TextDecoder().decode(result.stderr));
|
||||||
|
|
||||||
|
const path = (await readFile(outputFile, "utf8")).trim().slice(5);
|
||||||
|
const files = [
|
||||||
|
"src/prepare.ts",
|
||||||
|
"src/run.ts",
|
||||||
|
"src/post.ts",
|
||||||
|
"package.json",
|
||||||
|
"profiles.json",
|
||||||
|
"prompt.md",
|
||||||
|
];
|
||||||
|
const stats = await Promise.all(files.map((file) => stat(`${path}/${file}`)));
|
||||||
|
for (const [index, info] of stats.entries()) {
|
||||||
|
assert(info.isFile(), files[index]);
|
||||||
|
expect(info.mode & 0o022, files[index]).toStrictEqual(0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(temp, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("module-relative runtime assets exist", async () => {
|
||||||
|
const module = new URL("../src/run.ts", import.meta.url);
|
||||||
|
const source = await readFile(module, "utf8");
|
||||||
|
const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map(
|
||||||
|
([, asset = ""]) => new URL(asset, module),
|
||||||
|
);
|
||||||
|
const stats = await Promise.all(assets.map((asset) => stat(asset)));
|
||||||
|
for (const [index, info] of stats.entries()) {
|
||||||
|
assert(info.isFile(), assets[index]?.pathname);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the repository uses its trusted main action", async () => {
|
||||||
|
const workflow = await yaml(".gitea/workflows/agents.yml");
|
||||||
|
const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent");
|
||||||
|
const permissions = mapping(job.permissions, "jobs.agent.permissions");
|
||||||
|
expect(permissions).toStrictEqual({
|
||||||
|
code: "write",
|
||||||
|
issues: "write",
|
||||||
|
"pull-requests": "write",
|
||||||
|
});
|
||||||
|
const steps = sequence(job.steps, "jobs.agent.steps");
|
||||||
|
expect(steps.findIndex((step) => step.uses === "./")).toStrictEqual(-1);
|
||||||
|
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main");
|
||||||
|
expect(mapping(trusted.with, "trusted.with")["master-key"]).toBe(
|
||||||
|
"${{ secrets.BOT_AGENTS_MASTER_KEY }}",
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CI checks every phase and the root isolation suite", async () => {
|
||||||
|
const workflow = await yaml(".gitea/workflows/check.yml");
|
||||||
|
const job = mapping(mapping(workflow.jobs, "jobs").check, "jobs.check");
|
||||||
|
const commands = sequence(job.steps, "jobs.check.steps")
|
||||||
|
.map((step) => (typeof step.run === "string" ? step.run : ""))
|
||||||
|
.join("\n");
|
||||||
|
// `vp check` type-checks every phase, and `vp test` runs each of them under
|
||||||
|
// Node as well as the root-only isolation tests.
|
||||||
|
assert(commands.includes("npm ci"));
|
||||||
|
assert(commands.includes("vp check"));
|
||||||
|
assert(commands.includes("vp test"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the standard Skills installer without custom discovery", async () => {
|
||||||
|
const runner = await readFile("src/run.ts", "utf8");
|
||||||
|
assert(runner.includes("skills add obra/superpowers"));
|
||||||
|
const removed = await stat("superpowers.ts").then(
|
||||||
|
() => false,
|
||||||
|
(error: unknown) => isNotFound(error),
|
||||||
|
);
|
||||||
|
assert(removed, "superpowers.ts should be removed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("installs the OpenCode CLI", async () => {
|
||||||
|
const action = await readFile("action.yml", "utf8");
|
||||||
|
assert(action.includes("opencode-ai"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("profile preparation does not receive provider credentials", async () => {
|
||||||
|
const runner = await readFile("src/run.ts", "utf8");
|
||||||
|
assert(runner.includes("await prepareProfile(profile);"));
|
||||||
|
expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("central profiles fall back from Codex to Claude to OpenCode", async () => {
|
||||||
|
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
|
||||||
|
const profiles = sequence(mapping(credentials, "profiles.json").profiles, "profiles");
|
||||||
|
expect(profiles.map((profile) => profile.type)).toStrictEqual(["codex", "claude", "opencode"]);
|
||||||
|
expect(profiles[2]?.model).toBe("openrouter/qwen/qwen3.8-27b");
|
||||||
|
assert(String(profiles[2]?.token).startsWith("v1:"));
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2024",
|
||||||
|
"module": "nodenext",
|
||||||
|
"lib": ["es2024"],
|
||||||
|
"types": ["node"],
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"strict": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
"allowUnusedLabels": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from "vite-plus";
|
||||||
|
|
||||||
|
import projectLint from "./scripts/project-lint.ts";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// Keep package.json in its hand-written order.
|
||||||
|
fmt: { sortPackageJson: false },
|
||||||
|
lint: {
|
||||||
|
plugins: ["typescript", "unicorn", "oxc", "import"],
|
||||||
|
jsPlugins: ["./scripts/project-lint.ts"],
|
||||||
|
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
|
||||||
|
rules: projectLint.configs.project.rules,
|
||||||
|
// Repo tooling, not app code: the lint plugin compares AST node `type`s,
|
||||||
|
// which the AST types declare as an enum.
|
||||||
|
overrides: [
|
||||||
|
{ files: ["scripts/**"], rules: { "typescript/no-unsafe-enum-comparison": "off" } },
|
||||||
|
],
|
||||||
|
options: {
|
||||||
|
typeAware: true,
|
||||||
|
typeCheck: true,
|
||||||
|
maxWarnings: 0,
|
||||||
|
reportUnusedDisableDirectives: "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user