feat(agent-mode): per-backend env variable overrides (#2499)

Adds an "Environment variables" editor to each Agent Mode backend
(Claude, Codex, opencode) so users can inject vars like
CLAUDE_CONFIG_DIR, CODEX_HOME, XDG_CONFIG_HOME, or HTTPS_PROXY without
polluting the parent shell. Overrides are merged onto process.env at
spawn (Codex, opencode) or per prompt() (Claude SDK), and persisted
through a shared sanitizer that enforces POSIX-style names, drops
control chars, and caps the record at 64 entries. The editor debounces
commits to avoid rewriting settings on every keystroke.

Also relocates BinaryPathSetting into src/agentMode/backends/shared/
to honor the agent-mode layer boundaries.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-05-20 22:58:57 -07:00 committed by GitHub
parent 1c0a750b31
commit c39b9bf44c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 375 additions and 23 deletions

View file

@ -1,4 +1,5 @@
import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting";
import { BinaryPathSetting } from "@/agentMode/backends/shared/BinaryPathSetting";
import { EnvOverridesSetting } from "@/agentMode/backends/shared/EnvOverridesSetting";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import type CopilotPlugin from "@/main";
@ -149,6 +150,13 @@ export const ClaudeSettingsPanel: React.FC<Props> = () => {
checked={Boolean(settings.agentMode?.backends?.claude?.enableThinking)}
onCheckedChange={(checked) => updateClaudeFields({ enableThinking: checked })}
/>
<EnvOverridesSetting
backendDisplayName="Claude"
value={settings.agentMode?.backends?.claude?.envOverrides}
onChange={(next) => updateClaudeFields({ envOverrides: next })}
hintExamples={["CLAUDE_CONFIG_DIR", "HTTPS_PROXY"]}
/>
</>
);
};

View file

@ -191,6 +191,7 @@ export const ClaudeBackendDescriptor: BackendDescriptor = {
descriptor: args.descriptor,
askUserQuestion: (questions) => openAskUserQuestionModal(args.app, questions),
getEnableThinking: () => Boolean(getSettings().agentMode?.backends?.claude?.enableThinking),
getEnvOverrides: () => getSettings().agentMode?.backends?.claude?.envOverrides,
isPlanModePlanFilePath: isClaudePlanModePlanFilePath,
getDefaultModelId: () => getSettings().agentMode?.backends?.claude?.defaultModel?.baseModelId,
// Spawn-time skill-creation directive: read the current

View file

@ -23,7 +23,8 @@ export class CodexBackend implements AcpBackend {
async buildSpawnDescriptor(_ctx: { vaultBasePath: string }): Promise<AcpSpawnDescriptor> {
const descriptor = buildSimpleSpawnDescriptor(
getSettings().agentMode?.backends?.codex?.binaryPath,
"Codex binary path not configured. Open Agent Mode settings and set the path to codex-acp."
"Codex binary path not configured. Open Agent Mode settings and set the path to codex-acp.",
getSettings().agentMode?.backends?.codex?.envOverrides
);
// Spawn-time skill-creation directive: forwarded into codex's
// `developer_instructions` config field via codex-acp's `-c key=value`

View file

@ -1,5 +1,6 @@
import { EnvOverridesSetting } from "@/agentMode/backends/shared/EnvOverridesSetting";
import type CopilotPlugin from "@/main";
import { getSettings } from "@/settings/model";
import { getSettings, useSettingsValue } from "@/settings/model";
import type { App } from "obsidian";
import React from "react";
import { SimpleBackendSettingsPanel } from "@/agentMode/backends/shared/SimpleBackendSettingsPanel";
@ -11,16 +12,28 @@ interface Props {
app: App;
}
export const CodexSettingsPanel: React.FC<Props> = ({ app }) => (
<SimpleBackendSettingsPanel
displayName="Codex"
binaryName={CODEX_BINARY_NAME}
installCommand={CODEX_INSTALL_COMMAND}
pathPlaceholder="/absolute/path/to/codex-acp"
customPathTitle="Custom codex-acp path"
customPathDescription="Point Agent Mode at the codex-acp binary. Codex inherits auth from your local `codex login` credentials, or from `OPENAI_API_KEY` / `CODEX_API_KEY` exported in your shell."
readStoredPath={() => getSettings().agentMode?.backends?.codex?.binaryPath ?? ""}
persistPath={(path) => updateCodexFields({ binaryPath: path })}
openInstallModal={() => new CodexInstallModal(app).open()}
/>
);
export const CodexSettingsPanel: React.FC<Props> = ({ app }) => {
const settings = useSettingsValue();
return (
<>
<SimpleBackendSettingsPanel
displayName="Codex"
binaryName={CODEX_BINARY_NAME}
installCommand={CODEX_INSTALL_COMMAND}
pathPlaceholder="/absolute/path/to/codex-acp"
customPathTitle="Custom codex-acp path"
customPathDescription="Point Agent Mode at the codex-acp binary. Codex inherits auth from your local `codex login` credentials, or from `OPENAI_API_KEY` / `CODEX_API_KEY` exported in your shell."
readStoredPath={() => getSettings().agentMode?.backends?.codex?.binaryPath ?? ""}
persistPath={(path) => updateCodexFields({ binaryPath: path })}
openInstallModal={() => new CodexInstallModal(app).open()}
/>
<EnvOverridesSetting
backendDisplayName="Codex"
value={settings.agentMode?.backends?.codex?.envOverrides}
onChange={(next) => updateCodexFields({ envOverrides: next })}
hintExamples={["CODEX_HOME", "OPENAI_BASE_URL"]}
/>
</>
);
};

View file

@ -83,6 +83,7 @@ export class OpencodeBackend implements AcpBackend {
}
const config = await buildOpencodeConfig();
const envOverrides = getSettings().agentMode?.backends?.opencode?.envOverrides ?? {};
return {
command: binaryPath,
@ -90,6 +91,9 @@ export class OpencodeBackend implements AcpBackend {
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
// User overrides last — they can replace OPENCODE_CONFIG_CONTENT
// intentionally if they need to point opencode at a different config.
...envOverrides,
},
};
}

View file

@ -1,11 +1,12 @@
import { OpencodeInstallModal } from "@/agentMode/backends/opencode/OpencodeInstallModal";
import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting";
import { BinaryPathSetting } from "@/agentMode/backends/shared/BinaryPathSetting";
import { EnvOverridesSetting } from "@/agentMode/backends/shared/EnvOverridesSetting";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import { logError } from "@/logger";
import type CopilotPlugin from "@/main";
import { useSettingsValue } from "@/settings/model";
import { updateAgentModeBackendFields, useSettingsValue } from "@/settings/model";
import type { App } from "obsidian";
import { Notice } from "obsidian";
import React from "react";
@ -146,6 +147,13 @@ export const OpencodeSettingsPanel: React.FC<Props> = ({ plugin, app }) => {
)}
</div>
)}
<EnvOverridesSetting
backendDisplayName="opencode"
value={settings.agentMode?.backends?.opencode?.envOverrides}
onChange={(next) => updateAgentModeBackendFields("opencode", { envOverrides: next })}
hintExamples={["XDG_CONFIG_HOME", "HTTPS_PROXY"]}
/>
</>
);
};

View file

@ -1,5 +1,5 @@
import { ReactModal } from "@/components/modals/ReactModal";
import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting";
import { BinaryPathSetting } from "./BinaryPathSetting";
import { Button } from "@/components/ui/button";
import { logError } from "@/logger";
import { validateExecutableFile } from "@/utils/detectBinary";

View file

@ -0,0 +1,174 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ENV_VAR_NAME_RE } from "@/settings/model";
import { debounce } from "@/utils/debounce";
import { Plus, Trash2 } from "lucide-react";
import React from "react";
interface Props {
value: Record<string, string> | undefined;
onChange: (next: Record<string, string> | undefined) => void;
/** Backend label woven into the description, e.g. "Claude" or "opencode". */
backendDisplayName: string;
/**
* Two example var names shown in the description and as the first row's
* placeholder. Cosmetic only no validation is performed against this list.
*/
hintExamples: [string, string];
}
interface Row {
id: string;
name: string;
value: string;
}
let rowIdCounter = 0;
function nextRowId(): string {
rowIdCounter += 1;
return `row-${rowIdCounter}`;
}
function recordToRows(record: Record<string, string> | undefined): Row[] {
if (!record) return [];
return Object.entries(record).map(([name, value]) => ({
id: nextRowId(),
name,
value,
}));
}
function rowsToRecord(rows: Row[]): Record<string, string> | undefined {
const out: Record<string, string> = {};
for (const row of rows) {
const name = row.name.trim();
if (!name) continue;
out[name] = row.value;
}
return Object.keys(out).length > 0 ? out : undefined;
}
const COMMIT_DEBOUNCE_MS = 400;
/**
* Per-agent environment variable editor. Renders a labeled list of
* `name` / `value` rows plus an "Add variable" button. Values are stored
* verbatim `~` is not expanded and whitespace is preserved.
*
* Persistence is debounced (`COMMIT_DEBOUNCE_MS`) to avoid rewriting
* settings on every keystroke; the pending commit is flushed on unmount so
* the last edit always lands. The parent's `value` is consulted only at
* mount external reloads while the editor is open are uncommon.
*
* Validation is permissive: invalid names surface an inline warning but
* don't block typing. `sanitizeEnvOverrides` drops anything malformed at
* save time, so the persisted shape is always safe.
*/
export const EnvOverridesSetting: React.FC<Props> = ({
value,
onChange,
backendDisplayName,
hintExamples,
}) => {
const [rows, setRows] = React.useState<Row[]>(() => recordToRows(value));
const onChangeRef = React.useRef(onChange);
React.useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const debouncedCommit = React.useMemo(
() =>
debounce((next: Row[]): void => {
onChangeRef.current(rowsToRecord(next));
}, COMMIT_DEBOUNCE_MS),
[]
);
React.useEffect(() => () => debouncedCommit.flush(), [debouncedCommit]);
const commit = (next: Row[]): void => {
setRows(next);
debouncedCommit(next);
};
const updateRow = (id: string, patch: Partial<Pick<Row, "name" | "value">>): void => {
commit(rows.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
const removeRow = (id: string): void => {
commit(rows.filter((r) => r.id !== id));
};
const addRow = (): void => {
// Empty rows are visible locally but excluded from the committed record
// by `rowsToRecord` — so this purposely doesn't call `commit`.
setRows((prev) => [...prev, { id: nextRowId(), name: "", value: "" }]);
};
const [hintA, hintB] = hintExamples;
return (
<div className="tw-flex tw-flex-col tw-gap-2 tw-py-4">
<div className="tw-flex tw-flex-col tw-gap-1">
<div className="tw-text-sm tw-font-medium tw-leading-none">Environment variables</div>
<div className="tw-text-xs tw-text-muted">
Set values for {backendDisplayName}, like <code>{hintA}</code> or <code>{hintB}</code>.
</div>
</div>
<div className="tw-flex tw-w-full tw-flex-col tw-gap-2">
{rows.map((row) => {
const trimmed = row.name.trim();
const nameInvalid = trimmed.length > 0 && !ENV_VAR_NAME_RE.test(trimmed);
return (
<div key={row.id} className="tw-flex tw-flex-col tw-gap-1">
<div className="tw-flex tw-items-start tw-gap-2">
<Input
type="text"
placeholder={hintA}
value={row.name}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className="tw-flex-1 tw-font-mono"
aria-invalid={nameInvalid || undefined}
onChange={(e) => updateRow(row.id, { name: e.target.value })}
/>
<Input
type="text"
placeholder="value"
value={row.value}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className="tw-flex-[2] tw-font-mono"
onChange={(e) => updateRow(row.id, { value: e.target.value })}
/>
<Button
variant="ghost"
size="icon"
aria-label="Remove variable"
onClick={() => removeRow(row.id)}
>
<Trash2 className="tw-size-icon-s" />
</Button>
</div>
{nameInvalid && (
<div className="tw-text-xs tw-text-error">
Name must start with a letter or underscore and contain only letters, digits, and
underscores.
</div>
)}
</div>
);
})}
<div className="tw-flex tw-justify-end">
<Button variant="secondary" size="sm" onClick={addRow}>
<Plus className="tw-size-icon-xs" />
Add variable
</Button>
</div>
</div>
</div>
);
};

View file

@ -1,4 +1,4 @@
import { BinaryPathSetting } from "@/components/agent/BinaryPathSetting";
import { BinaryPathSetting } from "./BinaryPathSetting";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import { useSettingsValue } from "@/settings/model";

View file

@ -9,11 +9,13 @@ import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMod
* Build a spawn descriptor for a backend whose only configuration is a
* user-provided binary path (no managed install, no extra args). Auth is
* inherited from the user's environment / login state no API key
* injection.
* injection. `envOverrides` is merged last so user values can override even
* the augmented `PATH`.
*/
export function buildSimpleSpawnDescriptor(
binaryPath: string | undefined,
configErrorMessage: string
configErrorMessage: string,
envOverrides?: Record<string, string>
): AcpSpawnDescriptor {
if (!binaryPath) throw new Error(configErrorMessage);
return {
@ -22,6 +24,7 @@ export function buildSimpleSpawnDescriptor(
env: {
...process.env,
PATH: augmentPathForNodeShebang(binaryPath, process.env.PATH),
...(envOverrides ?? {}),
},
};
}

View file

@ -127,6 +127,11 @@ export interface ClaudeSdkBackendProcessOptions {
* Empty string / undefined disables the append.
*/
getSkillCreationDirective?: () => string | undefined;
/**
* User-defined env vars merged onto `process.env` for the spawned `claude`
* CLI. Read per `prompt()` so settings edits apply on the next turn.
*/
getEnvOverrides?: () => Record<string, string> | undefined;
}
/**
@ -282,6 +287,12 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
if (this.opts.getEnableThinking?.()) {
options.thinking = { type: "adaptive" };
}
const envOverrides = this.opts.getEnvOverrides?.();
if (envOverrides && Object.keys(envOverrides).length > 0) {
// Options.env replaces (not merges with) the child env, so include
// process.env to preserve PATH and friends.
options.env = { ...process.env, ...envOverrides };
}
logSdkOutbound(
"prompt",

View file

@ -5,7 +5,12 @@ import {
DEFAULT_SETTINGS,
SEND_SHORTCUT,
} from "@/constants";
import { sanitizeQaExclusions, sanitizeSettings, CopilotSettings } from "@/settings/model";
import {
sanitizeEnvOverrides,
sanitizeQaExclusions,
sanitizeSettings,
CopilotSettings,
} from "@/settings/model";
import { getEffectiveUserPrompt, getSystemPrompt } from "@/system-prompts/systemPromptBuilder";
import * as systemPromptsState from "@/system-prompts/state";
import * as settingsModel from "@/settings/model";
@ -262,6 +267,88 @@ describe("sanitizeSettings - agentMode shape migration", () => {
});
});
describe("sanitizeEnvOverrides", () => {
it("returns undefined for non-objects", () => {
expect(sanitizeEnvOverrides(undefined)).toBeUndefined();
expect(sanitizeEnvOverrides(null)).toBeUndefined();
expect(sanitizeEnvOverrides("foo")).toBeUndefined();
expect(sanitizeEnvOverrides(42)).toBeUndefined();
expect(sanitizeEnvOverrides([1, 2])).toBeUndefined();
});
it("returns undefined when no valid entries remain", () => {
expect(sanitizeEnvOverrides({})).toBeUndefined();
expect(sanitizeEnvOverrides({ "": "v", "1FOO": "v", "BAR=BAZ": "v" })).toBeUndefined();
});
it("keeps valid POSIX identifiers and string values", () => {
expect(
sanitizeEnvOverrides({
CLAUDE_CONFIG_DIR: "/tmp/claude",
_PRIVATE: "x",
myVar2: "y",
})
).toEqual({
CLAUDE_CONFIG_DIR: "/tmp/claude",
_PRIVATE: "x",
myVar2: "y",
});
});
it("drops keys with leading digits, equals signs, whitespace, or invalid characters", () => {
expect(
sanitizeEnvOverrides({
"1FOO": "v",
"FOO BAR": "v",
"FOO=BAR": "v",
"FOO-BAR": "v",
VALID: "v",
})
).toEqual({ VALID: "v" });
});
it("drops entries whose value isn't a string or contains control chars", () => {
expect(
sanitizeEnvOverrides({
OK: "fine",
NUM: 42,
NULLED: null,
UNDEF: undefined,
TABS: "ok\twith\ttabs", // tab is a control char — drop
NEWLINE: "ok\nnewline", // drop
})
).toEqual({ OK: "fine" });
});
it("caps at 64 entries to bound persisted size", () => {
const big: Record<string, string> = {};
for (let i = 0; i < 100; i++) big[`VAR_${i}`] = String(i);
const sanitized = sanitizeEnvOverrides(big);
expect(sanitized && Object.keys(sanitized).length).toBe(64);
});
it("round-trips through sanitizeSettings on the Claude backend slice", () => {
const settings = {
...DEFAULT_SETTINGS,
agentMode: {
enabled: true,
byok: {},
mcpServers: [],
activeBackend: "claude",
backends: {
claude: { envOverrides: { CLAUDE_CONFIG_DIR: "/x", "BAD KEY": "y" } },
},
},
} as unknown as CopilotSettings;
const sanitized = sanitizeSettings(settings);
expect(sanitized.agentMode.backends.claude?.envOverrides).toEqual({
CLAUDE_CONFIG_DIR: "/x",
});
});
});
describe("sanitizeSettings - legacy Miyo settings cleanup", () => {
it("migrates legacy Miyo settings and strips obsolete remote vault path state", () => {
const legacySettings = {

View file

@ -297,6 +297,13 @@ export interface ClaudeBackendSettings {
* surfaces reasoning chunks. Off by default (matches SDK default).
*/
enableThinking?: boolean;
/**
* User-defined environment variables merged on top of `process.env` when
* spawning the `claude` CLI. Used to redirect config dirs
* (`CLAUDE_CONFIG_DIR`), set proxies, or toggle vendor flags without
* polluting the parent shell environment.
*/
envOverrides?: Record<string, string>;
}
/** Settings slice owned by the Codex backend. */
@ -307,6 +314,8 @@ export interface CodexBackendSettings {
defaultModel?: ModelSelection | null;
/** Sparse user overrides; see `ClaudeBackendSettings.modelEnabledOverrides`. */
modelEnabledOverrides?: Record<string, boolean>;
/** See `ClaudeBackendSettings.envOverrides`. Applied to the spawned `codex-acp` subprocess. */
envOverrides?: Record<string, string>;
}
/** Settings slice owned by the OpenCode backend. */
@ -335,6 +344,8 @@ export interface OpencodeBackendSettings {
probeSessionId?: string;
/** Sparse user overrides; see `ClaudeBackendSettings.modelEnabledOverrides`. */
modelEnabledOverrides?: Record<string, boolean>;
/** See `ClaudeBackendSettings.envOverrides`. Applied to the spawned `opencode` subprocess. */
envOverrides?: Record<string, string>;
}
export const settingsStore = createStore();
@ -977,6 +988,34 @@ function sanitizeDefaultModel(raw: unknown): ModelSelection | undefined {
return { baseModelId, effort };
}
/**
* Strict env-var key check: POSIX-style identifier. Rejects empty strings,
* leading digits, `=`, whitespace, dots, hyphens, and control chars. Shared
* with the UI editor (`EnvOverridesSetting`) so live validation matches what
* the sanitizer accepts.
*/
export const ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
/**
* Sanitize a user-supplied env-var override record. Drops entries whose key
* fails POSIX-identifier validation or whose value isn't a string. Caps the
* record at 64 entries to bound the persisted size. Returns `undefined`
* when the record is empty so the persisted settings shape stays clean.
*/
export function sanitizeEnvOverrides(raw: unknown): Record<string, string> | undefined {
if (!raw || typeof raw !== "object") return undefined;
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
if (typeof k !== "string") continue;
if (!ENV_VAR_NAME_RE.test(k)) continue;
if (typeof v !== "string") continue;
if (CONTROL_CHAR_RE.test(v)) continue;
out[k] = v;
if (Object.keys(out).length >= 64) break;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function sanitizeClaudeBackendSettings(raw: unknown): ClaudeBackendSettings {
if (!raw || typeof raw !== "object") return {};
const r = raw as Record<string, unknown>;
@ -984,6 +1023,7 @@ function sanitizeClaudeBackendSettings(raw: unknown): ClaudeBackendSettings {
defaultModel: sanitizeDefaultModel(r.defaultModel),
modelEnabledOverrides: sanitizeModelEnabledOverrides(r.modelEnabledOverrides),
enableThinking: typeof r.enableThinking === "boolean" ? r.enableThinking : undefined,
envOverrides: sanitizeEnvOverrides(r.envOverrides),
};
}
@ -994,6 +1034,7 @@ function sanitizeCodexBackendSettings(raw: unknown): CodexBackendSettings {
binaryPath: nonEmptyString(r.binaryPath),
defaultModel: sanitizeDefaultModel(r.defaultModel),
modelEnabledOverrides: sanitizeModelEnabledOverrides(r.modelEnabledOverrides),
envOverrides: sanitizeEnvOverrides(r.envOverrides),
};
}
@ -1016,6 +1057,7 @@ function sanitizeOpencodeBackendSettings(raw: unknown): OpencodeBackendSettings
defaultModel: sanitizeDefaultModel(r.defaultModel),
probeSessionId: nonEmptyString(r.probeSessionId),
modelEnabledOverrides: sanitizeModelEnabledOverrides(r.modelEnabledOverrides),
envOverrides: sanitizeEnvOverrides(r.envOverrides),
};
}