Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
7 changes: 7 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/github/app.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -51,6 +52,15 @@ const TOKEN_SAFETY_MARGIN_MS = 120_000;
export async function createInstallationToken(env: Env, installationId: number): Promise<string> {
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",
Expand Down
45 changes: 45 additions & 0 deletions src/orb/broker-client.ts
Original file line number Diff line number Diff line change
@@ -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<BrokeredInstallationToken> {
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 };
}
17 changes: 17 additions & 0 deletions test/unit/github-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
55 changes: 55 additions & 0 deletions test/unit/orb-broker-client.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>).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<string, string>).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/);
});
});
Loading