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
25 changes: 25 additions & 0 deletions apps/loopover-ui/src/lib/selfhost-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,14 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "PAGERDUTY_COOLDOWN_MINUTES",
firstReference: "src/services/notify-pagerduty.ts",
},
{
name: "PAGERDUTY_MIN_SEVERITY",
firstReference: "src/services/notify-pagerduty.ts",
},
{
name: "PAGERDUTY_REPO_MIN_SEVERITY",
firstReference: "src/services/notify-pagerduty.ts",
},
{
name: "PAGERDUTY_ROUTING_KEY",
firstReference: "src/services/notify-pagerduty.ts",
Expand Down Expand Up @@ -441,10 +449,22 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "POSTHOG_HOST",
firstReference: "src/selfhost/otel.ts",
},
{
name: "POSTHOG_MIN_SEVERITY",
firstReference: "src/selfhost/posthog.ts",
},
{
name: "POSTHOG_RELEASE",
firstReference: "src/selfhost/otel.ts",
},
{
name: "POSTHOG_REPO_MIN_SEVERITY",
firstReference: "src/selfhost/posthog.ts",
},
{
name: "POSTHOG_SERVER_NAME",
firstReference: "src/selfhost/posthog.ts",
},
{
name: "PUBLIC_API_ORIGIN",
firstReference: "src/selfhost/preflight.ts",
Expand Down Expand Up @@ -648,14 +668,19 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| `OTEL_TRACES_SAMPLER` | `src/selfhost/otel.ts` |",
"| `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_MIN_SEVERITY` | `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` |",
"| `PORT` | `src/server.ts` |",
"| `POSTHOG_API_KEY` | `src/selfhost/otel.ts` |",
"| `POSTHOG_ENVIRONMENT` | `src/selfhost/otel.ts` |",
"| `POSTHOG_HOST` | `src/selfhost/otel.ts` |",
"| `POSTHOG_MIN_SEVERITY` | `src/selfhost/posthog.ts` |",
"| `POSTHOG_RELEASE` | `src/selfhost/otel.ts` |",
"| `POSTHOG_REPO_MIN_SEVERITY` | `src/selfhost/posthog.ts` |",
"| `POSTHOG_SERVER_NAME` | `src/selfhost/posthog.ts` |",
"| `PUBLIC_API_ORIGIN` | `src/selfhost/preflight.ts` |",
"| `PUBLIC_ORIGIN_ACKNOWLEDGED` | `src/server.ts` |",
"| `PUBLIC_SITE_ORIGIN` | `src/server.ts` |",
Expand Down
40 changes: 28 additions & 12 deletions scripts/gen-selfhost-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ function collectEnvReads(source: string, fileName: string): EnvRead[] {
} else if (ts.isCallExpression(node) && isProcessEnvNameHelperCall(node)) {
addRead((node.arguments[0] as ts.StringLiteralLike).text);
} else if (ts.isCallExpression(node) && isEnvNameLiteralArgHelperCall(node)) {
const argIndex = ENV_NAME_LITERAL_ARG_HELPERS.get((node.expression as ts.Identifier).text)!;
addRead((node.arguments[argIndex] as ts.StringLiteralLike).text);
for (const argIndex of ENV_NAME_LITERAL_ARG_HELPERS.get((node.expression as ts.Identifier).text)!) {
const arg = node.arguments[argIndex];
if (arg && ts.isStringLiteralLike(arg)) addRead(arg.text);
}
}
ts.forEachChild(node, visit);
};
Expand All @@ -115,12 +117,18 @@ function isStaticEnvHelperCall(node: ts.CallExpression): boolean {
// isStaticEnvHelperCall above (envString) because these take the var NAME as arg[0], not arg[1] after a
// container.
const PROCESS_ENV_NAME_HELPERS = new Set(["parsePositiveIntEnv"]);
const ENV_NAME_LITERAL_ARG_HELPERS = new Map([
["resolveLocalStoreDbPath", 1],
const ENV_NAME_LITERAL_ARG_HELPERS = new Map<string, readonly number[]>([
["resolveLocalStoreDbPath", [1]],
// createCliProvider(command, modelEnvKey, options, env) (packages/loopover-engine/src/miner/driver-factory.ts)
// reads env[modelEnvKey] -- a computed access AST-invisible without this, since modelEnvKey is a parameter,
// not a literal at the read site. The literal var name is only visible at the CALL site (arg index 1). (#6994)
["createCliProvider", 1],
["createCliProvider", [1]],
// resolveSeverityThreshold(env, repoFullName, globalVarName, repoMapVarName, fallback?) (src/services/
// severity-threshold.ts) reads env[globalVarName] and env[repoMapVarName] internally -- computed accesses
// AST-invisible at the read site, same as createCliProvider above. Both literal var names are only visible
// at the CALL site (arg indexes 2 and 3), e.g. POSTHOG_MIN_SEVERITY/POSTHOG_REPO_MIN_SEVERITY in
// src/selfhost/posthog.ts's resolvePostHogMinSeverity. (#8627)
["resolveSeverityThreshold", [2, 3]],
]);

function isProcessEnvNameHelperCall(node: ts.CallExpression): boolean {
Expand All @@ -129,8 +137,8 @@ function isProcessEnvNameHelperCall(node: ts.CallExpression): boolean {

function isEnvNameLiteralArgHelperCall(node: ts.CallExpression): boolean {
if (!ts.isIdentifier(node.expression)) return false;
const argIndex = ENV_NAME_LITERAL_ARG_HELPERS.get(node.expression.text);
return argIndex !== undefined && node.arguments.length > argIndex && ts.isStringLiteralLike(node.arguments[argIndex]!);
const argIndexes = ENV_NAME_LITERAL_ARG_HELPERS.get(node.expression.text);
return argIndexes !== undefined && argIndexes.some((argIndex) => node.arguments.length > argIndex && ts.isStringLiteralLike(node.arguments[argIndex]!));
}

function bindingElementName(element: ts.BindingElement): string | null {
Expand All @@ -150,11 +158,19 @@ function unwrapEnvExpression(node: ts.Expression): ts.Expression {
function isEnvContainer(rawNode: ts.Expression): boolean {
const node = unwrapEnvExpression(rawNode);
if (ts.isIdentifier(node)) return node.text === "env";
return (
ts.isPropertyAccessExpression(node) &&
node.name.text === "env" &&
((ts.isIdentifier(node.expression) && (node.expression.text === "process" || node.expression.text === "c")) || isEnvContainer(node.expression))
);
return ts.isPropertyAccessExpression(node) && node.name.text === "env" && (isProcessExpression(node.expression) || isEnvContainer(node.expression));
}

// The base of a `.env` access: a bare `process`/`c` identifier, or `globalThis.process` reached through a cast --
// src/selfhost/posthog.ts reads `(globalThis as unknown as {process?: {env?: ...}}).process?.env?.POSTHOG_SERVER_NAME`,
// where the base of `.env` is a property access on a cast expression rather than an identifier, so the
// identifier-only check silently dropped POSTHOG_SERVER_NAME from the generated reference (#8627).
function isProcessExpression(rawNode: ts.Expression): boolean {
const node = unwrapEnvExpression(rawNode);
if (ts.isIdentifier(node)) return node.text === "process" || node.text === "c";
if (!ts.isPropertyAccessExpression(node) || node.name.text !== "process") return false;
const base = unwrapEnvExpression(node.expression);
return ts.isIdentifier(base) && base.text === "globalThis";
}

function scriptKindFor(fileName: string): ts.ScriptKind {
Expand Down
49 changes: 49 additions & 0 deletions test/unit/selfhost-env-reference-script.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,55 @@ describe("gen-selfhost-env-reference (#2081)", () => {
});
});

describe("PostHog self-host env reads invisible to the visitor (#8627)", () => {
function rootWith(source: string): string {
const root = mkdtempSync(join(tmpdir(), "gt-env-reference-8627-"));
mkdirSync(join(root, "src", "selfhost"), { recursive: true });
writeFileSync(join(root, "src", "selfhost", "posthog.ts"), source);
return root;
}

function collectedNames(root: string): string[] {
return collectSelfHostEnvVars({ rootDir: root }).map((row) => row.name);
}

it("recognizes a .env property access whose base is globalThis.process behind an `as unknown as` cast", () => {
// The exact shape src/selfhost/posthog.ts uses to read POSTHOG_SERVER_NAME: the base of `.env` is a
// property access on a cast expression, not a bare `process` identifier.
const root = rootWith(
"const serverName = nonBlank((globalThis as unknown as { process?: { env?: Record<string, string | undefined> } }).process?.env?.POSTHOG_SERVER_NAME) ?? hostname();\n",
);
expect(collectSelfHostEnvVars({ rootDir: root })).toEqual([{ name: "POSTHOG_SERVER_NAME", firstReference: "src/selfhost/posthog.ts" }]);
});

it("does NOT match the cast shape when the .process base is not globalThis", () => {
// Same cast-and-property-access silhouette, different base identifier: the new branch must not treat any
// `<something>.process.env` as the ambient process env.
const root = rootWith(
"const serverName = ((someScope as unknown as { process?: { env?: Record<string, string | undefined> } }).process?.env?.NOT_AMBIENT_PROCESS_READ);\n",
);
expect(collectedNames(root)).toEqual([]);
});

it("captures both env var name literals of a resolveSeverityThreshold call", () => {
// The exact call shape of src/selfhost/posthog.ts's resolvePostHogMinSeverity: env var names appear only
// as string-literal arguments 2 and 3 -- the reads inside the helper are computed and AST-invisible.
const root = rootWith(
'const severity = resolveSeverityThreshold(processEnv as unknown as Env, repoFullName, "POSTHOG_MIN_SEVERITY", "POSTHOG_REPO_MIN_SEVERITY");\n',
);
expect(collectedNames(root)).toEqual(["POSTHOG_MIN_SEVERITY", "POSTHOG_REPO_MIN_SEVERITY"]);
});

it("does NOT capture name-shaped literals passed to a call outside the helper allowlist", () => {
// A same-arity call to a different function must stay invisible: recognition is keyed on the helper name,
// not on any string literal that merely looks like an env var name.
const root = rootWith(
'const severity = resolveSeverityCeiling(processEnv as unknown as Env, repoFullName, "NOT_CAPTURED_GLOBAL", "NOT_CAPTURED_REPO_MAP");\n',
);
expect(collectedNames(root)).toEqual([]);
});
});

describe("AI review-pipeline self-host env vars (#6993)", () => {
it("scans the AI review source roots so their self-host AI_* vars are collected", () => {
// Against the REAL repo with the default (now-extended) source roots: the four AI review-pipeline knobs are
Expand Down
Loading