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
26 changes: 13 additions & 13 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -175,19 +175,19 @@ GITTENSORY_REVIEW_DRAFT=false
# # 1024-dimensional (e.g. bge-m3 or mxbai-embed-large via Ollama).
# # Used only when RAG is enabled (GITTENSORY_REVIEW_RAG + allowlist).

# --- Gittensory Orb (#1255; opt-in fleet-calibration export) ---
# Orb is the central collector + analytics that aggregates anonymized gate-calibration data UP from
# self-hosted instances. There is NO separate Orb GitHub App and NO setup wizard: your existing main App
# already records de-noised outcomes (merged/closed + reversals) locally — flip ORB_ENABLED to ship an
# anonymized signal to gittensory's collector. That's it: no second App, no extra secret, no wizard.
# --- Gittensory Orb (#1255; ALWAYS-ON fleet-calibration telemetry) ---
# TELEMETRY NOTICE: running this self-hosted image contributes anonymized gate-calibration data to
# gittensory's central collector. This is ON BY DEFAULT and has no opt-out flag — it is part of the
# self-hosting contract: install the GitHub App, and your instance reports fleet-calibration signal so the
# gate can be tuned from real outcomes across all self-hosters. It activates automatically once your App is
# configured (no App = nothing is sent). There is NO separate Orb App and NO setup wizard.
#
# SECURITY MODEL (this image is self-hosted by many independent maintainers):
# • The image bakes NO secrets. repo/PR identifiers are HMAC-anonymized with YOUR own ORB_WEBHOOK_SECRET
# (a stable per-instance string), so even gittensory (running the collector) can never de-anonymize them.
# • Export carries NO shared key. The collector accepts the batch as untrusted, rate-limited, aggregate-only
# telemetry. Nothing in the container, if leaked, can compromise the collector, other operators, or any App.
# ORB_ENABLED=false # master switch: set to true to export fleet-calibration signal (default off)
# ORB_WEBHOOK_SECRET=<stable-random-string> # the per-instance HMAC key used to anonymize repo/PR identifiers
# ORB_AIR_GAP=false # set to true to compute locally but never send to the collector
# WHAT IS SENT (per resolved PR, hourly): the gate verdict, the realized outcome (merged/closed), a reversal
# flag, a bucketed reason category, and cycle time. NEVER sent: repo/owner/PR names, commit SHAs, code,
# diffs, comments, or logins. Repo/PR identifiers are HMAC-anonymized with a DEDICATED key derived from YOUR
# OWN App private key (GITHUB_APP_PRIVATE_KEY) — high-entropy and independent of your webhook secret, so even
# gittensory (running the collector) can never de-anonymize them.
# The export carries no shared key; the collector treats it as untrusted, rate-limited, aggregate-only data.
# 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)
66 changes: 49 additions & 17 deletions src/selfhost/orb-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@
// engine's outcomes-wire. This ships an anonymized, reversal-aware signal UP to gittensory's central
// collector so the gate can be calibrated across the whole self-host fleet.
//
// ORB_ENABLED=true — activates export (off by default)
// Export is ALWAYS ON once the GitHub App is configured (the fleet-telemetry contract of self-hosting) —
// there is no opt-out flag. It self-gates on a configured App private key (no App → no review data to
// export anyway) and anonymizes with a DEDICATED, per-instance secret generated once and persisted in
// system_flags (never the App private key or the webhook-verification secret — key separation).
// ORB_COLLECTOR_URL=<url> — endpoint (default: gittensory's hosted collector)
// ORB_AIR_GAP=true — keep everything local, never send externally
// ORB_AIR_GAP=true — air-gapped/offline deployments only: compute locally, never send
// ORB_ANONYMIZE=true — HMAC-hash repo/PR before export (default: true)
//
// No diffs, no code, no comments, no logins, no commit SHAs — only verdict + outcome + reversal + a bucketed
// reason category + cycle time, with repo/PR identifiers HMAC'd by THIS instance's own secret (the collector
// holds no instance secret, so it can never de-anonymize).
import { createHash, createHmac } from "node:crypto";
// reason category + cycle time, with repo/PR identifiers HMAC'd by a key the collector never holds (so it
// can never de-anonymize).
import { createHash, createHmac, randomBytes } from "node:crypto";
import { incr } from "./metrics";

/** Key under which the per-instance anonymization secret is persisted in system_flags. */
const ANON_SECRET_FLAG = "orb:anon_secret";

/** One de-noised, resolved-PR row read from review_audit (the join below). */
interface FleetRow {
project: string; // repo full name (review_audit.project)
Expand Down Expand Up @@ -55,6 +61,33 @@ function hmacField(value: string, secret: string): string {
return createHmac("sha256", secret).update(value).digest("hex").slice(0, 24);
}

/**
* The instance's DEDICATED anonymization secret: a 256-bit random key generated once and persisted in
* system_flags, then reused on every export. Stable across restarts so a repo/PR always hashes the same
* way (the collector can dedup), per-instance, and SINGLE-PURPOSE — never the App private key or the
* webhook-verification secret (key separation). The collector never holds it, so it cannot de-anonymize.
*/
export async function getOrCreateAnonSecret(db: D1Database): Promise<string> {
const read = async (): Promise<string | undefined> => {
const row = await db
.prepare(`SELECT value FROM system_flags WHERE key = ?`)
.bind(ANON_SECRET_FLAG)
.first<{ value: string }>();
return row?.value;
};
const existing = await read();
if (existing) return existing;
const generated = randomBytes(32).toString("hex"); // 256-bit, 64 hex chars
// Race-safe across instances sharing a Postgres DB: OR IGNORE keeps the first writer's key; the re-read
// returns whichever value won, so every instance converges on the same secret.
await db
.prepare(`INSERT OR IGNORE INTO system_flags (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`)
.bind(ANON_SECRET_FLAG, generated)
.run();
/* v8 ignore next -- a row always exists after INSERT OR IGNORE, so the ?? fallback is unreachable */
return (await read()) ?? generated;
}

/** Map the gate's free-text reasonCode to a fixed, low-cardinality category — done at the source so the raw
* (possibly repo-specific) reason string never leaves the instance. */
export function bucketReasonCode(summary: string | null | undefined): string {
Expand All @@ -69,12 +102,6 @@ export function bucketReasonCode(summary: string | null | undefined): string {
return "other";
}

/** Returns true only when Orb export is explicitly enabled. */
export function orbEnabled(): boolean {
const v = (process.env.ORB_ENABLED ?? "").toLowerCase();
return v === "true" || v === "1" || v === "yes";
}

// Latest gate_decision + latest pr_outcome per target_id, plus any reversal — portable (window functions +
// CASE, no SQLite-only bare-column-with-MAX) so it runs on the self-host SQLite OR Postgres backend.
const FLEET_QUERY = `
Expand Down Expand Up @@ -122,17 +149,22 @@ function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null {

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

// gittensory's hosted collector. No shared secret is sent: repo/PR identifiers are HMAC'd with THIS
// instance's own ORB_WEBHOOK_SECRET, and the collector accepts the batch as untrusted, rate-limited telemetry.
// No App configured → no review data to export anyway. Gate export on the App being set up.
if (!(process.env.GITHUB_APP_PRIVATE_KEY ?? "")) return 0;

// gittensory's hosted collector. No shared secret is sent: repo/PR identifiers are HMAC'd with this
// instance's DEDICATED anonymization secret (a 256-bit random key generated once and persisted in
// system_flags — see getOrCreateAnonSecret), single-purpose and never the App key, so the collector
// (which never holds it) can never de-anonymize them.
const collectorUrl = process.env.ORB_COLLECTOR_URL ?? "https://gittensory-api.aethereal.dev/v1/orb/ingest";
const secret = process.env.ORB_WEBHOOK_SECRET ?? "";
const secret = await getOrCreateAnonSecret(db);
const anonymize = (process.env.ORB_ANONYMIZE ?? "true").toLowerCase() !== "false";
const instance = instanceId();

Expand Down
20 changes: 9 additions & 11 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import worker from "./index";
import { processJob } from "./queue/processors";
import { createSelfHostAi } from "./selfhost/ai";
import { credentialsToEnv, exchangeManifestCode, renderSetupPage } from "./selfhost/setup-wizard";
import { orbEnabled, exportOrbBatch } from "./selfhost/orb-collector";
import { exportOrbBatch } from "./selfhost/orb-collector";
import { createD1Adapter, nodeSqliteDriver } from "./selfhost/d1-adapter";
import { readiness } from "./selfhost/health";
import { gauge, incr, observe, renderMetrics } from "./selfhost/metrics";
Expand Down Expand Up @@ -321,16 +321,14 @@ async function main(): Promise<void> {
);
}, intervalMs);

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

// Graceful shutdown: stop accepting HTTP, let the queue finish, close the backend.
let shuttingDown = false;
Expand Down
46 changes: 30 additions & 16 deletions test/unit/selfhost-orb-collector.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DatabaseSync } from "node:sqlite";
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter";
import { bucketReasonCode, exportOrbBatch, orbEnabled } from "../../src/selfhost/orb-collector";
import { bucketReasonCode, exportOrbBatch, getOrCreateAnonSecret } from "../../src/selfhost/orb-collector";
import { resetMetrics, renderMetrics } from "../../src/selfhost/metrics";

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

describe("orbEnabled()", () => {
afterEach(() => { delete process.env.ORB_ENABLED; });
it("true only for truthy values", () => {
for (const v of ["true", "1", "Yes"]) { process.env.ORB_ENABLED = v; expect(orbEnabled()).toBe(true); }
for (const v of ["", "false", "no"]) { process.env.ORB_ENABLED = v; expect(orbEnabled()).toBe(false); }
delete process.env.ORB_ENABLED; expect(orbEnabled()).toBe(false);
describe("getOrCreateAnonSecret()", () => {
it("generates a 256-bit (64 hex char) dedicated secret on first use and persists it", async () => {
const db = makeDb();
const secret = await getOrCreateAnonSecret(db);
expect(secret).toMatch(/^[0-9a-f]{64}$/);
const row = await db.prepare(`SELECT value FROM system_flags WHERE key = 'orb:anon_secret'`).first<{ value: string }>();
expect(row?.value).toBe(secret); // persisted, so it survives restarts
});

it("reuses the persisted secret on subsequent calls (stable → collector dedup holds)", async () => {
const db = makeDb();
const first = await getOrCreateAnonSecret(db);
const second = await getOrCreateAnonSecret(db);
expect(second).toBe(first);
expect(first).not.toBe(process.env.GITHUB_APP_PRIVATE_KEY); // never the App private key
});
});

describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-aware signal", () => {
describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized reversal-aware signal", () => {
beforeEach(() => {
resetMetrics();
process.env.ORB_ENABLED = "true";
process.env.ORB_WEBHOOK_SECRET = "test-secret";
(process.env as NodeJS.Dict<string>).GITHUB_APP_PRIVATE_KEY = "test-private-key"; // gates export (App configured); not the anon key
process.env.ORB_APP_ID = "555";
process.env.ORB_ANONYMIZE = "true";
delete process.env.ORB_AIR_GAP;
delete process.env.ORB_COLLECTOR_URL;
});
afterEach(() => {
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];
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];
});

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

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

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