diff --git a/apps/loopover-ui/src/lib/selfhost-env-reference.ts b/apps/loopover-ui/src/lib/selfhost-env-reference.ts index fd0bd65936..40b93278c4 100644 --- a/apps/loopover-ui/src/lib/selfhost-env-reference.ts +++ b/apps/loopover-ui/src/lib/selfhost-env-reference.ts @@ -107,7 +107,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "ANTHROPIC_API_KEY", - firstReference: "src/selfhost/ai.ts", + firstReference: "src/selfhost/ai-config.ts", }, { name: "BACKUP_ACKNOWLEDGED", @@ -261,6 +261,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "LOOPOVER_API_TOKEN", firstReference: "src/selfhost/preflight.ts", }, + { + name: "LOOPOVER_CENTRAL_POSTHOG_KEY", + firstReference: "src/selfhost/posthog.ts", + }, { name: "LOOPOVER_ENABLE_PAGERDUTY", firstReference: "src/services/notify-pagerduty.ts", @@ -305,6 +309,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "MAINTENANCE_ADMISSION_MAX_DEFER_AGE_MS", firstReference: "src/selfhost/maintenance-admission.ts", }, + { + name: "MAINTENANCE_ADMISSION_MAX_HOST_LOAD", + firstReference: "src/selfhost/maintenance-admission.ts", + }, { name: "MAINTENANCE_ADMISSION_MAX_LIVE_AGE_MS", firstReference: "src/selfhost/maintenance-admission.ts", @@ -351,7 +359,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "OPENAI_API_KEY", - firstReference: "src/selfhost/ai.ts", + firstReference: "src/selfhost/ai-config.ts", }, { name: "OPENAI_COMPATIBLE_AI_API_KEY", @@ -433,10 +441,18 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "PAGERDUTY_MIN_SEVERITY", firstReference: "src/services/notify-pagerduty.ts", }, + { + name: "PAGERDUTY_REPO_COOLDOWN_MINUTES", + firstReference: "src/services/notify-pagerduty.ts", + }, { name: "PAGERDUTY_REPO_MIN_SEVERITY", firstReference: "src/services/notify-pagerduty.ts", }, + { + name: "PAGERDUTY_REPO_ROUTING_KEYS", + firstReference: "src/services/notify-pagerduty.ts", + }, { name: "PAGERDUTY_ROUTING_KEY", firstReference: "src/services/notify-pagerduty.ts", @@ -517,10 +533,30 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS", firstReference: "src/selfhost/queue-common.ts", }, + { + name: "QUEUE_DEAD_LETTER_REVIVE_INTERVAL_MS", + firstReference: "src/selfhost/queue-common.ts", + }, + { + name: "QUEUE_PROCESSING_TIMEOUT_MS", + firstReference: "src/selfhost/queue-common.ts", + }, + { + name: "QUEUE_RATE_LIMIT_JITTER_MS", + firstReference: "src/selfhost/queue-common.ts", + }, + { + name: "QUEUE_RECOVERY_JITTER_MS", + firstReference: "src/selfhost/queue-common.ts", + }, { name: "QUEUE_STARTUP_JITTER_MIN_JOBS", firstReference: "src/selfhost/queue-common.ts", }, + { + name: "QUEUE_STARTUP_JITTER_MS", + firstReference: "src/selfhost/queue-common.ts", + }, { name: "REDEPLOY_COMPANION_SOCKET_PATH", firstReference: "src/server.ts", @@ -557,6 +593,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "REVIEW_AUDIT_S3_SECRET_ACCESS_KEY", firstReference: "src/server.ts", }, + { + name: "SCHEDULED_ENQUEUE_JITTER_MS", + firstReference: "src/selfhost/queue-common.ts", + }, { name: "SELFHOST_BUNDLE_ALL", firstReference: "scripts/build-selfhost.ts", @@ -573,6 +613,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "SETUP_OUTPUT_PATH", firstReference: "src/server.ts", }, + { + name: "SLACK_REPO_WEBHOOKS", + firstReference: "src/services/notify-discord.ts", + }, { name: "SLACK_WEBHOOK_URL", firstReference: "src/services/notify-discord.ts", @@ -607,7 +651,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `AI_VISION_MODEL` | `src/server.ts` |", "| `ANTHROPIC_AI_BASE_URL` | `src/selfhost/ai.ts` |", "| `ANTHROPIC_AI_MODEL` | `src/selfhost/ai.ts` |", - "| `ANTHROPIC_API_KEY` | `src/selfhost/ai.ts` |", + "| `ANTHROPIC_API_KEY` | `src/selfhost/ai-config.ts` |", "| `BACKUP_ACKNOWLEDGED` | `src/server.ts` |", "| `BROWSER_WS_ENDPOINT` | `src/selfhost/stubs/puppeteer.ts` |", "| `CLAUDE_AI_EFFORT` | `src/selfhost/ai.ts` |", @@ -646,6 +690,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `HOME` | `src/selfhost/ai.ts` |", "| `INTERNAL_JOB_TOKEN` | `src/selfhost/preflight.ts` |", "| `LOOPOVER_API_TOKEN` | `src/selfhost/preflight.ts` |", + "| `LOOPOVER_CENTRAL_POSTHOG_KEY` | `src/selfhost/posthog.ts` |", "| `LOOPOVER_ENABLE_PAGERDUTY` | `src/services/notify-pagerduty.ts` |", "| `LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER` | `src/selfhost/ai.ts` |", "| `LOOPOVER_MCP_TOKEN` | `src/selfhost/preflight.ts` |", @@ -657,6 +702,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `MAINTENANCE_ADMISSION_ENABLED` | `src/selfhost/maintenance-admission.ts` |", "| `MAINTENANCE_ADMISSION_MAX_BACKLOG_CONVERGENCE_PENDING` | `src/selfhost/maintenance-admission.ts` |", "| `MAINTENANCE_ADMISSION_MAX_DEFER_AGE_MS` | `src/selfhost/maintenance-admission.ts` |", + "| `MAINTENANCE_ADMISSION_MAX_HOST_LOAD` | `src/selfhost/maintenance-admission.ts` |", "| `MAINTENANCE_ADMISSION_MAX_LIVE_AGE_MS` | `src/selfhost/maintenance-admission.ts` |", "| `MAINTENANCE_ADMISSION_MAX_LIVE_PENDING` | `src/selfhost/maintenance-admission.ts` |", "| `MAINTENANCE_ADMISSION_MAX_PENDING` | `src/selfhost/maintenance-admission.ts` |", @@ -668,7 +714,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `OLLAMA_AI_MODEL` | `src/selfhost/ai.ts` |", "| `OPENAI_AI_BASE_URL` | `src/selfhost/ai.ts` |", "| `OPENAI_AI_MODEL` | `src/selfhost/ai.ts` |", - "| `OPENAI_API_KEY` | `src/selfhost/ai.ts` |", + "| `OPENAI_API_KEY` | `src/selfhost/ai-config.ts` |", "| `OPENAI_COMPATIBLE_AI_API_KEY` | `src/selfhost/ai.ts` |", "| `OPENAI_COMPATIBLE_AI_BASE_URL` | `src/selfhost/ai.ts` |", "| `OPENAI_COMPATIBLE_AI_MODEL` | `src/selfhost/ai.ts` |", @@ -689,7 +735,9 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `OTEL_TRACES_SAMPLER_ARG` | `src/selfhost/otel.ts` |", "| `PAGERDUTY_COOLDOWN_MINUTES` | `src/services/notify-pagerduty.ts` |", "| `PAGERDUTY_MIN_SEVERITY` | `src/services/notify-pagerduty.ts` |", + "| `PAGERDUTY_REPO_COOLDOWN_MINUTES` | `src/services/notify-pagerduty.ts` |", "| `PAGERDUTY_REPO_MIN_SEVERITY` | `src/services/notify-pagerduty.ts` |", + "| `PAGERDUTY_REPO_ROUTING_KEYS` | `src/services/notify-pagerduty.ts` |", "| `PAGERDUTY_ROUTING_KEY` | `src/services/notify-pagerduty.ts` |", "| `PGPOOL_MAX` | `src/selfhost/queue-common.ts` |", "| `PGVECTOR_ENABLED` | `src/server.ts` |", @@ -710,7 +758,12 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `QUEUE_BACKGROUND_CONCURRENCY` | `src/selfhost/queue-common.ts` |", "| `QUEUE_CONCURRENCY` | `src/selfhost/pg-queue.ts` |", "| `QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS` | `src/selfhost/queue-common.ts` |", + "| `QUEUE_DEAD_LETTER_REVIVE_INTERVAL_MS` | `src/selfhost/queue-common.ts` |", + "| `QUEUE_PROCESSING_TIMEOUT_MS` | `src/selfhost/queue-common.ts` |", + "| `QUEUE_RATE_LIMIT_JITTER_MS` | `src/selfhost/queue-common.ts` |", + "| `QUEUE_RECOVERY_JITTER_MS` | `src/selfhost/queue-common.ts` |", "| `QUEUE_STARTUP_JITTER_MIN_JOBS` | `src/selfhost/queue-common.ts` |", + "| `QUEUE_STARTUP_JITTER_MS` | `src/selfhost/queue-common.ts` |", "| `REDEPLOY_COMPANION_SOCKET_PATH` | `src/server.ts` |", "| `REDEPLOY_COMPANION_TOKEN` | `src/server.ts` |", "| `REDIS_URL` | `src/selfhost/preflight.ts` |", @@ -720,9 +773,11 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `REVIEW_AUDIT_S3_ENDPOINT` | `src/server.ts` |", "| `REVIEW_AUDIT_S3_REGION` | `src/server.ts` |", "| `REVIEW_AUDIT_S3_SECRET_ACCESS_KEY` | `src/server.ts` |", + "| `SCHEDULED_ENQUEUE_JITTER_MS` | `src/selfhost/queue-common.ts` |", "| `SELFHOST_BUNDLE_ALL` | `scripts/build-selfhost.ts` |", "| `SELFHOST_SERVICE` | `scripts/smoke-observability-traces.ts` |", "| `SELFHOST_SETUP_TOKEN` | `src/selfhost/preflight.ts` |", "| `SETUP_OUTPUT_PATH` | `src/server.ts` |", + "| `SLACK_REPO_WEBHOOKS` | `src/services/notify-discord.ts` |", "| `SLACK_WEBHOOK_URL` | `src/services/notify-discord.ts` |", ].join("\n"); diff --git a/scripts/gen-selfhost-env-reference.ts b/scripts/gen-selfhost-env-reference.ts index a9f006cff6..47053238e1 100644 --- a/scripts/gen-selfhost-env-reference.ts +++ b/scripts/gen-selfhost-env-reference.ts @@ -80,6 +80,11 @@ function collectEnvReads(source: string, fileName: string): EnvRead[] { // whose body reads `env[x]` can be resolved back to the concrete var names -- src/selfhost/preflight.ts's // CRITICAL_SECRET_VARS loop reads four tokens (GITHUB_WEBHOOK_SECRET etc.) this way and nowhere else (#8652). const literalArrays = collectLiteralStringArrays(sourceFile); + // Same-file wrapper functions that forward a string PARAMETER (not a literal) into an already-recognized + // env-name sink -- src/selfhost/queue-common.ts's `envDurationMs(name)` -> `parsePositiveIntEnv(name, ...)` + // and maintenance-admission.ts's `parsePositiveFloatEnv(name)` -> `process.env[name]`. The var name is only a + // literal at the wrapper's CALL sites, so each such wrapper is treated like a literal-arg helper (#8651). + const localWrappers = collectParamForwardingWrappers(sourceFile); const visit = (node: ts.Node) => { if (ts.isPropertyAccessExpression(node) && isEnvContainer(node.expression)) { addRead(node.name.text); @@ -101,6 +106,11 @@ function collectEnvReads(source: string, fileName: string): EnvRead[] { } } else if (ts.isForOfStatement(node)) { for (const name of envReadingForOfArrayLiterals(node, literalArrays)) addRead(name); + } else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && localWrappers.has(node.expression.text)) { + for (const argIndex of localWrappers.get(node.expression.text)!) { + const arg = node.arguments[argIndex]; + if (arg && ts.isStringLiteralLike(arg)) addRead(arg.text); + } } ts.forEachChild(node, visit); }; @@ -141,6 +151,86 @@ function isProcessEnvNameHelperCall(node: ts.CallExpression): boolean { return ts.isIdentifier(node.expression) && PROCESS_ENV_NAME_HELPERS.has(node.expression.text) && node.arguments.length >= 1 && ts.isStringLiteralLike(node.arguments[0]!); } +// Discover same-file wrapper functions that forward a string parameter, unmodified, into an already-recognized +// env-name sink, and return a map of wrapper name -> the parameter index(es) that carry the env-var name at the +// wrapper's call sites. A "sink" is a direct `env[param]`/`process.env[param]` read, an envString/parse*Env +// helper called with the parameter as its name argument, a literal-arg helper, or another already-discovered +// wrapper (resolved to a fixpoint so wrapper-of-wrapper chains are also caught). Generalizes to any such +// forwarding function -- no wrapper name is special-cased (#8651). +function collectParamForwardingWrappers(sourceFile: ts.SourceFile): Map { + const candidates: { name: string; params: ts.NodeArray; body: ts.Node }[] = []; + const walk = (node: ts.Node) => { + if (ts.isFunctionDeclaration(node) && node.name && node.body) { + candidates.push({ name: node.name.text, params: node.parameters, body: node.body }); + } else if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) + ) { + candidates.push({ name: node.name.text, params: node.initializer.parameters, body: node.initializer.body }); + } + ts.forEachChild(node, walk); + }; + walk(sourceFile); + + const wrappers = new Map(); + let changed = true; + while (changed) { + changed = false; + for (const candidate of candidates) { + if (wrappers.has(candidate.name)) continue; + const indexes: number[] = []; + candidate.params.forEach((param, index) => { + if (ts.isIdentifier(param.name) && bodyForwardsParamAsEnvName(candidate.body, param.name.text, wrappers)) indexes.push(index); + }); + if (indexes.length > 0) { + wrappers.set(candidate.name, indexes); + changed = true; + } + } + } + return wrappers; +} + +// True if `body` forwards the identifier `paramName` as an env-var NAME anywhere: a direct `env[paramName]` +// read, or a call passing `paramName` at the name-argument position of a recognized helper / discovered wrapper. +function bodyForwardsParamAsEnvName(body: ts.Node, paramName: string, knownWrappers: Map): boolean { + let found = false; + const walk = (node: ts.Node) => { + if (found) return; + if (ts.isElementAccessExpression(node) && isEnvContainer(node.expression) && ts.isIdentifier(node.argumentExpression) && node.argumentExpression.text === paramName) { + found = true; + return; + } + if (ts.isCallExpression(node) && callForwardsParamAsName(node, paramName, knownWrappers)) { + found = true; + return; + } + ts.forEachChild(node, walk); + }; + walk(body); + return found; +} + +// True if `node` calls a recognized name-helper (envString / process-env-name / literal-arg) or an +// already-discovered wrapper with the identifier `paramName` at that callee's env-var-name argument position. +function callForwardsParamAsName(node: ts.CallExpression, paramName: string, knownWrappers: Map): boolean { + if (!ts.isIdentifier(node.expression)) return false; + const callee = node.expression.text; + const argIsParam = (index: number): boolean => { + const arg = node.arguments[index]; + return arg !== undefined && ts.isIdentifier(arg) && arg.text === paramName; + }; + if (callee === "envString") return argIsParam(1); + if (PROCESS_ENV_NAME_HELPERS.has(callee)) return argIsParam(0); + const literalArgIndexes = ENV_NAME_LITERAL_ARG_HELPERS.get(callee); + if (literalArgIndexes) return literalArgIndexes.some((index) => argIsParam(index)); + const wrapperArgIndexes = knownWrappers.get(callee); + if (wrapperArgIndexes) return wrapperArgIndexes.some((index) => argIsParam(index)); + return false; +} + function isEnvNameLiteralArgHelperCall(node: ts.CallExpression): boolean { if (!ts.isIdentifier(node.expression)) return false; const argIndexes = ENV_NAME_LITERAL_ARG_HELPERS.get(node.expression.text); diff --git a/test/unit/selfhost-env-reference-script.test.ts b/test/unit/selfhost-env-reference-script.test.ts index 5817ccd0e2..f26bf797f0 100644 --- a/test/unit/selfhost-env-reference-script.test.ts +++ b/test/unit/selfhost-env-reference-script.test.ts @@ -276,3 +276,59 @@ describe("AI review-pipeline self-host env vars (#6993)", () => { expect(byName.get("AI_MAX_OUTPUT_TOKENS")).toBe("src/services/ai-review.ts"); }); }); + +describe("same-file parameter-forwarding wrapper functions (#8651)", () => { + function collectFrom(source: string): string[] { + const root = mkdtempSync(join(tmpdir(), "gt-env-reference-8651-")); + mkdirSync(join(root, "src", "selfhost"), { recursive: true }); + writeFileSync(join(root, "src", "selfhost", "wrappers.ts"), source); + return collectSelfHostEnvVars({ rootDir: root }).map((row) => row.name); + } + + it("detects a wrapper forwarding its name parameter into parsePositiveIntEnv / process.env / envString / a chained wrapper", () => { + const source = [ + // wrapper -> parsePositiveIntEnv (PROCESS_ENV_NAME_HELPERS); the console.log/plainCall decoys exercise the + // non-identifier-callee and unrecognized-callee branches of callForwardsParamAsName. + "function wrapInt(name, fallback) {", + " console.log(name);", + " plainCall(name);", + " return parsePositiveIntEnv(name, { min: 0, fallback });", + "}", + // wrapper -> direct process.env[name] read. + "function wrapFloat(name) {", + " return process.env[name];", + "}", + // wrapper-of-wrapper: forwards into wrapMap (declared AFTER it), forcing a second fixpoint iteration. + "function wrapOuter(k) {", + " return wrapMap(env, k);", + "}", + // wrapper -> envString(env, envName) with the param at arg index 1. + "function wrapMap(env, envName) {", + " return envString(env, envName);", + "}", + // wrapper -> a literal-arg helper (resolveLocalStoreDbPath reads env at arg index 1). + "function wrapLocal(p) {", + " return resolveLocalStoreDbPath(base, p);", + "}", + // arrow-function and function-expression wrapper forms. + "const wrapArrow = (name) => parsePositiveIntEnv(name, { fallback: 2 });", + "const wrapFnExpr = function (name) { return parsePositiveIntEnv(name, { fallback: 9 }); };", + // NOT a wrapper: never forwards its parameter into an env sink. + "function notWrapper(name) {", + " return name.length;", + "}", + // Call sites with literal names -> collected. Non-literal and non-wrapper calls -> ignored. + 'const a = wrapInt("ALPHA_MS", 1);', + 'const b = wrapFloat("BETA_LOAD");', + 'const c = wrapOuter("DELTA_CHAIN");', + 'const d = wrapMap(env, "GAMMA_MAP");', + 'const e = wrapArrow("EPSILON_MS");', + 'const f = wrapLocal("ZETA_DB");', + 'const g = wrapFnExpr("ETA_MS");', + "const h = wrapInt(dynamicName, 3);", + 'const i = notWrapper("NOT_ENV");', + "", + ].join("\n"); + expect(collectFrom(source)).toEqual(["ALPHA_MS", "BETA_LOAD", "DELTA_CHAIN", "EPSILON_MS", "ETA_MS", "GAMMA_MAP", "ZETA_DB"]); + }); +});