From 61508fb1143ade7017fa50861c5904ec61817e13 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:44:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(orb):=20self-host=20broker=20client=20?= =?UTF-8?q?=E2=80=94=20brokered=20installation=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Orb token broker end-to-end (server: #1330/#1332). A brokered self-host holds no GitHub App private key — it installs the central Orb App and sets the operator-issued ORB_ENROLLMENT_SECRET. createInstallationToken now sources tokens from the central Orb (POST /v1/orb/token) when that secret is present, caching them in the same in-isolate token cache as the App-key path (~1 mint/hour/install). Cloud never sets the secret, so the branch is inert there → byte-identical. - src/orb/broker-client.ts: isOrbBrokerMode (secret-presence gate) + fetchBrokeredInstallationToken (exchange secret → {token, installationId, expiresAt}; injectable fetch + 10s timeout; throws on non-OK / tokenless body). - src/github/app.ts: the broker branch slots in at the single token chokepoint, right after the cache check. - No App-key fallback by design (a brokered self-host has none) — a broker outage fails the request exactly like an App-key mint failure, and the queue's retry/dead-letter handling covers a transient blip. The secret is sent as a Bearer over the https default and never logged (errors carry only the status). Advances #1255. (Maintainer-OAuth self-enrollment remains a follow-up; today enrollments are operator-issued.) --- .env.example | 5 +++ src/env.d.ts | 7 ++++ src/github/app.ts | 10 ++++++ src/orb/broker-client.ts | 45 +++++++++++++++++++++++ test/unit/github-app.test.ts | 17 +++++++++ test/unit/orb-broker-client.test.ts | 55 +++++++++++++++++++++++++++++ 6 files changed, 139 insertions(+) create mode 100644 src/orb/broker-client.ts create mode 100644 test/unit/orb-broker-client.test.ts diff --git a/.env.example b/.env.example index 5005361df1..8f4ccb451f 100644 --- a/.env.example +++ b/.env.example @@ -205,3 +205,8 @@ GITTENSORY_REVIEW_DRAFT=false # ORB_AIR_GAP=false # air-gapped/OFFLINE deployments only: compute locally, never send # ORB_ANONYMIZE=true # HMAC-hash repo/PR before export (default true; false = raw names) # ORB_COLLECTOR_URL=https://gittensory-api.aethereal.dev/v1/orb/ingest # gittensory's hosted collector (default; override for your own) +# +# Token broker (optional): get GitHub tokens from the central Orb (you installed the Orb App) instead of running +# your own GitHub App. Set the enrollment secret the operator issued for your install; unset = use your own App key. +# ORB_ENROLLMENT_SECRET= # one-time enrollment secret (a secret — keep it out of version control) +# ORB_BROKER_URL=https://gittensory-api.aethereal.dev # the Orb broker base (default; override for a private deployment) diff --git a/src/env.d.ts b/src/env.d.ts index 15bfefb677..7ca3d22563 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -51,6 +51,13 @@ declare global { /** Master flag for the Orb token-broker (enrollment OAuth + /v1/orb/token). Default-off: every broker route * early-404s until this is "true", so the deploy is byte-identical until an operator enables it. */ ORB_BROKER_ENABLED?: string; + /** SELF-HOST broker CLIENT: the one-time enrollment secret the operator issued for this install. When set, the + * engine sources GitHub installation tokens from the central Orb (POST /v1/orb/token) instead of a local App + * key. Cloud never sets it ⇒ inert there. See src/orb/broker-client. (A secret — never commit a real value.) */ + ORB_ENROLLMENT_SECRET?: string; + /** Override the Orb broker base URL the self-host client calls (default https://gittensory-api.aethereal.dev); + * point at a private gittensory deployment if you self-host the broker too. */ + ORB_BROKER_URL?: string; GITHUB_APP_PRIVATE_KEY: string; GITHUB_APP_ID: string; GITHUB_APP_SLUG: string; diff --git a/src/github/app.ts b/src/github/app.ts index 4e30d8c226..acd1708ecf 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -1,4 +1,5 @@ import type { Advisory, GitHubWebhookPayload } from "../types"; +import { fetchBrokeredInstallationToken, isOrbBrokerMode } from "../orb/broker-client"; import { makeInstallationOctokit } from "./client"; import { maintainerControlPanelUrl } from "./footer"; import type { AgentActionMode } from "../settings/agent-execution"; @@ -51,6 +52,15 @@ const TOKEN_SAFETY_MARGIN_MS = 120_000; export async function createInstallationToken(env: Env, installationId: number): Promise { const cached = installationTokenCache.get(installationId); if (cached && cached.expiresAtMs - TOKEN_SAFETY_MARGIN_MS > Date.now()) return cached.token; + // Self-host broker mode: a brokered self-host holds no App private key, so source the installation token from + // the central Orb (enrollment secret → short-lived token) instead of minting locally. Cloud sets no enrollment + // secret, so this branch is inert there → byte-identical. The token caches the same way (the install id is the + // self-host's single bound install). See src/orb/broker-client. + if (isOrbBrokerMode(env)) { + const brokered = await fetchBrokeredInstallationToken(env); + installationTokenCache.set(installationId, { token: brokered.token, expiresAtMs: brokered.expiresAtMs }); + return brokered.token; + } const jwt = await createAppJwt(env); const response = await timeoutFetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, { method: "POST", diff --git a/src/orb/broker-client.ts b/src/orb/broker-client.ts new file mode 100644 index 0000000000..eaca6aa6d5 --- /dev/null +++ b/src/orb/broker-client.ts @@ -0,0 +1,45 @@ +// Self-host BROKER CLIENT (#1255). A self-hosted engine exchanges its operator-issued enrollment secret for a +// short-lived GitHub installation token from the central Orb (POST /v1/orb/token), so it can act on its own repos +// WITHOUT ever holding a GitHub App private key (gittensory holds the Orb App key centrally and mints on demand — +// the das-github-mirror model). Used by createInstallationToken in broker mode; the installation-token CACHE lives +// with the App-key path in src/github/app.ts (one mint per ~hour per installation, broker or local). +// +// The signal is the ENROLLMENT SECRET's presence: a brokered self-host sets ORB_ENROLLMENT_SECRET (issued by the +// operator), cloud never does — so this path is inert on cloud and the deploy is byte-identical there. + +/** The Orb's hosted broker base; override (ORB_BROKER_URL) only to point at a private gittensory deployment. */ +const DEFAULT_BROKER_URL = "https://gittensory-api.aethereal.dev"; +const BROKER_TIMEOUT_MS = 10_000; + +/** True when GitHub tokens should be sourced from the central Orb broker (a brokered self-host) rather than minted + * locally from an App key — i.e. an enrollment secret is configured. Cloud never sets it ⇒ false there. */ +export function isOrbBrokerMode(env: { ORB_ENROLLMENT_SECRET?: string | undefined }): boolean { + return Boolean(env.ORB_ENROLLMENT_SECRET); +} + +export type BrokeredInstallationToken = { token: string; installationId: number; expiresAtMs: number }; + +/** Exchange the enrollment secret for a brokered installation token + its expiry (ms epoch). Throws on a non-OK + * response (401 invalid_enrollment / 403 installation_not_eligible / 5xx) or a tokenless body — a brokered + * self-host holds no App key to fall back to, so a mint failure is fatal for that request exactly like the + * App-key path, and the queue's existing retry/dead-letter handling covers a transient broker outage. */ +export async function fetchBrokeredInstallationToken( + env: { ORB_ENROLLMENT_SECRET?: string | undefined; ORB_BROKER_URL?: string | undefined }, + fetchImpl: typeof fetch = fetch, +): Promise { + const base = (env.ORB_BROKER_URL ?? DEFAULT_BROKER_URL).replace(/\/+$/, ""); + const response = await fetchImpl(`${base}/v1/orb/token`, { + method: "POST", + headers: { authorization: `Bearer ${env.ORB_ENROLLMENT_SECRET ?? ""}` }, + signal: AbortSignal.timeout(BROKER_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`Orb broker token exchange failed (${response.status}).`); + } + const payload = (await response.json()) as { token?: string; installationId?: number; expiresAt?: string }; + if (!payload.token) { + throw new Error("Orb broker token response did not include a token."); + } + const expiresAtMs = payload.expiresAt ? Date.parse(payload.expiresAt) : Date.now() + 50 * 60_000; + return { token: payload.token, installationId: payload.installationId ?? 0, expiresAtMs }; +} diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 9b87d6fbc6..5b95c2d2d6 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -129,6 +129,23 @@ describe("GitHub check runs", () => { expect(mints).toBe(2); }); + it("sources the installation token from the Orb broker when an enrollment secret is set (and caches it)", async () => { + let brokerCalls = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/v1/orb/token")) { + brokerCalls += 1; + return Response.json({ token: "brokered-token", installationId: 999, expiresAt: new Date(Date.now() + 60 * 60_000).toISOString() }); + } + return new Response("not found", { status: 404 }); + }); + // No GITHUB_APP_PRIVATE_KEY needed — a brokered self-host holds no App key. + const env = createTestEnv({ ORB_ENROLLMENT_SECRET: "orbsec_test" }); + expect(await createInstallationToken(env, 888)).toBe("brokered-token"); + expect(await createInstallationToken(env, 888)).toBe("brokered-token"); // cached → no second broker exchange + expect(brokerCalls).toBe(1); + }); + it("fetches repository collaborator permissions with installation credentials", async () => { const privateKey = await generatePrivateKeyPem(); const calls: string[] = []; diff --git a/test/unit/orb-broker-client.test.ts b/test/unit/orb-broker-client.test.ts new file mode 100644 index 0000000000..35505c975b --- /dev/null +++ b/test/unit/orb-broker-client.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { fetchBrokeredInstallationToken, isOrbBrokerMode } from "../../src/orb/broker-client"; + +/** A fetch stub that records the URL + init and returns a fixed response. */ +function captureFetch(resp: Response): { fetchImpl: typeof fetch; calls: { url: string; init?: RequestInit | undefined }[] } { + const calls: { url: string; init?: RequestInit | undefined }[] = []; + const fetchImpl = (async (url: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return resp; + }) as typeof fetch; + return { fetchImpl, calls }; +} + +describe("isOrbBrokerMode", () => { + it("is on only when an enrollment secret is configured", () => { + expect(isOrbBrokerMode({})).toBe(false); + expect(isOrbBrokerMode({ ORB_ENROLLMENT_SECRET: "orbsec_x" })).toBe(true); + }); +}); + +describe("fetchBrokeredInstallationToken", () => { + it("exchanges the secret for a token + parses the expiry (default broker URL + Bearer secret)", async () => { + const { fetchImpl, calls } = captureFetch(Response.json({ token: "ghs_x", installationId: 42, expiresAt: "2026-06-25T09:00:00Z" })); + const out = await fetchBrokeredInstallationToken({ ORB_ENROLLMENT_SECRET: "orbsec_x" }, fetchImpl); + expect(out).toEqual({ token: "ghs_x", installationId: 42, expiresAtMs: Date.parse("2026-06-25T09:00:00Z") }); + expect(calls[0]?.url).toBe("https://gittensory-api.aethereal.dev/v1/orb/token"); + expect((calls[0]?.init?.headers as Record).authorization).toBe("Bearer orbsec_x"); + expect(calls[0]?.init?.method).toBe("POST"); + }); + + it("defaults installationId + expiry when absent, and strips a trailing slash from a custom broker URL", async () => { + const { fetchImpl, calls } = captureFetch(Response.json({ token: "ghs_y" })); + const out = await fetchBrokeredInstallationToken({ ORB_ENROLLMENT_SECRET: "s", ORB_BROKER_URL: "https://broker.example/" }, fetchImpl); + expect(out.token).toBe("ghs_y"); + expect(out.installationId).toBe(0); // payload.installationId ?? 0 + expect(out.expiresAtMs).toBeGreaterThan(Date.now()); // payload.expiresAt absent → ~50min default + expect(calls[0]?.url).toBe("https://broker.example/v1/orb/token"); + }); + + it("sends an empty Bearer when no secret is set (defensive ?? branch)", async () => { + const { fetchImpl, calls } = captureFetch(Response.json({ token: "t" })); + await fetchBrokeredInstallationToken({}, fetchImpl); + expect((calls[0]?.init?.headers as Record).authorization).toBe("Bearer "); + }); + + it("throws on a non-OK broker response (e.g. 403 installation_not_eligible)", async () => { + const fetchImpl = (async () => new Response("nope", { status: 403 })) as typeof fetch; + await expect(fetchBrokeredInstallationToken({ ORB_ENROLLMENT_SECRET: "s" }, fetchImpl)).rejects.toThrow(/403/); + }); + + it("throws when the broker response has no token", async () => { + const fetchImpl = (async () => Response.json({ installationId: 1 })) as typeof fetch; + await expect(fetchBrokeredInstallationToken({ ORB_ENROLLMENT_SECRET: "s" }, fetchImpl)).rejects.toThrow(/did not include a token/); + }); +});