From b9f1c32f6932fb49fd839fc329f54fa796d13d38 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 27 Jul 2026 15:37:13 +0000 Subject: [PATCH] fix(utils): parsed dotenv with bun-compatible syntax Handled export prefixes and quote-aware inline comments when parsing dotenv files, and filtered child shells by launcher provenance so injected values are dropped regardless of value formatting. Covered export and inline-comment forms in unit and shell-filter tests. Fixes #6813 --- .../test/non-interactive-env.test.ts | 15 +++- packages/utils/src/env.ts | 68 +++++++++++-------- packages/utils/test/env.test.ts | 18 +++++ 3 files changed, 72 insertions(+), 29 deletions(-) diff --git a/packages/coding-agent/test/non-interactive-env.test.ts b/packages/coding-agent/test/non-interactive-env.test.ts index 45970da09..20a5a0501 100644 --- a/packages/coding-agent/test/non-interactive-env.test.ts +++ b/packages/coding-agent/test/non-interactive-env.test.ts @@ -66,7 +66,14 @@ it("filters expanded dotenv values while preserving matching launcher values", a try { await Bun.write( path.join(tmp, ".env"), - "BASE=loaded-by-omp\nTEST_ENV_FROM_DOTENV=$BASE-suffix\nNODE_ENV=development\n", + [ + "BASE=loaded-by-omp", + "TEST_ENV_FROM_DOTENV=$BASE-suffix", + "NODE_ENV=development", + "export EXPORTED_SECRET=exported", + "COMMENTED_SECRET=secret # trailing comment", + "", + ].join("\n"), ); await Bun.write( path.join(tmp, ".env.local"), @@ -82,6 +89,8 @@ it("filters expanded dotenv values while preserving matching launcher values", a " url: env.CONVEX_URL ?? null,", " inherited: env.OMP_TEST_INHERITED_MARKER ?? null,", " matching: env.NODE_ENV ?? null,", + " exported: env.EXPORTED_SECRET ?? null,", + " commented: env.COMMENTED_SECRET ?? null,", "}));", ].join("\n"); const bunArgSets = process.platform === "linux" ? [[], ["--no-env-file"]] : [["--no-env-file"]]; @@ -112,6 +121,8 @@ it("filters expanded dotenv values while preserving matching launcher values", a url: string | null; inherited: string | null; matching: string | null; + exported: string | null; + commented: string | null; } = JSON.parse(stdout); expect(payload).toEqual({ project: null, @@ -119,6 +130,8 @@ it("filters expanded dotenv values while preserving matching launcher values", a url: null, inherited: "keep-me", matching: "development", + exported: null, + commented: null, }); } } finally { diff --git a/packages/utils/src/env.ts b/packages/utils/src/env.ts index 1ee4150ea..b6986c515 100644 --- a/packages/utils/src/env.ts +++ b/packages/utils/src/env.ts @@ -105,12 +105,16 @@ export function filterChildShellEnv( ...expandDotenvValues(localEnv, result), }; for (const key in launchEnv) { - if (projectEnvNamesLoadedByOmp.has(key)) { + // Launcher-owned names always survive with the launcher's own value. + if (launchEnvNames?.has(key)) continue; + if (launchEnvNames || projectEnvNamesLoadedByOmp.has(key)) { + // Strong provenance: the launch environment is known and this name is + // absent from it, or OMP itself injected the value — either way it came + // from a project dotenv file, not the parent shell. delete result[key]; - } else if ( - !launchEnvNames?.has(key) && - (result[key] === launchEnv[key] || result[key] === expandedLaunchEnv[key]) - ) { + } else if (result[key] === launchEnv[key] || result[key] === expandedLaunchEnv[key]) { + // No launch-env snapshot (non-Linux source install without + // `--no-env-file`): best-effort value match against the Bun-parsed dotenv. delete result[key]; } } @@ -118,35 +122,43 @@ export function filterChildShellEnv( } /** - * Parses a .env file synchronously and extracts key-value string pairs. - * Ignores lines that are empty or start with '#'. Trims whitespace. - * Allows values to be quoted with single or double quotes. - * Returns an object of key-value pairs. + * Parse one dotenv line with Bun-compatible semantics: an optional `export` + * prefix, full-line `#` comments, inline `#` comments after whitespace on + * unquoted values, and single/double/backtick quoting (a `#` inside quotes + * stays literal). Returns undefined for blank lines, comments, and malformed + * names. + */ +function parseEnvLine(line: string): { key: string; value: string } | undefined { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) return undefined; + const eqIndex = trimmed.indexOf("="); + if (eqIndex === -1) return undefined; + let key = trimmed.slice(0, eqIndex).trim(); + const exported = key.match(/^export[ \t]+(.*)$/); + if (exported) key = exported[1].trim(); + if (!isValidEnvName(key)) return undefined; + const raw = trimmed.slice(eqIndex + 1).replace(/^[ \t]+/, ""); + const quote = raw[0]; + if (quote === '"' || quote === "'" || quote === "`") { + const close = raw.indexOf(quote, 1); + return { key, value: close === -1 ? raw.slice(1) : raw.slice(1, close) }; + } + const commentIndex = raw.search(/[ \t]#/); + return { key, value: (commentIndex === -1 ? raw : raw.slice(0, commentIndex)).trimEnd() }; +} + +/** + * Parses a .env file synchronously into key-value string pairs using + * {@link parseEnvLine} for Bun-compatible line semantics, then mirrors valid + * `OMP_` variables to their `PI_` aliases. */ export function parseEnvFile(filePath: string): Record { const result: Record = {}; try { const content = fs.readFileSync(filePath, "utf-8"); for (const line of content.split("\n")) { - const trimmed = line.trim(); - // Skip comments and blank lines - if (!trimmed || trimmed.startsWith("#")) continue; - - const eqIndex = trimmed.indexOf("="); - if (eqIndex === -1) continue; - - const key = trimmed.slice(0, eqIndex).trim(); - if (!isValidEnvName(key)) continue; - - let value = trimmed.slice(eqIndex + 1).trim(); - - // Remove surrounding quotes (" or ') - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - if (!isSafeEnvValue(value)) continue; - - result[key] = value; + const parsed = parseEnvLine(line); + if (parsed && isSafeEnvValue(parsed.value)) result[parsed.key] = parsed.value; } } catch { // File doesn't exist or can't be read - return empty result diff --git a/packages/utils/test/env.test.ts b/packages/utils/test/env.test.ts index e43e8bc08..ae399f389 100644 --- a/packages/utils/test/env.test.ts +++ b/packages/utils/test/env.test.ts @@ -49,6 +49,24 @@ describe("parseEnvFile", () => { PI_FEATURE: "enabled", }); }); + + it("matches Bun dotenv syntax for export prefixes and inline comments", () => { + const filePath = writeTempEnv( + [ + "export EXPORTED=value", + "COMMENTED=secret # trailing comment", + 'QUOTED_HASH="keep # this"', + "NO_SPACE=http://host/path#frag", + ].join("\n"), + ); + + expect(parseEnvFile(filePath)).toEqual({ + EXPORTED: "value", + COMMENTED: "secret", + QUOTED_HASH: "keep # this", + NO_SPACE: "http://host/path#frag", + }); + }); }); describe("filterProcessEnv", () => {