diff --git a/src/api/routes.ts b/src/api/routes.ts index 3b09447856..ca7da07eaa 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -949,7 +949,7 @@ export function createApp() { app.get("/v1/public/subnet-interface", (c) => { const origin = c.env.PUBLIC_API_ORIGIN ?? new URL(c.req.url).origin; c.header("Cache-Control", "public, max-age=600, stale-while-revalidate=86400"); - return c.json(buildSubnetInterfaceDescriptor({ origin, generatedAt: nowIso(), appSlug: c.env.GITHUB_APP_SLUG, upstreamRepo: c.env.GITTENSOR_UPSTREAM_REPO })); + return c.json(buildSubnetInterfaceDescriptor({ origin, generatedAt: nowIso(), upstreamRepo: c.env.GITTENSOR_UPSTREAM_REPO })); }); // Proof of Power (#1059): unauthenticated homepage stats counter — lifetime PRs handled / merged / closed, diff --git a/src/env.d.ts b/src/env.d.ts index 86a9cf7f40..60b7e54c1c 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -146,7 +146,11 @@ declare global { * hasn't explicitly configured its own value. Unset/blank/anything else = off (the existing behavior). A * repo's own `contributorCapCancelCi` (DB or `.gittensory.yml`) always takes precedence over this. */ CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT?: string; - GITHUB_WEBHOOK_SECRET: string; + /** The retired review App's webhook secret. Optional: that App has been fully deleted (its installation was + * suspended, then removed); GitHub can no longer deliver to POST /v1/github/webhook for it either way — the + * route already fails closed on a missing/invalid secret. Kept only so a self-hoster running their OWN App + * under this same name still works without code changes. */ + GITHUB_WEBHOOK_SECRET?: string; GITHUB_WEBHOOK_MAX_BODY_BYTES?: string; /** Webhook secret for the central Gittensory Orb GitHub App (#1255) — distinct from the review app's * GITHUB_WEBHOOK_SECRET. Verifies inbound POST /v1/orb/webhook deliveries. Inject as a wrangler secret. */ @@ -168,9 +172,13 @@ declare global { /** 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; + /** The retired review App's own credentials. Optional: that App has been fully deleted — cloud no longer + * mints tokens or verifies check-run ownership with it (review execution is self-host-only now, brokered + * through the Orb App above). Kept optional (not removed) so a self-hoster running their OWN App under + * these same names still works without code changes. */ + GITHUB_APP_PRIVATE_KEY?: string; + GITHUB_APP_ID?: string; + GITHUB_APP_SLUG?: string; GITHUB_OAUTH_CLIENT_ID?: string; GITHUB_OAUTH_CLIENT_SECRET?: string; GITTENSOR_UPSTREAM_REPO?: string; diff --git a/src/github/app.ts b/src/github/app.ts index e8a426ea71..f0bd47f42d 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -286,6 +286,11 @@ async function mintInstallationToken( } } const jwt = await createAppJwt(env); + // createAppJwt(env) above already threw if GITHUB_APP_ID were missing, so it's always set here — this const + // only exists to narrow the type for expireCachedAppJwt below (TS can't infer that guarantee across the call). + const appId = env.GITHUB_APP_ID; + /* v8 ignore next -- unreachable: createAppJwt(env) just succeeded, which requires GITHUB_APP_ID to be set. */ + if (!appId) throw new Error("GitHub App credentials are not configured."); let response = await requestInstallationTokenWithJwt(jwt, installationId); if (response.status === 401) { // The cached App JWT itself was rejected (a transient GitHub-side validation hiccup, a clock-skew edge case, @@ -298,11 +303,11 @@ async function mintInstallationToken( JSON.stringify({ level: "warn", event: "github_app_jwt_rejected", - appId: env.GITHUB_APP_ID, + appId, status: response.status, }), ); - expireCachedAppJwt(env.GITHUB_APP_ID); + expireCachedAppJwt(appId); const freshJwt = await createAppJwt(env); response = await requestInstallationTokenWithJwt(freshJwt, installationId); if (response.status === 401) { @@ -312,7 +317,7 @@ async function mintInstallationToken( // (flagged by the gate's own review of #2453). Evict again; the throw below still surfaces this failure to // the caller, but the NEXT mint attempt (this one or any other installation's) gets a fresh JWT instead of // replaying the poisoned one. - expireCachedAppJwt(env.GITHUB_APP_ID); + expireCachedAppJwt(appId); } } if (!response.ok) { @@ -667,25 +672,27 @@ function expireCachedAppJwt(appId: string): void { * has since revoked (only a real API call would), but it catches the common "unset/invalid key" failure mode * that otherwise leaves /ready reporting 200 while the review pipeline is completely dead. */ export async function createAppJwt(env: Env): Promise { - if (!env.GITHUB_APP_PRIVATE_KEY) { + if (!env.GITHUB_APP_PRIVATE_KEY || !env.GITHUB_APP_ID) { throw new Error("GitHub App credentials are not configured."); } + const appId = env.GITHUB_APP_ID; + const privateKey = env.GITHUB_APP_PRIVATE_KEY; const nowMs = Date.now(); - const cached = appJwtCache.get(env.GITHUB_APP_ID); - if (cached && cached.privateKey === env.GITHUB_APP_PRIVATE_KEY && cached.expiresAtMs > nowMs) { + const cached = appJwtCache.get(appId); + if (cached && cached.privateKey === privateKey && cached.expiresAtMs > nowMs) { return cached.jwt; } const now = Math.floor(nowMs / 1000); const jwt = await signRs256Jwt( { - iss: env.GITHUB_APP_ID, + iss: appId, iat: now - 60, exp: now + 540, }, - env.GITHUB_APP_PRIVATE_KEY, + privateKey, ); - appJwtCache.set(env.GITHUB_APP_ID, { - privateKey: env.GITHUB_APP_PRIVATE_KEY, + appJwtCache.set(appId, { + privateKey, jwt, expiresAtMs: nowMs + APP_JWT_REUSE_MS, }); diff --git a/src/github/backfill.ts b/src/github/backfill.ts index a7bf74d2f0..1844ca2582 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -2514,7 +2514,9 @@ const GITHUB_ACTIONS_VALIDATE_AGGREGATE_PREREQUISITES = new Set([ function isOwnGitHubAppCheckRun(env: Env, run: { name: string; app?: { slug?: string | null } | null }): boolean { const appSlug = typeof run.app?.slug === "string" ? run.app.slug.trim().toLowerCase() : ""; - const ownSlug = env.GITHUB_APP_SLUG.trim().toLowerCase(); + // GITHUB_APP_SLUG is optional (the retired review App was deleted; a self-hoster's own App name may be unset + // too) — never throw on a missing/blank value, just fail the match like an empty appSlug already would. + const ownSlug = (env.GITHUB_APP_SLUG ?? "").trim().toLowerCase(); return ownSlug.length > 0 && appSlug === ownSlug && BOT_OWNED_CHECK_NAMES.has(run.name); } @@ -3936,7 +3938,9 @@ function isTrustedScannerReviewThreadAuthor(env: Env, login: string | null | und // other "is this our own bot" check in the codebase already does this (self-authored.ts, pr-actions.ts, // comments.ts, processors.ts) -- so a self-hoster who renamed their App still recognizes its own comments. export function isOwnReviewThreadAuthor(env: Env, login: string | null | undefined): boolean { - const slug = env.GITHUB_APP_SLUG.trim().toLowerCase(); + // GITHUB_APP_SLUG is optional (see isOwnGitHubAppCheckRun above) — an unset value just means "no own-app + // login recognized," never a thrown error. + const slug = (env.GITHUB_APP_SLUG ?? "").trim().toLowerCase(); if (!slug) return false; const escapedSlug = escapeRegExpForOwnAuthorSlug(slug); return new RegExp(`^${escapedSlug}[-\\w]*\\[bot\\]$`, "i").test(login ?? "") || new RegExp(`^(${escapedSlug}|${escapedSlug}-orb)$`, "i").test(login ?? ""); diff --git a/src/services/subnet-interface.ts b/src/services/subnet-interface.ts index cdc408d368..1527f0eecf 100644 --- a/src/services/subnet-interface.ts +++ b/src/services/subnet-interface.ts @@ -5,6 +5,11 @@ import { GITTENSORY_MCP_PACKAGE_NAME, LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SU export const GITTENSOR_NETUID = 74; const DEFAULT_GITTENSOR_UPSTREAM_REPO = "entrius/gittensor"; const SUBNET_INTERFACE_SCHEMA_VERSION = "1.0"; +// The publicly installable GitHub App maintainers add to a gittensor-registered repo (#695's onboarding step +// below). Hardcoded like the other product-identity constants in this file (not env-driven): it's the same +// stable, real app across every deployment of this descriptor, independent of which credentials any one +// Worker instance happens to hold for its own operational purposes. +const PUBLIC_GITHUB_APP_SLUG = "gittensory-orb"; // Curated, contribution-relevant MCP tools surfaced to agents/devs who discover gittensor via metagraphed. // Names mirror src/mcp/server.ts registrations; the list is intentionally a miner-facing subset (not all 33). @@ -51,7 +56,7 @@ export type SubnetInterfaceDescriptor = { * metagraphed (and any agent) can route discovery → contribution (#695). Pure product metadata (URLs, tool * names) — no private/reward/score wording, so it never needs sanitization. Public + unauthenticated. */ -export function buildSubnetInterfaceDescriptor(args: { origin: string; generatedAt: string; appSlug: string; upstreamRepo?: string | undefined }): SubnetInterfaceDescriptor { +export function buildSubnetInterfaceDescriptor(args: { origin: string; generatedAt: string; upstreamRepo?: string | undefined }): SubnetInterfaceDescriptor { const origin = args.origin.replace(/\/+$/, ""); return { schemaVersion: SUBNET_INTERFACE_SCHEMA_VERSION, @@ -80,8 +85,8 @@ export function buildSubnetInterfaceDescriptor(args: { origin: string; generated }, githubApp: { kind: "github_app", - slug: args.appSlug, - installUrl: `https://github.com/apps/${args.appSlug}`, + slug: PUBLIC_GITHUB_APP_SLUG, + installUrl: `https://github.com/apps/${PUBLIC_GITHUB_APP_SLUG}`, }, }, onboarding: { diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index d731f3abb6..1325378993 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -3,7 +3,7 @@ export async function sha256Hex(input: string): Promise { return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } -export async function verifyGitHubSignature(rawBody: string, signatureHeader: string | null, secret: string): Promise { +export async function verifyGitHubSignature(rawBody: string, signatureHeader: string | null, secret: string | undefined): Promise { if (!signatureHeader?.startsWith("sha256=")) return false; if (!secret) return false; diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index e78a95316d..4cdd344442 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -6657,7 +6657,8 @@ describe("api routes", () => { }); }); -async function signWebhook(body: string, secret: string): Promise { +async function signWebhook(body: string, secret: string | undefined): Promise { + if (!secret) throw new Error("signWebhook requires a real test secret — createTestEnv() should always set one."); const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [ "sign", ]); diff --git a/test/integration/subnet-interface.test.ts b/test/integration/subnet-interface.test.ts index c27bd86c50..24a35e3142 100644 --- a/test/integration/subnet-interface.test.ts +++ b/test/integration/subnet-interface.test.ts @@ -5,7 +5,7 @@ import { createTestEnv } from "../helpers/d1"; describe("public subnet-interface descriptor route", () => { it("serves the SN74 contribution-interface descriptor without authentication", async () => { const app = createApp(); - const env = createTestEnv({ GITHUB_APP_SLUG: "gittensory", PUBLIC_API_ORIGIN: "https://gittensory-api.aethereal.dev" }); + const env = createTestEnv({ PUBLIC_API_ORIGIN: "https://gittensory-api.aethereal.dev" }); const response = await app.request("/v1/public/subnet-interface", {}, env); expect(response.status).toBe(200); @@ -16,14 +16,16 @@ describe("public subnet-interface descriptor route", () => { provider: { name: "Gittensory", role: "contribution_interface" }, interfaces: { mcp: { endpoint: "https://gittensory-api.aethereal.dev/mcp", transport: "http" }, - githubApp: { slug: "gittensory", installUrl: "https://github.com/apps/gittensory" }, + // The publicly installable App slug is a stable hardcoded product identity now (see + // subnet-interface.ts) -- independent of the Worker's own GITHUB_APP_SLUG, which no longer exists. + githubApp: { slug: "gittensory-orb", installUrl: "https://github.com/apps/gittensory-orb" }, }, }); }); it("falls back to the request origin when PUBLIC_API_ORIGIN is unset", async () => { const app = createApp(); - const env = createTestEnv({ GITHUB_APP_SLUG: "gittensory" }); + const env = createTestEnv(); delete (env as Partial).PUBLIC_API_ORIGIN; const response = await app.request("https://fallback.example/v1/public/subnet-interface", {}, env); diff --git a/test/unit/backfill-2.test.ts b/test/unit/backfill-2.test.ts index c37d6f6979..06291c7726 100644 --- a/test/unit/backfill-2.test.ts +++ b/test/unit/backfill-2.test.ts @@ -601,6 +601,28 @@ describe("GitHub backfill", () => { expect(aggregate.failingDetails).toEqual([expect.objectContaining({ name: "Gittensory Orb Review Agent", summary: "External gate failed" })]); }); + it("does not skip a same-slug bot-owned-named check-run when GITHUB_APP_SLUG is unset (no self-hoster crash)", async () => { + // GITHUB_APP_SLUG is optional now (the retired review App was deleted) -- isOwnGitHubAppCheckRun must + // degrade to "never matches" instead of throwing on `env.GITHUB_APP_SLUG.trim()` when it's absent. + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + delete (env as Partial).GITHUB_APP_SLUG; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/check-runs?")) { + return Response.json({ + check_runs: [{ name: "Gittensory Orb Review Agent", status: "completed", conclusion: "failure", output: { title: "Real gate failure" }, app: { slug: "gittensory" } }], + }); + } + if (url.includes("/status?")) return Response.json({ statuses: [] }); + return new Response("not found", { status: 404 }); + }); + + const aggregate = await fetchLiveCiAggregate(env, "JSONbored/gittensory", "abc123", "public-token", new Set(["Gittensory Orb Review Agent"])); + + expect(aggregate.ciState).toBe("failed"); + expect(aggregate.failingDetails).toEqual([expect.objectContaining({ name: "Gittensory Orb Review Agent", summary: "Real gate failure" })]); + }); + it("does not ignore classic statuses named like the Gate", async () => { const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { @@ -2561,5 +2583,12 @@ describe("isOwnReviewThreadAuthor", () => { expect(isOwnReviewThreadAuthor(blank, "gittensory[bot]")).toBe(false); expect(isOwnReviewThreadAuthor(blank, "")).toBe(false); }); + + it("fails closed when GITHUB_APP_SLUG is unset (the retired review App was deleted)", () => { + const unset = createTestEnv(); + delete (unset as Partial).GITHUB_APP_SLUG; + expect(isOwnReviewThreadAuthor(unset, "gittensory[bot]")).toBe(false); + expect(isOwnReviewThreadAuthor(unset, "")).toBe(false); + }); }); diff --git a/test/unit/subnet-interface.test.ts b/test/unit/subnet-interface.test.ts index e8a6e6a875..ee6a504ef3 100644 --- a/test/unit/subnet-interface.test.ts +++ b/test/unit/subnet-interface.test.ts @@ -6,7 +6,6 @@ describe("buildSubnetInterfaceDescriptor", () => { const descriptor = buildSubnetInterfaceDescriptor({ origin: "https://gittensory-api.aethereal.dev/", generatedAt: "2026-06-14T00:00:00.000Z", - appSlug: "gittensory", upstreamRepo: "entrius/gittensor", }); @@ -16,7 +15,9 @@ describe("buildSubnetInterfaceDescriptor", () => { // Trailing slash on origin is normalized before appending /mcp. expect(descriptor.interfaces.mcp.endpoint).toBe("https://gittensory-api.aethereal.dev/mcp"); expect(descriptor.interfaces.mcp.transport).toBe("http"); - expect(descriptor.interfaces.githubApp.installUrl).toBe("https://github.com/apps/gittensory"); + // The publicly installable App slug is a stable hardcoded product identity, not derived from any Worker + // var (the old review App's GITHUB_APP_SLUG was removed; gittensory-orb is the real, current, installable App). + expect(descriptor.interfaces.githubApp).toMatchObject({ kind: "github_app", slug: "gittensory-orb", installUrl: "https://github.com/apps/gittensory-orb" }); const toolNames = descriptor.interfaces.mcp.tools.map((tool) => tool.name); expect(toolNames).toContain("gittensory_get_decision_pack"); @@ -26,7 +27,7 @@ describe("buildSubnetInterfaceDescriptor", () => { }); it("defaults the upstream repo when not provided and contains no private/reward wording", () => { - const descriptor = buildSubnetInterfaceDescriptor({ origin: "https://x.dev", generatedAt: "2026-06-14T00:00:00.000Z", appSlug: "gittensory" }); + const descriptor = buildSubnetInterfaceDescriptor({ origin: "https://x.dev", generatedAt: "2026-06-14T00:00:00.000Z" }); expect(descriptor.subnet.upstreamRepo).toBe("entrius/gittensor"); expect(JSON.stringify(descriptor)).not.toMatch(/wallet|hotkey|reward|payout|earn|scoring|multiplier|trust score|scoreability|rank(?:ing)?/i); }); diff --git a/test/unit/webhook.test.ts b/test/unit/webhook.test.ts index 3438439a7e..1444751107 100644 --- a/test/unit/webhook.test.ts +++ b/test/unit/webhook.test.ts @@ -693,7 +693,8 @@ describe("handleOrbRelay (brokered self-host relay receiver)", () => { }); }); -async function signWebhook(body: string, secret: string): Promise { +async function signWebhook(body: string, secret: string | undefined): Promise { + if (!secret) throw new Error("signWebhook requires a real test secret — createTestEnv() should always set one."); const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const signed = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)); return `sha256=${[...new Uint8Array(signed)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 1a258232a8..405ed4928a 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,11 +1,9 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 3699106af002eb177a4ca0f3fbbc2f7e) +// Generated by Wrangler by running `wrangler types` (hash: 902e76d8bfe7cb797b601690c9e44c8d) // Runtime types generated with workerd@1.20260701.1 2026-05-28 nodejs_compat interface __BaseEnv_Env { DB: D1Database; JOBS: Queue; - GITHUB_APP_ID: "3824093"; - GITHUB_APP_SLUG: "gittensory"; GITHUB_OAUTH_CLIENT_ID: "Ov23lixYxDzyUKE070sm"; GITHUB_WEBHOOK_MAX_BODY_BYTES: "1048576"; GITTENSOR_UPSTREAM_REPO: "entrius/gittensor"; @@ -64,8 +62,6 @@ type StringifyValues> = { declare namespace NodeJS { interface ProcessEnv extends StringifyValues