diff --git a/docs/help/scripts.md b/docs/help/scripts.md index 4f41954f727a..2828574b7d29 100644 --- a/docs/help/scripts.md +++ b/docs/help/scripts.md @@ -21,6 +21,32 @@ Use these when a task is clearly tied to a script; otherwise prefer the CLI. Auth monitoring is covered in [Authentication](/gateway/authentication). The scripts under `scripts/` are optional extras for systemd/Termux phone workflows. +## GitHub read helper + +Use `scripts/gh-read` when you want `gh` to use a GitHub App installation token for repo-scoped read calls while leaving normal `gh` on your personal login for write actions. + +Required env: + +- `OPENCLAW_GH_READ_APP_ID` +- `OPENCLAW_GH_READ_PRIVATE_KEY_FILE` + +Optional env: + +- `OPENCLAW_GH_READ_INSTALLATION_ID` when you want to skip repo-based installation lookup +- `OPENCLAW_GH_READ_PERMISSIONS` as a comma-separated override for the read permission subset to request + +Repo resolution order: + +- `gh ... -R owner/repo` +- `GH_REPO` +- `git remote origin` + +Examples: + +- `scripts/gh-read pr view 123` +- `scripts/gh-read run list -R openclaw/openclaw` +- `scripts/gh-read api repos/openclaw/openclaw/pulls/123` + ## When adding scripts - Keep scripts focused and documented. diff --git a/scripts/gh-read b/scripts/gh-read new file mode 100755 index 000000000000..ebd3c892a189 --- /dev/null +++ b/scripts/gh-read @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "$0")" && pwd)" + +exec node --import tsx "$script_dir/gh-read.ts" "$@" diff --git a/scripts/gh-read.ts b/scripts/gh-read.ts new file mode 100644 index 000000000000..70749348dd7e --- /dev/null +++ b/scripts/gh-read.ts @@ -0,0 +1,259 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { createPrivateKey, createSign } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +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_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; +type RequestedPermissions = Record; + +type InstallationResponse = { + id: number; + permissions?: GrantedPermissions; +}; + +type AccessTokenResponse = { + token: string; +}; + +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; +} + +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 githubJson( + path: string, + bearerToken: string, + init?: { + method?: "GET" | "POST"; + body?: unknown; + }, +): Promise { + const response = await fetch(`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), + }); + + if (!response.ok) { + const text = await response.text(); + fail(`${init?.method ?? "GET"} ${path} failed (${response.status}): ${text}`); + } + + return (await response.json()) as T; +} + +async function resolveInstallation( + appJwt: string, + repo: string | null, +): Promise { + const installationId = process.env[INSTALLATION_ID_ENV]?.trim(); + if (repo) { + return githubJson(`/repos/${repo}/installation`, appJwt); + } + if (installationId) { + return githubJson(`/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`, + ); +} + +async function createInstallationToken( + appJwt: string, + installation: InstallationResponse, + repo: string | null, +): Promise { + 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( + `/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 \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(); +} diff --git a/test/scripts/gh-read.test.ts b/test/scripts/gh-read.test.ts new file mode 100644 index 000000000000..e5272a6515bc --- /dev/null +++ b/test/scripts/gh-read.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + buildReadPermissions, + normalizeRepo, + parsePermissionKeys, + parseRepoArg, +} from "../../scripts/gh-read.js"; + +describe("gh-read helpers", () => { + it("finds repo from gh args", () => { + expect(parseRepoArg(["pr", "view", "42", "-R", "openclaw/openclaw"])).toBe("openclaw/openclaw"); + expect(parseRepoArg(["run", "list", "--repo=openclaw/docs"])).toBe("openclaw/docs"); + expect(parseRepoArg(["pr", "view", "42"])).toBeNull(); + }); + + it("normalizes repo strings from common git formats", () => { + expect(normalizeRepo("openclaw/openclaw")).toBe("openclaw/openclaw"); + expect(normalizeRepo("github.com/openclaw/openclaw")).toBe("openclaw/openclaw"); + expect(normalizeRepo("https://github.com/openclaw/openclaw.git")).toBe("openclaw/openclaw"); + expect(normalizeRepo("git@github.com:openclaw/openclaw.git")).toBe("openclaw/openclaw"); + expect(normalizeRepo("invalid")).toBeNull(); + }); + + it("builds a read-only permission subset from granted permissions", () => { + expect( + buildReadPermissions( + { + actions: "write", + issues: "read", + administration: "write", + metadata: "read", + statuses: null, + }, + ["actions", "issues", "metadata", "statuses", "administration"], + ), + ).toEqual({ + administration: "read", + actions: "read", + issues: "read", + metadata: "read", + }); + }); + + it("parses permission key overrides", () => { + expect(parsePermissionKeys(undefined)).toContain("pull_requests"); + expect(parsePermissionKeys("actions, contents ,issues")).toEqual([ + "actions", + "contents", + "issues", + ]); + }); +});