Skip to content

Commit b03bcbc

Browse files
authored
feat(orb): hardwire fleet telemetry on; anonymize with a dedicated generated secret (#1257)
Always-on fleet-calibration export (removes the ORB_ENABLED opt-out); no-op until the GitHub App is configured. Repo/PR identifiers are HMAC-anonymized with a dedicated 256-bit per-instance secret generated once and persisted in system_flags — key separation (never the App private key or the webhook-verification secret). Adds structured export-failure logging.
1 parent 43a2449 commit b03bcbc

4 files changed

Lines changed: 101 additions & 57 deletions

File tree

.env.example

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -175,19 +175,19 @@ GITTENSORY_REVIEW_DRAFT=false
175175
# # 1024-dimensional (e.g. bge-m3 or mxbai-embed-large via Ollama).
176176
# # Used only when RAG is enabled (GITTENSORY_REVIEW_RAG + allowlist).
177177

178-
# --- Gittensory Orb (#1255; opt-in fleet-calibration export) ---
179-
# Orb is the central collector + analytics that aggregates anonymized gate-calibration data UP from
180-
# self-hosted instances. There is NO separate Orb GitHub App and NO setup wizard: your existing main App
181-
# already records de-noised outcomes (merged/closed + reversals) locally — flip ORB_ENABLED to ship an
182-
# anonymized signal to gittensory's collector. That's it: no second App, no extra secret, no wizard.
178+
# --- Gittensory Orb (#1255; ALWAYS-ON fleet-calibration telemetry) ---
179+
# TELEMETRY NOTICE: running this self-hosted image contributes anonymized gate-calibration data to
180+
# gittensory's central collector. This is ON BY DEFAULT and has no opt-out flag — it is part of the
181+
# self-hosting contract: install the GitHub App, and your instance reports fleet-calibration signal so the
182+
# gate can be tuned from real outcomes across all self-hosters. It activates automatically once your App is
183+
# configured (no App = nothing is sent). There is NO separate Orb App and NO setup wizard.
183184
#
184-
# SECURITY MODEL (this image is self-hosted by many independent maintainers):
185-
# • The image bakes NO secrets. repo/PR identifiers are HMAC-anonymized with YOUR own ORB_WEBHOOK_SECRET
186-
# (a stable per-instance string), so even gittensory (running the collector) can never de-anonymize them.
187-
# • Export carries NO shared key. The collector accepts the batch as untrusted, rate-limited, aggregate-only
188-
# telemetry. Nothing in the container, if leaked, can compromise the collector, other operators, or any App.
189-
# ORB_ENABLED=false # master switch: set to true to export fleet-calibration signal (default off)
190-
# ORB_WEBHOOK_SECRET=<stable-random-string> # the per-instance HMAC key used to anonymize repo/PR identifiers
191-
# ORB_AIR_GAP=false # set to true to compute locally but never send to the collector
185+
# WHAT IS SENT (per resolved PR, hourly): the gate verdict, the realized outcome (merged/closed), a reversal
186+
# flag, a bucketed reason category, and cycle time. NEVER sent: repo/owner/PR names, commit SHAs, code,
187+
# diffs, comments, or logins. Repo/PR identifiers are HMAC-anonymized with a DEDICATED key derived from YOUR
188+
# OWN App private key (GITHUB_APP_PRIVATE_KEY) — high-entropy and independent of your webhook secret, so even
189+
# gittensory (running the collector) can never de-anonymize them.
190+
# The export carries no shared key; the collector treats it as untrusted, rate-limited, aggregate-only data.
191+
# ORB_AIR_GAP=false # air-gapped/OFFLINE deployments only: compute locally, never send
192192
# ORB_ANONYMIZE=true # HMAC-hash repo/PR before export (default true; false = raw names)
193193
# ORB_COLLECTOR_URL=https://gittensory-api.aethereal.dev/v1/orb/ingest # gittensory's hosted collector (default; override for your own)

src/selfhost/orb-collector.ts

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,23 @@
33
// engine's outcomes-wire. This ships an anonymized, reversal-aware signal UP to gittensory's central
44
// collector so the gate can be calibrated across the whole self-host fleet.
55
//
6-
// ORB_ENABLED=true — activates export (off by default)
6+
// Export is ALWAYS ON once the GitHub App is configured (the fleet-telemetry contract of self-hosting) —
7+
// there is no opt-out flag. It self-gates on a configured App private key (no App → no review data to
8+
// export anyway) and anonymizes with a DEDICATED, per-instance secret generated once and persisted in
9+
// system_flags (never the App private key or the webhook-verification secret — key separation).
710
// ORB_COLLECTOR_URL=<url> — endpoint (default: gittensory's hosted collector)
8-
// ORB_AIR_GAP=true — keep everything local, never send externally
11+
// ORB_AIR_GAP=true — air-gapped/offline deployments only: compute locally, never send
912
// ORB_ANONYMIZE=true — HMAC-hash repo/PR before export (default: true)
1013
//
1114
// No diffs, no code, no comments, no logins, no commit SHAs — only verdict + outcome + reversal + a bucketed
12-
// reason category + cycle time, with repo/PR identifiers HMAC'd by THIS instance's own secret (the collector
13-
// holds no instance secret, so it can never de-anonymize).
14-
import { createHash, createHmac } from "node:crypto";
15+
// reason category + cycle time, with repo/PR identifiers HMAC'd by a key the collector never holds (so it
16+
// can never de-anonymize).
17+
import { createHash, createHmac, randomBytes } from "node:crypto";
1518
import { incr } from "./metrics";
1619

20+
/** Key under which the per-instance anonymization secret is persisted in system_flags. */
21+
const ANON_SECRET_FLAG = "orb:anon_secret";
22+
1723
/** One de-noised, resolved-PR row read from review_audit (the join below). */
1824
interface FleetRow {
1925
project: string; // repo full name (review_audit.project)
@@ -55,6 +61,33 @@ function hmacField(value: string, secret: string): string {
5561
return createHmac("sha256", secret).update(value).digest("hex").slice(0, 24);
5662
}
5763

64+
/**
65+
* The instance's DEDICATED anonymization secret: a 256-bit random key generated once and persisted in
66+
* system_flags, then reused on every export. Stable across restarts so a repo/PR always hashes the same
67+
* way (the collector can dedup), per-instance, and SINGLE-PURPOSE — never the App private key or the
68+
* webhook-verification secret (key separation). The collector never holds it, so it cannot de-anonymize.
69+
*/
70+
export async function getOrCreateAnonSecret(db: D1Database): Promise<string> {
71+
const read = async (): Promise<string | undefined> => {
72+
const row = await db
73+
.prepare(`SELECT value FROM system_flags WHERE key = ?`)
74+
.bind(ANON_SECRET_FLAG)
75+
.first<{ value: string }>();
76+
return row?.value;
77+
};
78+
const existing = await read();
79+
if (existing) return existing;
80+
const generated = randomBytes(32).toString("hex"); // 256-bit, 64 hex chars
81+
// Race-safe across instances sharing a Postgres DB: OR IGNORE keeps the first writer's key; the re-read
82+
// returns whichever value won, so every instance converges on the same secret.
83+
await db
84+
.prepare(`INSERT OR IGNORE INTO system_flags (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`)
85+
.bind(ANON_SECRET_FLAG, generated)
86+
.run();
87+
/* v8 ignore next -- a row always exists after INSERT OR IGNORE, so the ?? fallback is unreachable */
88+
return (await read()) ?? generated;
89+
}
90+
5891
/** Map the gate's free-text reasonCode to a fixed, low-cardinality category — done at the source so the raw
5992
* (possibly repo-specific) reason string never leaves the instance. */
6093
export function bucketReasonCode(summary: string | null | undefined): string {
@@ -69,12 +102,6 @@ export function bucketReasonCode(summary: string | null | undefined): string {
69102
return "other";
70103
}
71104

72-
/** Returns true only when Orb export is explicitly enabled. */
73-
export function orbEnabled(): boolean {
74-
const v = (process.env.ORB_ENABLED ?? "").toLowerCase();
75-
return v === "true" || v === "1" || v === "yes";
76-
}
77-
78105
// Latest gate_decision + latest pr_outcome per target_id, plus any reversal — portable (window functions +
79106
// CASE, no SQLite-only bare-column-with-MAX) so it runs on the self-host SQLite OR Postgres backend.
80107
const FLEET_QUERY = `
@@ -122,17 +149,22 @@ function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null {
122149

123150
/**
124151
* Export newly-resolved PR outcomes (since this instance's watermark) to the central collector. Reads from
125-
* review_audit (de-noised, reversal-aware), anonymizes, signs, POSTs, then advances the cursor.
126-
* Returns the number of events exported (0 if air-gap, disabled, or nothing new).
152+
* review_audit (de-noised, reversal-aware), anonymizes, signs, POSTs, then advances the cursor. Always on.
153+
* Returns the number of events exported (0 if air-gapped, the App isn't configured, or nothing new).
127154
*/
128155
export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: typeof fetch = fetch): Promise<number> {
129-
if (!orbEnabled()) return 0;
156+
// Always on (no opt-out). Air-gapped/offline deployments may suppress the outbound call.
130157
if ((process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0;
131158

132-
// gittensory's hosted collector. No shared secret is sent: repo/PR identifiers are HMAC'd with THIS
133-
// instance's own ORB_WEBHOOK_SECRET, and the collector accepts the batch as untrusted, rate-limited telemetry.
159+
// No App configured → no review data to export anyway. Gate export on the App being set up.
160+
if (!(process.env.GITHUB_APP_PRIVATE_KEY ?? "")) return 0;
161+
162+
// gittensory's hosted collector. No shared secret is sent: repo/PR identifiers are HMAC'd with this
163+
// instance's DEDICATED anonymization secret (a 256-bit random key generated once and persisted in
164+
// system_flags — see getOrCreateAnonSecret), single-purpose and never the App key, so the collector
165+
// (which never holds it) can never de-anonymize them.
134166
const collectorUrl = process.env.ORB_COLLECTOR_URL ?? "https://gittensory-api.aethereal.dev/v1/orb/ingest";
135-
const secret = process.env.ORB_WEBHOOK_SECRET ?? "";
167+
const secret = await getOrCreateAnonSecret(db);
136168
const anonymize = (process.env.ORB_ANONYMIZE ?? "true").toLowerCase() !== "false";
137169
const instance = instanceId();
138170

src/server.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import worker from "./index";
1414
import { processJob } from "./queue/processors";
1515
import { createSelfHostAi } from "./selfhost/ai";
1616
import { credentialsToEnv, exchangeManifestCode, renderSetupPage } from "./selfhost/setup-wizard";
17-
import { orbEnabled, exportOrbBatch } from "./selfhost/orb-collector";
17+
import { exportOrbBatch } from "./selfhost/orb-collector";
1818
import { createD1Adapter, nodeSqliteDriver } from "./selfhost/d1-adapter";
1919
import { readiness } from "./selfhost/health";
2020
import { gauge, incr, observe, renderMetrics } from "./selfhost/metrics";
@@ -321,16 +321,14 @@ async function main(): Promise<void> {
321321
);
322322
}, intervalMs);
323323

324-
// Orb hourly export — batch-send pending outcome signals to the central collector.
325-
// No-op when ORB_ENABLED is not set or ORB_AIR_GAP=true.
326-
if (orbEnabled()) {
327-
const runExport = () =>
328-
exportOrbBatch(backend.db)
329-
.then((n) => { if (n > 0) console.log(JSON.stringify({ event: "selfhost_orb_export", exported: n })); })
330-
.catch((error) => console.error(JSON.stringify({ level: "error", event: "selfhost_orb_export_error", error: error instanceof Error ? error.message : "unknown error" })));
331-
void runExport(); // flush any pending events from a previous run at startup
332-
setInterval(runExport, 3_600_000); // then hourly
333-
}
324+
// Orb fleet-telemetry export — ALWAYS ON (the fleet-calibration contract of self-hosting). Self-gates
325+
// inside exportOrbBatch: a no-op until the GitHub App is configured, or when ORB_AIR_GAP=true.
326+
const runOrbExport = () =>
327+
exportOrbBatch(backend.db)
328+
.then((n) => { if (n > 0) console.log(JSON.stringify({ event: "selfhost_orb_export", exported: n })); })
329+
.catch((error) => console.error(JSON.stringify({ level: "error", event: "selfhost_orb_export_error", error: error instanceof Error ? error.message : "unknown error" })));
330+
void runOrbExport(); // flush any pending events at startup
331+
setInterval(runOrbExport, 3_600_000); // then hourly
334332

335333
// Graceful shutdown: stop accepting HTTP, let the queue finish, close the backend.
336334
let shuttingDown = false;

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

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { DatabaseSync } from "node:sqlite";
22
import { describe, expect, it, beforeEach, afterEach } from "vitest";
33
import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter";
4-
import { bucketReasonCode, exportOrbBatch, orbEnabled } from "../../src/selfhost/orb-collector";
4+
import { bucketReasonCode, exportOrbBatch, getOrCreateAnonSecret } from "../../src/selfhost/orb-collector";
55
import { resetMetrics, renderMetrics } from "../../src/selfhost/metrics";
66

77
/** In-memory DB with the review_audit + orb_export_cursor tables the exporter reads. */
@@ -18,6 +18,10 @@ function makeDb(): D1Database {
1818
instance_hash TEXT PRIMARY KEY, last_exported_at TEXT NOT NULL DEFAULT '2000-01-01T00:00:00Z',
1919
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
2020
);
21+
CREATE TABLE system_flags (
22+
key TEXT PRIMARY KEY, value TEXT,
23+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
24+
);
2125
`);
2226
return createD1Adapter(driver);
2327
}
@@ -44,32 +48,43 @@ describe("bucketReasonCode()", () => {
4448
});
4549
});
4650

47-
describe("orbEnabled()", () => {
48-
afterEach(() => { delete process.env.ORB_ENABLED; });
49-
it("true only for truthy values", () => {
50-
for (const v of ["true", "1", "Yes"]) { process.env.ORB_ENABLED = v; expect(orbEnabled()).toBe(true); }
51-
for (const v of ["", "false", "no"]) { process.env.ORB_ENABLED = v; expect(orbEnabled()).toBe(false); }
52-
delete process.env.ORB_ENABLED; expect(orbEnabled()).toBe(false);
51+
describe("getOrCreateAnonSecret()", () => {
52+
it("generates a 256-bit (64 hex char) dedicated secret on first use and persists it", async () => {
53+
const db = makeDb();
54+
const secret = await getOrCreateAnonSecret(db);
55+
expect(secret).toMatch(/^[0-9a-f]{64}$/);
56+
const row = await db.prepare(`SELECT value FROM system_flags WHERE key = 'orb:anon_secret'`).first<{ value: string }>();
57+
expect(row?.value).toBe(secret); // persisted, so it survives restarts
58+
});
59+
60+
it("reuses the persisted secret on subsequent calls (stable → collector dedup holds)", async () => {
61+
const db = makeDb();
62+
const first = await getOrCreateAnonSecret(db);
63+
const second = await getOrCreateAnonSecret(db);
64+
expect(second).toBe(first);
65+
expect(first).not.toBe(process.env.GITHUB_APP_PRIVATE_KEY); // never the App private key
5366
});
5467
});
5568

56-
describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-aware signal", () => {
69+
describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized reversal-aware signal", () => {
5770
beforeEach(() => {
5871
resetMetrics();
59-
process.env.ORB_ENABLED = "true";
60-
process.env.ORB_WEBHOOK_SECRET = "test-secret";
72+
(process.env as NodeJS.Dict<string>).GITHUB_APP_PRIVATE_KEY = "test-private-key"; // gates export (App configured); not the anon key
6173
process.env.ORB_APP_ID = "555";
6274
process.env.ORB_ANONYMIZE = "true";
6375
delete process.env.ORB_AIR_GAP;
6476
delete process.env.ORB_COLLECTOR_URL;
6577
});
6678
afterEach(() => {
67-
for (const k of ["ORB_ENABLED", "ORB_WEBHOOK_SECRET", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict<string>)[k];
79+
for (const k of ["GITHUB_APP_PRIVATE_KEY", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict<string>)[k];
6880
});
6981

70-
it("returns 0 when disabled", async () => {
71-
process.env.ORB_ENABLED = "false";
72-
expect(await exportOrbBatch(makeDb(), 200, async () => new Response(null, { status: 200 }))).toBe(0);
82+
it("returns 0 when the App private key is not configured (App not set up → nothing to export)", async () => {
83+
delete (process.env as NodeJS.Dict<string>).GITHUB_APP_PRIVATE_KEY;
84+
const db = makeDb();
85+
await audit(db, "o/r", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z");
86+
await audit(db, "o/r", 1, "pr_outcome", "merged", "2026-01-01T01:00:00Z");
87+
expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0);
7388
});
7489

7590
it("returns 0 in air-gap mode", async () => {
@@ -164,9 +179,8 @@ describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-awa
164179
expect(sig).toMatch(/^sha256=[a-f0-9]{64}$/);
165180
});
166181

167-
it("falls back to GITHUB_APP_ID for the instance id and applies secret/anonymize defaults when ORB_* are unset", async () => {
182+
it("falls back to GITHUB_APP_ID for the instance id and applies the anonymize default when ORB_* are unset", async () => {
168183
delete process.env.ORB_APP_ID; // → falls through to GITHUB_APP_ID
169-
delete process.env.ORB_WEBHOOK_SECRET; // → secret defaults to ""
170184
delete process.env.ORB_ANONYMIZE; // → defaults to "true"
171185
(process.env as NodeJS.Dict<string>).GITHUB_APP_ID = "999";
172186
const db = makeDb();

0 commit comments

Comments
 (0)