diff --git a/src/orb/broker.ts b/src/orb/broker.ts index ddec6a16bf..fa77468c4d 100644 --- a/src/orb/broker.ts +++ b/src/orb/broker.ts @@ -32,6 +32,17 @@ export const ORB_SECRET_TYPE_GITHUB_TOKEN = "github_token"; // brokerOrbToken's own secret_type branch below. export const ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL = "tenant_db_credential"; +// A SECOND mint-style type (#7674, ratified on #4941: hosted AMS reuses ORB's installation-based broker rather +// than a parallel identity system), mechanically IDENTICAL to ORB_SECRET_TYPE_GITHUB_TOKEN -- a GitHub App +// installation token's permissions come from the App + what the installer granted, not from anything the +// broker's caller specifies, so there is no real behavioral difference to build here. The distinct value exists +// purely so an enrollment row records WHICH product's container it was issued for (audit/bookkeeping), not +// because AMS needs a different mint mechanism. Deliberately distinct from the self-host session-based GitHub +// auth `packages/loopover-miner/lib/github-token-resolution.ts` uses (a human's own OAuth token via +// `/v1/auth/github/token`) -- that flow exists for an interactive human tool acting as themselves; this one is +// for a headless hosted container acting as the installed App, the same reason ORB's own broker exists at all. +export const ORB_SECRET_TYPE_AMS_GITHUB_TOKEN = "ams_github_token"; + export function isOrbBrokerEnabled(env: Env): boolean { return /^(1|true|yes|on)$/i.test(String(env.ORB_BROKER_ENABLED ?? "").trim()); } @@ -126,9 +137,10 @@ type OrbEnrollmentRow = { }; /** The container's token-exchange: a valid enrollment secret → either a short-lived GitHub installation token - * (the original, mint-style flow) or a decrypted stored secret value (#8064's store-style flow), branching on - * the enrollment row's own secret_type. installation_id/eligibility only apply to the GitHub-token flow — a - * stored secret has no GitHub installation to re-check at all (see issueOrbStoredSecret's header comment). */ + * (the mint-style flow, shared identically by GITHUB_TOKEN and AMS_GITHUB_TOKEN, #7674) or a decrypted stored + * secret value (#8064's store-style flow), branching on the enrollment row's own secret_type. + * installation_id/eligibility only apply to the mint-style flow — a stored secret has no GitHub installation + * to re-check at all (see issueOrbStoredSecret's header comment). */ export async function brokerOrbToken(env: Env, secret: string, options: { forceRefresh?: boolean } = {}): Promise { // Warn when TOKEN_ENCRYPTION_SECRET is absent — without it, the broker cache is bypassed and every exchange hits // GitHub's token endpoint, dramatically increasing exposure to throttle-induced failures. @@ -146,8 +158,12 @@ export async function brokerOrbToken(env: Env, secret: string, options: { forceR if (!row || row.state !== "enrolled" || row.revoked_at !== null) return { error: "invalid_enrollment" }; if (row.secret_type === ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL) return resolveStoredSecret(env, row); // Checked once the caller is already proven to hold a valid enrollment (same ordering rationale as the App- - // credential check below, #2710) — anything else here belongs to a mint strategy that doesn't exist yet. - if (row.secret_type !== ORB_SECRET_TYPE_GITHUB_TOKEN) return { error: "unsupported_secret_type" }; + // credential check below, #2710) — GITHUB_TOKEN and AMS_GITHUB_TOKEN both mint the SAME kind of GitHub App + // installation token through the identical flow below (#7674): the distinct value is bookkeeping only, not a + // different mint strategy. Anything else here belongs to a strategy that doesn't exist yet. + if (row.secret_type !== ORB_SECRET_TYPE_GITHUB_TOKEN && row.secret_type !== ORB_SECRET_TYPE_AMS_GITHUB_TOKEN) { + return { error: "unsupported_secret_type" }; + } const install = await env.DB .prepare("SELECT registered, suspended_at, removed_at FROM orb_github_installations WHERE installation_id = ?") .bind(row.installation_id) diff --git a/test/integration/orb-broker.test.ts b/test/integration/orb-broker.test.ts index 9878cc9160..dbc3a35cad 100644 --- a/test/integration/orb-broker.test.ts +++ b/test/integration/orb-broker.test.ts @@ -6,6 +6,7 @@ import { isOrbBrokerEnabled, issueOrbEnrollment, issueOrbStoredSecret, + ORB_SECRET_TYPE_AMS_GITHUB_TOKEN, ORB_SECRET_TYPE_GITHUB_TOKEN, ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL, revokeOrbEnrollment, @@ -79,6 +80,14 @@ describe("issueOrbEnrollment", () => { const row = await db(e).prepare("SELECT secret_type FROM orb_enrollments WHERE installation_id=202").first<{ secret_type: string }>(); expect(row?.secret_type).toBe("ai_provider_key"); }); + + it("#7674: records ORB_SECRET_TYPE_AMS_GITHUB_TOKEN when issued for a hosted AMS container", async () => { + const e = await brokerEnv(); + await seedInstall(e, 203, { registered: 1 }); + await issueOrbEnrollment(e, 203, undefined, ORB_SECRET_TYPE_AMS_GITHUB_TOKEN); + const row = await db(e).prepare("SELECT secret_type, installation_id FROM orb_enrollments WHERE installation_id=203").first<{ secret_type: string; installation_id: number }>(); + expect(row).toMatchObject({ secret_type: ORB_SECRET_TYPE_AMS_GITHUB_TOKEN, installation_id: 203 }); + }); }); describe("issueOrbStoredSecret", () => { @@ -162,6 +171,45 @@ describe("brokerOrbToken", () => { expect(await brokerOrbToken(e, secret)).toEqual({ error: "unsupported_secret_type" }); }); + it("#7674: mints the SAME kind of GitHub installation token for an ams_github_token enrollment as for github_token", async () => { + const e = await brokerEnv(); + await seedInstall(e, 320, { registered: 1 }); + const { secret } = (await issueOrbEnrollment(e, 320, undefined, ORB_SECRET_TYPE_AMS_GITHUB_TOKEN)) as { secret: string }; + tokenFetch("ghs_ams_minted", "2026-06-25T08:00:00Z", { contents: "write" }); + + expect(await brokerOrbToken(e, secret)).toEqual({ token: "ghs_ams_minted", installationId: 320, expiresAt: "2026-06-25T08:00:00Z", permissions: { contents: "write" } }); + }); + + it("#7674: an ams_github_token enrollment shares the SAME cache as github_token — a second exchange doesn't re-mint", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-25T07:00:00Z")); + const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "orb-ams-cache-test" }); + await seedInstall(e, 321, { registered: 1 }); + const { secret } = (await issueOrbEnrollment(e, 321, undefined, ORB_SECRET_TYPE_AMS_GITHUB_TOKEN)) as { secret: string }; + const fetchCalls = countingTokenFetch("2026-06-25T08:00:00Z"); + + expect(await brokerOrbToken(e, secret)).toMatchObject({ token: "ghs_minted_1" }); + expect(await brokerOrbToken(e, secret)).toMatchObject({ token: "ghs_minted_1" }); + expect(fetchCalls()).toBe(1); + }); + + it("#7674: an ams_github_token enrollment is re-checked for install eligibility just like github_token", async () => { + const e = await brokerEnv(); + await seedInstall(e, 322, { registered: 1 }); + const { secret } = (await issueOrbEnrollment(e, 322, undefined, ORB_SECRET_TYPE_AMS_GITHUB_TOKEN)) as { secret: string }; + await db(e).prepare("UPDATE orb_github_installations SET suspended_at=CURRENT_TIMESTAMP WHERE installation_id=322").run(); + + expect(await brokerOrbToken(e, secret)).toEqual({ error: "installation_not_eligible" }); + }); + + it("#7674: a genuinely unrecognized secret type is still rejected (the widened check isn't a blanket allow)", async () => { + const e = await brokerEnvMissingAppCreds("both"); + await seedInstall(e, 323, { registered: 1 }); + const { secret } = (await issueOrbEnrollment(e, 323, undefined, "some_future_type_not_yet_built")) as { secret: string }; + + expect(await brokerOrbToken(e, secret)).toEqual({ error: "unsupported_secret_type" }); + }); + it("caches a freshly minted token and serves repeated exchanges without reminting", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-25T07:00:00Z"));