mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
1c0a750b31
commit
c39b9bf44c
14 changed files with 375 additions and 23 deletions
|
|
@ -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"]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
174
src/agentMode/backends/shared/EnvOverridesSetting.tsx
Normal file
174
src/agentMode/backends/shared/EnvOverridesSetting.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue