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
20 changes: 14 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,12 @@ GITTENSORY_REVIEW_DRAFT=false
# GITHUB_APP_PRIVATE_KEY=
# GITHUB_APP_SLUG=
# GITTENSOR_REGISTRY_URL=
# GITTENSORY_API_TOKEN=
# GITTENSORY_MCP_TOKEN=
# LOOPOVER_API_TOKEN= # #4774: the loopover rebrand's name for this token; the legacy
# # GITTENSORY_API_TOKEN below still works unchanged (dual-read;
# # this new name wins if BOTH are set)
# GITTENSORY_API_TOKEN= # legacy name, still read
# LOOPOVER_MCP_TOKEN= # #4774: ditto — legacy GITTENSORY_MCP_TOKEN below still works
# GITTENSORY_MCP_TOKEN= # legacy name, still read
# INTERNAL_JOB_TOKEN=

# --- Optional (capability-gated; degrade safely when absent) -----------------
Expand Down Expand Up @@ -521,8 +525,9 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# # never prompts, diffs, tokens, or bodies.
# SENTRY_RELEASE= # custom images only: set this ONLY when you uploaded source maps for
# # the exact built bundle under this exact release id. Future official
# # images bake GITTENSORY_VERSION=gittensory-selfhost@<version>, so do
# # not override SENTRY_RELEASE for those images.
# # images bake GITTENSORY_VERSION=gittensory-selfhost@<version> (the
# # app also reads LOOPOVER_VERSION -- #4774 dual-read, new name wins
# # if both are set), so do not override SENTRY_RELEASE for those images.
# OTEL_METRIC_EXPORT_INTERVAL=10000 # ms between metric exports (default 10s here; CLI default is 60s)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 # override only for an external collector
# OTEL_SERVICE_NAME=gittensory-selfhost
Expand Down Expand Up @@ -555,7 +560,8 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
#
# Sentry error tracking. OFF when SENTRY_DSN is unset. Official self-host release images bake
# GITTENSORY_VERSION=gittensory-selfhost@<version>; initSentry uses that as the release id unless
# SENTRY_RELEASE is set explicitly (useful for custom/local images).
# SENTRY_RELEASE is set explicitly (useful for custom/local images). A LOOPOVER_VERSION override is also
# read (#4774 dual-read: set LOOPOVER_VERSION to take priority over the image-baked legacy name).
# SENTRY_DSN=
# SENTRY_DSN_FILE= # optional mounted secret file; existing *_FILE loader reads it
# SENTRY_ENVIRONMENT=selfhost
Expand Down Expand Up @@ -649,7 +655,9 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# Codex (ChatGPT subscription) reviewer is fail-closed by default for self-host PR review: `codex exec` stores its
# OAuth credential in auth.json on the same filesystem that prompt-influenced reviews can read. Isolated maintainer
# deployments can opt in explicitly after mounting auth at /data/codex (the image exposes it as ~/.codex).
# GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1
# LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER=1 # #4774: preferred name; legacy GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1
# # below still works unchanged (dual-read; this new name wins if both are set)
# GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1 # legacy name, still read
# Do NOT set CODEX_HOME for the app container; the provider rejects it so credentials are not advertised in env.
# CODEX_AI_MODEL=gpt-5.5 # omit to let the Codex account default choose; set explicitly for repeatable reviews
# CODEX_AI_EFFORT=medium # low | medium | high | xhigh. `max` is accepted and maps to xhigh.
Expand Down
14 changes: 10 additions & 4 deletions .env.selfhost.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ GITTENSOR_REGISTRY_URL=https://example.invalid/registry.json
# value, so copy-pasting the same string into more than one of these will also fail preflight. Or
# skip uncommenting these entirely and write the value into secrets/<name>.txt instead (see above).
# GITHUB_WEBHOOK_SECRET= # the sole HMAC key GitHub webhook deliveries are verified against
# GITTENSORY_API_TOKEN= # server-to-server API bearer token — bypasses per-repo write checks
# GITTENSORY_MCP_TOKEN= # shared MCP bearer token
# LOOPOVER_API_TOKEN= # server-to-server API bearer token — bypasses per-repo write checks
# # (#4774: preferred name; legacy GITTENSORY_API_TOKEN below still works
# # unchanged — dual-read, this new name wins if both are set)
# GITTENSORY_API_TOKEN= # legacy name, still read
# LOOPOVER_MCP_TOKEN= # shared MCP bearer token (#4774: ditto — legacy GITTENSORY_MCP_TOKEN
# # below still works unchanged)
# GITTENSORY_MCP_TOKEN= # legacy name, still read
# INTERNAL_JOB_TOKEN= # gates internal-only routes

# REQUIRED for the first-run /setup wizard that creates the GitHub App manifest, AND required ongoing:
Expand Down Expand Up @@ -88,13 +93,14 @@ GITTENSORY_REVIEW_ENRICHMENT=false
# filesystem prompt-influenced reviews can read) -- the explicit opt-in below is required, and you
# must mount your Codex auth at /data/codex (the image exposes it as ~/.codex).
# AI_PROVIDER=codex
# GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1
# LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER=1 # #4774: preferred name; legacy GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1
# # still works unchanged (dual-read, this new name wins if both are set)

# --- Both, synthesized into one decision --------------------------------------
# AI_PROVIDER=claude-code,codex
# AI_COMBINE=synthesis
# CLAUDE_CODE_OAUTH_TOKEN=
# GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER=1
# LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER=1 # #4774: same dual-read as above

# =============================================================================
# 5. Telemetry — informational only, no action needed
Expand Down
25 changes: 25 additions & 0 deletions apps/gittensory-ui/src/lib/selfhost-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "GITTENSORY_ENABLE_PAGERDUTY",
firstReference: "src/services/notify-pagerduty.ts",
},
{
name: "GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER",
firstReference: "src/selfhost/ai.ts",
},
{
name: "GITTENSORY_REPO_CONFIG_DIR",
firstReference: "src/server.ts",
Expand All @@ -205,6 +209,22 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "HOME",
firstReference: "src/selfhost/ai.ts",
},
{
name: "LOOPOVER_ENABLE_PAGERDUTY",
firstReference: "src/services/notify-pagerduty.ts",
},
{
name: "LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER",
firstReference: "src/selfhost/ai.ts",
},
{
name: "LOOPOVER_REPO_CONFIG_DIR",
firstReference: "src/server.ts",
},
{
name: "LOOPOVER_VERSION",
firstReference: "src/selfhost/otel.ts",
},
{
name: "MAINTENANCE_ADMISSION_DEFER_MS",
firstReference: "src/selfhost/maintenance-admission.ts",
Expand Down Expand Up @@ -529,9 +549,14 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| `GITHUB_INSTALLATION_CONCURRENCY_ENABLED` | `src/selfhost/installation-concurrency-admission.ts` |",
"| `GITHUB_INSTALLATION_CONCURRENCY_LIMIT` | `src/selfhost/installation-concurrency-admission.ts` |",
"| `GITTENSORY_ENABLE_PAGERDUTY` | `src/services/notify-pagerduty.ts` |",
"| `GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER` | `src/selfhost/ai.ts` |",
"| `GITTENSORY_REPO_CONFIG_DIR` | `src/server.ts` |",
"| `GITTENSORY_VERSION` | `src/selfhost/otel.ts` |",
"| `HOME` | `src/selfhost/ai.ts` |",
"| `LOOPOVER_ENABLE_PAGERDUTY` | `src/services/notify-pagerduty.ts` |",
"| `LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER` | `src/selfhost/ai.ts` |",
"| `LOOPOVER_REPO_CONFIG_DIR` | `src/server.ts` |",
"| `LOOPOVER_VERSION` | `src/selfhost/otel.ts` |",
"| `MAINTENANCE_ADMISSION_DEFER_MS` | `src/selfhost/maintenance-admission.ts` |",
"| `MAINTENANCE_ADMISSION_DRAIN_AGE_MS` | `src/selfhost/maintenance-admission.ts` |",
"| `MAINTENANCE_ADMISSION_ENABLED` | `src/selfhost/maintenance-admission.ts` |",
Expand Down
23 changes: 23 additions & 0 deletions scripts/gen-selfhost-env-reference.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ function collectEnvReads(source, fileName) {
}
} else if (ts.isCallExpression(node) && isStaticEnvHelperCall(node)) {
addRead(node.arguments[1].text);
} else if (ts.isCallExpression(node) && isDualPrefixEnvHelperCall(node)) {
const suffix = node.arguments[1].text;
addRead(`LOOPOVER_${suffix}`);
addRead(`GITTENSORY_${suffix}`);
} else if (ts.isCallExpression(node) && isProcessEnvNameHelperCall(node)) {
addRead(node.arguments[0].text);
} else if (ts.isCallExpression(node) && isEnvNameLiteralArgHelperCall(node)) {
Expand All @@ -99,6 +103,25 @@ function isStaticEnvHelperCall(node) {
);
}

// #4774 dual-read (src/utils/env.ts): dualPrefixEnvString/dualPrefixEnvFlag/dualPrefixEnvStrictFlag each read
// BOTH `LOOPOVER_<suffix>` and the legacy `GITTENSORY_<suffix>` under the hood via template-literal key
// concatenation this AST scanner can't see into otherwise -- without this, converting a call site from a
// literal `env.GITTENSORY_X` read to `dualPrefixEnvString(env, "X")` would silently DROP that var from the
// generated reference instead of adding its new LOOPOVER_ alias. Recognized by function name only (no
// isEnvContainer check on arg[0]): real call sites pass several different container variable names for the
// same underlying env object (`env`, `parent`, `parentEnv`, `rawEnv`, a `(... as unknown as Record<...>)`
// cast, or `process.env`), so requiring isEnvContainer here would silently miss most of them.
const DUAL_PREFIX_ENV_HELPERS = new Set(["dualPrefixEnvString", "dualPrefixEnvFlag", "dualPrefixEnvStrictFlag"]);

function isDualPrefixEnvHelperCall(node) {
return (
ts.isIdentifier(node.expression) &&
DUAL_PREFIX_ENV_HELPERS.has(node.expression.text) &&
node.arguments.length >= 2 &&
ts.isStringLiteralLike(node.arguments[1])
);
}

// Some self-host helpers read `process.env` internally by name rather than taking an env container argument --
// e.g. `parsePositiveIntEnv("QUEUE_CONCURRENCY", { min: 1, fallback: 4 })`. Recognized separately from
// isStaticEnvHelperCall above (envString) because these take the var NAME as arg[0], not arg[1] after a
Expand Down
9 changes: 6 additions & 3 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ import type {
RepositorySettings,
} from "../types";
import { errorMessage, nowIso } from "../utils/json";
import { dualPrefixEnvString } from "../utils/env";
import {
queueDeadLetterPageFromBinding,
queueDeleteDeadLetterJobViaBinding,
Expand Down Expand Up @@ -2083,7 +2084,8 @@ export function createApp() {
...(installationHealth.some((health) => health.status !== "healthy") ? ["One or more GitHub App installations need attention."] : []),
];
const upstreamLaunchBlocking = upstreamDrift.status === "unavailable" || upstreamDrift.highestSeverity === "high" || upstreamDrift.highestSeverity === "blocking";
const ready = Boolean(snapshot) && Boolean(c.env.INTERNAL_JOB_TOKEN) && Boolean(c.env.GITTENSORY_API_TOKEN);
// #4774 dual-read: LOOPOVER_API_TOKEN counts as configured too, same precedence as authenticatePrivateToken.
const ready = Boolean(snapshot) && Boolean(c.env.INTERNAL_JOB_TOKEN) && Boolean(dualPrefixEnvString(c.env as unknown as Record<string, string | undefined>, "API_TOKEN"));
const readyForPublicReview = snapshot
? snapshot.repoCount > 0 &&
ready &&
Expand Down Expand Up @@ -2148,8 +2150,9 @@ export function createApp() {
githubAppPrivateKey: Boolean(c.env.GITHUB_APP_PRIVATE_KEY),
githubWebhookSecret: Boolean(c.env.GITHUB_WEBHOOK_SECRET),
githubPublicToken: Boolean(c.env.GITHUB_PUBLIC_TOKEN),
apiToken: Boolean(c.env.GITTENSORY_API_TOKEN),
mcpToken: Boolean(c.env.GITTENSORY_MCP_TOKEN),
// #4774 dual-read: reflects whichever of LOOPOVER_/GITTENSORY_ actually resolves (see dualPrefixEnvString).
apiToken: Boolean(dualPrefixEnvString(c.env as unknown as Record<string, string | undefined>, "API_TOKEN")),
mcpToken: Boolean(dualPrefixEnvString(c.env as unknown as Record<string, string | undefined>, "MCP_TOKEN")),
internalJobToken: Boolean(c.env.INTERNAL_JOB_TOKEN),
},
warnings,
Expand Down
9 changes: 7 additions & 2 deletions src/auth/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "../db/repositories";
import type { AuthSessionRecord, JsonValue } from "../types";
import { nowIso } from "../utils/json";
import { dualPrefixEnvString } from "../utils/env";

export type AuthIdentity =
| { kind: "static"; actor: "api" | "mcp" | "internal" }
Expand Down Expand Up @@ -102,8 +103,12 @@ export function createOpaqueToken(prefix = "gts"): string {

export async function authenticatePrivateToken(env: Env, token: string | undefined): Promise<AuthIdentity | null> {
if (!token) return null;
if (await timingSafeEqual(token, env.GITTENSORY_API_TOKEN)) return { kind: "static", actor: "api" };
if (await timingSafeEqual(token, env.GITTENSORY_MCP_TOKEN)) return { kind: "static", actor: "mcp" };
// #4774 dual-read: LOOPOVER_API_TOKEN / LOOPOVER_MCP_TOKEN win over their legacy GITTENSORY_ names when
// both are set -- this is the real auth gate, so it must change in lockstep with preflight.ts's strength
// check, or a self-hoster who only set the new name would pass preflight but fail every authenticated call.
const rawEnv = env as unknown as Record<string, string | undefined>;
if (await timingSafeEqual(token, dualPrefixEnvString(rawEnv, "API_TOKEN"))) return { kind: "static", actor: "api" };
if (await timingSafeEqual(token, dualPrefixEnvString(rawEnv, "MCP_TOKEN"))) return { kind: "static", actor: "mcp" };
return authenticateSessionToken(env, token);
}

Expand Down
23 changes: 19 additions & 4 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,11 @@ declare global {
/** 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). */
* fetch (cloud, or a self-host without the dir, is byte-identical to before).
* #4774 dual-read: LOOPOVER_REPO_CONFIG_DIR below wins over this legacy name when both are set. */
GITTENSORY_REPO_CONFIG_DIR?: string;
/** #4774: LOOPOVER_ companion for GITTENSORY_REPO_CONFIG_DIR above — wins when both are set. */
LOOPOVER_REPO_CONFIG_DIR?: string;
GITTENSORY_AUTO_FILE_DRIFT_ISSUES?: string;
GITTENSORY_DRIFT_ISSUE_REPO?: string;
GITTENSORY_DRIFT_ISSUE_TOKEN?: string;
Expand All @@ -236,8 +239,11 @@ declare global {
* (merged/closed/manual) for ANY repo. Sibling of DISCORD_WEBHOOK_URL; set either, both, or neither. */
SLACK_WEBHOOK_URL?: string;
/** Experimental (#4937/#5007): enables PagerDuty incident paging from src/services/notify-pagerduty.ts.
* Default OFF — unset/false keeps every export there a no-op. Truthy: `/^(1|true|yes|on)$/i`. */
* Default OFF — unset/false keeps every export there a no-op. Truthy: `/^(1|true|yes|on)$/i`.
* #4774 dual-read: LOOPOVER_ENABLE_PAGERDUTY below wins over this legacy name when both are set. */
GITTENSORY_ENABLE_PAGERDUTY?: string;
/** #4774: LOOPOVER_ companion for GITTENSORY_ENABLE_PAGERDUTY above — wins when both are set. */
LOOPOVER_ENABLE_PAGERDUTY?: string;
/** Global fallback PagerDuty Events API v2 routing key (32 lowercase hex chars) for any repo not present in
* PAGERDUTY_REPO_ROUTING_KEYS (a JSON `{repoFullName: routingKey}` map, read directly off the env — same
* deliberately-untyped pattern as DISCORD_REPO_WEBHOOKS, since a free-form per-repo map isn't worth a
Expand All @@ -258,8 +264,17 @@ declare global {
PAGERDUTY_COOLDOWN_MINUTES?: string;
GITTENSORY_CONTRIBUTOR_ISSUE_TOKEN?: string;
PRODUCT_USAGE_HASH_SALT?: string;
GITTENSORY_API_TOKEN: string;
GITTENSORY_MCP_TOKEN: string;
/** Server-to-server API bearer token — bypasses per-repo write checks (src/auth/security.ts).
* #4774 dual-read: no longer always-present at the type level, since either this OR LOOPOVER_API_TOKEN
* below may supply the effective value (LOOPOVER_ wins when both are set) — see dualPrefixEnvString. */
GITTENSORY_API_TOKEN?: string;
/** #4774: LOOPOVER_ companion for GITTENSORY_API_TOKEN above — wins when both are set. */
LOOPOVER_API_TOKEN?: string;
/** Shared MCP bearer token (src/auth/security.ts). #4774 dual-read: see GITTENSORY_API_TOKEN's note above —
* either this OR LOOPOVER_MCP_TOKEN below may supply the effective value. */
GITTENSORY_MCP_TOKEN?: string;
/** #4774: LOOPOVER_ companion for GITTENSORY_MCP_TOKEN above — wins when both are set. */
LOOPOVER_MCP_TOKEN?: string;
INTERNAL_JOB_TOKEN: string;
/** Repos the shared GITTENSORY_MCP_TOKEN may propose/decide/manage actions on (comma/whitespace `owner/repo`
* list, or `*`/`all` for every repo). Unset ⇒ none — GITTENSORY_MCP_TOKEN is a shared, end-user-obtainable
Expand Down
5 changes: 4 additions & 1 deletion src/selfhost/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export { assertNoLegacySharedAiEnv } from "./ai-config";
import { incr, observe } from "./metrics";
import { withReviewSpan } from "./tracing";
import { delimiter } from "node:path";
import { dualPrefixEnvStrictFlag } from "../utils/env";

interface AiRunOptions {
// Content is a plain string for every message any pre-#4111 caller ever built (byte-identical). A
Expand Down Expand Up @@ -476,7 +477,9 @@ function assertCodexCredentialIsolation(parent: Record<string, string | undefine
// `codex exec` receives attacker-controlled PR title/body/diff text. Its read-only sandbox prevents writes, but not
// reads, so a self-hosted OAuth home mounted into the same filesystem can be prompt-injected into public output.
// Fail closed until Codex exposes a brokered credential mode that does not put auth.json in the review sandbox.
if (parent.CODEX_HOME || parent.GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER !== "1") {
// #4774 dual-read: LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER wins over the legacy name when both are set; strict
// "1"-only, matching health.ts's codexAuthReadinessProbe and this flag's narrow opt-in convention.
if (parent.CODEX_HOME || !dualPrefixEnvStrictFlag(parent, "ENABLE_UNSAFE_CODEX_REVIEWER")) {
throw new Error("codex_credential_isolation_required");
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/selfhost/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// the things a request actually depends on — the DB answers and the schema migrations have been applied.
// Backend-agnostic: runs through the D1 surface, so it works on both the SQLite and Postgres adapters.

import { dualPrefixEnvStrictFlag } from "../utils/env";

export interface Readiness {
ok: boolean;
checks: Record<string, boolean>;
Expand Down Expand Up @@ -109,7 +111,9 @@ export function codexAuthReadinessProbe(
checkAuthFile: (env: Record<string, string | undefined>) => Promise<boolean> = defaultCodexAuthFileCheck,
cacheMs = 30_000,
): ReadinessProbe | null {
if (parentEnv.GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER !== "1") return null;
// #4774 dual-read: LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER wins over the legacy name when both are set; strict
// "1"-only, matching this flag's intentionally narrow (non-loose-truthy) opt-in convention.
if (!dualPrefixEnvStrictFlag(parentEnv, "ENABLE_UNSAFE_CODEX_REVIEWER")) return null;
let cached: boolean | undefined;
let cachedUntil = 0;
let inFlight: Promise<boolean> | undefined;
Expand Down
Loading
Loading