From 59cad9c1d1e86292f69ad431bbbe6887092b0036 Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Fri, 23 Oct 2026 15:38:52 +0530 Subject: [PATCH 1/3] fix(cli): preserve quoted multiline .env values applyDotEnv parsed .env content line by line: a quoted value's closing quote was only ever looked for on the same physical line, so a value like HYPERFRAMES_VERTEX_SERVICE_ACCOUNT='{...}' spread across several lines got truncated to the leftover text on its opening line, and the continuation lines were silently dropped (no "=" to parse as entries). Join physical lines into logical ones before parsing: when a line opens a quote that isn't closed on that same line, keep appending following lines until one contains the closing quote character. This matches how Node's util.parseEnv treats quoted multiline values. Fixes #6075 --- packages/cli/src/utils/dotEnv.test.ts | 18 ++++++++++++++ packages/cli/src/utils/dotEnv.ts | 36 ++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/dotEnv.test.ts b/packages/cli/src/utils/dotEnv.test.ts index 10fcb48270d..965f76d42dc 100644 --- a/packages/cli/src/utils/dotEnv.test.ts +++ b/packages/cli/src/utils/dotEnv.test.ts @@ -44,4 +44,22 @@ describe("applyDotEnv", () => { applyDotEnv("GEMINI_API_KEY=project", env, []); expect(env).toEqual({}); }); + + it("keeps a quoted multiline value whole instead of truncating it to its first line", () => { + const env: NodeJS.ProcessEnv = {}; + applyDotEnv( + "HYPERFRAMES_VERTEX_PROJECT_ID=synthetic-project\n" + + "HYPERFRAMES_VERTEX_SERVICE_ACCOUNT='{\n" + + ' "type": "service_account",\n' + + ' "project_id": "synthetic-project"\n' + + "}'\n" + + "AFTER=still-parsed", + env, + ); + expect(env.HYPERFRAMES_VERTEX_SERVICE_ACCOUNT).toBe( + '{\n "type": "service_account",\n "project_id": "synthetic-project"\n}', + ); + expect(() => JSON.parse(env.HYPERFRAMES_VERTEX_SERVICE_ACCOUNT!)).not.toThrow(); + expect(env.AFTER).toBe("still-parsed"); + }); }); diff --git a/packages/cli/src/utils/dotEnv.ts b/packages/cli/src/utils/dotEnv.ts index 4c3c4e204ba..f6e18cfe23b 100644 --- a/packages/cli/src/utils/dotEnv.ts +++ b/packages/cli/src/utils/dotEnv.ts @@ -33,6 +33,40 @@ function entryOf(rawLine: string): [string, string] | null { return [line.slice(0, eq).trim(), valueOf(line.slice(eq + 1).trim())]; } +// The quote a line's value opens without closing, if any; `logicalLines` keeps +// reading further lines for it until one closes the quote. +function openQuoteOf(trimmedLine: string): string | null { + const eq = trimmedLine.indexOf("="); + if (eq < 1) return null; + const valueStart = trimmedLine.slice(eq + 1).trim(); + const quote = valueStart.charAt(0); + if (quote !== '"' && quote !== "'") return null; + return valueStart.indexOf(quote, 1) < 0 ? quote : null; +} + +// A quoted value left open at end of line continues on the lines that follow it; +// join them into one logical line so `entryOf`/`valueOf` see the whole value. +function logicalLines(content: string): string[] { + const rawLines = content.split("\n"); + const lines: string[] = []; + for (let i = 0; i < rawLines.length; i++) { + const line = rawLines[i] ?? ""; + const quote = openQuoteOf(line.trim()); + if (quote === null) { + lines.push(line); + continue; + } + const continuation = [line]; + while (++i < rawLines.length) { + const next = rawLines[i] ?? ""; + continuation.push(next); + if (next.includes(quote)) break; + } + lines.push(continuation.join("\n")); + } + return lines; +} + // Never over a key already set, and never the feedback email in any letter case (Windows env names are case-blind): // only the launching app may attach it, and an agent can write a project `.env`. // An optional exact-name allowlist narrows callers that load less-trusted project settings. @@ -42,7 +76,7 @@ export function applyDotEnv( allowedKeys?: readonly string[], ): void { const allowed = allowedKeys === undefined ? undefined : new Set(allowedKeys); - for (const entry of content.split("\n").map(entryOf)) { + for (const entry of logicalLines(content).map(entryOf)) { if (!entry) continue; const [key, value] = entry; if ( From 666bf1575582d1ca1c3455966cdf7c3b8b63053c Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Sat, 24 Oct 2026 21:59:40 +0530 Subject: [PATCH 2/3] fix(cli): do not let a commented or unclosed quote swallow later .env lines logicalLines looked for an open quote on every physical line before entryOf had a chance to classify it as blank or a comment, so a commented-out example like `# KEY="sk-old` (an unclosed quote inside a comment) opened a quote that never closed, joining every line below it into one value and dropping the real keys that followed. A genuinely stray unclosed quote with no later closing line had the same failure mode: the join ran to EOF and swallowed the rest of the file instead of leaving the malformed line on its own, so a later file like A='x / B=2 lost B entirely. Skip blank/comment lines before checking for an open quote (matching entryOf's own skip), and if the closing-quote search reaches EOF without finding one, fall back to the opening line on its own and resume parsing from the next line. Also strip a trailing \r per line so a CRLF multiline value doesn't carry it into the joined string. --- packages/cli/src/utils/dotEnv.test.ts | 21 +++++++++++++++++++ packages/cli/src/utils/dotEnv.ts | 29 ++++++++++++++++++--------- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/utils/dotEnv.test.ts b/packages/cli/src/utils/dotEnv.test.ts index 965f76d42dc..c49b19bc3bf 100644 --- a/packages/cli/src/utils/dotEnv.test.ts +++ b/packages/cli/src/utils/dotEnv.test.ts @@ -44,7 +44,9 @@ describe("applyDotEnv", () => { applyDotEnv("GEMINI_API_KEY=project", env, []); expect(env).toEqual({}); }); +}); +describe("applyDotEnv quoted multiline values", () => { it("keeps a quoted multiline value whole instead of truncating it to its first line", () => { const env: NodeJS.ProcessEnv = {}; applyDotEnv( @@ -62,4 +64,23 @@ describe("applyDotEnv", () => { expect(() => JSON.parse(env.HYPERFRAMES_VERTEX_SERVICE_ACCOUNT!)).not.toThrow(); expect(env.AFTER).toBe("still-parsed"); }); + + it("a commented-out example with an unclosed quote does not swallow the real keys below it", () => { + const env: NodeJS.ProcessEnv = {}; + applyDotEnv('# OPENROUTER_API_KEY="sk-old\nGEMINI_API_KEY=real1\nGOOGLE_API_KEY=real2', env); + expect(env).toEqual({ GEMINI_API_KEY: "real1", GOOGLE_API_KEY: "real2" }); + }); + + it("a stray unclosed quote with no later closing line does not swallow the rest of the file", () => { + const env: NodeJS.ProcessEnv = {}; + applyDotEnv("A='x\nB=2", env); + expect(env).toEqual({ A: "x", B: "2" }); + }); + + it("strips the trailing \\r from a CRLF multiline value", () => { + const env: NodeJS.ProcessEnv = {}; + applyDotEnv("KEY='line1\r\nline2'\r\nAFTER=2", env); + expect(env.KEY).toBe("line1\nline2"); + expect(env.AFTER).toBe("2"); + }); }); diff --git a/packages/cli/src/utils/dotEnv.ts b/packages/cli/src/utils/dotEnv.ts index f6e18cfe23b..162af7e088e 100644 --- a/packages/cli/src/utils/dotEnv.ts +++ b/packages/cli/src/utils/dotEnv.ts @@ -44,25 +44,34 @@ function openQuoteOf(trimmedLine: string): string | null { return valueStart.indexOf(quote, 1) < 0 ? quote : null; } +// The index of the next line at or after `from` that contains `quote`, or -1 if +// none closes it before EOF. +function closingLineIndex(rawLines: string[], from: number, quote: string): number { + for (let j = from; j < rawLines.length; j++) { + if ((rawLines[j] ?? "").includes(quote)) return j; + } + return -1; +} + // A quoted value left open at end of line continues on the lines that follow it; // join them into one logical line so `entryOf`/`valueOf` see the whole value. +// Blank/comment lines never open a continuation (matches `entryOf`'s own skip), +// and a quote that never closes before EOF falls back to its own line rather +// than swallowing the rest of the file as one value. function logicalLines(content: string): string[] { - const rawLines = content.split("\n"); + const rawLines = content.split("\n").map((line) => line.replace(/\r$/, "")); const lines: string[] = []; for (let i = 0; i < rawLines.length; i++) { const line = rawLines[i] ?? ""; - const quote = openQuoteOf(line.trim()); - if (quote === null) { + const trimmed = line.trim(); + const quote = trimmed && !trimmed.startsWith("#") ? openQuoteOf(trimmed) : null; + const end = quote === null ? -1 : closingLineIndex(rawLines, i + 1, quote); + if (end === -1) { lines.push(line); continue; } - const continuation = [line]; - while (++i < rawLines.length) { - const next = rawLines[i] ?? ""; - continuation.push(next); - if (next.includes(quote)) break; - } - lines.push(continuation.join("\n")); + lines.push(rawLines.slice(i, end + 1).join("\n")); + i = end; } return lines; } From a5582a19cdfc8bd13341067fa474b5c911d56d11 Mon Sep 17 00:00:00 2001 From: STiFLeR7 Date: Sat, 24 Oct 2026 23:49:19 +0530 Subject: [PATCH 3/3] fix(cli): trim comments on dotEnv.ts to stay within the comment-ratchet budget Dropped the comments on openQuoteOf and closingLineIndex (their names already say what they do) and cut the logicalLines comment down to two lines, bringing the file back to 6/93 comment lines against the base's 4/57. --- packages/cli/src/utils/dotEnv.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/utils/dotEnv.ts b/packages/cli/src/utils/dotEnv.ts index 162af7e088e..336063f7f56 100644 --- a/packages/cli/src/utils/dotEnv.ts +++ b/packages/cli/src/utils/dotEnv.ts @@ -33,8 +33,6 @@ function entryOf(rawLine: string): [string, string] | null { return [line.slice(0, eq).trim(), valueOf(line.slice(eq + 1).trim())]; } -// The quote a line's value opens without closing, if any; `logicalLines` keeps -// reading further lines for it until one closes the quote. function openQuoteOf(trimmedLine: string): string | null { const eq = trimmedLine.indexOf("="); if (eq < 1) return null; @@ -44,8 +42,6 @@ function openQuoteOf(trimmedLine: string): string | null { return valueStart.indexOf(quote, 1) < 0 ? quote : null; } -// The index of the next line at or after `from` that contains `quote`, or -1 if -// none closes it before EOF. function closingLineIndex(rawLines: string[], from: number, quote: string): number { for (let j = from; j < rawLines.length; j++) { if ((rawLines[j] ?? "").includes(quote)) return j; @@ -53,11 +49,8 @@ function closingLineIndex(rawLines: string[], from: number, quote: string): numb return -1; } -// A quoted value left open at end of line continues on the lines that follow it; -// join them into one logical line so `entryOf`/`valueOf` see the whole value. -// Blank/comment lines never open a continuation (matches `entryOf`'s own skip), -// and a quote that never closes before EOF falls back to its own line rather -// than swallowing the rest of the file as one value. +// A quoted value left open at end of line continues on later lines until one closes it; +// blank/comment lines and an EOF with no closing quote never open or complete a continuation. function logicalLines(content: string): string[] { const rawLines = content.split("\n").map((line) => line.replace(/\r$/, "")); const lines: string[] = [];