Skip to content
Open
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
21 changes: 21 additions & 0 deletions core/config/yaml/LocalPlatformClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -93,6 +94,7 @@ export class LocalPlatformClient implements PlatformClient {
}

const results: (SecretResult | undefined)[] = [];
let shellEnvVars: Record<string, string> | undefined;

for (let i = 0; i < fqsns.length; i++) {
let secretResult = await this.findSecretInEnvFiles(fqsns[i]);
Expand All @@ -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;
}

Expand Down
95 changes: 93 additions & 2 deletions core/config/yaml/loadYaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>) {
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<T>(
value: T,
envValues: Record<string, string>,
): 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<T>(
value: T,
remoteName?: string,
): Promise<T> {
const envVarNames = new Set<string>();
collectEnvPlaceholderNames(value, envVarNames);

if (envVarNames.size === 0) {
return value;
}

const envValues: Record<string, string> = {};
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;
Expand Down Expand Up @@ -190,10 +276,15 @@ export async function configYamlToContinueConfig(options: {
subagent: null,
},
rules: [],
requestOptions: { ...unrolledAssistant.requestOptions },
requestOptions: {},
};

const config = nonNullifyConfigYaml(unrolledAssistant);
const config = await resolveConfigEnvPlaceholders(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
nonNullifyConfigYaml(unrolledAssistant),
ideInfo.remoteName,
);

continueConfig.requestOptions = { ...config.requestOptions };

for (const rule of config.rules ?? []) {
const convertedRule = convertYamlRuleToContinueRule(rule);
Expand Down
47 changes: 42 additions & 5 deletions core/util/shellPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,31 @@ import { exec } from "child_process";
import { promisify } from "util";

const execAsync = promisify(exec);
export async function getEnvPathFromUserShell(

function parseEnvEntries(entries: string[]): Record<string, string> {
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<string | undefined> {
): Promise<Record<string, string> | undefined> {
const isWindowsHostWithWslRemote =
process.platform === "win32" && remoteName === "wsl";
if (process.platform === "win32" && !isWindowsHostWithWslRemote) {
Expand All @@ -17,14 +39,29 @@ 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; 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 stdout.trim();
return stdout.includes("\0")
? parseEnvEntries(stdout.split("\0"))
: parseEnvEntries(stdout.split(/\r?\n/));
} catch (error) {
return process.env.PATH; // Fallback to current PATH
return undefined;
}
}

export async function getEnvVarsFromUserShell(
remoteName?: string,
): Promise<Record<string, string> | undefined> {
return getUserShellEnvironment(remoteName);
}

export async function getEnvPathFromUserShell(
remoteName?: string,
): Promise<string | undefined> {
const shellEnvironment = await getUserShellEnvironment(remoteName);
return shellEnvironment?.PATH ?? process.env.PATH;
}
Loading