Format the TypeScript sources with Oxfmt
This commit is contained in:
+23
-22
@@ -1,13 +1,9 @@
|
|||||||
import { dirname, join } from "jsr:@std/path@1";
|
import { dirname, join } from "jsr:@std/path@1";
|
||||||
|
|
||||||
export type CodexAuthentication =
|
export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" };
|
||||||
| { kind: "api-key"; token: string }
|
|
||||||
| { kind: "persistent" };
|
|
||||||
|
|
||||||
export function codexAuthentication(token?: string): CodexAuthentication {
|
export function codexAuthentication(token?: string): CodexAuthentication {
|
||||||
return token === undefined
|
return token === undefined ? { kind: "persistent" } : { kind: "api-key", token };
|
||||||
? { kind: "persistent" }
|
|
||||||
: { kind: "api-key", token };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -22,17 +18,22 @@ 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(
|
export function isClaudeAuthFailure(result: {
|
||||||
result: { api_error_status?: number | null; result?: string },
|
api_error_status?: number | null;
|
||||||
): boolean {
|
result?: string;
|
||||||
return result.api_error_status === 401 ||
|
}): boolean {
|
||||||
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/i
|
return (
|
||||||
.test(result.result ?? "");
|
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(
|
||||||
|
result.result ?? "",
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function retryInvalidToken<
|
export async function retryInvalidToken<T extends { status: { success: boolean }; error: string }>(
|
||||||
T extends { status: { success: boolean }; error: string },
|
attempt: () => Promise<T>,
|
||||||
>(attempt: () => Promise<T>, relogin: () => Promise<void>): Promise<T> {
|
relogin: () => Promise<void>,
|
||||||
|
): Promise<T> {
|
||||||
let result = await attempt();
|
let result = await attempt();
|
||||||
if (!result.status.success && isCodexAuthFailure(result.error)) {
|
if (!result.status.success && isCodexAuthFailure(result.error)) {
|
||||||
await relogin();
|
await relogin();
|
||||||
@@ -59,9 +60,12 @@ async function readCodexAuth(path: string): Promise<string> {
|
|||||||
const record = value as Record<string, unknown> | null;
|
const record = value as Record<string, unknown> | null;
|
||||||
const tokens = record?.tokens as Record<string, unknown> | null;
|
const tokens = record?.tokens as Record<string, unknown> | null;
|
||||||
if (
|
if (
|
||||||
record === null || typeof record !== "object" ||
|
record === null ||
|
||||||
typeof record.auth_mode !== "string" || record.auth_mode === "" ||
|
typeof record !== "object" ||
|
||||||
tokens === null || typeof tokens !== "object" ||
|
typeof record.auth_mode !== "string" ||
|
||||||
|
record.auth_mode === "" ||
|
||||||
|
tokens === null ||
|
||||||
|
typeof tokens !== "object" ||
|
||||||
!["access_token", "refresh_token", "id_token", "account_id"].every(
|
!["access_token", "refresh_token", "id_token", "account_id"].every(
|
||||||
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
|
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
|
||||||
)
|
)
|
||||||
@@ -94,10 +98,7 @@ export async function stageCodexAuth(
|
|||||||
return stagedPath;
|
return stagedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function persistCodexAuth(
|
export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise<void> {
|
||||||
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 Deno.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
|
|||||||
+29
-46
@@ -15,9 +15,7 @@ Deno.test("retries once after an invalid refresh token", async () => {
|
|||||||
() =>
|
() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
status: { success: false },
|
status: { success: false },
|
||||||
error: attempts++ === 0
|
error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed",
|
||||||
? "ERROR: invalid_refresh_token"
|
|
||||||
: "still failed",
|
|
||||||
}),
|
}),
|
||||||
() => {
|
() => {
|
||||||
relogins++;
|
relogins++;
|
||||||
@@ -25,11 +23,14 @@ Deno.test("retries once after an invalid refresh token", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals({ attempts, relogins, error: result.error }, {
|
assertEquals(
|
||||||
attempts: 2,
|
{ attempts, relogins, error: result.error },
|
||||||
relogins: 1,
|
{
|
||||||
error: "still failed",
|
attempts: 2,
|
||||||
});
|
relogins: 1,
|
||||||
|
error: "still failed",
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("does not retry an unrelated failure", async () => {
|
Deno.test("does not retry an unrelated failure", async () => {
|
||||||
@@ -61,14 +62,12 @@ const REVOKED_CODEX = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
Deno.test("recognizes each Codex sign-in failure line", () => {
|
Deno.test("recognizes each Codex sign-in failure line", () => {
|
||||||
for (
|
for (const line of [
|
||||||
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);
|
assertEquals(isCodexAuthFailure(`exec\n${line}\n`), true, line);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -76,8 +75,7 @@ Deno.test("recognizes each Codex sign-in failure line", () => {
|
|||||||
Deno.test("ignores auth words outside Codex error lines", () => {
|
Deno.test("ignores auth words outside Codex error lines", () => {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
isCodexAuthFailure(
|
isCodexAuthFailure(
|
||||||
"exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\n" +
|
"exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\n" + "ERROR: tests failed\n",
|
||||||
"ERROR: tests failed\n",
|
|
||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -98,32 +96,30 @@ Deno.test("re-logs in after the job 9837 revoked-session stderr", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals({ relogins, success: result.status.success }, {
|
assertEquals(
|
||||||
relogins: 1,
|
{ relogins, success: result.status.success },
|
||||||
success: true,
|
{
|
||||||
});
|
relogins: 1,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("recognizes a rejected Claude OAuth token", () => {
|
Deno.test("recognizes a rejected Claude OAuth token", () => {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
isClaudeAuthFailure({
|
isClaudeAuthFailure({
|
||||||
api_error_status: 401,
|
api_error_status: 401,
|
||||||
result:
|
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.",
|
||||||
"Failed to authenticate. API Error: 401 OAuth access token is invalid.",
|
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
isClaudeAuthFailure({
|
isClaudeAuthFailure({
|
||||||
result:
|
result: "OAuth token has expired. Please obtain a new token or refresh your existing token.",
|
||||||
"OAuth token has expired. Please obtain a new token or refresh your existing token.",
|
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assertEquals(
|
assertEquals(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" }), false);
|
||||||
isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" }),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("selects API-key login only for a configured Codex token", () => {
|
Deno.test("selects API-key login only for a configured Codex token", () => {
|
||||||
@@ -154,12 +150,7 @@ Deno.test("stages and persists only a regular private Codex login", async () =>
|
|||||||
const home = `${root}/profile`;
|
const home = `${root}/profile`;
|
||||||
await Deno.mkdir(home);
|
await Deno.mkdir(home);
|
||||||
|
|
||||||
const staged = await stageCodexAuth(
|
const staged = await stageCodexAuth(canonical, home, Deno.uid()!, Deno.gid()!);
|
||||||
canonical,
|
|
||||||
home,
|
|
||||||
Deno.uid()!,
|
|
||||||
Deno.gid()!,
|
|
||||||
);
|
|
||||||
assertEquals(await Deno.readTextFile(staged), AUTH);
|
assertEquals(await Deno.readTextFile(staged), AUTH);
|
||||||
assertEquals((await Deno.stat(staged)).mode! & 0o777, 0o600);
|
assertEquals((await Deno.stat(staged)).mode! & 0o777, 0o600);
|
||||||
|
|
||||||
@@ -187,16 +178,8 @@ Deno.test("rejects a symlink or malformed Codex login", async () => {
|
|||||||
await Deno.writeTextFile(malformed, '{"tokens":{}}', { mode: 0o600 });
|
await Deno.writeTextFile(malformed, '{"tokens":{}}', { mode: 0o600 });
|
||||||
await Deno.symlink(canonical, linked);
|
await Deno.symlink(canonical, linked);
|
||||||
|
|
||||||
await assertRejects(
|
await assertRejects(() => persistCodexAuth(linked, canonical), Error, "regular file");
|
||||||
() => persistCodexAuth(linked, canonical),
|
await assertRejects(() => persistCodexAuth(malformed, canonical), Error, "invalid Codex login");
|
||||||
Error,
|
|
||||||
"regular file",
|
|
||||||
);
|
|
||||||
await assertRejects(
|
|
||||||
() => persistCodexAuth(malformed, canonical),
|
|
||||||
Error,
|
|
||||||
"invalid Codex login",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
await Deno.remove(root, { recursive: true });
|
await Deno.remove(root, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-35
@@ -19,36 +19,22 @@ function decodeBase64Url(value: string): Uint8Array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.fromCharCode(byte)).join("");
|
||||||
"",
|
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||||
);
|
|
||||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_")
|
|
||||||
.replaceAll("=", "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = (value: Uint8Array): ArrayBuffer =>
|
const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer;
|
||||||
Uint8Array.from(value).buffer;
|
|
||||||
|
|
||||||
async function importMasterKey(
|
async function importMasterKey(encoded: string, usage: KeyUsage): Promise<CryptoKey> {
|
||||||
encoded: string,
|
|
||||||
usage: KeyUsage,
|
|
||||||
): Promise<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, [
|
return await crypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]);
|
||||||
usage,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptCredential(
|
export async function decryptCredential(encrypted: string, masterKey: string): Promise<string> {
|
||||||
encrypted: string,
|
const [version, encodedNonce, encodedCiphertext, extra] = encrypted.split(":");
|
||||||
masterKey: string,
|
|
||||||
): Promise<string> {
|
|
||||||
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>");
|
||||||
}
|
}
|
||||||
@@ -73,15 +59,10 @@ export async function encryptCredential(
|
|||||||
await importMasterKey(masterKey, "encrypt"),
|
await importMasterKey(masterKey, "encrypt"),
|
||||||
new TextEncoder().encode(plaintext),
|
new TextEncoder().encode(plaintext),
|
||||||
);
|
);
|
||||||
return `v1:${encodeBase64Url(nonce)}:${
|
return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`;
|
||||||
encodeBase64Url(new Uint8Array(ciphertext))
|
|
||||||
}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseCredentials(
|
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> {
|
||||||
text: string,
|
|
||||||
masterKey: string,
|
|
||||||
): Promise<BotCredentials> {
|
|
||||||
const value: unknown = parse(text);
|
const value: unknown = parse(text);
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
throw new Error("credentials must be a mapping");
|
throw new Error("credentials must be a mapping");
|
||||||
@@ -112,17 +93,12 @@ export async function parseCredentials(
|
|||||||
}
|
}
|
||||||
const { type, model, token } = profile;
|
const { type, model, token } = profile;
|
||||||
if (type !== "claude" && type !== "codex" && type !== "opencode") {
|
if (type !== "claude" && type !== "codex" && type !== "opencode") {
|
||||||
throw new Error(
|
throw new Error(`${at}.type: expected \"claude\", \"codex\", or \"opencode\"`);
|
||||||
`${at}.type: expected \"claude\", \"codex\", or \"opencode\"`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (model !== undefined && (typeof model !== "string" || model === "")) {
|
if (model !== undefined && (typeof model !== "string" || model === "")) {
|
||||||
throw new Error(`${at}.model: expected a model name`);
|
throw new Error(`${at}.model: expected a model name`);
|
||||||
}
|
}
|
||||||
if (
|
if ((type === "claude" || type === "opencode") && typeof token !== "string") {
|
||||||
(type === "claude" || type === "opencode") &&
|
|
||||||
typeof token !== "string"
|
|
||||||
) {
|
|
||||||
throw new Error(`${at}.token: expected an encrypted credential`);
|
throw new Error(`${at}.token: expected an encrypted credential`);
|
||||||
}
|
}
|
||||||
if (token !== undefined && typeof token !== "string") {
|
if (token !== undefined && typeof token !== "string") {
|
||||||
|
|||||||
+6
-28
@@ -1,9 +1,5 @@
|
|||||||
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
||||||
import {
|
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts";
|
||||||
decryptCredential,
|
|
||||||
encryptCredential,
|
|
||||||
parseCredentials,
|
|
||||||
} from "./credentials.ts";
|
|
||||||
|
|
||||||
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
||||||
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
|
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
|
||||||
@@ -11,17 +7,11 @@ const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32);
|
|||||||
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
||||||
|
|
||||||
Deno.test("decrypts a known AES-256-GCM credential", async () => {
|
Deno.test("decrypts a known AES-256-GCM credential", async () => {
|
||||||
assertEquals(
|
assertEquals(await decryptCredential(ENCRYPTED, MASTER_KEY), "secret-token");
|
||||||
await decryptCredential(ENCRYPTED, MASTER_KEY),
|
|
||||||
"secret-token",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("encrypts a credential with a fresh supplied nonce", async () => {
|
Deno.test("encrypts a credential with a fresh supplied nonce", async () => {
|
||||||
assertEquals(
|
assertEquals(await encryptCredential("secret-token", MASTER_KEY, NONCE), ENCRYPTED);
|
||||||
await encryptCredential("secret-token", MASTER_KEY, NONCE),
|
|
||||||
ENCRYPTED,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("rejects a credential encrypted by another master key", async () => {
|
Deno.test("rejects a credential encrypted by another master key", async () => {
|
||||||
@@ -30,11 +20,7 @@ Deno.test("rejects a credential encrypted by another master key", async () => {
|
|||||||
|
|
||||||
Deno.test("rejects a credential with a nonstandard nonce", async () => {
|
Deno.test("rejects a credential with a nonstandard nonce", async () => {
|
||||||
await assertRejects(
|
await assertRejects(
|
||||||
() =>
|
() => decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY),
|
||||||
decryptCredential(
|
|
||||||
"v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA",
|
|
||||||
MASTER_KEY,
|
|
||||||
),
|
|
||||||
Error,
|
Error,
|
||||||
"nonce must be 12 bytes",
|
"nonce must be 12 bytes",
|
||||||
);
|
);
|
||||||
@@ -75,17 +61,9 @@ profiles:
|
|||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("rejects malformed central credentials", async () => {
|
Deno.test("rejects malformed central credentials", async () => {
|
||||||
|
await assertRejects(() => parseCredentials("profiles: []", MASTER_KEY), Error, "reviewer-token");
|
||||||
await assertRejects(
|
await assertRejects(
|
||||||
() => parseCredentials("profiles: []", MASTER_KEY),
|
() => parseCredentials(`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude`, MASTER_KEY),
|
||||||
Error,
|
|
||||||
"reviewer-token",
|
|
||||||
);
|
|
||||||
await assertRejects(
|
|
||||||
() =>
|
|
||||||
parseCredentials(
|
|
||||||
`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude`,
|
|
||||||
MASTER_KEY,
|
|
||||||
),
|
|
||||||
Error,
|
Error,
|
||||||
"profiles[0].token",
|
"profiles[0].token",
|
||||||
);
|
);
|
||||||
|
|||||||
+4
-15
@@ -14,10 +14,7 @@ export function agentCommand(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRunnableProfileProcess(
|
export function isRunnableProfileProcess(status: string, uid: number): boolean {
|
||||||
status: string,
|
|
||||||
uid: number,
|
|
||||||
): boolean {
|
|
||||||
const owner = status.match(/^Uid:\s+(\d+)/m)?.[1];
|
const owner = status.match(/^Uid:\s+(\d+)/m)?.[1];
|
||||||
const state = status.match(/^State:\s+(\w)/m)?.[1];
|
const state = status.match(/^State:\s+(\w)/m)?.[1];
|
||||||
return owner !== undefined && Number(owner) === uid && state !== "Z";
|
return owner !== undefined && Number(owner) === uid && state !== "Z";
|
||||||
@@ -27,9 +24,7 @@ async function profileProcessIds(uid: number): Promise<number[]> {
|
|||||||
const ids: number[] = [];
|
const ids: number[] = [];
|
||||||
for await (const entry of Deno.readDir("/proc")) {
|
for await (const entry of Deno.readDir("/proc")) {
|
||||||
if (!entry.isDirectory || !/^\d+$/.test(entry.name)) continue;
|
if (!entry.isDirectory || !/^\d+$/.test(entry.name)) continue;
|
||||||
const status = await Deno.readTextFile(`/proc/${entry.name}/status`).catch(
|
const status = await Deno.readTextFile(`/proc/${entry.name}/status`).catch(() => null);
|
||||||
() => null,
|
|
||||||
);
|
|
||||||
if (status !== null && isRunnableProfileProcess(status, uid)) {
|
if (status !== null && isRunnableProfileProcess(status, uid)) {
|
||||||
ids.push(Number(entry.name));
|
ids.push(Number(entry.name));
|
||||||
}
|
}
|
||||||
@@ -53,15 +48,9 @@ export async function killProfileProcesses(uid: number): Promise<void> {
|
|||||||
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
|
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readProfileFile(
|
export async function readProfileFile(profile: RuntimeProfile, path: string): Promise<string> {
|
||||||
profile: RuntimeProfile,
|
|
||||||
path: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const stat = await Deno.lstat(path);
|
const stat = await Deno.lstat(path);
|
||||||
if (
|
if (!stat.isFile || stat.isSymlink || stat.uid !== profile.uid || (stat.mode! & 0o077) !== 0) {
|
||||||
!stat.isFile || stat.isSymlink || 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 await Deno.readTextFile(path);
|
||||||
|
|||||||
+18
-23
@@ -69,12 +69,12 @@ Deno.test({
|
|||||||
const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], {
|
const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], {
|
||||||
cwd: "/",
|
cwd: "/",
|
||||||
}).output();
|
}).output();
|
||||||
const sibling = await agentCommand(selected, "/bin/cat", [
|
const sibling = await agentCommand(selected, "/bin/cat", [`${homeB}/token`], {
|
||||||
`${homeB}/token`,
|
cwd: "/",
|
||||||
], { cwd: "/" }).output();
|
}).output();
|
||||||
const reviewer = await agentCommand(selected, "/bin/cat", [
|
const reviewer = await agentCommand(selected, "/bin/cat", [`${root}/reviewer`], {
|
||||||
`${root}/reviewer`,
|
cwd: "/",
|
||||||
], { cwd: "/" }).output();
|
}).output();
|
||||||
|
|
||||||
assertEquals(new TextDecoder().decode(own.stdout), "own");
|
assertEquals(new TextDecoder().decode(own.stdout), "own");
|
||||||
assertEquals(own.success, true);
|
assertEquals(own.success, true);
|
||||||
@@ -92,25 +92,22 @@ Deno.test({
|
|||||||
async fn() {
|
async fn() {
|
||||||
if (Deno.uid() !== 0) return;
|
if (Deno.uid() !== 0) return;
|
||||||
const selected = profile(20_010, "/tmp");
|
const selected = profile(20_010, "/tmp");
|
||||||
const launched = await agentCommand(selected, "/bin/sh", [
|
const launched = await agentCommand(
|
||||||
"-c",
|
selected,
|
||||||
"sleep 60 >/dev/null 2>&1 & echo $!",
|
"/bin/sh",
|
||||||
], { cwd: "/" }).output();
|
["-c", "sleep 60 >/dev/null 2>&1 & echo $!"],
|
||||||
|
{ cwd: "/" },
|
||||||
|
).output();
|
||||||
const pid = Number(new TextDecoder().decode(launched.stdout).trim());
|
const pid = Number(new TextDecoder().decode(launched.stdout).trim());
|
||||||
assertEquals(Number.isInteger(pid), true);
|
assertEquals(Number.isInteger(pid), true);
|
||||||
|
|
||||||
await killProfileProcesses(selected.uid);
|
await killProfileProcesses(selected.uid);
|
||||||
|
|
||||||
const status = await Deno.readTextFile(`/proc/${pid}/status`).catch(
|
const status = await Deno.readTextFile(`/proc/${pid}/status`).catch((error) => {
|
||||||
(error) => {
|
if (error instanceof Deno.errors.NotFound) return null;
|
||||||
if (error instanceof Deno.errors.NotFound) return null;
|
throw error;
|
||||||
throw error;
|
});
|
||||||
},
|
assertEquals(status === null || !isRunnableProfileProcess(status, selected.uid), true);
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
status === null || !isRunnableProfileProcess(status, selected.uid),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,9 +117,7 @@ Deno.test({
|
|||||||
async fn() {
|
async fn() {
|
||||||
if (Deno.uid() !== 0) return;
|
if (Deno.uid() !== 0) return;
|
||||||
const selected = profile(20_020, "/tmp");
|
const selected = profile(20_020, "/tmp");
|
||||||
const result = await agentCommand(selected, "/usr/bin/cat", [
|
const result = await agentCommand(selected, "/usr/bin/cat", [`/proc/${Deno.pid}/environ`], {
|
||||||
`/proc/${Deno.pid}/environ`,
|
|
||||||
], {
|
|
||||||
cwd: "/",
|
cwd: "/",
|
||||||
env: { PARENT_SECRET: "must-not-cross-boundary" },
|
env: { PARENT_SECRET: "must-not-cross-boundary" },
|
||||||
clearEnv: true,
|
clearEnv: true,
|
||||||
|
|||||||
+9
-20
@@ -1,15 +1,7 @@
|
|||||||
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[] {
|
||||||
return [
|
return ["run", "--format", "json", "--auto", "--model", model, prompt];
|
||||||
"run",
|
|
||||||
"--format",
|
|
||||||
"json",
|
|
||||||
"--auto",
|
|
||||||
"--model",
|
|
||||||
model,
|
|
||||||
prompt,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
|
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
|
||||||
@@ -20,10 +12,7 @@ export async function collectOpenCodeOutput(
|
|||||||
writeStdout: ChunkWriter,
|
writeStdout: ChunkWriter,
|
||||||
writeStderr: ChunkWriter,
|
writeStderr: ChunkWriter,
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
const collect = async (
|
const collect = async (stream: ReadableStream<Uint8Array>, write: ChunkWriter) => {
|
||||||
stream: ReadableStream<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);
|
||||||
@@ -68,11 +57,12 @@ export function openCodeError(output: string): Error | undefined {
|
|||||||
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"}`;
|
||||||
failure = statusCode === 401 || statusCode === 403
|
failure =
|
||||||
? new AgentAuthError("opencode sign-in failed", {
|
statusCode === 401 || statusCode === 403
|
||||||
cause: new Error(detail),
|
? new AgentAuthError("opencode sign-in failed", {
|
||||||
})
|
cause: new Error(detail),
|
||||||
: new Error(detail);
|
})
|
||||||
|
: new Error(detail);
|
||||||
}
|
}
|
||||||
return failure;
|
return failure;
|
||||||
}
|
}
|
||||||
@@ -89,8 +79,7 @@ export function parseOpenCodeEvents(output: string): string {
|
|||||||
if (typeof text === "string" && text.trim() !== "") result = text;
|
if (typeof text === "string" && text.trim() !== "") result = text;
|
||||||
}
|
}
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
throw openCodeError(output) ??
|
throw openCodeError(output) ?? new Error("OpenCode produced no text output");
|
||||||
new Error("OpenCode produced no text output");
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-28
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { assertEquals, assertInstanceOf, assertRejects, assertThrows } from "jsr:@std/assert@1";
|
||||||
assertEquals,
|
|
||||||
assertInstanceOf,
|
|
||||||
assertRejects,
|
|
||||||
assertThrows,
|
|
||||||
} from "jsr:@std/assert@1";
|
|
||||||
import {
|
import {
|
||||||
collectOpenCodeOutput,
|
collectOpenCodeOutput,
|
||||||
openCodeArgs,
|
openCodeArgs,
|
||||||
@@ -13,28 +8,27 @@ import {
|
|||||||
import { AgentAuthError } from "./profiles.ts";
|
import { AgentAuthError } from "./profiles.ts";
|
||||||
|
|
||||||
Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
|
Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
|
||||||
assertEquals(
|
assertEquals(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"), [
|
||||||
openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"),
|
"run",
|
||||||
[
|
"--format",
|
||||||
"run",
|
"json",
|
||||||
"--format",
|
"--auto",
|
||||||
"json",
|
"--model",
|
||||||
"--auto",
|
"openrouter/qwen/qwen3.8-27b",
|
||||||
"--model",
|
"Review this",
|
||||||
"openrouter/qwen/qwen3.8-27b",
|
]);
|
||||||
"Review this",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("returns the final visible OpenCode text event", () => {
|
Deno.test("returns the final visible OpenCode text event", () => {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
parseOpenCodeEvents([
|
parseOpenCodeEvents(
|
||||||
JSON.stringify({ type: "step_start", part: { type: "step-start" } }),
|
[
|
||||||
JSON.stringify({ type: "text", part: { type: "text", text: "draft" } }),
|
JSON.stringify({ type: "step_start", part: { type: "step-start" } }),
|
||||||
JSON.stringify({ type: "text", part: { type: "text", text: "done" } }),
|
JSON.stringify({ type: "text", part: { type: "text", text: "draft" } }),
|
||||||
JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }),
|
JSON.stringify({ type: "text", part: { type: "text", text: "done" } }),
|
||||||
].join("\n")),
|
JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }),
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
"done",
|
"done",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -113,8 +107,5 @@ Deno.test("surfaces an OpenCode error event instead of empty output", () => {
|
|||||||
Error,
|
Error,
|
||||||
"API error 429",
|
"API error 429",
|
||||||
);
|
);
|
||||||
assertEquals(
|
assertEquals(openCodeError('{"type":"text","part":{"text":"ok"}}'), undefined);
|
||||||
openCodeError('{"type":"text","part":{"text":"ok"}}'),
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+45
-78
@@ -1,15 +1,6 @@
|
|||||||
import { join } from "jsr:@std/path@1";
|
import { join } from "jsr:@std/path@1";
|
||||||
import {
|
import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "./runtime.ts";
|
||||||
readRuntimeManifest,
|
import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "./profiles.ts";
|
||||||
type RuntimeManifest,
|
|
||||||
type RuntimeOutcome,
|
|
||||||
} from "./runtime.ts";
|
|
||||||
import {
|
|
||||||
failureFooter,
|
|
||||||
type FailureReason,
|
|
||||||
type ProfileFailure,
|
|
||||||
withProfile,
|
|
||||||
} from "./profiles.ts";
|
|
||||||
|
|
||||||
export type PostContext = {
|
export type PostContext = {
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
@@ -75,27 +66,30 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
failures: failures.flatMap((entry: unknown) => {
|
failures: failures.flatMap((entry: unknown) => {
|
||||||
const { type, model, reason } = (entry ?? {}) as Record<
|
const { type, model, reason } = (entry ?? {}) as Record<string, unknown>;
|
||||||
string,
|
return typeof type === "string" &&
|
||||||
unknown
|
PROFILE_TYPES.includes(type) &&
|
||||||
>;
|
typeof model === "string" &&
|
||||||
return typeof type === "string" && PROFILE_TYPES.includes(type) &&
|
typeof reason === "string" &&
|
||||||
typeof model === "string" && typeof reason === "string" &&
|
FAILURE_REASONS.includes(reason)
|
||||||
FAILURE_REASONS.includes(reason)
|
? [
|
||||||
? [{
|
{
|
||||||
type: type as ProfileFailure["type"],
|
type: type as ProfileFailure["type"],
|
||||||
model,
|
model,
|
||||||
reason: reason as FailureReason,
|
reason: reason as FailureReason,
|
||||||
}]
|
},
|
||||||
|
]
|
||||||
: [];
|
: [];
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
outcome.ok !== true || typeof outcome.body !== "string" ||
|
outcome.ok !== true ||
|
||||||
|
typeof outcome.body !== "string" ||
|
||||||
typeof outcome.reviewPath !== "string" ||
|
typeof outcome.reviewPath !== "string" ||
|
||||||
typeof outcome.anchorsPath !== "string" ||
|
typeof outcome.anchorsPath !== "string" ||
|
||||||
typeof outcome.profile !== "object" || outcome.profile === null
|
typeof outcome.profile !== "object" ||
|
||||||
|
outcome.profile === null
|
||||||
) {
|
) {
|
||||||
throw new Error("invalid agent outcome");
|
throw new Error("invalid agent outcome");
|
||||||
}
|
}
|
||||||
@@ -120,9 +114,7 @@ async function readAgentArtifact(
|
|||||||
path: string,
|
path: string,
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const profile = manifest.profiles.find((entry) =>
|
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
|
||||||
join(entry.home, name) === path
|
|
||||||
);
|
|
||||||
if (profile === undefined) throw new Error(`${name}: outside profile home`);
|
if (profile === undefined) throw new Error(`${name}: outside profile home`);
|
||||||
let stat: Deno.FileInfo;
|
let stat: Deno.FileInfo;
|
||||||
try {
|
try {
|
||||||
@@ -131,10 +123,7 @@ async function readAgentArtifact(
|
|||||||
if (error instanceof Deno.errors.NotFound) return null;
|
if (error instanceof Deno.errors.NotFound) return null;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (
|
if (!stat.isFile || stat.isSymlink || stat.uid !== profile.uid || (stat.mode! & 0o077) !== 0) {
|
||||||
!stat.isFile || stat.isSymlink || stat.uid !== profile.uid ||
|
|
||||||
(stat.mode! & 0o077) !== 0
|
|
||||||
) {
|
|
||||||
throw new Error(`${name}: expected a profile-owned regular private file`);
|
throw new Error(`${name}: expected a profile-owned regular private file`);
|
||||||
}
|
}
|
||||||
const text = await Deno.readTextFile(path);
|
const text = await Deno.readTextFile(path);
|
||||||
@@ -148,8 +137,7 @@ 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))
|
const reviewerToken = (await Deno.readTextFile(manifest.reviewerFile)).trim();
|
||||||
.trim();
|
|
||||||
const request = async (path: string, body: unknown): Promise<void> => {
|
const request = async (path: string, body: unknown): Promise<void> => {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
@@ -166,16 +154,13 @@ export async function postRuntime(
|
|||||||
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
|
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new GiteaRequestError(
|
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
|
||||||
`${path}: ${response.status} ${await response.text()}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const postComment = (body: string) =>
|
const postComment = (body: string) =>
|
||||||
request(
|
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
|
||||||
`repos/${context.repository}/issues/${context.issueIndex}/comments`,
|
body: stripAnsi(body),
|
||||||
{ body: stripAnsi(body) },
|
});
|
||||||
);
|
|
||||||
|
|
||||||
let outcome: RuntimeOutcome;
|
let outcome: RuntimeOutcome;
|
||||||
try {
|
try {
|
||||||
@@ -190,11 +175,7 @@ export async function postRuntime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const review = await readAgentArtifact(
|
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md");
|
||||||
manifest,
|
|
||||||
outcome.reviewPath,
|
|
||||||
"review.md",
|
|
||||||
);
|
|
||||||
if (review === null) {
|
if (review === null) {
|
||||||
if (context.eventName === "pull_request") {
|
if (context.eventName === "pull_request") {
|
||||||
throw new Error("agent did not write a pull request review");
|
throw new Error("agent did not write a pull request review");
|
||||||
@@ -205,31 +186,21 @@ export async function postRuntime(
|
|||||||
|
|
||||||
const [verdict, ...rest] = review.split("\n");
|
const [verdict, ...rest] = review.split("\n");
|
||||||
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
|
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
|
||||||
const anchorText = await readAgentArtifact(
|
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
|
||||||
manifest,
|
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
|
||||||
outcome.anchorsPath,
|
|
||||||
"anchors.json",
|
|
||||||
);
|
|
||||||
const anchors = anchorText === null
|
|
||||||
? []
|
|
||||||
: parseAnchors(anchorText, outcome.anchorsPath);
|
|
||||||
const postReview = (entries: Anchor[]) =>
|
const postReview = (entries: Anchor[]) =>
|
||||||
request(
|
request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
|
||||||
`repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
|
body: stripAnsi(
|
||||||
{
|
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
|
||||||
body: stripAnsi(withProfile(
|
),
|
||||||
[`${mark} ${verdict.trim()}`, ...rest].join("\n"),
|
event,
|
||||||
outcome.profile,
|
comments: entries.map(({ path, line, side, body }) => ({
|
||||||
)),
|
path,
|
||||||
event,
|
body: stripAnsi(withProfile(body, outcome.profile)),
|
||||||
comments: entries.map(({ path, line, side, body }) => ({
|
new_position: side === "new" ? line : 0,
|
||||||
path,
|
old_position: side === "old" ? line : 0,
|
||||||
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) => {
|
await postReview(anchors).catch(async (error: Error) => {
|
||||||
if (anchors.length === 0) throw error;
|
if (anchors.length === 0) throw error;
|
||||||
console.error(`inline comments rejected: ${error.message}`);
|
console.error(`inline comments rejected: ${error.message}`);
|
||||||
@@ -237,17 +208,13 @@ export async function postRuntime(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof GiteaRequestError) throw error;
|
if (error instanceof GiteaRequestError) throw error;
|
||||||
await postComment(
|
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
|
||||||
failureComment([{ ...outcome.profile, reason: "failed" }]),
|
|
||||||
);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(
|
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch((error) => {
|
||||||
(error) => {
|
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+62
-55
@@ -28,11 +28,11 @@ async function fixture(
|
|||||||
await Deno.writeTextFile(reviewerFile, "reviewer-secret");
|
await Deno.writeTextFile(reviewerFile, "reviewer-secret");
|
||||||
const outcome: RuntimeOutcome = source.ok
|
const outcome: RuntimeOutcome = source.ok
|
||||||
? {
|
? {
|
||||||
...source,
|
...source,
|
||||||
profile: { type: "codex", model: "gpt-test" },
|
profile: { type: "codex", model: "gpt-test" },
|
||||||
reviewPath: `${profileHome}/review.md`,
|
reviewPath: `${profileHome}/review.md`,
|
||||||
anchorsPath: `${profileHome}/anchors.json`,
|
anchorsPath: `${profileHome}/anchors.json`,
|
||||||
}
|
}
|
||||||
: source;
|
: source;
|
||||||
await Deno.writeTextFile(outcomeFile, JSON.stringify(outcome));
|
await Deno.writeTextFile(outcomeFile, JSON.stringify(outcome));
|
||||||
if (outcome.ok) {
|
if (outcome.ok) {
|
||||||
@@ -53,15 +53,17 @@ async function fixture(
|
|||||||
manifestPath,
|
manifestPath,
|
||||||
reviewerFile,
|
reviewerFile,
|
||||||
outcomeFile,
|
outcomeFile,
|
||||||
profiles: [{
|
profiles: [
|
||||||
index: 0,
|
{
|
||||||
type: "claude",
|
index: 0,
|
||||||
model: "test",
|
type: "claude",
|
||||||
uid: Deno.uid()!,
|
model: "test",
|
||||||
gid: Deno.gid()!,
|
uid: Deno.uid()!,
|
||||||
home: profileHome,
|
gid: Deno.gid()!,
|
||||||
tokenFile: null,
|
home: profileHome,
|
||||||
}],
|
tokenFile: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
await Deno.writeTextFile(manifestPath, JSON.stringify(manifest));
|
await Deno.writeTextFile(manifestPath, JSON.stringify(manifest));
|
||||||
return {
|
return {
|
||||||
@@ -93,10 +95,7 @@ Deno.test("posts a successful issue result and removes runtime credentials", asy
|
|||||||
});
|
});
|
||||||
|
|
||||||
assertEquals(requests.length, 1);
|
assertEquals(requests.length, 1);
|
||||||
assertEquals(
|
assertEquals(requests[0].url, `${context.apiUrl}/repos/owner/repo/issues/7/comments`);
|
||||||
requests[0].url,
|
|
||||||
`${context.apiUrl}/repos/owner/repo/issues/7/comments`,
|
|
||||||
);
|
|
||||||
assertEquals(JSON.parse(String(requests[0].init?.body)), {
|
assertEquals(JSON.parse(String(requests[0].init?.body)), {
|
||||||
body: "done\n\n> Profile: `codex` · `gpt-test`",
|
body: "done\n\n> Profile: `codex` · `gpt-test`",
|
||||||
});
|
});
|
||||||
@@ -108,22 +107,22 @@ Deno.test("posts a successful issue result and removes runtime credentials", asy
|
|||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("retries a rejected inline review without anchors", async () => {
|
Deno.test("retries a rejected inline review without anchors", async () => {
|
||||||
const result = await fixture(
|
const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [
|
||||||
{ ok: true, body: "ignored final response" },
|
{ path: "run.ts", line: 1, side: "new", body: "Finding" },
|
||||||
"Approved\nReady",
|
]);
|
||||||
[{ path: "run.ts", line: 1, side: "new", body: "Finding" }],
|
|
||||||
);
|
|
||||||
const bodies: unknown[] = [];
|
const bodies: unknown[] = [];
|
||||||
|
|
||||||
await postRuntime(result.manifestPath, {
|
await postRuntime(
|
||||||
...context,
|
result.manifestPath,
|
||||||
eventName: "pull_request",
|
{
|
||||||
}, (_url, init) => {
|
...context,
|
||||||
bodies.push(JSON.parse(String(init?.body)));
|
eventName: "pull_request",
|
||||||
return Promise.resolve(
|
},
|
||||||
new Response(null, { status: bodies.length === 1 ? 422 : 200 }),
|
(_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: unknown[] }).comments.length, 1);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -159,11 +158,14 @@ Deno.test("reports each tried profile and keeps the action failed", async () =>
|
|||||||
"agent phase failed",
|
"agent phase failed",
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(bodies, [{
|
assertEquals(bodies, [
|
||||||
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " +
|
{
|
||||||
"`claude` · `claude-test` (usage exhausted), " +
|
body:
|
||||||
"`opencode` · `openrouter/test` (failed)",
|
"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(!JSON.stringify(bodies).includes("reviewer-secret"));
|
||||||
assert(await missing(result.runtimeDir));
|
assert(await missing(result.runtimeDir));
|
||||||
});
|
});
|
||||||
@@ -193,10 +195,11 @@ Deno.test("drops failure entries that are not known labels", async () => {
|
|||||||
"agent phase failed",
|
"agent phase failed",
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(bodies, [{
|
assertEquals(bodies, [
|
||||||
body:
|
{
|
||||||
"AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)",
|
body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)",
|
||||||
}]);
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("reports a generic failure when the runner wrote no outcome", async () => {
|
Deno.test("reports a generic failure when the runner wrote no outcome", async () => {
|
||||||
@@ -223,10 +226,8 @@ Deno.test("removes runtime credentials when posting fails", async () => {
|
|||||||
|
|
||||||
await assertRejects(
|
await assertRejects(
|
||||||
() =>
|
() =>
|
||||||
postRuntime(
|
postRuntime(result.manifestPath, context, () =>
|
||||||
result.manifestPath,
|
Promise.resolve(new Response("failed", { status: 500 })),
|
||||||
context,
|
|
||||||
() => Promise.resolve(new Response("failed", { status: 500 })),
|
|
||||||
),
|
),
|
||||||
Error,
|
Error,
|
||||||
"500",
|
"500",
|
||||||
@@ -278,19 +279,25 @@ Deno.test("reports an attributed failure when a pull request review is missing",
|
|||||||
|
|
||||||
await assertRejects(
|
await assertRejects(
|
||||||
() =>
|
() =>
|
||||||
postRuntime(result.manifestPath, {
|
postRuntime(
|
||||||
...context,
|
result.manifestPath,
|
||||||
eventName: "pull_request",
|
{
|
||||||
}, (_url, init) => {
|
...context,
|
||||||
bodies.push(JSON.parse(String(init?.body)));
|
eventName: "pull_request",
|
||||||
return Promise.resolve(new Response(null, { status: 201 }));
|
},
|
||||||
}),
|
(_url, init) => {
|
||||||
|
bodies.push(JSON.parse(String(init?.body)));
|
||||||
|
return Promise.resolve(new Response(null, { status: 201 }));
|
||||||
|
},
|
||||||
|
),
|
||||||
Error,
|
Error,
|
||||||
"did not write",
|
"did not write",
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(bodies, [{
|
assertEquals(bodies, [
|
||||||
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
|
{
|
||||||
}]);
|
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
|
||||||
|
},
|
||||||
|
]);
|
||||||
assert(await missing(result.runtimeDir));
|
assert(await missing(result.runtimeDir));
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-8
@@ -12,14 +12,10 @@ const manifest = await prepareRuntime(
|
|||||||
env("BOT_AGENTS_MASTER_KEY"),
|
env("BOT_AGENTS_MASTER_KEY"),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await Deno.writeTextFile(
|
await Deno.writeTextFile(env("GITHUB_OUTPUT"), `manifest=${manifest.manifestPath}\n`, {
|
||||||
env("GITHUB_OUTPUT"),
|
append: true,
|
||||||
`manifest=${manifest.manifestPath}\n`,
|
});
|
||||||
{ append: true },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(() =>
|
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(() => undefined);
|
||||||
undefined
|
|
||||||
);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-51
@@ -4,16 +4,11 @@ export type BotProfile = {
|
|||||||
token?: string;
|
token?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function profileFooter(
|
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
|
||||||
profile: Pick<BotProfile, "type" | "model">,
|
|
||||||
): string {
|
|
||||||
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
|
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withProfile(
|
export function withProfile(body: string, profile: Pick<BotProfile, "type" | "model">): string {
|
||||||
body: string,
|
|
||||||
profile: Pick<BotProfile, "type" | "model">,
|
|
||||||
): string {
|
|
||||||
return `${body}\n\n${profileFooter(profile)}`;
|
return `${body}\n\n${profileFooter(profile)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +22,9 @@ 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
|
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(
|
||||||
.test(String(error));
|
String(error),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function failureReason(error: unknown): FailureReason {
|
function failureReason(error: unknown): FailureReason {
|
||||||
@@ -38,19 +34,12 @@ function failureReason(error: unknown): FailureReason {
|
|||||||
|
|
||||||
export function failureFooter(failures: ProfileFailure[]): string {
|
export function failureFooter(failures: ProfileFailure[]): string {
|
||||||
if (failures.length === 0) return "> Profile: none";
|
if (failures.length === 0) return "> Profile: none";
|
||||||
return `> Tried: ${
|
return `> Tried: ${failures
|
||||||
failures
|
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
|
||||||
.map(({ type, model, reason }) =>
|
.join(", ")}`;
|
||||||
`\`${type}\` · \`${model}\` (${reason})`
|
|
||||||
)
|
|
||||||
.join(", ")
|
|
||||||
}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runProfiles<
|
export async function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||||
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[] = [],
|
||||||
@@ -68,38 +57,34 @@ export async function runProfiles<
|
|||||||
throw new Error("profiles must contain at least one profile");
|
throw new Error("profiles must contain at least one profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizedEnvironment(
|
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
|
||||||
environment: Record<string, string>,
|
|
||||||
): Record<string, string> {
|
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
for (
|
for (const name of [
|
||||||
const name of [
|
"PATH",
|
||||||
"PATH",
|
"HOME",
|
||||||
"HOME",
|
"LANG",
|
||||||
"LANG",
|
"LANGUAGE",
|
||||||
"LANGUAGE",
|
"LC_ALL",
|
||||||
"LC_ALL",
|
"TERM",
|
||||||
"TERM",
|
"COLORTERM",
|
||||||
"COLORTERM",
|
"CI",
|
||||||
"CI",
|
"TMPDIR",
|
||||||
"TMPDIR",
|
"GITEA_TOKEN",
|
||||||
"GITEA_TOKEN",
|
"GITEA_API_URL",
|
||||||
"GITEA_API_URL",
|
"GITEA_REPOSITORY",
|
||||||
"GITEA_REPOSITORY",
|
"GITHUB_WORKSPACE",
|
||||||
"GITHUB_WORKSPACE",
|
"HTTP_PROXY",
|
||||||
"HTTP_PROXY",
|
"HTTPS_PROXY",
|
||||||
"HTTPS_PROXY",
|
"ALL_PROXY",
|
||||||
"ALL_PROXY",
|
"NO_PROXY",
|
||||||
"NO_PROXY",
|
"http_proxy",
|
||||||
"http_proxy",
|
"https_proxy",
|
||||||
"https_proxy",
|
"all_proxy",
|
||||||
"all_proxy",
|
"no_proxy",
|
||||||
"no_proxy",
|
"SSL_CERT_FILE",
|
||||||
"SSL_CERT_FILE",
|
"SSL_CERT_DIR",
|
||||||
"SSL_CERT_DIR",
|
"NODE_EXTRA_CA_CERTS",
|
||||||
"NODE_EXTRA_CA_CERTS",
|
]) {
|
||||||
]
|
|
||||||
) {
|
|
||||||
if (environment[name] !== undefined) result[name] = environment[name];
|
if (environment[name] !== undefined) result[name] = environment[name];
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+20
-24
@@ -55,19 +55,18 @@ Deno.test("recognizes Claude's weekly limit message", async () => {
|
|||||||
|
|
||||||
Deno.test("recognizes provider API exhaustion status", async () => {
|
Deno.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"]
|
["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) =>
|
||||||
.map((message) =>
|
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) => {
|
if (profile.type === "claude") throw new Error(message);
|
||||||
if (profile.type === "claude") throw new Error(message);
|
return Promise.resolve("fallback");
|
||||||
return Promise.resolve("fallback");
|
},
|
||||||
},
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(results, ["fallback", "fallback"]);
|
assertEquals(results, ["fallback", "fallback"]);
|
||||||
@@ -120,10 +119,10 @@ Deno.test("throws the last sign-in failure when every profile fails", async () =
|
|||||||
AgentAuthError,
|
AgentAuthError,
|
||||||
"claude sign-in failed",
|
"claude sign-in failed",
|
||||||
);
|
);
|
||||||
assertEquals(failures.map(({ reason }) => reason), [
|
assertEquals(
|
||||||
"sign-in failed",
|
failures.map(({ reason }) => reason),
|
||||||
"sign-in failed",
|
["sign-in failed", "sign-in failed"],
|
||||||
]);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("preserves an ordinary agent failure", async () => {
|
Deno.test("preserves an ordinary agent failure", async () => {
|
||||||
@@ -191,14 +190,11 @@ Deno.test("exposes only the selected profile credential to the agent", () => {
|
|||||||
tokenFile: "/runtime/profile-0/token",
|
tokenFile: "/runtime/profile-0/token",
|
||||||
};
|
};
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(agentEnvironment(base, profile, "selected"), {
|
||||||
agentEnvironment(base, profile, "selected"),
|
PATH: "/usr/bin",
|
||||||
{
|
HOME: "/runtime/profile-0",
|
||||||
PATH: "/usr/bin",
|
CLAUDE_CODE_OAUTH_TOKEN: "selected",
|
||||||
HOME: "/runtime/profile-0",
|
});
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: "selected",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assertEquals(sanitizedEnvironment(base), {
|
assertEquals(sanitizedEnvironment(base), {
|
||||||
PATH: "/usr/bin",
|
PATH: "/usr/bin",
|
||||||
HOME: "/home/bot",
|
HOME: "/home/bot",
|
||||||
|
|||||||
+95
-136
@@ -10,11 +10,7 @@ import {
|
|||||||
retryInvalidToken,
|
retryInvalidToken,
|
||||||
stageCodexAuth,
|
stageCodexAuth,
|
||||||
} from "./auth.ts";
|
} from "./auth.ts";
|
||||||
import {
|
import { agentCommand, killProfileProcesses, readProfileFile } from "./isolation.ts";
|
||||||
agentCommand,
|
|
||||||
killProfileProcesses,
|
|
||||||
readProfileFile,
|
|
||||||
} from "./isolation.ts";
|
|
||||||
import {
|
import {
|
||||||
AgentAuthError,
|
AgentAuthError,
|
||||||
agentEnvironment,
|
agentEnvironment,
|
||||||
@@ -29,11 +25,7 @@ import {
|
|||||||
openCodeError,
|
openCodeError,
|
||||||
parseOpenCodeEvents,
|
parseOpenCodeEvents,
|
||||||
} from "./opencode.ts";
|
} from "./opencode.ts";
|
||||||
import {
|
import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "./runtime.ts";
|
||||||
readRuntimeManifest,
|
|
||||||
type RuntimeOutcome,
|
|
||||||
type RuntimeProfile,
|
|
||||||
} from "./runtime.ts";
|
|
||||||
|
|
||||||
type GiteaUser = { login: string; email: string };
|
type GiteaUser = { login: string; email: string };
|
||||||
type GiteaComment = { user: GiteaUser; created_at: string; body: string };
|
type GiteaComment = { user: GiteaUser; created_at: string; body: string };
|
||||||
@@ -92,17 +84,16 @@ async function runRoot(command: string, args: string[]): Promise<void> {
|
|||||||
|
|
||||||
async function configureGitAuthor(): Promise<void> {
|
async function configureGitAuthor(): Promise<void> {
|
||||||
const user: GiteaUser = await (await gitea("user")).json();
|
const user: GiteaUser = await (await gitea("user")).json();
|
||||||
for (const [key, value] of [["name", user.login], ["email", user.email]]) {
|
for (const [key, value] of [
|
||||||
|
["name", user.login],
|
||||||
|
["email", user.email],
|
||||||
|
]) {
|
||||||
await runRoot("git", ["config", `user.${key}`, value]);
|
await runRoot("git", ["config", `user.${key}`, value]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readProfileToken(
|
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
|
||||||
profile: RuntimeProfile,
|
return profile.tokenFile === null ? undefined : await Deno.readTextFile(profile.tokenFile);
|
||||||
): Promise<string | undefined> {
|
|
||||||
return profile.tokenFile === null
|
|
||||||
? undefined
|
|
||||||
: await Deno.readTextFile(profile.tokenFile);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAgentCommand(
|
async function runAgentCommand(
|
||||||
@@ -119,9 +110,7 @@ async function runAgentCommand(
|
|||||||
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
|
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareProfile(
|
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
|
||||||
profile: RuntimeProfile,
|
|
||||||
): Promise<void> {
|
|
||||||
await runAgentCommand(profile, "git", [
|
await runAgentCommand(profile, "git", [
|
||||||
"config",
|
"config",
|
||||||
"--global",
|
"--global",
|
||||||
@@ -144,15 +133,13 @@ async function renderPrompt(
|
|||||||
): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> {
|
): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> {
|
||||||
const reviewPath = await makeProfileFile(profile, "review.md");
|
const reviewPath = await makeProfileFile(profile, "review.md");
|
||||||
const anchorsPath = await makeProfileFile(profile, "anchors.json");
|
const anchorsPath = await makeProfileFile(profile, "anchors.json");
|
||||||
const comments: GiteaComment[] = await (await gitea(
|
const comments: GiteaComment[] = await (
|
||||||
`repos/${REPO}/issues/${INDEX}/comments?limit=100`,
|
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
|
||||||
)).json();
|
).json();
|
||||||
const values: Record<string, string> = {
|
const values: Record<string, string> = {
|
||||||
COMMENT: env("COMMENT"),
|
COMMENT: env("COMMENT"),
|
||||||
ISSUE_COMMENTS: comments
|
ISSUE_COMMENTS: comments
|
||||||
.map((comment) =>
|
.map((comment) => `## ${comment.user.login} at ${comment.created_at}\n\n${comment.body}\n`)
|
||||||
`## ${comment.user.login} at ${comment.created_at}\n\n${comment.body}\n`
|
|
||||||
)
|
|
||||||
.join("\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,
|
||||||
@@ -162,50 +149,46 @@ async function renderPrompt(
|
|||||||
REVIEW_PATH: reviewPath,
|
REVIEW_PATH: reviewPath,
|
||||||
ANCHORS_PATH: anchorsPath,
|
ANCHORS_PATH: anchorsPath,
|
||||||
};
|
};
|
||||||
const template = await Deno.readTextFile(
|
const template = await Deno.readTextFile(new URL("../prompt.md", import.meta.url));
|
||||||
new URL("../prompt.md", import.meta.url),
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
prompt: template.replace(
|
prompt: template.replace(/\$\{(\w+)\}/g, (match, name) => values[name] ?? match),
|
||||||
/\$\{(\w+)\}/g,
|
|
||||||
(match, name) => values[name] ?? match,
|
|
||||||
),
|
|
||||||
reviewPath,
|
reviewPath,
|
||||||
anchorsPath,
|
anchorsPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runClaude(
|
async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise<string> {
|
||||||
profile: RuntimeProfile,
|
const claude = agentCommand(
|
||||||
token: string,
|
profile,
|
||||||
prompt: string,
|
"claude",
|
||||||
): Promise<string> {
|
[
|
||||||
const claude = agentCommand(profile, "claude", [
|
"--print",
|
||||||
"--print",
|
"--dangerously-skip-permissions",
|
||||||
"--dangerously-skip-permissions",
|
"--model",
|
||||||
"--model",
|
profile.model,
|
||||||
profile.model,
|
"--output-format",
|
||||||
"--output-format",
|
"stream-json",
|
||||||
"stream-json",
|
"--verbose",
|
||||||
"--verbose",
|
prompt,
|
||||||
prompt,
|
],
|
||||||
], {
|
{
|
||||||
cwd: WORKSPACE,
|
cwd: WORKSPACE,
|
||||||
env: {
|
env: {
|
||||||
...agentEnvironment(PROCESS_ENV, profile, token),
|
...agentEnvironment(PROCESS_ENV, profile, token),
|
||||||
IS_SANDBOX: "1",
|
IS_SANDBOX: "1",
|
||||||
|
},
|
||||||
|
clearEnv: true,
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
},
|
},
|
||||||
clearEnv: true,
|
).spawn();
|
||||||
stdout: "piped",
|
|
||||||
stderr: "piped",
|
|
||||||
}).spawn();
|
|
||||||
let result:
|
let result:
|
||||||
| {
|
| {
|
||||||
is_error?: boolean;
|
is_error?: boolean;
|
||||||
result?: string;
|
result?: string;
|
||||||
subtype: string;
|
subtype: string;
|
||||||
api_error_status?: number | null;
|
api_error_status?: number | null;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
let error = "";
|
let error = "";
|
||||||
const copyError = claude.stderr.pipeThrough(new TextDecoderStream()).pipeTo(
|
const copyError = claude.stderr.pipeThrough(new TextDecoderStream()).pipeTo(
|
||||||
@@ -230,18 +213,14 @@ async function runClaude(
|
|||||||
}
|
}
|
||||||
const [status] = await Promise.all([claude.status, copyError]);
|
const [status] = await Promise.all([claude.status, copyError]);
|
||||||
if (result?.is_error) {
|
if (result?.is_error) {
|
||||||
const failure = new Error(
|
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
|
||||||
result.result ?? `claude ended with ${result.subtype}`,
|
|
||||||
);
|
|
||||||
if (isClaudeAuthFailure(result)) {
|
if (isClaudeAuthFailure(result)) {
|
||||||
throw new AgentAuthError("claude sign-in failed", { cause: failure });
|
throw new AgentAuthError("claude sign-in failed", { cause: failure });
|
||||||
}
|
}
|
||||||
throw failure;
|
throw failure;
|
||||||
}
|
}
|
||||||
if (result?.result === undefined) {
|
if (result?.result === undefined) {
|
||||||
throw new Error(
|
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
|
||||||
error.trim() || `claude ended with ${result?.subtype ?? "no result"}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!status.success) {
|
if (!status.success) {
|
||||||
throw new Error(error.trim() || `claude exited with ${status.code}`);
|
throw new Error(error.trim() || `claude exited with ${status.code}`);
|
||||||
@@ -262,9 +241,9 @@ async function codexDeviceLogin(
|
|||||||
}).spawn();
|
}).spawn();
|
||||||
let shown = "";
|
let shown = "";
|
||||||
const collect = (stream: ReadableStream<Uint8Array>): Promise<void> =>
|
const collect = (stream: ReadableStream<Uint8Array>): Promise<void> =>
|
||||||
stream.pipeThrough(new TextDecoderStream()).pipeTo(
|
stream
|
||||||
new WritableStream({ write: (chunk) => void (shown += chunk) }),
|
.pipeThrough(new TextDecoderStream())
|
||||||
);
|
.pipeTo(new WritableStream({ write: (chunk) => void (shown += chunk) }));
|
||||||
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
|
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
|
||||||
const status = login.status;
|
const status = login.status;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
@@ -299,19 +278,13 @@ async function codexApiLogin(
|
|||||||
const writer = login.stdin.getWriter();
|
const writer = login.stdin.getWriter();
|
||||||
await writer.write(new TextEncoder().encode(`${token}\n`));
|
await writer.write(new TextEncoder().encode(`${token}\n`));
|
||||||
await writer.close();
|
await writer.close();
|
||||||
const [status, error] = await Promise.all([
|
const [status, error] = await Promise.all([login.status, new Response(login.stderr).text()]);
|
||||||
login.status,
|
|
||||||
new Response(login.stderr).text(),
|
|
||||||
]);
|
|
||||||
if (!status.success) {
|
if (!status.success) {
|
||||||
throw new Error(error.trim() || `codex login exited with ${status.code}`);
|
throw new Error(error.trim() || `codex login exited with ${status.code}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function makeProfileFile(
|
async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
|
||||||
profile: RuntimeProfile,
|
|
||||||
name: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const path = `${profile.home}/${name}`;
|
const path = `${profile.home}/${name}`;
|
||||||
await Deno.writeTextFile(path, "", { mode: 0o600 });
|
await Deno.writeTextFile(path, "", { mode: 0o600 });
|
||||||
await Deno.chmod(path, 0o600);
|
await Deno.chmod(path, 0o600);
|
||||||
@@ -324,17 +297,12 @@ async function runCodex(
|
|||||||
prompt: string,
|
prompt: string,
|
||||||
authentication: CodexAuthentication,
|
authentication: CodexAuthentication,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const token = authentication.kind === "api-key"
|
const token = authentication.kind === "api-key" ? authentication.token : undefined;
|
||||||
? authentication.token
|
|
||||||
: undefined;
|
|
||||||
const environment = agentEnvironment(PROCESS_ENV, profile, token);
|
const environment = agentEnvironment(PROCESS_ENV, profile, token);
|
||||||
if (authentication.kind === "api-key") {
|
if (authentication.kind === "api-key") {
|
||||||
await codexApiLogin(profile, environment, authentication.token);
|
await codexApiLogin(profile, environment, authentication.token);
|
||||||
} else {
|
} else {
|
||||||
const loggedIn = await agentCommand(profile, "codex", [
|
const loggedIn = await agentCommand(profile, "codex", ["login", "status"], {
|
||||||
"login",
|
|
||||||
"status",
|
|
||||||
], {
|
|
||||||
cwd: WORKSPACE,
|
cwd: WORKSPACE,
|
||||||
env: environment,
|
env: environment,
|
||||||
clearEnv: true,
|
clearEnv: true,
|
||||||
@@ -343,21 +311,26 @@ async function runCodex(
|
|||||||
}
|
}
|
||||||
const outputPath = await makeProfileFile(profile, "result.md");
|
const outputPath = await makeProfileFile(profile, "result.md");
|
||||||
const attempt = async () => {
|
const attempt = async () => {
|
||||||
const codex = agentCommand(profile, "codex", [
|
const codex = agentCommand(
|
||||||
"exec",
|
profile,
|
||||||
"--model",
|
"codex",
|
||||||
profile.model,
|
[
|
||||||
"--dangerously-bypass-approvals-and-sandbox",
|
"exec",
|
||||||
"--output-last-message",
|
"--model",
|
||||||
outputPath,
|
profile.model,
|
||||||
prompt,
|
"--dangerously-bypass-approvals-and-sandbox",
|
||||||
], {
|
"--output-last-message",
|
||||||
cwd: WORKSPACE,
|
outputPath,
|
||||||
env: environment,
|
prompt,
|
||||||
clearEnv: true,
|
],
|
||||||
stdout: "inherit",
|
{
|
||||||
stderr: "piped",
|
cwd: WORKSPACE,
|
||||||
}).spawn();
|
env: environment,
|
||||||
|
clearEnv: true,
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "piped",
|
||||||
|
},
|
||||||
|
).spawn();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let error = "";
|
let error = "";
|
||||||
for await (const chunk of codex.stderr) {
|
for await (const chunk of codex.stderr) {
|
||||||
@@ -368,16 +341,15 @@ async function runCodex(
|
|||||||
// Codex echoes the prompt, whose comments may quote auth errors.
|
// Codex echoes the prompt, whose comments may quote auth errors.
|
||||||
return { status: await codex.status, error: error.replaceAll(prompt, "") };
|
return { status: await codex.status, error: error.replaceAll(prompt, "") };
|
||||||
};
|
};
|
||||||
const result = authentication.kind === "api-key"
|
const result =
|
||||||
? await attempt()
|
authentication.kind === "api-key"
|
||||||
: await retryInvalidToken(attempt, async () => {
|
? await attempt()
|
||||||
await runAgentCommand(profile, "codex", ["logout"]);
|
: await retryInvalidToken(attempt, async () => {
|
||||||
await codexDeviceLogin(profile, environment);
|
await runAgentCommand(profile, "codex", ["logout"]);
|
||||||
});
|
await codexDeviceLogin(profile, environment);
|
||||||
|
});
|
||||||
if (!result.status.success) {
|
if (!result.status.success) {
|
||||||
const failure = new Error(
|
const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`);
|
||||||
result.error.trim() || `codex exited with ${result.status.code}`,
|
|
||||||
);
|
|
||||||
if (isCodexAuthFailure(result.error)) {
|
if (isCodexAuthFailure(result.error)) {
|
||||||
throw new AgentAuthError("codex sign-in failed", { cause: failure });
|
throw new AgentAuthError("codex sign-in failed", { cause: failure });
|
||||||
}
|
}
|
||||||
@@ -391,18 +363,13 @@ async function runOpenCode(
|
|||||||
token: string,
|
token: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const process = agentCommand(
|
const process = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), {
|
||||||
profile,
|
cwd: WORKSPACE,
|
||||||
"opencode",
|
env: agentEnvironment(PROCESS_ENV, profile, token),
|
||||||
openCodeArgs(profile.model, prompt),
|
clearEnv: true,
|
||||||
{
|
stdout: "piped",
|
||||||
cwd: WORKSPACE,
|
stderr: "piped",
|
||||||
env: agentEnvironment(PROCESS_ENV, profile, token),
|
}).spawn();
|
||||||
clearEnv: true,
|
|
||||||
stdout: "piped",
|
|
||||||
stderr: "piped",
|
|
||||||
},
|
|
||||||
).spawn();
|
|
||||||
const output = collectOpenCodeOutput(
|
const output = collectOpenCodeOutput(
|
||||||
process.stdout,
|
process.stdout,
|
||||||
process.stderr,
|
process.stderr,
|
||||||
@@ -416,17 +383,16 @@ async function runOpenCode(
|
|||||||
const [status, streams] = await Promise.all([process.status, output]);
|
const [status, streams] = await Promise.all([process.status, output]);
|
||||||
const error = streams.stderr.trim();
|
const error = streams.stderr.trim();
|
||||||
if (!status.success) {
|
if (!status.success) {
|
||||||
throw openCodeError(streams.stdout) ??
|
throw (
|
||||||
new Error(error || `opencode exited with ${status.code}`);
|
openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return parseOpenCodeEvents(streams.stdout);
|
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"
|
const authentication = profile.type === "codex" ? codexAuthentication(token) : null;
|
||||||
? codexAuthentication(token)
|
|
||||||
: null;
|
|
||||||
let stagedCodexAuth: string | null = null;
|
let stagedCodexAuth: string | null = null;
|
||||||
let body: string | undefined;
|
let body: string | undefined;
|
||||||
let resultPath: string | null = null;
|
let resultPath: string | null = null;
|
||||||
@@ -434,12 +400,7 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
|||||||
let anchorsPath: string | undefined;
|
let anchorsPath: string | undefined;
|
||||||
try {
|
try {
|
||||||
if (authentication?.kind === "persistent") {
|
if (authentication?.kind === "persistent") {
|
||||||
stagedCodexAuth = await stageCodexAuth(
|
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
|
||||||
CODEX_AUTH,
|
|
||||||
profile.home,
|
|
||||||
profile.uid,
|
|
||||||
profile.gid,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await prepareProfile(profile);
|
await prepareProfile(profile);
|
||||||
const rendered = await renderPrompt(profile);
|
const rendered = await renderPrompt(profile);
|
||||||
@@ -459,9 +420,7 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (resultPath !== null) body = await readProfileFile(profile, resultPath);
|
if (resultPath !== null) body = await readProfileFile(profile, resultPath);
|
||||||
if (
|
if (body === undefined || reviewPath === undefined || anchorsPath === undefined) {
|
||||||
body === undefined || reviewPath === undefined || anchorsPath === undefined
|
|
||||||
) {
|
|
||||||
throw new Error("agent attempt produced no result");
|
throw new Error("agent attempt produced no result");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -484,7 +443,7 @@ try {
|
|||||||
await configureGitAuthor();
|
await configureGitAuthor();
|
||||||
await writeOutcome({
|
await writeOutcome({
|
||||||
ok: true,
|
ok: true,
|
||||||
...await runProfiles(MANIFEST.profiles, runProfile, failures),
|
...(await runProfiles(MANIFEST.profiles, runProfile, failures)),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
+8
-15
@@ -22,20 +22,15 @@ export type RuntimeManifest = {
|
|||||||
|
|
||||||
export type RuntimeOutcome =
|
export type RuntimeOutcome =
|
||||||
| {
|
| {
|
||||||
ok: true;
|
ok: true;
|
||||||
body: string;
|
body: string;
|
||||||
reviewPath: string;
|
reviewPath: string;
|
||||||
anchorsPath: string;
|
anchorsPath: string;
|
||||||
profile: Pick<RuntimeProfile, "type" | "model">;
|
profile: Pick<RuntimeProfile, "type" | "model">;
|
||||||
}
|
}
|
||||||
| { ok: false; failures: ProfileFailure[] };
|
| { ok: false; failures: ProfileFailure[] };
|
||||||
|
|
||||||
async function writePrivate(
|
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
|
||||||
path: string,
|
|
||||||
value: string,
|
|
||||||
uid: number,
|
|
||||||
gid: number,
|
|
||||||
): Promise<void> {
|
|
||||||
await Deno.writeTextFile(path, value, { mode: 0o600 });
|
await Deno.writeTextFile(path, value, { mode: 0o600 });
|
||||||
await Deno.chmod(path, 0o600);
|
await Deno.chmod(path, 0o600);
|
||||||
await Deno.chown(path, uid, gid);
|
await Deno.chown(path, uid, gid);
|
||||||
@@ -97,8 +92,6 @@ export async function prepareRuntime(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readRuntimeManifest(
|
export async function readRuntimeManifest(path: string): Promise<RuntimeManifest> {
|
||||||
path: string,
|
|
||||||
): Promise<RuntimeManifest> {
|
|
||||||
return JSON.parse(await Deno.readTextFile(path));
|
return JSON.parse(await Deno.readTextFile(path));
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-8
@@ -21,14 +21,8 @@ Deno.test({
|
|||||||
const manifest = await prepareRuntime(action, root, MASTER_KEY);
|
const manifest = await prepareRuntime(action, root, MASTER_KEY);
|
||||||
const serialized = await Deno.readTextFile(manifest.manifestPath);
|
const serialized = await Deno.readTextFile(manifest.manifestPath);
|
||||||
assert(!serialized.includes("secret-token"));
|
assert(!serialized.includes("secret-token"));
|
||||||
assertEquals(
|
assertEquals(await Deno.readTextFile(manifest.reviewerFile), "secret-token");
|
||||||
await Deno.readTextFile(manifest.reviewerFile),
|
assertEquals((await Deno.stat(manifest.reviewerFile)).mode! & 0o777, 0o600);
|
||||||
"secret-token",
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
(await Deno.stat(manifest.reviewerFile)).mode! & 0o777,
|
|
||||||
0o600,
|
|
||||||
);
|
|
||||||
assertEquals((await Deno.stat(manifest.reviewerFile)).uid, 0);
|
assertEquals((await Deno.stat(manifest.reviewerFile)).uid, 0);
|
||||||
|
|
||||||
const [claude, codex] = manifest.profiles;
|
const [claude, codex] = manifest.profiles;
|
||||||
|
|||||||
+19
-33
@@ -4,10 +4,7 @@ import { parse } from "jsr:@std/yaml@1";
|
|||||||
type Mapping = Record<string, unknown>;
|
type Mapping = Record<string, unknown>;
|
||||||
|
|
||||||
const mapping = (value: unknown, at: string): Mapping => {
|
const mapping = (value: unknown, at: string): Mapping => {
|
||||||
assert(
|
assert(typeof value === "object" && value !== null && !Array.isArray(value), at);
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value),
|
|
||||||
at,
|
|
||||||
);
|
|
||||||
return value as Mapping;
|
return value as Mapping;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,7 +22,7 @@ async function yamlFiles(directory = "."): Promise<string[]> {
|
|||||||
for await (const entry of Deno.readDir(directory)) {
|
for await (const entry of Deno.readDir(directory)) {
|
||||||
if (entry.name === ".git" || entry.name === ".superpowers") continue;
|
if (entry.name === ".git" || entry.name === ".superpowers") continue;
|
||||||
const path = directory === "." ? entry.name : `${directory}/${entry.name}`;
|
const path = directory === "." ? entry.name : `${directory}/${entry.name}`;
|
||||||
if (entry.isDirectory) result.push(...await yamlFiles(path));
|
if (entry.isDirectory) result.push(...(await yamlFiles(path)));
|
||||||
if (entry.isFile && /\.ya?ml$/.test(entry.name)) result.push(path);
|
if (entry.isFile && /\.ya?ml$/.test(entry.name)) result.push(path);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -52,20 +49,14 @@ Deno.test("the action exposes only the master key and scopes it to preparation",
|
|||||||
const post = steps.find((step) => step.id === "post");
|
const post = steps.find((step) => step.id === "post");
|
||||||
const source = steps.find((step) => step.id === "source");
|
const source = steps.find((step) => step.id === "source");
|
||||||
assert(checkout && source && prepare && agent && post);
|
assert(checkout && source && prepare && agent && post);
|
||||||
assertEquals(
|
assertEquals(mapping(checkout.with, "checkout.with").token, "${{ gitea.token }}");
|
||||||
mapping(checkout.with, "checkout.with").token,
|
|
||||||
"${{ gitea.token }}",
|
|
||||||
);
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY,
|
mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY,
|
||||||
"${{ inputs.master-key }}",
|
"${{ inputs.master-key }}",
|
||||||
);
|
);
|
||||||
assertEquals(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY"), false);
|
assertEquals(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY"), false);
|
||||||
assertEquals(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY"), false);
|
assertEquals(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY"), false);
|
||||||
assertEquals(
|
assertEquals(mapping(agent.env, "agent.env").GITEA_TOKEN, "${{ gitea.token }}");
|
||||||
mapping(agent.env, "agent.env").GITEA_TOKEN,
|
|
||||||
"${{ gitea.token }}",
|
|
||||||
);
|
|
||||||
assert(String(post.if).includes("always()"));
|
assert(String(post.if).includes("always()"));
|
||||||
for (const step of [prepare, agent, post]) {
|
for (const step of [prepare, agent, post]) {
|
||||||
assert(String(step.run).includes("steps.source.outputs.path"));
|
assert(String(step.run).includes("steps.source.outputs.path"));
|
||||||
@@ -88,15 +79,13 @@ Deno.test("the source protection script stages immutable action files", async ()
|
|||||||
assert(output.success, new TextDecoder().decode(output.stderr));
|
assert(output.success, new TextDecoder().decode(output.stderr));
|
||||||
|
|
||||||
const path = (await Deno.readTextFile(outputFile)).trim().slice(5);
|
const path = (await Deno.readTextFile(outputFile)).trim().slice(5);
|
||||||
for (
|
for (const file of [
|
||||||
const file of [
|
"src/prepare.ts",
|
||||||
"src/prepare.ts",
|
"src/run.ts",
|
||||||
"src/run.ts",
|
"src/post.ts",
|
||||||
"src/post.ts",
|
"profiles.yml",
|
||||||
"profiles.yml",
|
"prompt.md",
|
||||||
"prompt.md",
|
]) {
|
||||||
]
|
|
||||||
) {
|
|
||||||
const stat = await Deno.stat(`${path}/${file}`);
|
const stat = await Deno.stat(`${path}/${file}`);
|
||||||
assert(stat.isFile, file);
|
assert(stat.isFile, file);
|
||||||
assertEquals(stat.mode! & 0o022, 0, file);
|
assertEquals(stat.mode! & 0o022, 0, file);
|
||||||
@@ -109,9 +98,7 @@ Deno.test("the source protection script stages immutable action files", async ()
|
|||||||
Deno.test("module-relative runtime assets exist", async () => {
|
Deno.test("module-relative runtime assets exist", async () => {
|
||||||
const module = new URL("run.ts", import.meta.url);
|
const module = new URL("run.ts", import.meta.url);
|
||||||
const source = await Deno.readTextFile(module);
|
const source = await Deno.readTextFile(module);
|
||||||
for (
|
for (const match of source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/g)) {
|
||||||
const match of source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/g)
|
|
||||||
) {
|
|
||||||
const asset = new URL(match[1], module);
|
const asset = new URL(match[1], module);
|
||||||
const stat = await Deno.stat(asset);
|
const stat = await Deno.stat(asset);
|
||||||
assert(stat.isFile, asset.pathname);
|
assert(stat.isFile, asset.pathname);
|
||||||
@@ -130,8 +117,8 @@ Deno.test("the repository uses its trusted main action", async () => {
|
|||||||
const steps = sequence(job.steps, "jobs.agent.steps");
|
const steps = sequence(job.steps, "jobs.agent.steps");
|
||||||
const local = steps.findIndex((step) => step.uses === "./");
|
const local = steps.findIndex((step) => step.uses === "./");
|
||||||
assertEquals(local, -1);
|
assertEquals(local, -1);
|
||||||
const trusted = steps.findIndex((step) =>
|
const trusted = steps.findIndex(
|
||||||
step.uses === "https://git.capsulizers.com/commons/bot-agents@main"
|
(step) => step.uses === "https://git.capsulizers.com/commons/bot-agents@main",
|
||||||
);
|
);
|
||||||
assert(trusted >= 0);
|
assert(trusted >= 0);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -145,7 +132,7 @@ Deno.test("CI checks every phase and the root isolation suite", async () => {
|
|||||||
const workflow = await yaml(".gitea/workflows/check.yml");
|
const workflow = await yaml(".gitea/workflows/check.yml");
|
||||||
const job = mapping(mapping(workflow.jobs, "jobs").deno, "jobs.deno");
|
const job = mapping(mapping(workflow.jobs, "jobs").deno, "jobs.deno");
|
||||||
const commands = sequence(job.steps, "jobs.deno.steps")
|
const commands = sequence(job.steps, "jobs.deno.steps")
|
||||||
.map((step) => typeof step.run === "string" ? step.run : "")
|
.map((step) => (typeof step.run === "string" ? step.run : ""))
|
||||||
.join("\n");
|
.join("\n");
|
||||||
for (const entrypoint of ["src/prepare.ts", "src/run.ts", "src/post.ts"]) {
|
for (const entrypoint of ["src/prepare.ts", "src/run.ts", "src/post.ts"]) {
|
||||||
assert(commands.includes(entrypoint), entrypoint);
|
assert(commands.includes(entrypoint), entrypoint);
|
||||||
@@ -178,11 +165,10 @@ Deno.test("profile preparation does not receive provider credentials", async ()
|
|||||||
Deno.test("central profiles fall back from Codex to Claude to OpenCode", async () => {
|
Deno.test("central profiles fall back from Codex to Claude to OpenCode", async () => {
|
||||||
const credentials = await yaml("profiles.yml");
|
const credentials = await yaml("profiles.yml");
|
||||||
const profiles = sequence(credentials.profiles, "profiles");
|
const profiles = sequence(credentials.profiles, "profiles");
|
||||||
assertEquals(profiles.map((profile) => profile.type), [
|
assertEquals(
|
||||||
"codex",
|
profiles.map((profile) => profile.type),
|
||||||
"claude",
|
["codex", "claude", "opencode"],
|
||||||
"opencode",
|
);
|
||||||
]);
|
|
||||||
assertEquals(profiles[2].model, "openrouter/qwen/qwen3.8-27b");
|
assertEquals(profiles[2].model, "openrouter/qwen/qwen3.8-27b");
|
||||||
assert(String(profiles[2].token).startsWith("v1:"));
|
assert(String(profiles[2].token).startsWith("v1:"));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user