Files
adolf/scripts/gh-read.ts
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

403 lines
11 KiB
TypeScript

// Gh Read script supports OpenClaw repository automation.
import { execFileSync, spawnSync } from "node:child_process";
import { createPrivateKey, createSign } from "node:crypto";
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { readBoundedResponseText } from "./lib/bounded-response.ts";
import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts";
const APP_ID_ENV = "OPENCLAW_GH_READ_APP_ID";
const KEY_FILE_ENV = "OPENCLAW_GH_READ_PRIVATE_KEY_FILE";
const INSTALLATION_ID_ENV = "OPENCLAW_GH_READ_INSTALLATION_ID";
const PERMISSIONS_ENV = "OPENCLAW_GH_READ_PERMISSIONS";
const API_VERSION = "2022-11-28";
const DEFAULT_GITHUB_FETCH_TIMEOUT_MS = 30_000;
const GITHUB_ERROR_BODY_MAX_CHARS = 4096;
const GITHUB_JSON_BODY_MAX_BYTES = 1024 * 1024;
const DEFAULT_READ_PERMISSION_KEYS = [
"actions",
"checks",
"contents",
"issues",
"metadata",
"pull_requests",
"statuses",
] as const;
type GrantedPermissionLevel = "read" | "write" | "admin" | null | undefined;
type RequestedPermissionLevel = "read" | "write";
type GrantedPermissions = Record<string, GrantedPermissionLevel>;
type RequestedPermissions = Record<string, RequestedPermissionLevel>;
type InstallationResponse = {
id: number;
permissions?: GrantedPermissions;
};
type AccessTokenResponse = {
token: string;
};
type GitHubJsonOptions = {
fetchImpl?: typeof fetch;
timeoutMs?: number;
};
type GitHubBodyReadOptions = {
signal?: AbortSignal;
timeoutPromise?: Promise<never>;
};
export function parseRepoArg(args: string[]): string | null {
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "-R" || arg === "--repo") {
return normalizeRepo(args[i + 1] ?? null);
}
if (arg.startsWith("--repo=")) {
return normalizeRepo(arg.slice("--repo=".length));
}
if (arg.startsWith("-R") && arg.length > 2) {
return normalizeRepo(arg.slice(2));
}
}
return null;
}
export function normalizeRepo(value: string | null | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
const withoutProtocol = trimmed.replace(/^[a-z]+:\/\//i, "");
const withoutHost = withoutProtocol.replace(/^(?:[^@/]+@)?github\.com[:/]/i, "");
const normalized = withoutHost.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
const parts = normalized.split("/").filter(Boolean);
if (parts.length < 2) {
return null;
}
return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
}
export function parsePermissionKeys(raw: string | null | undefined): string[] {
const trimmed = raw?.trim();
if (!trimmed) {
return [...DEFAULT_READ_PERMISSION_KEYS];
}
return trimmed
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
export function buildReadPermissions(
grantedPermissions: GrantedPermissions | null | undefined,
requestedKeys: readonly string[],
): RequestedPermissions {
const permissions: RequestedPermissions = {};
for (const key of requestedKeys) {
const granted = grantedPermissions?.[key];
if (granted === "read" || granted === "write") {
permissions[key] = "read";
}
}
return permissions;
}
export function resolveGitHubFetchTimeoutMs(raw = process.env.OPENCLAW_GH_READ_FETCH_TIMEOUT_MS) {
return parseStrictIntegerOption({
fallback: DEFAULT_GITHUB_FETCH_TIMEOUT_MS,
label: "OPENCLAW_GH_READ_FETCH_TIMEOUT_MS",
min: 1,
raw,
});
}
function isMainModule() {
const entry = process.argv[1];
return entry ? import.meta.url === pathToFileURL(entry).href : false;
}
function fail(message: string): never {
console.error(`gh-read: ${message}`);
process.exit(1);
}
function readRequiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
fail(`missing ${name}`);
}
return value;
}
function resolveRepo(args: string[]): string | null {
const fromArgs = parseRepoArg(args);
if (fromArgs) {
return fromArgs;
}
const fromEnv = normalizeRepo(process.env.GH_REPO);
if (fromEnv) {
return fromEnv;
}
try {
const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return normalizeRepo(remote);
} catch {
return null;
}
}
function base64UrlEncode(value: string | Uint8Array) {
return Buffer.from(value)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function createAppJwt(appId: string, privateKeyPem: string) {
const now = Math.floor(Date.now() / 1000);
const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const payload = base64UrlEncode(JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: appId }));
const signingInput = `${header}.${payload}`;
const signer = createSign("RSA-SHA256");
signer.update(signingInput);
signer.end();
const signature = signer.sign(createPrivateKey(privateKeyPem));
return `${signingInput}.${base64UrlEncode(signature)}`;
}
async function withGitHubFetchTimeout<T>(
label: string,
timeoutMs: number,
run: (signal: AbortSignal, timeoutPromise: Promise<never>) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
const error = new Error(`${label} exceeded timeout of ${timeoutMs}ms`);
reject(error);
controller.abort(error);
}, timeoutMs);
});
try {
return await Promise.race([run(controller.signal), timeoutPromise]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
function cancelReaderSoon(reader: ReadableStreamDefaultReader<Uint8Array>): void {
void Promise.resolve()
.then(() => reader.cancel())
.catch(() => undefined);
}
async function readGitHubErrorChunk(
reader: ReadableStreamDefaultReader<Uint8Array>,
timeoutPromise: Promise<never> | undefined,
markCanceled: () => void,
): Promise<ReadableStreamReadResult<Uint8Array>> {
const read = reader.read();
if (!timeoutPromise) {
return await read;
}
return await Promise.race([
read,
timeoutPromise.catch((error: unknown) => {
markCanceled();
cancelReaderSoon(reader);
throw error;
}),
]);
}
export async function readBoundedGitHubErrorText(
response: Response,
maxChars = GITHUB_ERROR_BODY_MAX_CHARS,
options: Pick<GitHubBodyReadOptions, "timeoutPromise"> = {},
): Promise<string> {
if (!response.body) {
return "";
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = "";
let truncated = false;
let canceled = false;
try {
while (text.length <= maxChars) {
const { done, value } = await readGitHubErrorChunk(reader, options.timeoutPromise, () => {
canceled = true;
});
if (done) {
text += decoder.decode();
break;
}
text += decoder.decode(value, { stream: true });
if (text.length > maxChars) {
text = text.slice(0, maxChars);
truncated = true;
break;
}
}
} finally {
if (truncated) {
await reader.cancel().catch(() => undefined);
} else if (!canceled) {
reader.releaseLock();
}
}
return truncated ? `${text}\n[truncated]` : text;
}
export async function readBoundedGitHubJson<T>(
response: Response,
maxBytes = GITHUB_JSON_BODY_MAX_BYTES,
options: GitHubBodyReadOptions = {},
): Promise<T> {
const text = await readBoundedResponseText(response, "GitHub API", maxBytes, {
createTooLargeError: (message) =>
Object.assign(new Error(message), {
code: "ETOOBIG",
}),
signal: options.signal,
timeoutPromise: options.timeoutPromise,
});
return JSON.parse(text) as T;
}
export async function githubJson<T>(
path: string,
bearerToken: string,
init?: {
method?: "GET" | "POST";
body?: unknown;
},
options: GitHubJsonOptions = {},
): Promise<T> {
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = options.timeoutMs ?? resolveGitHubFetchTimeoutMs();
return await withGitHubFetchTimeout(
`GitHub API ${init?.method ?? "GET"} ${path}`,
timeoutMs,
async (signal, timeoutPromise) => {
const response = await fetchImpl(`https://api.github.com${path}`, {
method: init?.method ?? "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${bearerToken}`,
"Content-Type": "application/json",
"User-Agent": "openclaw-gh-read",
"X-GitHub-Api-Version": API_VERSION,
},
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
signal,
});
if (!response.ok) {
const text = await readBoundedGitHubErrorText(response, undefined, { timeoutPromise });
fail(`${init?.method ?? "GET"} ${path} failed (${response.status}): ${text}`);
}
return await readBoundedGitHubJson<T>(response, undefined, { signal, timeoutPromise });
},
);
}
async function resolveInstallation(
appJwt: string,
repo: string | null,
): Promise<InstallationResponse> {
const installationId = process.env[INSTALLATION_ID_ENV]?.trim();
if (repo) {
return githubJson<InstallationResponse>(`/repos/${repo}/installation`, appJwt);
}
if (installationId) {
return githubJson<InstallationResponse>(`/app/installations/${installationId}`, appJwt);
}
fail(
`missing repo context; pass -R owner/repo, set GH_REPO, or set ${INSTALLATION_ID_ENV} for a direct installation lookup`,
);
throw new Error("unreachable");
}
async function createInstallationToken(
appJwt: string,
installation: InstallationResponse,
repo: string | null,
): Promise<string> {
const repoName = repo?.split("/")[1] ?? null;
const requestedPermissionKeys = parsePermissionKeys(process.env[PERMISSIONS_ENV]);
const permissions = buildReadPermissions(installation.permissions, requestedPermissionKeys);
const body: {
repositories?: string[];
permissions?: RequestedPermissions;
} = {};
if (repoName) {
body.repositories = [repoName];
}
if (Object.keys(permissions).length > 0) {
body.permissions = permissions;
}
const tokenResponse = await githubJson<AccessTokenResponse>(
`/app/installations/${installation.id}/access_tokens`,
appJwt,
{ method: "POST", body },
);
return tokenResponse.token;
}
async function main() {
if (process.argv.length <= 2) {
fail(
"usage: scripts/gh-read <gh args...>\nset OPENCLAW_GH_READ_APP_ID and OPENCLAW_GH_READ_PRIVATE_KEY_FILE first",
);
}
const ghArgs = process.argv.slice(2);
const appId = readRequiredEnv(APP_ID_ENV);
const privateKeyPath = readRequiredEnv(KEY_FILE_ENV);
const privateKeyPem = readFileSync(privateKeyPath, "utf8");
const repo = resolveRepo(ghArgs);
const appJwt = createAppJwt(appId, privateKeyPem);
const installation = await resolveInstallation(appJwt, repo);
const token = await createInstallationToken(appJwt, installation, repo);
const child = spawnSync("gh", ghArgs, {
stdio: "inherit",
env: {
...process.env,
GH_TOKEN: token,
GITHUB_TOKEN: token,
},
});
if (child.error) {
fail(child.error.message);
}
process.exit(child.status ?? 1);
}
if (isMainModule()) {
await main();
}