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
9 changes: 9 additions & 0 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,15 @@ turned on. Per-repo settings (autonomy, required approvals, protected paths) liv
repository settings. The authoritative reference for all of these is
[`docs/review-configuration.md`](./review-configuration.md).

**Container-private per-repo config (keep policy off the public repo).** `.gittensory.yml` lives in the repo, so
contributors can read it — and whoever can see the gate thresholds, autonomy, or label policy can game them. To
keep review policy private, set **`GITTENSORY_REPO_CONFIG_DIR`** to a mounted directory and drop one file per repo
named `{owner}__{repo}.yml` (lowercased, `/` → double underscore) — e.g. `jsonbored__metagraphed.yml`. When a file
exists for a repo the engine reads it **instead of** fetching the public `.gittensory.yml`, so the policy never
appears in contributor-facing previews. It uses the same schema (`gate:` / `settings:` / `review:` — autonomy,
labels, model/effort), is read fresh each review (edits apply immediately), and `.yaml` / `.json` are also
accepted. Unset ⇒ the public file is fetched exactly as before.

---

## 6. Operations
Expand Down
5 changes: 5 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ declare global {
/** Self-host instance-wide write switch: "dry-run" | "disabled" forces EVERY installation write to be
* suppressed regardless of per-repo mode (the cloud→self-host parallel-run kill switch). Unset = live. */
SELFHOST_DEPLOYMENT_MODE?: string;
/** Self-host container-private per-repo config dir. When set, the focus-manifest loader reads
* `{dir}/{owner}__{repo}.{yml,yaml,json}` INSTEAD of the public `.gittensory.yml`, so review policy (gate,
* autonomy, labels, model/effort) is set privately and contributors can't read or game it. Unset ⇒ public
* fetch (cloud, or a self-host without the dir, is byte-identical to before). */
GITTENSORY_REPO_CONFIG_DIR?: string;
GITTENSORY_AUTO_FILE_DRIFT_ISSUES?: string;
GITTENSORY_DRIFT_ISSUE_REPO?: string;
GITTENSORY_DRIFT_ISSUE_TOKEN?: string;
Expand Down
38 changes: 38 additions & 0 deletions src/selfhost/private-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Container-private per-repo config (self-host). A self-host operator mounts a directory at
// GITTENSORY_REPO_CONFIG_DIR and drops one `{owner}__{repo}.yml` file per repo; the focus-manifest loader reads
// it INSTEAD of fetching the public `.gittensory.yml`, so review policy (gate, autonomy, labels, model/effort) is
// configured PRIVATELY and never exposed to contributors who could read and game the public file. Node-only — it
// is registered into the Workers-safe loader via setLocalManifestReader at boot (server.ts), so this module's fs
// import never reaches the Cloudflare bundle.
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { RepoFocusManifestFetcher } from "../signals/focus-manifest-loader";

/** Candidate filenames for a repo's private config, in priority order. The slug is the lowercased GitHub
* `owner__repo` (double underscore because `/` is not filename-safe) — e.g. `JSONbored/metagraphed` →
* `jsonbored__metagraphed.yml`. An invalid repo full name (no single interior slash) yields no candidates. */
export function localConfigCandidates(repoFullName: string): string[] {
const slash = repoFullName.indexOf("/");
if (slash <= 0 || slash === repoFullName.length - 1) return [];
const slug = `${repoFullName.slice(0, slash)}__${repoFullName.slice(slash + 1)}`.toLowerCase();
return [`${slug}.yml`, `${slug}.yaml`, `${slug}.json`];
}

/** Build the container-local manifest reader over GITTENSORY_REPO_CONFIG_DIR, or null when the dir is unset/blank
* (⇒ the loader keeps fetching the public `.gittensory.yml`). Each lookup returns the first existing
* `{dir}/{owner}__{repo}.{yml,yaml,json}` file's text; null when none exist for the repo (⇒ the loader falls
* through to the public file). A read error on one candidate is swallowed so the next candidate is tried. */
export function makeLocalManifestReader(dir: string | undefined): RepoFocusManifestFetcher | null {
const base = (dir ?? "").trim();
if (!base) return null;
return async (repoFullName: string): Promise<string | null> => {
for (const candidate of localConfigCandidates(repoFullName)) {
try {
return await readFile(join(base, candidate), "utf8");
} catch {
// ENOENT / unreadable → try the next candidate
}
}
return null;
};
}
6 changes: 6 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import { createPgQueue } from "./selfhost/pg-queue";
import { createPgVectorize, initPgVectorize } from "./selfhost/pg-vectorize";
import { createSqliteQueue } from "./selfhost/sqlite-queue";
import { createSqliteVectorize } from "./selfhost/vectorize";
import { makeLocalManifestReader } from "./selfhost/private-config";
import { setLocalManifestReader } from "./signals/focus-manifest-loader";
import type { JobMessage } from "./types";

/** Resolve `<NAME>_FILE` env vars (Docker secrets / multi-line keys) into `<NAME>` at startup. */
Expand Down Expand Up @@ -157,6 +159,10 @@ function buildSqliteBackend(consume: (m: JobMessage) => Promise<void>): Backend

async function main(): Promise<void> {
loadFileSecrets();
// Container-private per-repo config (self-host): register the GITTENSORY_REPO_CONFIG_DIR reader so the focus-
// manifest loader prefers a mounted `{owner}__{repo}.yml` over the public `.gittensory.yml` (review policy stays
// private). Unset dir ⇒ null reader ⇒ unchanged public-fetch behavior.
setLocalManifestReader(makeLocalManifestReader(process.env.GITTENSORY_REPO_CONFIG_DIR));
const startedAt = Date.now();

// The queue consumer captures `env`, assigned below (the first job only runs once an HTTP/cron event
Expand Down
19 changes: 19 additions & 0 deletions src/signals/focus-manifest-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ export const MANIFEST_FILE_CANDIDATES = [
*/
export type RepoFocusManifestFetcher = (repoFullName: string) => Promise<string | null>;

/**
* Optional container-private per-repo config reader (self-host GITTENSORY_REPO_CONFIG_DIR). When registered it
* takes priority over — and fully REPLACES — the public `.gittensory.yml` for the normal (non-preview) load, so a
* self-host operator sets review policy privately and contributors can't read or game it. Registered once at boot
* by the Node entry (server.ts); the filesystem access lives inside that injected closure, keeping THIS module
* Workers-safe. Unset (cloud, or a self-host without the dir) ⇒ behavior is byte-identical to the public fetch.
*/
let localManifestReader: RepoFocusManifestFetcher | null = null;
export function setLocalManifestReader(reader: RepoFocusManifestFetcher | null): void {
localManifestReader = reader;
}

/**
* Fetch a maintainer-owned manifest file from the public GitHub raw endpoint. Network or HTTP
* failures resolve to null so the loader falls back to deterministic signals.
Expand Down Expand Up @@ -78,6 +90,13 @@ async function loadRepoFocusManifestWithCachePolicy(
options: { fetcher?: RepoFocusManifestFetcher; maxAgeMs?: number; refresh?: boolean } = {},
cachePolicy: { publicOnly?: boolean } = {},
): Promise<FocusManifest> {
// Container-private per-repo config (self-host) takes priority over the public `.gittensory.yml`: read fresh from
// local fs each call (cheap, no network) so operator edits apply immediately. NEVER consulted on the publicOnly
// (contributor-preview) path, and never persisted — so private policy can't leak into previews or the cache.
if (!cachePolicy.publicOnly && localManifestReader) {
const localRaw = await localManifestReader(repoFullName);
if (localRaw !== null) return parseFocusManifestContent(localRaw, "api_record");
}
const fetcher = options.fetcher ?? fetchRepoFocusManifestFile;
const maxAgeMs = options.maxAgeMs ?? REPO_FOCUS_MANIFEST_MAX_AGE_MS;
if (!options.refresh) {
Expand Down
50 changes: 50 additions & 0 deletions test/unit/focus-manifest-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
loadPublicRepoFocusManifest,
loadRepoFocusManifest,
loadRepoFocusManifests,
setLocalManifestReader,
upsertRepoFocusManifest,
REPO_FOCUS_MANIFEST_MAX_AGE_MS,
REPO_FOCUS_MANIFEST_MAX_CONCURRENT_LOADS,
Expand Down Expand Up @@ -331,3 +332,52 @@ describe("focus-manifest loader", () => {
expect(manifest.warnings.join(" ")).toMatch(/mapping/i);
});
});

describe("focus-manifest loader — container-private config (self-host)", () => {
afterEach(() => setLocalManifestReader(null));

it("prefers the registered local reader over the public fetcher and tags it api_record", async () => {
const env = createTestEnv();
let fetched = 0;
setLocalManifestReader(async (repo) => (repo === "owner/private" ? "wantedPaths:\n - private/\n" : null));
const manifest = await loadRepoFocusManifest(env, "owner/private", {
fetcher: async () => {
fetched += 1;
return JSON.stringify({ wantedPaths: ["public/"] });
},
});
expect(manifest.source).toBe("api_record");
expect(manifest.wantedPaths).toEqual(["private/"]);
expect(fetched).toBe(0); // the public `.gittensory.yml` was never fetched
});

it("falls through to the public fetcher when the local reader has no file for the repo", async () => {
const env = createTestEnv();
let fetched = 0;
setLocalManifestReader(async () => null);
const manifest = await loadRepoFocusManifest(env, "owner/public", {
fetcher: async () => {
fetched += 1;
return JSON.stringify({ wantedPaths: ["src/"] });
},
});
expect(manifest.source).toBe("repo_file");
expect(manifest.wantedPaths).toEqual(["src/"]);
expect(fetched).toBe(1);
});

it("never consults the local reader on the publicOnly (contributor-preview) path", async () => {
const env = createTestEnv();
let localCalls = 0;
setLocalManifestReader(async () => {
localCalls += 1;
return "wantedPaths:\n - private/\n";
});
const manifest = await loadPublicRepoFocusManifest(env, "owner/preview", {
fetcher: async () => JSON.stringify({ wantedPaths: ["src/"] }),
});
expect(localCalls).toBe(0); // private config must never leak into a contributor-facing preview
expect(manifest.source).toBe("repo_file");
expect(manifest.wantedPaths).toEqual(["src/"]);
});
});
51 changes: 51 additions & 0 deletions test/unit/private-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { localConfigCandidates, makeLocalManifestReader } from "../../src/selfhost/private-config";

describe("localConfigCandidates (container-private config filenames)", () => {
it("builds lowercased {owner}__{repo} candidates in .yml/.yaml/.json order", () => {
expect(localConfigCandidates("JSONbored/metagraphed")).toEqual(["jsonbored__metagraphed.yml", "jsonbored__metagraphed.yaml", "jsonbored__metagraphed.json"]);
});
it("returns no candidates for an invalid repo full name", () => {
expect(localConfigCandidates("no-slash")).toEqual([]); // slash < 0 → slash <= 0
expect(localConfigCandidates("/leading")).toEqual([]); // slash at 0 → slash <= 0
expect(localConfigCandidates("trailing/")).toEqual([]); // slash at len-1
});
});

describe("makeLocalManifestReader (GITTENSORY_REPO_CONFIG_DIR)", () => {
it("returns null when the dir is unset or blank (⇒ public fetch)", () => {
expect(makeLocalManifestReader(undefined)).toBeNull(); // ?? right side
expect(makeLocalManifestReader("")).toBeNull();
expect(makeLocalManifestReader(" ")).toBeNull(); // blank after trim
});

it("reads the first existing {owner}__{repo} file and returns its text", async () => {
const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
writeFileSync(join(dir, "jsonbored__metagraphed.yml"), "gate:\n enabled: false\n");
const reader = makeLocalManifestReader(dir);
expect(reader).not.toBeNull();
expect(await reader!("JSONbored/metagraphed")).toBe("gate:\n enabled: false\n");
});

it("falls through .yml → .yaml → .json when earlier candidates are absent (read error → next)", async () => {
const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
writeFileSync(join(dir, "owner__repo.json"), '{"gate":{"enabled":true}}');
const reader = makeLocalManifestReader(dir);
expect(await reader!("owner/repo")).toBe('{"gate":{"enabled":true}}');
});

it("returns null when no private config file exists for the repo (⇒ loader uses the public file)", async () => {
const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
const reader = makeLocalManifestReader(dir);
expect(await reader!("owner/unconfigured")).toBeNull();
});

it("returns null for an invalid repo full name (no candidates to try)", async () => {
const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
const reader = makeLocalManifestReader(dir);
expect(await reader!("no-slash")).toBeNull();
});
});
Loading