From 256675e9cf291502261f7bc8b0d461a1694f4a1e Mon Sep 17 00:00:00 2001 From: Manpreet Kaur Date: Tue, 23 Jun 2026 17:33:44 +0530 Subject: [PATCH 1/2] fix: resolve YAML env placeholders for API keys and remote shell env --- core/config/yaml/LocalPlatformClient.ts | 21 ++++++ core/config/yaml/loadYaml.ts | 91 ++++++++++++++++++++++++- core/util/shellPath.ts | 42 ++++++++++-- 3 files changed, 148 insertions(+), 6 deletions(-) diff --git a/core/config/yaml/LocalPlatformClient.ts b/core/config/yaml/LocalPlatformClient.ts index c03ce387bb1..b96c7530bc2 100644 --- a/core/config/yaml/LocalPlatformClient.ts +++ b/core/config/yaml/LocalPlatformClient.ts @@ -7,6 +7,7 @@ import { import * as dotenv from "dotenv"; import { IDE } from "../.."; import { getContinueDotEnv } from "../../util/paths"; +import { getEnvVarsFromUserShell } from "../../util/shellPath"; import { joinPathsToUri } from "../../util/uri"; export class LocalPlatformClient implements PlatformClient { @@ -93,6 +94,7 @@ export class LocalPlatformClient implements PlatformClient { } const results: (SecretResult | undefined)[] = []; + let shellEnvVars: Record | undefined; for (let i = 0; i < fqsns.length; i++) { let secretResult = await this.findSecretInEnvFiles(fqsns[i]); @@ -113,6 +115,25 @@ export class LocalPlatformClient implements PlatformClient { } } + if (!secretResult?.found) { + shellEnvVars ??= await getEnvVarsFromUserShell( + (await this.ide.getIdeInfo()).remoteName, + ); + + const secretValueFromShellEnv = shellEnvVars?.[fqsns[i].secretName]; + if (secretValueFromShellEnv !== undefined) { + secretResult = { + found: true, + fqsn: fqsns[i], + value: secretValueFromShellEnv, + secretLocation: { + secretName: fqsns[i].secretName, + secretType: SecretType.ProcessEnv as SecretType.ProcessEnv, + }, + }; + } + } + results[i] = secretResult; } diff --git a/core/config/yaml/loadYaml.ts b/core/config/yaml/loadYaml.ts index b60a7290603..ca34cfd5845 100644 --- a/core/config/yaml/loadYaml.ts +++ b/core/config/yaml/loadYaml.ts @@ -32,6 +32,7 @@ import { convertPromptBlockToSlashCommand } from "../../commands/slash/promptBlo import { slashCommandFromPromptFile } from "../../commands/slash/promptFileSlashCommand"; import { loadJsonMcpConfigs } from "../../context/mcp/json/loadJsonMcpConfigs"; import { getBaseToolDefinitions } from "../../tools"; +import { getEnvVarsFromUserShell } from "../../util/shellPath"; import { getCleanUriPath } from "../../util/uri"; import { loadConfigContextProviders } from "../loadContextProviders"; import { getAllDotContinueDefinitionFiles } from "../loadLocalAssistants"; @@ -153,6 +154,91 @@ function nonNullifyConfigYaml( }; } +const ENV_PLACEHOLDER_REGEX = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g; + +function collectEnvPlaceholderNames(value: unknown, envVarNames: Set) { + if (typeof value === "string") { + for (const match of value.matchAll(ENV_PLACEHOLDER_REGEX)) { + envVarNames.add(match[1]); + } + return; + } + + if (Array.isArray(value)) { + value.forEach((item) => collectEnvPlaceholderNames(item, envVarNames)); + return; + } + + if (value && typeof value === "object") { + Object.values(value).forEach((item) => + collectEnvPlaceholderNames(item, envVarNames), + ); + } +} + +function replaceEnvPlaceholders( + value: T, + envValues: Record, +): T { + if (typeof value === "string") { + return value.replace( + ENV_PLACEHOLDER_REGEX, + (fullMatch, envVarName) => envValues[envVarName] ?? fullMatch, + ) as T; + } + + if (Array.isArray(value)) { + return value.map((item) => replaceEnvPlaceholders(item, envValues)) as T; + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + replaceEnvPlaceholders(item, envValues), + ]), + ) as T; + } + + return value; +} + +async function resolveConfigEnvPlaceholders( + value: T, + remoteName?: string, +): Promise { + const envVarNames = new Set(); + collectEnvPlaceholderNames(value, envVarNames); + + if (envVarNames.size === 0) { + return value; + } + + const envValues: Record = {}; + for (const envVarName of envVarNames) { + const envValue = process.env[envVarName]; + if (envValue !== undefined) { + envValues[envVarName] = envValue; + } + } + + const missingEnvVarNames = [...envVarNames].filter( + (envVarName) => envValues[envVarName] === undefined, + ); + + if (missingEnvVarNames.length > 0) { + const shellEnvVars = await getEnvVarsFromUserShell(remoteName); + for (const envVarName of missingEnvVarNames) { + const envValue = shellEnvVars?.[envVarName]; + if (envValue !== undefined) { + envValues[envVarName] = envValue; + } + } + } + + return replaceEnvPlaceholders(value, envValues); +} + export async function configYamlToContinueConfig(options: { unrolledAssistant: AssistantUnrolled; ide: IDE; @@ -193,7 +279,10 @@ export async function configYamlToContinueConfig(options: { requestOptions: { ...unrolledAssistant.requestOptions }, }; - const config = nonNullifyConfigYaml(unrolledAssistant); + const config = await resolveConfigEnvPlaceholders( + nonNullifyConfigYaml(unrolledAssistant), + ideInfo.remoteName, + ); for (const rule of config.rules ?? []) { const convertedRule = convertYamlRuleToContinueRule(rule); diff --git a/core/util/shellPath.ts b/core/util/shellPath.ts index 961307c28bb..8895d34c181 100644 --- a/core/util/shellPath.ts +++ b/core/util/shellPath.ts @@ -2,9 +2,10 @@ import { exec } from "child_process"; import { promisify } from "util"; const execAsync = promisify(exec); -export async function getEnvPathFromUserShell( + +async function getUserShellEnvironment( remoteName?: string, -): Promise { +): Promise | undefined> { const isWindowsHostWithWslRemote = process.platform === "win32" && remoteName === "wsl"; if (process.platform === "win32" && !isWindowsHostWithWslRemote) { @@ -17,14 +18,45 @@ export async function getEnvPathFromUserShell( try { // Source common profile files - const command = `${process.env.SHELL} -l -c 'for f in ~/.zprofile ~/.zshrc ~/.bash_profile ~/.bashrc; do [ -f "$f" ] && source "$f" 2>/dev/null; done; echo $PATH'`; + const command = `${process.env.SHELL} -l -c 'for f in ~/.zprofile ~/.zshrc ~/.bash_profile ~/.bashrc; do [ -f "$f" ] && . "$f" 2>/dev/null; done; env'`; const { stdout } = await execAsync(command, { encoding: "utf8", }); - return stdout.trim(); + return Object.fromEntries( + stdout + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + const separatorIndex = line.indexOf("="); + if (separatorIndex === -1) { + return undefined; + } + + return [ + line.slice(0, separatorIndex), + line.slice(separatorIndex + 1), + ] as const; + }) + .filter( + (entry): entry is readonly [string, string] => entry !== undefined, + ), + ); } catch (error) { - return process.env.PATH; // Fallback to current PATH + return undefined; } } + +export async function getEnvVarsFromUserShell( + remoteName?: string, +): Promise | undefined> { + return getUserShellEnvironment(remoteName); +} + +export async function getEnvPathFromUserShell( + remoteName?: string, +): Promise { + const shellEnvironment = await getUserShellEnvironment(remoteName); + return shellEnvironment?.PATH ?? process.env.PATH; +} From 1bcea8676f07cfbdc5f46111571fe4a541a64fff Mon Sep 17 00:00:00 2001 From: Manpreet Kaur Date: Tue, 23 Jun 2026 18:31:46 +0530 Subject: [PATCH 2/2] fix: resolve YAML env request options and preserve multiline shell env values --- core/config/yaml/loadYaml.ts | 4 +++- core/util/shellPath.ts | 45 ++++++++++++++++++++---------------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/core/config/yaml/loadYaml.ts b/core/config/yaml/loadYaml.ts index ca34cfd5845..99556777fd4 100644 --- a/core/config/yaml/loadYaml.ts +++ b/core/config/yaml/loadYaml.ts @@ -276,7 +276,7 @@ export async function configYamlToContinueConfig(options: { subagent: null, }, rules: [], - requestOptions: { ...unrolledAssistant.requestOptions }, + requestOptions: {}, }; const config = await resolveConfigEnvPlaceholders( @@ -284,6 +284,8 @@ export async function configYamlToContinueConfig(options: { ideInfo.remoteName, ); + continueConfig.requestOptions = { ...config.requestOptions }; + for (const rule of config.rules ?? []) { const convertedRule = convertYamlRuleToContinueRule(rule); continueConfig.rules.push(convertedRule); diff --git a/core/util/shellPath.ts b/core/util/shellPath.ts index 8895d34c181..8fa1e8d1c4c 100644 --- a/core/util/shellPath.ts +++ b/core/util/shellPath.ts @@ -3,6 +3,27 @@ import { promisify } from "util"; const execAsync = promisify(exec); +function parseEnvEntries(entries: string[]): Record { + return Object.fromEntries( + entries + .filter(Boolean) + .map((entry) => { + const separatorIndex = entry.indexOf("="); + if (separatorIndex === -1) { + return undefined; + } + + return [ + entry.slice(0, separatorIndex), + entry.slice(separatorIndex + 1), + ] as const; + }) + .filter( + (entry): entry is readonly [string, string] => entry !== undefined, + ), + ); +} + async function getUserShellEnvironment( remoteName?: string, ): Promise | undefined> { @@ -18,31 +39,15 @@ async function getUserShellEnvironment( try { // Source common profile files - const command = `${process.env.SHELL} -l -c 'for f in ~/.zprofile ~/.zshrc ~/.bash_profile ~/.bashrc; do [ -f "$f" ] && . "$f" 2>/dev/null; done; env'`; + const command = `${process.env.SHELL} -l -c 'for f in ~/.zprofile ~/.zshrc ~/.bash_profile ~/.bashrc; do [ -f "$f" ] && . "$f" 2>/dev/null; done; if env -0 >/dev/null 2>&1; then env -0; elif printenv -0 >/dev/null 2>&1; then printenv -0; else env; fi'`; const { stdout } = await execAsync(command, { encoding: "utf8", }); - return Object.fromEntries( - stdout - .split(/\r?\n/) - .filter(Boolean) - .map((line) => { - const separatorIndex = line.indexOf("="); - if (separatorIndex === -1) { - return undefined; - } - - return [ - line.slice(0, separatorIndex), - line.slice(separatorIndex + 1), - ] as const; - }) - .filter( - (entry): entry is readonly [string, string] => entry !== undefined, - ), - ); + return stdout.includes("\0") + ? parseEnvEntries(stdout.split("\0")) + : parseEnvEntries(stdout.split(/\r?\n/)); } catch (error) { return undefined; }