diff --git a/packages/cli/src/utils/dotEnv.test.ts b/packages/cli/src/utils/dotEnv.test.ts index 10fcb48270d..c49b19bc3bf 100644 --- a/packages/cli/src/utils/dotEnv.test.ts +++ b/packages/cli/src/utils/dotEnv.test.ts @@ -45,3 +45,42 @@ describe("applyDotEnv", () => { 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( + "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"); + }); + + 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 4c3c4e204ba..336063f7f56 100644 --- a/packages/cli/src/utils/dotEnv.ts +++ b/packages/cli/src/utils/dotEnv.ts @@ -33,6 +33,42 @@ function entryOf(rawLine: string): [string, string] | null { return [line.slice(0, eq).trim(), valueOf(line.slice(eq + 1).trim())]; } +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; +} + +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 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[] = []; + for (let i = 0; i < rawLines.length; i++) { + const line = rawLines[i] ?? ""; + 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; + } + lines.push(rawLines.slice(i, end + 1).join("\n")); + i = end; + } + 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 +78,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 (