Files
shockbot/utils/github.ts
T
Colin McDonnell 6d25adfd1a Agent & model refactor (#478)
* agent & model refactor with ASKPASS git auth, UI restructure, clerk v7

Made-with: Cursor

* fix stale agent/effort refs, add tests for askpass + model resolution

- reviewCleanup.ts: payload.agent -> payload.model, remove effort
- selectMode.ts PlanEdit: remove delegation/subagent/effort references
- pullfrog.yml.ts: update env vars (drop GOOGLE_API_KEY/CURSOR_API_KEY,
  add GOOGLE_GENERATIVE_AI_API_KEY/XAI_API_KEY/MOONSHOT_API_KEY/OPENCODE_API_KEY)
- FlagsSettings/RepoInstructionsSection: remove stale effort/timeout copy
- new: gitAuthServer.test.ts (10 tests — lifecycle, token delivery, tamper detection, script gen)
- new: agent.test.ts (4 tests — default opentoad, AGENT_OVERRIDE, invalid override)
- new: models.test.ts (19 tests — parseModel, resolution, registry invariants)
- update models.dev snapshot

Made-with: Cursor

* fix changed-agents.sh to filter legacy agent files from CI matrix

legacy agent files (claude.ts, codex.ts, etc.) are @ts-nocheck and not
exported from index.ts. changed-agents.sh now reads index.ts imports to
build the active agent set and treats changes to inactive files as
non-agent changes (opentoad canary only).

Made-with: Cursor

* remove MCP file tools, old agent harnesses, and obsolete security tests

ASKPASS-based git auth makes the old MCP file tool security layer unnecessary:
- token never in subprocess env, so symlink/gitattributes/hook attacks can't exfiltrate it
- agents now use native file tools (OpenCode builtin read/edit)

deleted:
- action/mcp/file.ts (file_read, file_write, file_edit, file_delete, list_directory)
- action/mcp/index.ts (dead re-export)
- agent harnesses: claude.ts, codex.ts, cursor.ts, gemini.ts, opencode.ts
- opencode-runner.ts (inlined into opentoad.ts)
- security tests that validated MCP file tool restrictions
- commented-out three-step review flow (~300 lines)
- sanitizeSchema/wrapSchema dead code from mcp/shared.ts
- OPENCODE_MODEL_MINI/MAX env vars (effort-level model overrides removed)

updated test prompts to use generic file ops instead of MCP tool names.
restored pkg-json-scripts + requirements-txt-attack (test --ignore-scripts defense).

Made-with: Cursor

* bump actions/checkout v4 → v6 (node 24)

node 20 actions deprecated june 2, 2026.

Made-with: Cursor

* temporarily disable fail-fast on agnostic tests to debug checkout@v6

Made-with: Cursor

* re-enable fail-fast on agnostic tests

Made-with: Cursor

* fix test token mismatch: mint OIDC tokens scoped to target repo

CI tests override GITHUB_REPOSITORY to pullfrog/test-repo but inherit
the runner's GITHUB_TOKEN (scoped to pullfrog/app), causing 401s on
every run-context fetch. Clear GITHUB_TOKEN in the test subprocess so
ensureGitHubToken() mints a properly scoped token via OIDC.

Also centralizes the default GITHUB_REPOSITORY in runAgentStreaming
instead of repeating it in every test file, and fixes preview-cleanup
to remove workers from all queues (not just name-matching ones).

Made-with: Cursor

* fix ensureGitHubToken to try OIDC when app credentials are absent

ensureGitHubToken only attempted token minting when GITHUB_APP_ID and
GITHUB_PRIVATE_KEY were set. In CI, OIDC is available but app creds
aren't exposed — so the guard prevented minting entirely.

Made-with: Cursor

* dead code cleanup: remove remnants of deleted agents, file tools, effort system

remove unused @anthropic-ai/claude-agent-sdk and @openai/codex-sdk deps,
orphaned file-tool security tests, dead GEMINI_MODEL passthrough, stale
opencode-runner wiki refs, deleted test file references, and MCP file tool
docs. rename docs/effort → docs/models. fix vitest setup: move dotenv to
globalSetup (runs once before forks instead of per-file, 19s → 200ms).

Made-with: Cursor

* address review feedback: remove dead code, update stale references

- remove AGENT_OVERRIDE (only opentoad exists)
- remove shellToolName plumbing (always restricted shell)
- bump action version to 0.0.179
- remove CURSOR_API_KEY from all workflows/configs
- remove OPENCODE_MODEL_MINI/MAX from workflows/docs
- delete wiki/effort.md, rewrite docs/effort.mdx as "Models"
- rewrite wiki/modes.md: orchestrator/subagent → single agent
- simplify flag system: drop builtin flag extraction (debug, effort,
  timeout, agent), keep custom flag replacement only
- reserve all legacy flag names to prevent custom flag conflicts

Made-with: Cursor

* regenerate lockfile after removing claude-agent-sdk and codex-sdk

Made-with: Cursor

* fix import ordering, add lockfile check to pre-push hook

Made-with: Cursor

* remove dead debug payload field, stale packageExtensions

Made-with: Cursor

* merge proc-sandbox and token-exfil into a single test

proc-sandbox and token-exfil were duplicative — both tested that
SANDBOX_TEST_TOKEN couldn't be exfiltrated. consolidated into
token-exfil with shell:restricted (which actually exercises filterEnv)
and the /proc attack vector hints from proc-sandbox.

Made-with: Cursor

* fix wiki adversarial.md to match actual tokenExfil validator

Made-with: Cursor
2026-03-12 05:22:51 +00:00

456 lines
13 KiB
TypeScript

import { createSign } from "node:crypto";
import { rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import * as core from "@actions/core";
import { throttling } from "@octokit/plugin-throttling";
import { Octokit } from "@octokit/rest";
import { apiFetch } from "./apiFetch.ts";
import { retry } from "./retry.ts";
function isObject(value: unknown) {
return typeof value === "object" && value !== null;
}
// we don't get access to the actual class from @octokit/rest
// it's reachable from @octokit/request-error but we'd have to add a dependency on it
// and it would pose a risk of accidentally pulling a different version of that class (node_modules dep graphs ❤️)
// so it's safer to ducktype this
interface OctokitResponseShim {
headers: Record<string, string | number | undefined>;
}
export interface InstallationToken {
token: string;
expires_at: string;
installation_id: number;
repository: string;
ref: string;
runner_environment: string;
owner?: string;
}
interface GitHubAppConfig {
appId: string;
privateKey: string;
repoOwner: string;
repoName: string;
}
interface Installation {
id: number;
account: {
login: string;
type: string;
};
}
interface Repository {
owner: {
login: string;
};
name: string;
}
interface InstallationTokenResponse {
token: string;
expires_at: string;
}
interface RepositoriesResponse {
repositories: Repository[];
}
function isOIDCAvailable(): boolean {
// OIDC requires both env vars to be set (only in real GitHub Actions with id-token permission)
return Boolean(
process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
);
}
type ReadWrite = "read" | "write";
type WriteOnly = "write";
/**
* GitHub App installation access token permissions.
* passed to `POST /app/installations/{id}/access_tokens` to scope the token.
* fields and allowed values come from the `app-permissions` OpenAPI schema.
* @see https://docs.github.com/en/rest/apps/installations#create-an-installation-access-token-for-an-app
* @see https://github.com/github/rest-api-description — components.schemas.app-permissions
*/
type GitHubAppPermissions = {
actions?: ReadWrite;
artifact_metadata?: ReadWrite;
attestations?: ReadWrite;
checks?: ReadWrite;
contents?: ReadWrite;
deployments?: ReadWrite;
discussions?: ReadWrite;
issues?: ReadWrite;
packages?: ReadWrite;
pages?: ReadWrite;
pull_requests?: ReadWrite;
security_events?: ReadWrite;
statuses?: ReadWrite;
workflows?: WriteOnly;
};
type AcquireTokenOptions = {
repos?: string[];
permissions?: GitHubAppPermissions;
};
async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string> {
const oidcToken = await core.getIDToken("pullfrog-api");
const repos = [...(opts?.repos ?? [])];
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
if (targetRepo) {
repos.push(targetRepo);
}
const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const tokenResponse = await apiFetch({
path: `/api/github/installation-token${reposParam}`,
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json",
},
body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!tokenResponse.ok) {
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
}
const tokenData = (await tokenResponse.json()) as InstallationToken;
return tokenData.token;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === "AbortError") {
throw new Error(`Token exchange timed out after ${timeoutMs}ms`);
}
throw error;
}
}
const base64UrlEncode = (str: string): string => {
return Buffer.from(str)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
};
const generateJWT = (appId: string, privateKey: string): string => {
const now = Math.floor(Date.now() / 1000);
const payload = {
iat: now - 60,
exp: now + 5 * 60,
iss: appId,
};
const header = {
alg: "RS256",
typ: "JWT",
};
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signaturePart = `${encodedHeader}.${encodedPayload}`;
const signature = createSign("RSA-SHA256")
.update(signaturePart)
.sign(privateKey, "base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return `${signaturePart}.${signature}`;
};
const githubRequest = async <T>(
path: string,
options: {
method?: string;
headers?: Record<string, string>;
body?: string;
} = {}
): Promise<T> => {
const { method = "GET", headers = {}, body } = options;
const url = `https://api.github.com${path}`;
const requestHeaders = {
Accept: "application/vnd.github.v3+json",
"User-Agent": "Pullfrog-Installation-Token-Generator/1.0",
...headers,
};
const response = await fetch(url, {
method,
headers: requestHeaders,
...(body && { body }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`GitHub API request failed: ${response.status} ${response.statusText}\n${errorText}`
);
}
return response.json() as T;
};
const checkRepositoryAccess = async (
token: string,
repoOwner: string,
repoName: string
): Promise<boolean> => {
try {
const response = await githubRequest<RepositoriesResponse>("/installation/repositories", {
headers: { Authorization: `token ${token}` },
});
return response.repositories.some(
(repo) => repo.owner.login === repoOwner && repo.name === repoName
);
} catch {
return false;
}
};
const createInstallationToken = async (
jwt: string,
installationId: number,
permissions?: GitHubAppPermissions
): Promise<string> => {
const requestOpts: { method: string; headers: Record<string, string>; body?: string } = {
method: "POST",
headers: { Authorization: `Bearer ${jwt}` },
};
if (permissions) {
requestOpts.body = JSON.stringify({ permissions });
}
const response = await githubRequest<InstallationTokenResponse>(
`/app/installations/${installationId}/access_tokens`,
requestOpts
);
return response.token;
};
const findInstallationId = async (
jwt: string,
repoOwner: string,
repoName: string
): Promise<number> => {
const installations = await githubRequest<Installation[]>("/app/installations", {
headers: { Authorization: `Bearer ${jwt}` },
});
for (const installation of installations) {
try {
const tempToken = await createInstallationToken(jwt, installation.id);
const hasAccess = await checkRepositoryAccess(tempToken, repoOwner, repoName);
if (hasAccess) {
return installation.id;
}
} catch {}
}
throw new Error(
`No installation found with access to ${repoOwner}/${repoName}. ` +
"Ensure the GitHub App is installed on the target repository."
);
};
// for local development only
async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<string> {
const repoContext = parseRepoContext();
const config: GitHubAppConfig = {
appId: process.env.GITHUB_APP_ID!,
privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n")!,
repoOwner: repoContext.owner,
repoName: repoContext.name,
};
const jwt = generateJWT(config.appId, config.privateKey);
const installationId = await findInstallationId(jwt, config.repoOwner, config.repoName);
return await createInstallationToken(jwt, installationId, opts?.permissions);
}
/**
* Ensure a GitHub token is available in the environment.
*
* If neither `GITHUB_TOKEN` nor `GH_TOKEN` is set, attempts to acquire an
* installation token using `GITHUB_APP_ID` / `GITHUB_PRIVATE_KEY`.
*
* **Not intended for production use** — this is a convenience for local
* development and test harnesses where tokens aren't pre-provisioned.
*/
export async function ensureGitHubToken(): Promise<void> {
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
if (isOIDCAvailable() || (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY)) {
const token = await acquireNewToken();
process.env.GITHUB_TOKEN = token;
}
}
}
export async function acquireNewToken(opts?: AcquireTokenOptions): Promise<string> {
if (isOIDCAvailable()) {
return await retry(() => acquireTokenViaOIDC(opts), {
label: "token exchange",
shouldRetry: (error) =>
error instanceof Error &&
(error.name === "AbortError" ||
error.message.includes("fetch failed") ||
error.message.includes("ECONNRESET") ||
error.message.includes("ETIMEDOUT") ||
error.message.includes("Token exchange failed")),
});
} else {
// local development via GitHub App
return await acquireTokenViaGitHubApp(opts);
}
}
export interface RepoContext {
owner: string;
name: string;
}
/**
* Parse repository context from GITHUB_REPOSITORY environment variable.
*/
export function parseRepoContext(): RepoContext {
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo) {
throw new Error("GITHUB_REPOSITORY environment variable is required");
}
const [owner, name] = githubRepo.split("/");
if (!owner || !name) {
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
}
return { owner, name };
}
export type OctokitWithPlugins = InstanceType<
ReturnType<typeof Octokit.plugin<typeof Octokit, [typeof throttling]>>
>;
export interface ResourceUsage {
requestCount: number;
rateLimitRemaining: number | null;
rateLimitResetMs: number | null;
}
function emptyResourceUsage(): ResourceUsage {
return {
requestCount: 0,
rateLimitRemaining: null,
rateLimitResetMs: null,
};
}
const usageByResource: Record<string, ResourceUsage> = {
core: emptyResourceUsage(),
graphql: emptyResourceUsage(),
};
export interface UsageSummary {
version: 1;
github: {
core: ResourceUsage;
graphql: ResourceUsage;
};
}
function getGitHubUsageSummary(): UsageSummary {
return {
version: 1,
github: {
core: usageByResource.core,
graphql: usageByResource.graphql,
},
};
}
export async function writeGitHubUsageSummaryToFile(path: string): Promise<void> {
const summary = getGitHubUsageSummary();
const tmpPath = join(dirname(path), `.usage-summary-${process.pid}.tmp`);
await writeFile(tmpPath, JSON.stringify(summary));
await rename(tmpPath, path);
}
export function createOctokit(token: string): OctokitWithPlugins {
// `OctokitWithPlugins` initialization based on https://github.com/actions/toolkit/blob/2506e78e82fbd2f9e94d63e75f5309118c8de1b1/packages/github/src/github.ts#L15-L22
// we can't use it directly because it's stuck on `@octokit/core@v5` and we use the hottest `@octokit/core@v7`
const OctokitWithPlugins = Octokit.plugin(throttling);
const octokit = new OctokitWithPlugins({
auth: token,
throttle: {
onRateLimit: (_retryAfter, _options, _octokit, retryCount) => {
return retryCount <= 2;
},
onSecondaryRateLimit: (_retryAfter, _options, _octokit, retryCount) => {
return retryCount <= 2;
},
},
});
const onResponse = (response: OctokitResponseShim) => {
const resource = response.headers["x-ratelimit-resource"];
if (!resource) {
return response;
}
usageByResource[resource] ??= emptyResourceUsage();
const usage = usageByResource[resource];
usage.requestCount++;
const remaining = response.headers["x-ratelimit-remaining"];
const reset = response.headers["x-ratelimit-reset"];
if (remaining !== undefined) {
usage.rateLimitRemaining = Number(remaining);
}
if (reset !== undefined) {
usage.rateLimitResetMs = Number(reset) * 1000;
}
return response;
};
octokit.hook.wrap("request", async (request, options) => {
try {
const response = await request(options);
onResponse(response);
return response;
} catch (error) {
if (
isObject(error) &&
"response" in error &&
isObject(error.response) &&
"headers" in error.response &&
isObject(error.response.headers)
) {
onResponse(error.response as OctokitResponseShim);
}
throw error;
}
});
return octokit;
}