feat: add backend config settings and tests

This commit is contained in:
Thomas Marchand
2026-01-18 11:25:34 +00:00
parent e872eee19c
commit a809ddd162
16 changed files with 873 additions and 47 deletions

View File

@@ -3109,6 +3109,7 @@ export default function ControlClient() {
workspaceId?: string;
agent?: string;
modelOverride?: string;
backend?: string;
}) => {
try {
setMissionLoading(true);
@@ -3116,6 +3117,7 @@ export default function ControlClient() {
workspaceId: options?.workspaceId,
agent: options?.agent,
modelOverride: options?.modelOverride,
backend: options?.backend,
});
pendingMissionNavRef.current = mission.id;
router.replace(`/control?mission=${mission.id}`, { scroll: false });

View File

@@ -46,12 +46,13 @@ export default function OverviewPage() {
const isActive = (stats?.active_tasks ?? 0) > 0;
const handleNewMission = useCallback(
async (options?: { workspaceId?: string; agent?: string }) => {
async (options?: { workspaceId?: string; agent?: string; backend?: string }) => {
try {
setCreatingMission(true);
const mission = await createMission({
workspaceId: options?.workspaceId,
agent: options?.agent,
backend: options?.backend,
});
toast.success('New mission created');
router.push(`/control?mission=${mission.id}`);

View File

@@ -16,6 +16,9 @@ import {
AIProviderTypeInfo,
getSettings,
updateLibraryRemote,
listBackends,
getBackendConfig,
updateBackendConfig,
} from '@/lib/api';
import {
Server,
@@ -101,6 +104,22 @@ export default function SettingsPage() {
const [libraryRemoteValue, setLibraryRemoteValue] = useState('');
const [savingLibraryRemote, setSavingLibraryRemote] = useState(false);
// Backend settings state
const [activeBackendTab, setActiveBackendTab] = useState<'opencode' | 'claudecode'>('opencode');
const [savingBackend, setSavingBackend] = useState(false);
const [opencodeForm, setOpencodeForm] = useState({
base_url: '',
default_agent: '',
permissive: false,
enabled: true,
});
const [claudeForm, setClaudeForm] = useState({
api_key: '',
default_model: '',
api_key_configured: false,
enabled: true,
});
// SWR: fetch health status
const { data: health, isLoading: healthLoading, mutate: mutateHealth } = useSWR(
'health',
@@ -129,6 +148,26 @@ export default function SettingsPage() {
{ revalidateOnFocus: false }
);
// SWR: fetch backends
const { data: backends = [] } = useSWR('backends', listBackends, {
revalidateOnFocus: false,
fallbackData: [
{ id: 'opencode', name: 'OpenCode' },
{ id: 'claudecode', name: 'Claude Code' },
],
});
const { data: opencodeBackendConfig, mutate: mutateOpenCodeBackend } = useSWR(
'backend-opencode-config',
() => getBackendConfig('opencode'),
{ revalidateOnFocus: false }
);
const { data: claudecodeBackendConfig, mutate: mutateClaudeBackend } = useSWR(
'backend-claudecode-config',
() => getBackendConfig('claudecode'),
{ revalidateOnFocus: false }
);
// Check if there are unsaved changes
const hasUnsavedChanges = apiUrl !== originalValues.apiUrl;
@@ -175,6 +214,28 @@ export default function SettingsPage() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [apiUrl]);
useEffect(() => {
if (!opencodeBackendConfig?.settings) return;
const settings = opencodeBackendConfig.settings as Record<string, unknown>;
setOpencodeForm({
base_url: typeof settings.base_url === 'string' ? settings.base_url : '',
default_agent: typeof settings.default_agent === 'string' ? settings.default_agent : '',
permissive: Boolean(settings.permissive),
enabled: opencodeBackendConfig.enabled,
});
}, [opencodeBackendConfig]);
useEffect(() => {
if (!claudecodeBackendConfig?.settings) return;
const settings = claudecodeBackendConfig.settings as Record<string, unknown>;
setClaudeForm((prev) => ({
...prev,
default_model: typeof settings.default_model === 'string' ? settings.default_model : '',
api_key_configured: Boolean(settings.api_key_configured),
enabled: claudecodeBackendConfig.enabled,
}));
}, [claudecodeBackendConfig]);
const handleSave = () => {
if (!validateUrl(apiUrl)) {
toast.error('Please fix validation errors before saving');
@@ -259,6 +320,58 @@ export default function SettingsPage() {
}
};
const handleSaveOpenCodeBackend = async () => {
setSavingBackend(true);
try {
const result = await updateBackendConfig(
'opencode',
{
base_url: opencodeForm.base_url,
default_agent: opencodeForm.default_agent || null,
permissive: opencodeForm.permissive,
},
{ enabled: opencodeForm.enabled }
);
toast.success(result.message || 'OpenCode settings updated');
mutateOpenCodeBackend();
} catch (err) {
toast.error(
`Failed to update OpenCode settings: ${
err instanceof Error ? err.message : 'Unknown error'
}`
);
} finally {
setSavingBackend(false);
}
};
const handleSaveClaudeBackend = async () => {
setSavingBackend(true);
try {
const settings: Record<string, unknown> = {
default_model: claudeForm.default_model || null,
};
if (claudeForm.api_key) {
settings.api_key = claudeForm.api_key;
}
const result = await updateBackendConfig('claudecode', settings, {
enabled: claudeForm.enabled,
});
toast.success(result.message || 'Claude Code settings updated');
setClaudeForm((prev) => ({ ...prev, api_key: '' }));
mutateClaudeBackend();
} catch (err) {
toast.error(
`Failed to update Claude Code settings: ${
err instanceof Error ? err.message : 'Unknown error'
}`
);
} finally {
setSavingBackend(false);
}
};
const handleStartEdit = (provider: AIProvider) => {
setEditingProvider(provider.id);
setEditForm({
@@ -565,6 +678,176 @@ export default function SettingsPage() {
</div>
</div>
{/* Backends */}
<div className="rounded-xl bg-white/[0.02] border border-white/[0.04] p-5">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/10">
<Server className="h-5 w-5 text-emerald-400" />
</div>
<div>
<h2 className="text-sm font-medium text-white">Backends</h2>
<p className="text-xs text-white/40">
Configure execution backends and authentication
</p>
</div>
</div>
<div className="flex items-center gap-2 mb-4">
{backends.map((backend) => (
<button
key={backend.id}
onClick={() =>
setActiveBackendTab(
backend.id === 'claudecode' ? 'claudecode' : 'opencode'
)
}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
activeBackendTab === backend.id
? 'bg-white/[0.08] border-white/[0.12] text-white'
: 'bg-white/[0.02] border-white/[0.06] text-white/50 hover:text-white/70'
)}
>
{backend.name}
</button>
))}
</div>
{activeBackendTab === 'opencode' ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-white/60">Enabled</span>
<label className="flex items-center gap-2 text-xs text-white/60 cursor-pointer">
<input
type="checkbox"
checked={opencodeForm.enabled}
onChange={(e) =>
setOpencodeForm((prev) => ({ ...prev, enabled: e.target.checked }))
}
className="rounded border-white/20 cursor-pointer"
/>
Enabled
</label>
</div>
<div>
<label className="block text-xs text-white/60 mb-1.5">Base URL</label>
<input
type="text"
value={opencodeForm.base_url}
onChange={(e) =>
setOpencodeForm((prev) => ({ ...prev, base_url: e.target.value }))
}
placeholder="http://127.0.0.1:4096"
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50"
/>
</div>
<div>
<label className="block text-xs text-white/60 mb-1.5">Default Agent</label>
<input
type="text"
value={opencodeForm.default_agent}
onChange={(e) =>
setOpencodeForm((prev) => ({ ...prev, default_agent: e.target.value }))
}
placeholder="Sisyphus"
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50"
/>
</div>
<label className="flex items-center gap-2 text-xs text-white/60 cursor-pointer">
<input
type="checkbox"
checked={opencodeForm.permissive}
onChange={(e) =>
setOpencodeForm((prev) => ({ ...prev, permissive: e.target.checked }))
}
className="rounded border-white/20 cursor-pointer"
/>
Permissive mode (auto-allow tool permissions)
</label>
<div className="flex items-center gap-2 pt-1">
<button
onClick={handleSaveOpenCodeBackend}
disabled={savingBackend}
className="flex items-center gap-2 rounded-lg bg-indigo-500 px-3 py-1.5 text-xs text-white hover:bg-indigo-600 transition-colors disabled:opacity-50"
>
{savingBackend ? (
<Loader className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
Save OpenCode
</button>
<span className="text-xs text-white/40">Restart required to apply runtime changes</span>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-white/60">Enabled</span>
<label className="flex items-center gap-2 text-xs text-white/60 cursor-pointer">
<input
type="checkbox"
checked={claudeForm.enabled}
onChange={(e) =>
setClaudeForm((prev) => ({ ...prev, enabled: e.target.checked }))
}
className="rounded border-white/20 cursor-pointer"
/>
Enabled
</label>
</div>
<div className="text-xs text-white/50">
API key status:{' '}
<span className={claudeForm.api_key_configured ? 'text-emerald-400' : 'text-amber-400'}>
{claudeForm.api_key_configured ? 'Configured' : 'Not configured'}
</span>
</div>
<div>
<label className="block text-xs text-white/60 mb-1.5">API Key</label>
<input
type="password"
value={claudeForm.api_key}
onChange={(e) =>
setClaudeForm((prev) => ({ ...prev, api_key: e.target.value }))
}
placeholder="sk-..."
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50"
/>
<p className="mt-1.5 text-xs text-white/30">
Stored securely in the secrets vault; leave blank to keep existing key.
</p>
</div>
<div>
<label className="block text-xs text-white/60 mb-1.5">Default Model</label>
<input
type="text"
value={claudeForm.default_model}
onChange={(e) =>
setClaudeForm((prev) => ({ ...prev, default_model: e.target.value }))
}
placeholder="claude-sonnet-4-20250514"
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50"
/>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={handleSaveClaudeBackend}
disabled={savingBackend}
className="flex items-center gap-2 rounded-lg bg-indigo-500 px-3 py-1.5 text-xs text-white hover:bg-indigo-600 transition-colors disabled:opacity-50"
>
{savingBackend ? (
<Loader className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
Save Claude Code
</button>
<span className="text-xs text-white/40">Restart required to apply runtime changes</span>
</div>
</div>
)}
</div>
{/* Library Settings */}
<div className="rounded-xl bg-white/[0.02] border border-white/[0.04] p-5">
<div className="flex items-center gap-3 mb-4">

View File

@@ -2874,12 +2874,13 @@ export async function getBackendConfig(backendId: string): Promise<BackendConfig
// Update backend configuration
export async function updateBackendConfig(
backendId: string,
settings: Record<string, unknown>
settings: Record<string, unknown>,
options?: { enabled?: boolean }
): Promise<{ ok: boolean; message?: string }> {
const res = await apiFetch(`/api/backends/${encodeURIComponent(backendId)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings }),
body: JSON.stringify({ settings, enabled: options?.enabled }),
});
if (!res.ok) throw new Error('Failed to update backend config');
return res.json();

View File

@@ -0,0 +1,21 @@
import { test, expect } from '@playwright/test';
test.describe('Backend Selection', () => {
test('can select backend when creating mission', async ({ page }) => {
await page.goto('/');
const newMissionButton = page.getByRole('button', { name: /New\s+Mission/i });
await expect(newMissionButton).toBeVisible();
await newMissionButton.click();
const backendSelect = page.getByText('Backend').locator('..').locator('select');
await expect(backendSelect).toBeVisible();
const options = await backendSelect.locator('option').allTextContents();
expect(options.some((opt) => opt.toLowerCase().includes('opencode'))).toBeTruthy();
expect(options.some((opt) => opt.toLowerCase().includes('claude'))).toBeTruthy();
await backendSelect.selectOption('claudecode');
await expect(backendSelect).toHaveValue('claudecode');
});
});

View File

@@ -0,0 +1,69 @@
import { test, expect } from '@playwright/test';
import fs from 'fs/promises';
import path from 'path';
async function waitForFile(filePath: string, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
await fs.access(filePath);
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
throw new Error(`Timed out waiting for file: ${filePath}`);
}
test('claude code mission generates correct config', async ({ page }) => {
const workspacesRes = await page.request.get('/api/workspaces');
if (!workspacesRes.ok()) {
test.skip(true, 'Backend API not available');
}
const workspaces = await workspacesRes.json();
const hostWorkspace =
workspaces.find((ws: { id: string }) => ws.id === '00000000-0000-0000-0000-000000000000') ||
workspaces.find((ws: { workspace_type: string }) => ws.workspace_type === 'host');
if (!hostWorkspace) {
test.skip(true, 'No host workspace found');
}
const missionRes = await page.request.post('/api/control/missions', {
data: {
title: `pw-claude-${Date.now()}`,
workspace_id: hostWorkspace.id,
backend: 'claudecode',
},
});
if (!missionRes.ok()) {
test.skip(true, `Mission create failed (${missionRes.status()})`);
}
const mission = await missionRes.json();
const messageRes = await page.request.post('/api/control/message', {
data: { content: 'Generate Claude config', mission_id: mission.id },
});
if (!messageRes.ok()) {
test.skip(true, 'Mission execution not available');
}
const shortId = mission.id.slice(0, 8);
const missionDir = path.join(hostWorkspace.path, 'workspaces', `mission-${shortId}`);
const claudeSettings = path.join(missionDir, '.claude', 'settings.local.json');
await waitForFile(claudeSettings);
const settingsContent = await fs.readFile(claudeSettings, 'utf8');
expect(settingsContent).toContain('mcpServers');
const skillsRes = await page.request.get('/api/library/skills');
if (skillsRes.ok()) {
const skills = await skillsRes.json();
if (skills.length > 0) {
const claudeMd = path.join(missionDir, 'CLAUDE.md');
await waitForFile(claudeMd);
const claudeContent = await fs.readFile(claudeMd, 'utf8');
expect(claudeContent).toContain('Project Context');
}
}
});

View File

@@ -0,0 +1,61 @@
import { test, expect } from '@playwright/test';
import fs from 'fs/promises';
import path from 'path';
async function waitForFile(filePath: string, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
await fs.access(filePath);
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
throw new Error(`Timed out waiting for file: ${filePath}`);
}
test('opencode mission generates correct config', async ({ page }) => {
const workspacesRes = await page.request.get('/api/workspaces');
if (!workspacesRes.ok()) {
test.skip(true, 'Backend API not available');
}
const workspaces = await workspacesRes.json();
const hostWorkspace =
workspaces.find((ws: { id: string }) => ws.id === '00000000-0000-0000-0000-000000000000') ||
workspaces.find((ws: { workspace_type: string }) => ws.workspace_type === 'host');
if (!hostWorkspace) {
test.skip(true, 'No host workspace found');
}
const missionRes = await page.request.post('/api/control/missions', {
data: {
title: `pw-opencode-${Date.now()}`,
workspace_id: hostWorkspace.id,
backend: 'opencode',
},
});
if (!missionRes.ok()) {
test.skip(true, `Mission create failed (${missionRes.status()})`);
}
const mission = await missionRes.json();
const messageRes = await page.request.post('/api/control/message', {
data: { content: 'Ping', mission_id: mission.id },
});
if (!messageRes.ok()) {
test.skip(true, 'Mission execution not available');
}
const shortId = mission.id.slice(0, 8);
const missionDir = path.join(hostWorkspace.path, 'workspaces', `mission-${shortId}`);
const opencodeConfig = path.join(missionDir, '.opencode', 'opencode.json');
const rootConfig = path.join(missionDir, 'opencode.json');
await waitForFile(opencodeConfig);
await waitForFile(rootConfig);
const contents = await fs.readFile(opencodeConfig, 'utf8');
expect(contents).toContain('mcp');
});

89
docs/BACKEND_API.md Normal file
View File

@@ -0,0 +1,89 @@
# Backend API
All endpoints require authentication via `Authorization: Bearer <token>` header.
## List Backends
```
GET /api/backends
```
**Response**:
```json
[
{"id": "opencode", "name": "OpenCode"},
{"id": "claudecode", "name": "Claude Code"}
]
```
## Get Backend
```
GET /api/backends/:id
```
**Response**:
```json
{"id": "opencode", "name": "OpenCode"}
```
## List Backend Agents
```
GET /api/backends/:id/agents
```
**Response**:
```json
[{"id": "Sisyphus", "name": "Sisyphus"}]
```
## Get Backend Config
```
GET /api/backends/:id/config
```
**Response**:
```json
{
"id": "opencode",
"name": "OpenCode",
"enabled": true,
"settings": {
"base_url": "http://127.0.0.1:4096",
"default_agent": "Sisyphus",
"permissive": true
}
}
```
For `claudecode`, `settings` includes `api_key_configured` and optional fields like `default_model`.
## Update Backend Config
```
PUT /api/backends/:id/config
```
**Body**:
```json
{
"enabled": true,
"settings": {
"base_url": "http://127.0.0.1:4096",
"default_agent": "Sisyphus",
"permissive": true
}
}
```
Claude Code accepts `api_key` in `settings` to store it securely in the secrets vault.
**Response**:
```json
{
"ok": true,
"message": "Backend configuration updated. Restart Open Agent to apply runtime changes."
}
```

View File

@@ -14,10 +14,13 @@ POST /api/control/missions
"title": "My Mission",
"workspace_id": "uuid",
"agent": "code-reviewer",
"model_override": "anthropic/claude-sonnet-4-20250514"
"model_override": "anthropic/claude-sonnet-4-20250514",
"backend": "opencode"
}
```
`backend` can be `"opencode"` or `"claudecode"`. Defaults to `"opencode"` if omitted.
**Response**: `Mission` object (see below).
## Load/Switch to a Mission
@@ -155,9 +158,9 @@ data: {"id":"uuid","content":"Done!","success":true,"cost_cents":5,"model":"clau
"workspace_name": "my-workspace",
"agent": "code-reviewer",
"model_override": null,
"backend": "opencode",
"history": [],
"created_at": "2025-01-13T10:00:00Z",
"updated_at": "2025-01-13T10:05:00Z"
}
```

View File

@@ -124,6 +124,10 @@ Manually syncs the workspace's skills and tools from the library to the `.openco
**Response**: `Workspace` object.
Note: Mission workspaces also generate backend-specific config on execution:
- OpenCode: `.opencode/opencode.json` (and `opencode.json` in the workspace root)
- Claude Code: `.claude/settings.local.json` + `CLAUDE.md`
## Execute Command
```

View File

@@ -111,43 +111,38 @@ pub async fn get_backend_config(
let backend = registry
.get(&id)
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not found", id)))?;
drop(registry);
// Return backend-specific configuration
let settings = match id.as_str() {
"opencode" => {
let base_url = std::env::var("OPENCODE_BASE_URL")
.unwrap_or_else(|_| "http://127.0.0.1:4096".to_string());
let default_agent = std::env::var("OPENCODE_DEFAULT_AGENT").ok();
let permissive = std::env::var("OPENCODE_PERMISSIVE")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
serde_json::json!({
"base_url": base_url,
"default_agent": default_agent,
"permissive": permissive,
})
}
"claudecode" => {
// Check if Claude Code API key is configured
let api_key_configured = state
.secrets
.as_ref()
.map(|_s| {
// TODO: implement proper secret check
false
})
.unwrap_or(false);
serde_json::json!({
"api_key_configured": api_key_configured,
})
}
_ => serde_json::json!({}),
};
let config_entry = state
.backend_configs
.get(&id)
.await
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not configured", id)))?;
let mut settings = config_entry.settings.clone();
if id == "claudecode" {
let api_key_configured = if let Some(store) = state.secrets.as_ref() {
match store.list_secrets("claudecode").await {
Ok(secrets) => secrets.iter().any(|s| s.key == "api_key" && !s.is_expired),
Err(_) => false,
}
} else {
false
};
let mut obj = settings.as_object().cloned().unwrap_or_default();
obj.insert(
"api_key_configured".to_string(),
serde_json::Value::Bool(api_key_configured),
);
settings = serde_json::Value::Object(obj);
}
Ok(Json(BackendConfig {
id: backend.id().to_string(),
name: backend.name().to_string(),
enabled: true,
enabled: config_entry.enabled,
settings,
}))
}
@@ -156,6 +151,7 @@ pub async fn get_backend_config(
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateBackendConfigRequest {
pub settings: serde_json::Value,
pub enabled: Option<bool>,
}
/// Update backend configuration
@@ -163,18 +159,88 @@ pub async fn update_backend_config(
State(state): State<Arc<AppState>>,
Extension(_user): Extension<AuthUser>,
Path(id): Path<String>,
Json(_req): Json<UpdateBackendConfigRequest>,
Json(req): Json<UpdateBackendConfigRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let registry = state.backend_registry.read().await;
if registry.get(&id).is_none() {
return Err((StatusCode::NOT_FOUND, format!("Backend {} not found", id)));
}
drop(registry);
// Backend configuration is currently read from environment variables
// TODO: Implement persistent backend configuration storage
let updated_settings = match id.as_str() {
"opencode" => {
let settings = req
.settings
.as_object()
.ok_or_else(|| (StatusCode::BAD_REQUEST, "Invalid settings payload".to_string()))?;
let base_url = settings
.get("base_url")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| (StatusCode::BAD_REQUEST, "base_url is required".to_string()))?;
let default_agent = settings
.get("default_agent")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let permissive = settings
.get("permissive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
serde_json::json!({
"base_url": base_url,
"default_agent": default_agent,
"permissive": permissive,
})
}
"claudecode" => {
let mut settings = req.settings.clone();
if let Some(api_key) = settings.get("api_key").and_then(|v| v.as_str()) {
let store = state
.secrets
.as_ref()
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
"Secrets store not available".to_string(),
)
})?;
store
.set_secret("claudecode", "api_key", api_key, None)
.await
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Failed to store API key: {}", e),
)
})?;
}
if let Some(obj) = settings.as_object_mut() {
obj.remove("api_key");
}
settings
}
_ => req.settings.clone(),
};
let updated = state
.backend_configs
.update_settings(&id, updated_settings, req.enabled)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to persist backend config: {}", e),
)
})?;
if updated.is_none() {
return Err((StatusCode::NOT_FOUND, format!("Backend {} not found", id)));
}
Ok(Json(serde_json::json!({
"ok": true,
"message": "Backend configuration is currently read-only"
"message": "Backend configuration updated. Restart Open Agent to apply runtime changes."
})))
}

View File

@@ -3676,7 +3676,7 @@ async fn run_single_control_turn(
// Context for agent execution.
let mut ctx = AgentContext::new(config.clone(), working_dir_path);
ctx.mission_control = mission_control;
ctx.control_events = Some(events_tx);
ctx.control_events = Some(events_tx.clone());
ctx.frontend_tool_hub = Some(tool_hub);
ctx.control_status = Some(status);
ctx.cancel_token = Some(cancel);
@@ -3685,6 +3685,24 @@ async fn run_single_control_turn(
ctx.mission_id = mission_id;
ctx.mcp = Some(mcp);
if let Some(ref backend) = backend_id {
if backend != "opencode" {
let _ = events_tx.send(AgentEvent::Error {
message: format!(
"Backend '{}' is not supported for in-app execution yet. Please use OpenCode or run Claude Code locally.",
backend
),
mission_id,
resumable: mission_id.is_some(),
});
return crate::agents::AgentResult::failure(
format!("Unsupported backend: {}", backend),
0,
)
.with_terminal_reason(TerminalReason::LlmError);
}
}
let result = root_agent.execute(&mut task, &ctx).await;
result
}

View File

@@ -18,7 +18,7 @@ use tokio::sync::{broadcast, mpsc, RwLock};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::agents::{AgentContext, AgentRef, AgentResult};
use crate::agents::{AgentContext, AgentRef, AgentResult, TerminalReason};
use crate::config::Config;
use crate::mcp::McpRegistry;
use crate::task::{extract_deliverables, DeliverableSet};
@@ -502,7 +502,7 @@ async fn run_mission_turn(
let mut ctx = AgentContext::new(config.clone(), mission_work_dir);
ctx.mission_control = mission_control;
ctx.control_events = Some(events_tx);
ctx.control_events = Some(events_tx.clone());
ctx.frontend_tool_hub = Some(tool_hub);
ctx.control_status = Some(status);
ctx.cancel_token = Some(cancel);
@@ -511,6 +511,19 @@ async fn run_mission_turn(
ctx.mission_id = Some(mission_id);
ctx.mcp = Some(mcp);
if backend_id != "opencode" {
let _ = events_tx.send(AgentEvent::Error {
message: format!(
"Backend '{}' is not supported for in-app execution yet. Please use OpenCode or run Claude Code locally.",
backend_id
),
mission_id: Some(mission_id),
resumable: true,
});
return AgentResult::failure(format!("Unsupported backend: {}", backend_id), 0)
.with_terminal_reason(TerminalReason::LlmError);
}
let result = root_agent.execute(&mut task, &ctx).await;
tracing::info!(
mission_id = %mission_id,

View File

@@ -23,6 +23,7 @@ use uuid::Uuid;
use crate::agents::{AgentContext, AgentRef, OpenCodeAgent};
use crate::backend::registry::BackendRegistry;
use crate::backend_config::BackendConfigEntry;
use crate::config::{AuthMode, Config};
use crate::mcp::McpRegistry;
use crate::workspace;
@@ -76,16 +77,16 @@ pub struct AppState {
pub settings: Arc<crate::settings::SettingsStore>,
/// Backend registry for multi-backend support
pub backend_registry: Arc<RwLock<BackendRegistry>>,
/// Backend configuration store
pub backend_configs: Arc<crate::backend_config::BackendConfigStore>,
}
/// Start the HTTP server.
pub async fn serve(config: Config) -> anyhow::Result<()> {
let mut config = config;
// Start monitoring background collector early so clients get history immediately
monitoring::init_monitoring();
// Always use OpenCode backend
let root_agent: AgentRef = Arc::new(OpenCodeAgent::new(config.clone()));
// Initialize MCP registry
let mcp = Arc::new(McpRegistry::new(&config.working_dir).await);
if let Err(e) = crate::opencode_config::ensure_global_config(&mcp).await {
@@ -140,6 +141,51 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
// Initialize global settings store
let settings = Arc::new(crate::settings::SettingsStore::new(&config.working_dir).await);
// Initialize backend config store (persisted settings)
let backend_defaults = vec![
BackendConfigEntry::new(
"opencode",
"OpenCode",
serde_json::json!({
"base_url": config.opencode_base_url,
"default_agent": config.opencode_agent,
"permissive": config.opencode_permissive,
}),
),
BackendConfigEntry::new("claudecode", "Claude Code", serde_json::json!({})),
];
let backend_configs = Arc::new(
crate::backend_config::BackendConfigStore::new(
config
.working_dir
.join(".openagent/backend_config.json"),
backend_defaults,
)
.await,
);
// Apply persisted OpenCode settings (if present)
if let Some(entry) = backend_configs.get("opencode").await {
if let Some(settings) = entry.settings.as_object() {
if let Some(base_url) = settings.get("base_url").and_then(|v| v.as_str()) {
if !base_url.trim().is_empty() {
config.opencode_base_url = base_url.to_string();
}
}
if let Some(agent) = settings.get("default_agent").and_then(|v| v.as_str()) {
if !agent.trim().is_empty() {
config.opencode_agent = Some(agent.to_string());
}
}
if let Some(permissive) = settings.get("permissive").and_then(|v| v.as_bool()) {
config.opencode_permissive = permissive;
}
}
}
// Always use OpenCode backend
let root_agent: AgentRef = Arc::new(OpenCodeAgent::new(config.clone()));
// Initialize backend registry with OpenCode and Claude Code backends
let opencode_base_url =
std::env::var("OPENCODE_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:4096".to_string());
@@ -251,6 +297,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
console_pool,
settings,
backend_registry,
backend_configs,
});
// Start background desktop session cleanup task

147
src/backend_config.rs Normal file
View File

@@ -0,0 +1,147 @@
//! Backend configuration storage and persistence.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendConfigEntry {
pub id: String,
pub name: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub settings: serde_json::Value,
}
fn default_enabled() -> bool {
true
}
impl BackendConfigEntry {
pub fn new(id: impl Into<String>, name: impl Into<String>, settings: serde_json::Value) -> Self {
Self {
id: id.into(),
name: name.into(),
enabled: true,
settings,
}
}
}
#[derive(Debug)]
pub struct BackendConfigStore {
configs: Arc<RwLock<HashMap<String, BackendConfigEntry>>>,
storage_path: PathBuf,
}
impl BackendConfigStore {
pub async fn new(storage_path: PathBuf, defaults: Vec<BackendConfigEntry>) -> Self {
let mut configs = HashMap::new();
let mut needs_save = false;
if storage_path.exists() {
if let Ok(loaded) = Self::load_from_disk(&storage_path) {
configs = loaded;
}
} else {
needs_save = true;
}
for default in defaults {
match configs.get_mut(&default.id) {
Some(existing) => {
if existing.name.is_empty() {
existing.name = default.name.clone();
needs_save = true;
}
if existing.settings.is_null() {
existing.settings = default.settings.clone();
needs_save = true;
}
}
None => {
configs.insert(default.id.clone(), default);
needs_save = true;
}
}
}
let store = Self {
configs: Arc::new(RwLock::new(configs)),
storage_path,
};
if needs_save {
if let Err(e) = store.save_to_disk().await {
tracing::warn!("Failed to persist backend config defaults: {}", e);
}
}
store
}
fn load_from_disk(path: &Path) -> Result<HashMap<String, BackendConfigEntry>, std::io::Error> {
let contents = std::fs::read_to_string(path)?;
let entries: Vec<BackendConfigEntry> = serde_json::from_str(&contents)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(entries
.into_iter()
.map(|entry| (entry.id.clone(), entry))
.collect())
}
async fn save_to_disk(&self) -> Result<(), std::io::Error> {
let configs = self.configs.read().await;
let mut entries: Vec<BackendConfigEntry> = configs.values().cloned().collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
if let Some(parent) = self.storage_path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(&entries)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&self.storage_path, contents)?;
Ok(())
}
pub async fn list(&self) -> Vec<BackendConfigEntry> {
let configs = self.configs.read().await;
let mut list: Vec<_> = configs.values().cloned().collect();
list.sort_by(|a, b| a.name.cmp(&b.name));
list
}
pub async fn get(&self, id: &str) -> Option<BackendConfigEntry> {
let configs = self.configs.read().await;
configs.get(id).cloned()
}
pub async fn update_settings(
&self,
id: &str,
settings: serde_json::Value,
enabled: Option<bool>,
) -> Result<Option<BackendConfigEntry>, std::io::Error> {
let mut configs = self.configs.write().await;
let entry = configs.get_mut(id);
let Some(entry) = entry else {
return Ok(None);
};
entry.settings = settings;
if let Some(enabled) = enabled {
entry.enabled = enabled;
}
let updated = entry.clone();
drop(configs);
self.save_to_disk().await?;
Ok(Some(updated))
}
}
pub type SharedBackendConfigStore = Arc<BackendConfigStore>;

View File

@@ -37,6 +37,7 @@ pub mod agents;
pub mod ai_providers;
pub mod api;
pub mod backend;
pub mod backend_config;
pub mod config;
pub mod library;
pub mod mcp;