Skip to content

Commit 634408b

Browse files
committed
fix(orb): make fleet telemetry work + non-optional in broker mode
A brokered self-host (ORB_ENROLLMENT_SECRET set) relies on the central Orb for GitHub tokens + webhook relay, so fleet telemetry is part of the self-hosting contract. But the exporter gated on a local GITHUB_APP_PRIVATE_KEY — which a brokered instance never holds — so broker-mode telemetry never ran at all, and ORB_AIR_GAP could suppress it. Also, brokered instances share no Orb/App id, so they collided on the 'unknown' instanceId fallback. - Gate export on the enrollment OR a local App key (brokered = configured). - Air-gap suppresses export only for a self-managed (non-brokered) instance; a brokered instance always exports. - Derive instanceId from the enrollment secret when no Orb/App id is present (hashed — the high-entropy secret never leaks; unique + stable per install).
1 parent 92959ba commit 634408b

2 files changed

Lines changed: 35 additions & 8 deletions

File tree

src/selfhost/orb-collector.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,12 @@ interface OrbExportPayload {
5252
events: FleetEvent[];
5353
}
5454

55-
/** Stable instance identifier (hash of the Orb/App ID — no PII). */
55+
/** Stable instance identifier (hash of the Orb/App ID — no PII). A brokered instance holds no App id, so its
56+
* per-install enrollment secret is the stable identity (hashed, so the high-entropy secret never leaks and two
57+
* brokered instances never collide on the "unknown" fallback). */
5658
function instanceId(): string {
57-
return createHash("sha256").update(process.env.ORB_APP_ID ?? process.env.GITHUB_APP_ID ?? "unknown").digest("hex").slice(0, 16);
59+
const seed = process.env.ORB_APP_ID ?? process.env.GITHUB_APP_ID ?? process.env.ORB_ENROLLMENT_SECRET ?? "unknown";
60+
return createHash("sha256").update(seed).digest("hex").slice(0, 16);
5861
}
5962

6063
/** HMAC a string with the instance's own secret for anonymized export. */
@@ -154,11 +157,17 @@ function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null {
154157
* Returns the number of events exported (0 if air-gapped, the App isn't configured, or nothing new).
155158
*/
156159
export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: typeof fetch = fetch): Promise<number> {
157-
// Always on (no opt-out). Air-gapped/offline deployments may suppress the outbound call.
158-
if ((process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0;
160+
// A brokered self-host relies on the central Orb for tokens + webhook relay, so fleet telemetry is part of the
161+
// self-hosting contract — air-gap cannot opt it out, and the export is gated on the enrollment (not a local App
162+
// key, which a brokered instance never holds).
163+
const brokered = Boolean((process.env.ORB_ENROLLMENT_SECRET ?? "").trim());
159164

160-
// No App configured → no review data to export anyway. Gate export on the App being set up.
161-
if (!(process.env.GITHUB_APP_PRIVATE_KEY ?? "")) return 0;
165+
// Air-gapped/offline deployments may suppress the outbound call — but ONLY a self-managed (non-brokered) instance.
166+
if (!brokered && (process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0;
167+
168+
// Gate export on the instance being CONFIGURED: a local App private key, OR brokered mode (the Orb holds the App
169+
// key and mints tokens on demand, so the instance has review data to export but no local App key).
170+
if (!brokered && !(process.env.GITHUB_APP_PRIVATE_KEY ?? "")) return 0;
162171

163172
// gittensory's hosted collector. No shared secret is sent: repo/PR identifiers are HMAC'd with this
164173
// instance's DEDICATED anonymization secret (a 256-bit random key generated once and persisted in

test/unit/selfhost-orb-collector.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DatabaseSync } from "node:sqlite";
2+
import { createHash } from "node:crypto";
23
import { describe, expect, it, beforeEach, afterEach } from "vitest";
34
import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter";
45
import { bucketReasonCode, exportOrbBatch, getOrCreateAnonSecret } from "../../src/selfhost/orb-collector";
@@ -77,7 +78,7 @@ describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized r
7778
delete process.env.ORB_COLLECTOR_TOKEN;
7879
});
7980
afterEach(() => {
80-
for (const k of ["GITHUB_APP_PRIVATE_KEY", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "ORB_COLLECTOR_TOKEN", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict<string>)[k];
81+
for (const k of ["GITHUB_APP_PRIVATE_KEY", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "ORB_COLLECTOR_TOKEN", "GITHUB_APP_ID", "ORB_ENROLLMENT_SECRET"]) delete (process.env as NodeJS.Dict<string>)[k];
8182
});
8283

8384
it("returns 0 when the App private key is not configured (App not set up → nothing to export)", async () => {
@@ -88,11 +89,28 @@ describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized r
8889
expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0);
8990
});
9091

91-
it("returns 0 in air-gap mode", async () => {
92+
it("returns 0 in air-gap mode (self-managed instance)", async () => {
9293
process.env.ORB_AIR_GAP = "true";
9394
expect(await exportOrbBatch(makeDb(), 200, async () => new Response(null, { status: 200 }))).toBe(0);
9495
});
9596

97+
it("brokered mode exports despite air-gap AND without a local App key — telemetry is the fleet contract", async () => {
98+
// A brokered self-host (ORB_ENROLLMENT_SECRET set) relies on the Orb for tokens + webhook relay, so it holds
99+
// no local App key and air-gap must NOT suppress the export. With no Orb/App id, instanceId derives from the
100+
// enrollment secret (stable + unique per install, not the "unknown" collision).
101+
delete (process.env as NodeJS.Dict<string>).GITHUB_APP_PRIVATE_KEY;
102+
delete (process.env as NodeJS.Dict<string>).ORB_APP_ID;
103+
process.env.ORB_AIR_GAP = "true";
104+
process.env.ORB_ENROLLMENT_SECRET = "orbsec_test_enrollment";
105+
const db = makeDb();
106+
await audit(db, "owner/repo", 9, "gate_decision", "merge", "2026-03-01T00:00:00Z");
107+
await audit(db, "owner/repo", 9, "pr_outcome", "merged", "2026-03-01T01:00:00Z");
108+
let captured: { instance_id: string } | undefined;
109+
const n = await exportOrbBatch(db, 200, async (_u, init) => { captured = JSON.parse(init!.body as string); return new Response(null, { status: 200 }); });
110+
expect(n).toBe(1); // exported despite air-gap + no local App key
111+
expect(captured!.instance_id).toBe(createHash("sha256").update("orbsec_test_enrollment").digest("hex").slice(0, 16));
112+
});
113+
96114
it("returns 0 when nothing is resolved", async () => {
97115
const db = makeDb();
98116
await audit(db, "o/r", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z"); // decision but no outcome

0 commit comments

Comments
 (0)