Add environment variables to executor profiles (#1444)

* The implementation is complete. Here's a summary of what was done:

Successfully added environment variable configuration support to executor profiles. Users can now configure env vars like this:

```json
{
  "CLAUDE_CODE": {
    "DEFAULT": {
      "CLAUDE_CODE": {
        "env": {
          "ANTHROPIC_AUTH_TOKEN": "...",
          "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
          "API_TIMEOUT_MS": "3000000",
          "ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.6"
        }
      }
    }
  }
}
```

1. **`crates/executors/src/command.rs`**:
   - Added `env: Option<HashMap<String, String>>` to `CmdOverrides` struct
   - Added `apply_env_vars()` helper function

2. **Updated all executors to apply env vars**:
   - `claude.rs` - `spawn_internal()`
   - `codex.rs` - `spawn()`
   - `amp.rs` - `spawn()` and `spawn_follow_up()`
   - `droid.rs` - module-level `spawn()` function
   - `copilot.rs` - both spawn methods
   - `cursor.rs` - both spawn methods
   - `opencode.rs` - both spawn methods
   - `acp/harness.rs` - `spawn_with_command()` and `spawn_follow_up_with_command()` (used by Gemini/Qwen)

3. **Type Generation**: All JSON schemas and TypeScript types now include the `env` field

This allows users to set environment variables for all supported coding agents (Claude Code, Codex, Gemini, Amp, Droid, Copilot, Cursor, OpenCode, Qwen) through profile configuration, eliminating the need for external tools like claude-code-router.

* The implementation is complete. Here's a summary of what was done:

## Summary

### Backend Changes
- Added `env: Option<HashMap<String, String>>` field to `CmdOverrides` struct in `crates/executors/src/command.rs`
- Created `apply_env_vars()` helper function to inject env vars into `tokio::process::Command`
- Updated all 9 executors to apply env vars during spawn:
  - claude.rs, codex.rs, amp.rs, droid.rs, copilot.rs, cursor.rs, opencode.rs
  - gemini.rs and qwen.rs (via ACP harness)
- Modified ACP harness signature to accept `Option<&CmdOverrides>`

### Frontend Changes
- Created `KeyValueField.tsx` - custom RJSF field for editing key-value pairs
- Registered the field in `theme.ts`
- Added `uiSchema` to `ExecutorConfigForm.tsx` to use the custom field for `env`

### Generated Files
- `shared/types.ts` - TypeScript types updated with `env` field
- `shared/schemas/*.json` - All 9 executor schemas include `env` property

The environment variables UI will now appear in Settings > Agent Settings as a key-value editor with "Environment Variables" label and description. Users can add/remove/edit env vars that will be passed to the CLI execution environment.

* cleanup env structs

* fix form

* fmt

* remove mise.toml

* fmt

* Seprate config form per selected variant

---------

Co-authored-by: Louis Knight-Webb <louis@bloop.ai>
Co-authored-by: Solomon <abcpro11051@disroot.org>
This commit is contained in:
Stephan Fitzpatrick
2025-12-07 07:01:12 -08:00
committed by GitHub
parent e1c9c15f43
commit 7da884bc3a
28 changed files with 399 additions and 44 deletions

View File

@@ -1,4 +1,4 @@
import { useMemo, useEffect, useState } from 'react';
import { useMemo, useEffect, useState, useCallback } from 'react';
import Form from '@rjsf/core';
import type { IChangeEvent } from '@rjsf/core';
import { RJSFValidationError } from '@rjsf/utils';
@@ -44,6 +44,38 @@ export function ExecutorConfigForm({
return schemas[executor];
}, [executor]);
// Custom handler for env field updates
const handleEnvChange = useCallback(
(envData: Record<string, string> | undefined) => {
const newFormData = {
...(formData as Record<string, unknown>),
env: envData,
};
setFormData(newFormData);
if (onChange) {
onChange(newFormData);
}
},
[formData, onChange]
);
const uiSchema = useMemo(
() => ({
env: {
'ui:field': 'KeyValueField',
},
}),
[]
);
// Pass the env update handler via formContext
const formContext = useMemo(
() => ({
onEnvChange: handleEnvChange,
}),
[handleEnvChange]
);
useEffect(() => {
setFormData(value || {});
setValidationErrors([]);
@@ -87,7 +119,9 @@ export function ExecutorConfigForm({
<CardContent className="p-0">
<Form
schema={schema}
uiSchema={uiSchema}
formData={formData}
formContext={formContext}
onChange={handleChange}
onSubmit={handleSubmit}
onError={handleError}
@@ -97,6 +131,7 @@ export function ExecutorConfigForm({
showErrorList={false}
widgets={shadcnTheme.widgets}
templates={shadcnTheme.templates}
fields={shadcnTheme.fields}
>
{onSave && (
<div className="flex justify-end pt-4">

View File

@@ -0,0 +1,137 @@
import { FieldProps } from '@rjsf/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Plus, X } from 'lucide-react';
import { useState, useCallback, useMemo } from 'react';
type KeyValueData = Record<string, string>;
interface EnvFormContext {
onEnvChange?: (envData: KeyValueData | undefined) => void;
}
export function KeyValueField({
formData,
disabled,
readonly,
registry,
}: FieldProps<KeyValueData>) {
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
// Get the custom env change handler from formContext
const formContext = registry.formContext as EnvFormContext | undefined;
// Ensure we have a stable object reference
const data: KeyValueData = useMemo(() => formData ?? {}, [formData]);
const entries = useMemo(() => Object.entries(data), [data]);
// Use the formContext handler to update env correctly
const updateValue = useCallback(
(newData: KeyValueData | undefined) => {
formContext?.onEnvChange?.(newData);
},
[formContext]
);
const handleAdd = useCallback(() => {
const trimmedKey = newKey.trim();
if (trimmedKey) {
updateValue({
...data,
[trimmedKey]: newValue,
});
setNewKey('');
setNewValue('');
}
}, [data, newKey, newValue, updateValue]);
const handleRemove = useCallback(
(key: string) => {
const updated = { ...data };
delete updated[key];
updateValue(Object.keys(updated).length > 0 ? updated : undefined);
},
[data, updateValue]
);
const handleValueChange = useCallback(
(key: string, value: string) => {
updateValue({ ...data, [key]: value });
},
[data, updateValue]
);
const isDisabled = disabled || readonly;
return (
<div className="space-y-3">
{entries.map(([key, value]) => (
<div key={key} className="flex gap-2 items-center">
<Input
value={key}
disabled
className="flex-1 font-mono text-sm"
aria-label="Environment variable key"
/>
<Input
value={value ?? ''}
onChange={(e) => handleValueChange(key, e.target.value)}
disabled={isDisabled}
className="flex-1 font-mono text-sm"
placeholder="Value"
aria-label={`Value for ${key}`}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemove(key)}
disabled={isDisabled}
className="h-8 w-8 p-0 shrink-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
aria-label={`Remove ${key}`}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{/* Add new entry row */}
<div className="flex gap-2 items-center">
<Input
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
disabled={isDisabled}
placeholder="KEY"
className="flex-1 font-mono text-sm"
aria-label="New environment variable key"
/>
<Input
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
disabled={isDisabled}
placeholder="value"
className="flex-1 font-mono text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAdd();
}
}}
aria-label="New environment variable value"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAdd}
disabled={isDisabled || !newKey.trim()}
className="h-8 w-8 p-0 shrink-0"
aria-label="Add environment variable"
>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { KeyValueField } from './KeyValueField';

View File

@@ -1,3 +1,9 @@
export { shadcnTheme, customWidgets, customTemplates } from './theme';
export {
shadcnTheme,
customWidgets,
customTemplates,
customFields,
} from './theme';
export * from './widgets';
export * from './templates';
export * from './fields';

View File

@@ -1,4 +1,4 @@
import { RegistryWidgetsType } from '@rjsf/utils';
import { RegistryFieldsType, RegistryWidgetsType } from '@rjsf/utils';
import {
TextWidget,
SelectWidget,
@@ -12,6 +12,7 @@ import {
ObjectFieldTemplate,
FormTemplate,
} from './templates';
import { KeyValueField } from './fields';
export const customWidgets: RegistryWidgetsType = {
TextWidget,
@@ -29,7 +30,12 @@ export const customTemplates = {
FormTemplate,
};
export const customFields: RegistryFieldsType = {
KeyValueField,
};
export const shadcnTheme = {
widgets: customWidgets,
templates: customTemplates,
fields: customFields,
};

View File

@@ -515,6 +515,7 @@ export function AgentSettings() {
selectedConfiguration
]?.[selectedExecutorType] && (
<ExecutorConfigForm
key={`${selectedExecutorType}-${selectedConfiguration}`}
executor={selectedExecutorType}
value={
(executorsMap[selectedExecutorType][