From e6f73f49f99f5acdf94a74c5eb361e977553c9e8 Mon Sep 17 00:00:00 2001 From: syn Date: Tue, 26 May 2026 16:21:32 -0500 Subject: [PATCH 1/5] feat(kiloclaw): add agent config CRUD routes --- .specs/kiloclaw-controller.md | 22 + .../src/endpoint-capabilities.test.ts | 12 + .../controller/src/endpoint-capabilities.ts | 5 + .../controller/src/openclaw-agent-cli.test.ts | 94 +++ .../controller/src/openclaw-agent-cli.ts | 176 ++++++ .../src/openclaw-agent-config.test.ts | 218 +++++++ .../controller/src/openclaw-agent-config.ts | 552 ++++++++++++++++++ .../src/routes/config-agents.test.ts | 227 +++++++ .../controller/src/routes/config-agents.ts | 156 +++++ .../controller/src/routes/config.test.ts | 9 + .../kiloclaw/controller/src/routes/config.ts | 3 + .../controller-entrypoint-smoke-test.sh | 46 ++ 12 files changed, 1520 insertions(+) create mode 100644 services/kiloclaw/controller/src/openclaw-agent-cli.test.ts create mode 100644 services/kiloclaw/controller/src/openclaw-agent-cli.ts create mode 100644 services/kiloclaw/controller/src/openclaw-agent-config.test.ts create mode 100644 services/kiloclaw/controller/src/openclaw-agent-config.ts create mode 100644 services/kiloclaw/controller/src/routes/config-agents.test.ts create mode 100644 services/kiloclaw/controller/src/routes/config-agents.ts diff --git a/.specs/kiloclaw-controller.md b/.specs/kiloclaw-controller.md index 0af540a417..06f8885829 100644 --- a/.specs/kiloclaw-controller.md +++ b/.specs/kiloclaw-controller.md @@ -651,11 +651,33 @@ Version capability hint rules: | POST | `/_kilo/config/restore/base` | Regenerate config from env vars, signal gateway reload | | POST | `/_kilo/config/replace` | Atomically replace openclaw.json (etag concurrency) | | POST | `/_kilo/config/patch` | Deep-merge a JSON patch into openclaw.json | +| GET | `/_kilo/config/agents` | List normalized configured-agent summaries and effective defaults | +| GET | `/_kilo/config/agents/:agentId` | Read one normalized configured-agent summary | +| POST | `/_kilo/config/agents` | Create a basic agent through non-interactive OpenClaw CLI behavior | +| PATCH | `/_kilo/config/agents/:agentId` | Update approved agent model/settings fields | +| DELETE | `/_kilo/config/agents/:agentId` | Delete a configured agent through non-interactive OpenClaw CLI behavior | +| PATCH | `/_kilo/config/agent-defaults` | Update approved inherited agent-default fields | | POST | `/_kilo/config/tools-md/google-workspace` | Enable/disable managed Google Workspace `TOOLS.md` section | The restore endpoint only accepts `base` as the version parameter. Other values MUST return 400. +##### Agent configuration CRUD + +1. All agent configuration endpoints MUST require bearer-token authentication. +2. `GET /_kilo/config/agents` MUST return configured agent summaries, effective defaults, and an etag representing the read config snapshot. +3. Agent reads MAY represent the implicit default `main` agent as `configured: false` when no explicit list entry exists for it. +4. `PATCH /_kilo/config/agents/:agentId` and `PATCH /_kilo/config/agent-defaults` MUST expose only controller-approved model/settings fields and MUST reject unknown patch fields. +5. Agent/default native updates MUST use guarded read-modify-write behavior that preserves unrelated configuration and sibling agent entries. +6. `POST /_kilo/config/agents` MUST delegate basic creation to non-interactive OpenClaw CLI behavior and MUST return the normalized created agent identifier. +7. `POST /_kilo/config/agents` MAY accept arbitrary absolute workspace paths. Clients MUST NOT assume every configured workspace is exposed by `/_kilo/files/*`. +8. `DELETE /_kilo/config/agents/:agentId` MUST delegate deletion to non-interactive OpenClaw CLI behavior and MUST reject deletion of `main`. +9. The delete response MUST NOT claim verified filesystem deletion or verified file retention. Filesystem disposition is controlled by the installed OpenClaw CLI/runtime behavior and is not represented by the controller response. +10. The following capability hints MUST be advertised when the corresponding CRUD routes are registered: `config.agents.read`, `config.agents.create.basic.cli`, `config.agents.update`, `config.agents.delete.cli`, and `config.agent-defaults.update`. +11. Native updates MUST report stale config etags or config changes observed before commit as `409 config_etag_conflict`. +12. Controller-originated agent create, update, defaults-update, and delete mutations MUST be serialized per config path so a lifecycle CLI mutation cannot be overwritten by a concurrent native controller update. +13. CLI lifecycle operations MUST report known reserved/not-found/conflict validation failures using stable HTTP error codes, and MUST report timeout or malformed/process failures without exposing secret environment values. + #### Environment (bearer token) | Method | Path | Description | diff --git a/services/kiloclaw/controller/src/endpoint-capabilities.test.ts b/services/kiloclaw/controller/src/endpoint-capabilities.test.ts index 4c4978e917..681a7e2577 100644 --- a/services/kiloclaw/controller/src/endpoint-capabilities.test.ts +++ b/services/kiloclaw/controller/src/endpoint-capabilities.test.ts @@ -16,6 +16,18 @@ describe('getControllerEndpointCapabilities', () => { expect(capabilities).toEqual([...new Set(CONTROLLER_ENDPOINT_CAPABILITIES)].sort()); }); + it('advertises operation-specific agent CRUD capabilities', () => { + expect(getControllerEndpointCapabilities()).toEqual( + expect.arrayContaining([ + 'config.agents.read', + 'config.agents.create.basic.cli', + 'config.agents.update', + 'config.agents.delete.cli', + 'config.agent-defaults.update', + ]) + ); + }); + it('includes conditional Kilo Chat capabilities only when requested', () => { const defaultCapabilities = getControllerEndpointCapabilities(); const kiloChatCapabilities = getControllerEndpointCapabilities({ diff --git a/services/kiloclaw/controller/src/endpoint-capabilities.ts b/services/kiloclaw/controller/src/endpoint-capabilities.ts index 0fbc65df6f..fb889012ae 100644 --- a/services/kiloclaw/controller/src/endpoint-capabilities.ts +++ b/services/kiloclaw/controller/src/endpoint-capabilities.ts @@ -5,6 +5,11 @@ export const CONTROLLER_ENDPOINT_CAPABILITIES = [ 'config.restore', 'config.replace', 'config.patch', + 'config.agents.read', + 'config.agents.create.basic.cli', + 'config.agents.update', + 'config.agents.delete.cli', + 'config.agent-defaults.update', 'config.tools-md.google-workspace', 'env.patch', 'doctor.run', diff --git a/services/kiloclaw/controller/src/openclaw-agent-cli.test.ts b/services/kiloclaw/controller/src/openclaw-agent-cli.test.ts new file mode 100644 index 0000000000..64a10d88d0 --- /dev/null +++ b/services/kiloclaw/controller/src/openclaw-agent-cli.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + BasicAgentCreateBodySchema, + OpenClawAgentCliError, + createAgentViaCli, + deleteAgentViaCli, +} from './openclaw-agent-cli'; + +describe('createAgentViaCli', () => { + it('uses argv-only non-interactive JSON creation arguments', async () => { + const run = vi.fn(async () => ({ + stdout: JSON.stringify({ + agentId: 'research', + name: 'Research', + workspace: '/root/.openclaw/workspace-research', + agentDir: '/root/.openclaw/agents/research/agent', + model: 'kilocode/default', + bindings: { added: [], updated: [], skipped: [], conflicts: [] }, + }), + stderr: '', + })); + const body = BasicAgentCreateBodySchema.parse({ + name: 'Research', + workspace: '/root/.openclaw/workspace-research', + agentDir: '/root/.openclaw/agents/research/agent', + model: 'kilocode/default', + bindings: ['discord:team'], + }); + + const result = await createAgentViaCli(body, { run }); + + expect(result.agentId).toBe('research'); + expect(run).toHaveBeenCalledWith([ + 'agents', + 'add', + 'Research', + '--workspace', + '/root/.openclaw/workspace-research', + '--agent-dir', + '/root/.openclaw/agents/research/agent', + '--model', + 'kilocode/default', + '--bind', + 'discord:team', + '--non-interactive', + '--json', + ]); + }); + + it('rejects malformed CLI JSON output', async () => { + await expect( + createAgentViaCli( + BasicAgentCreateBodySchema.parse({ name: 'Research', workspace: '/tmp/research' }), + { run: async () => ({ stdout: 'not-json', stderr: '' }) } + ) + ).rejects.toMatchObject({ code: 'openclaw_cli_failed', status: 502 }); + }); +}); + +describe('deleteAgentViaCli', () => { + it('uses forced JSON deletion arguments and parses deletion summary', async () => { + const run = vi.fn(async () => ({ + stdout: JSON.stringify({ + agentId: 'research', + workspace: '/root/.openclaw/workspace-research', + agentDir: '/root/.openclaw/agents/research/agent', + sessionsDir: '/root/.openclaw/agents/research/sessions', + removedBindings: 2, + removedAllow: 1, + }), + stderr: '', + })); + + const result = await deleteAgentViaCli('research', { run }); + + expect(run).toHaveBeenCalledWith(['agents', 'delete', 'research', '--force', '--json']); + expect(result.removedBindings).toBe(2); + expect(result.removedAllow).toBe(1); + }); + + it('propagates typed CLI operation failures', async () => { + await expect( + deleteAgentViaCli('main', { + run: async () => { + throw new OpenClawAgentCliError( + 400, + 'reserved_agent_id', + 'The default agent is reserved' + ); + }, + }) + ).rejects.toMatchObject({ code: 'reserved_agent_id', status: 400 }); + }); +}); diff --git a/services/kiloclaw/controller/src/openclaw-agent-cli.ts b/services/kiloclaw/controller/src/openclaw-agent-cli.ts new file mode 100644 index 0000000000..e3e8c5631a --- /dev/null +++ b/services/kiloclaw/controller/src/openclaw-agent-cli.ts @@ -0,0 +1,176 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { z } from 'zod'; + +const AGENT_CLI_TIMEOUT_MS = 30_000; +const AGENT_CLI_MAX_OUTPUT_BYTES = 1_048_576; + +export const BasicAgentCreateBodySchema = z + .object({ + name: z.string().trim().min(1), + workspace: z + .string() + .trim() + .min(1) + .refine(value => path.isAbsolute(value), { + message: 'Workspace must be an absolute path', + }), + agentDir: z + .string() + .trim() + .min(1) + .refine(value => path.isAbsolute(value), { + message: 'Agent directory must be an absolute path', + }) + .optional(), + model: z.string().trim().min(1).optional(), + bindings: z.array(z.string().trim().min(1)).optional(), + }) + .strict(); + +export type BasicAgentCreateBody = z.infer; + +const CreateResultSchema = z.object({ + agentId: z.string().min(1), + name: z.string().min(1), + workspace: z.string().min(1), + agentDir: z.string().min(1), + model: z.string().optional(), + bindings: z + .object({ + added: z.array(z.string()), + updated: z.array(z.string()), + skipped: z.array(z.string()), + conflicts: z.array(z.string()), + }) + .optional(), +}); + +const DeleteResultSchema = z.object({ + agentId: z.string().min(1), + workspace: z.string().min(1), + agentDir: z.string().min(1), + sessionsDir: z.string().min(1), + removedBindings: z.number().int().min(0), + removedAllow: z.number().int().min(0), +}); + +export type CreateAgentCliResult = z.infer; +export type DeleteAgentCliResult = z.infer; + +type CliProcessResult = { + stdout: string; + stderr: string; +}; + +export type OpenClawAgentCliDeps = { + run: (args: string[]) => Promise; +}; + +export class OpenClawAgentCliError extends Error { + readonly status: number; + readonly code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.name = 'OpenClawAgentCliError'; + this.status = status; + this.code = code; + } +} + +const defaultDeps: OpenClawAgentCliDeps = { + run: args => + new Promise((resolve, reject) => { + execFile( + 'openclaw', + args, + { + env: process.env, + timeout: AGENT_CLI_TIMEOUT_MS, + maxBuffer: AGENT_CLI_MAX_OUTPUT_BYTES, + encoding: 'utf8', + }, + (error, stdout, stderr) => { + if (error) { + if ('killed' in error && error.killed === true) { + reject( + new OpenClawAgentCliError( + 504, + 'openclaw_cli_timeout', + 'OpenClaw agent command timed out' + ) + ); + return; + } + reject(mapCliFailure(`${stderr}\n${error.message}`)); + return; + } + resolve({ stdout, stderr }); + } + ); + }), +}; + +function mapCliFailure(output: string): OpenClawAgentCliError { + if (/cannot be deleted|is reserved/i.test(output)) { + return new OpenClawAgentCliError(400, 'reserved_agent_id', 'The default agent is reserved'); + } + if (/already exists/i.test(output)) { + return new OpenClawAgentCliError(409, 'agent_exists', 'Agent already exists'); + } + if (/not found/i.test(output)) { + return new OpenClawAgentCliError(404, 'agent_not_found', 'Agent not found'); + } + return new OpenClawAgentCliError(502, 'openclaw_cli_failed', 'OpenClaw agent command failed'); +} + +function parseCliJson(stdout: string, schema: z.ZodType): T { + let parsed: unknown; + try { + parsed = JSON.parse(stdout.trim()); + } catch { + throw new OpenClawAgentCliError( + 502, + 'openclaw_cli_failed', + 'OpenClaw agent command returned invalid JSON' + ); + } + const result = schema.safeParse(parsed); + if (!result.success) { + throw new OpenClawAgentCliError( + 502, + 'openclaw_cli_failed', + 'OpenClaw agent command returned an invalid response' + ); + } + return result.data; +} + +export async function createAgentViaCli( + body: BasicAgentCreateBody, + deps: OpenClawAgentCliDeps = defaultDeps +): Promise { + const args = [ + 'agents', + 'add', + body.name, + '--workspace', + body.workspace, + ...(body.agentDir ? ['--agent-dir', body.agentDir] : []), + ...(body.model ? ['--model', body.model] : []), + ...(body.bindings ?? []).flatMap(binding => ['--bind', binding]), + '--non-interactive', + '--json', + ]; + const result = await deps.run(args); + return parseCliJson(result.stdout, CreateResultSchema); +} + +export async function deleteAgentViaCli( + agentId: string, + deps: OpenClawAgentCliDeps = defaultDeps +): Promise { + const result = await deps.run(['agents', 'delete', agentId, '--force', '--json']); + return parseCliJson(result.stdout, DeleteResultSchema); +} diff --git a/services/kiloclaw/controller/src/openclaw-agent-config.test.ts b/services/kiloclaw/controller/src/openclaw-agent-config.test.ts new file mode 100644 index 0000000000..c6d2068766 --- /dev/null +++ b/services/kiloclaw/controller/src/openclaw-agent-config.test.ts @@ -0,0 +1,218 @@ +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + AgentConfigError, + AgentDefaultsPatchBodySchema, + AgentSettingsPatchBodySchema, + readAgentConfigSnapshot, + readAgentSummary, + serializeAgentConfigMutation, + summarizeAgentConfig, + updateAgentDefaults, + updateAgentSettings, +} from './openclaw-agent-config'; + +const tempDirs: string[] = []; + +async function configFixture(config: unknown): Promise { + const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'kiloclaw-agent-config-')); + tempDirs.push(dir); + const configPath = path.join(dir, 'openclaw.json'); + await fsPromises.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + return configPath; +} + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map(dir => fsPromises.rm(dir, { recursive: true, force: true })) + ); +}); + +describe('agent config summaries', () => { + it('synthesizes an unconfigured main agent using default model settings', async () => { + const configPath = await configFixture({ + agents: { + defaults: { + model: { primary: 'kilocode/kilo-auto/balanced', fallbacks: ['kilocode/fallback'] }, + thinkingDefault: 'medium', + }, + }, + }); + const snapshot = readAgentConfigSnapshot({ configPath }); + + const result = summarizeAgentConfig(snapshot.config); + + expect(result.agents).toEqual([ + expect.objectContaining({ + id: 'main', + configured: false, + model: { + primary: 'kilocode/kilo-auto/balanced', + fallbacks: ['kilocode/fallback'], + source: 'defaults', + }, + }), + ]); + expect(result.defaults.settings.thinkingDefault).toBe('medium'); + }); + + it('reads implicit main but rejects an absent non-default agent', async () => { + const configPath = await configFixture({ agents: { defaults: {} } }); + + expect(readAgentSummary('main', { configPath }).agent.configured).toBe(false); + expect(() => readAgentSummary('research', { configPath })).toThrowError(AgentConfigError); + }); +}); + +describe('native agent config mutations', () => { + it('updates one agent without replacing sibling entries and writes mode 0600', async () => { + const configPath = await configFixture({ + agents: { + list: [ + { id: 'main', model: { primary: 'kilocode/main' } }, + { id: 'research', model: 'kilocode/old' }, + ], + }, + gateway: { port: 3001 }, + }); + const initial = readAgentConfigSnapshot({ configPath }); + const patch = AgentSettingsPatchBodySchema.parse({ + etag: initial.etag, + set: { model: { primary: 'kilocode/new', fallbacks: ['kilocode/fallback'] } }, + }); + + const result = await updateAgentSettings('research', patch, { configPath }); + + expect(result.agent.model).toEqual({ + primary: 'kilocode/new', + fallbacks: ['kilocode/fallback'], + source: 'agent', + }); + expect(result.snapshot.config.agents?.list).toEqual([ + { id: 'main', model: { primary: 'kilocode/main' } }, + { id: 'research', model: { primary: 'kilocode/new', fallbacks: ['kilocode/fallback'] } }, + ]); + expect(result.snapshot.config.gateway).toEqual({ port: 3001 }); + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + }); + + it('preserves model subfields omitted from a partial model patch', async () => { + const configPath = await configFixture({ + agents: { + list: [ + { id: 'research', model: { primary: 'kilocode/old', fallbacks: ['kilocode/keep'] } }, + ], + }, + }); + const patch = AgentSettingsPatchBodySchema.parse({ + set: { model: { primary: 'kilocode/new' } }, + }); + + const result = await updateAgentSettings('research', patch, { configPath }); + + expect(result.agent.model).toEqual({ + primary: 'kilocode/new', + fallbacks: ['kilocode/keep'], + source: 'agent', + }); + }); + + it('materializes and updates implicit main', async () => { + const configPath = await configFixture({ agents: { defaults: {} } }); + const patch = AgentSettingsPatchBodySchema.parse({ + set: { verboseDefault: 'off' }, + }); + + const result = await updateAgentSettings('main', patch, { configPath }); + + expect(result.agent.configured).toBe(true); + expect(result.snapshot.config.agents?.list).toEqual([{ id: 'main', verboseDefault: 'off' }]); + }); + + it('updates agent defaults while preserving configured agents', async () => { + const configPath = await configFixture({ + agents: { list: [{ id: 'research' }] }, + }); + const patch = AgentDefaultsPatchBodySchema.parse({ + set: { model: { primary: 'kilocode/default' }, thinkingDefault: 'medium' }, + }); + + const result = await updateAgentDefaults(patch, { configPath }); + + expect(result.defaults.model).toEqual({ primary: 'kilocode/default', fallbacks: [] }); + expect(result.snapshot.config.agents?.list).toEqual([{ id: 'research' }]); + }); + + it('serializes lifecycle mutations with native patches', async () => { + const configPath = await configFixture({ agents: { list: [{ id: 'research' }] } }); + let markLifecycleStarted: () => void = () => { + throw new Error('Lifecycle start signal was not initialized'); + }; + const lifecycleStarted = new Promise(resolve => { + markLifecycleStarted = resolve; + }); + let releaseLifecycle: () => void = () => { + throw new Error('Lifecycle release signal was not initialized'); + }; + const lifecycleGate = new Promise(resolve => { + releaseLifecycle = resolve; + }); + + const lifecycleMutation = serializeAgentConfigMutation( + async () => { + markLifecycleStarted(); + await lifecycleGate; + const config = JSON.parse(await fsPromises.readFile(configPath, 'utf8')) as { + agents: { list: Array> }; + }; + config.agents.list.push({ id: 'created-by-cli' }); + await fsPromises.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); + }, + { configPath } + ); + await lifecycleStarted; + + const patch = AgentSettingsPatchBodySchema.parse({ set: { verboseDefault: 'off' } }); + const nativePatch = updateAgentSettings('research', patch, { configPath }); + releaseLifecycle(); + await Promise.all([lifecycleMutation, nativePatch]); + + const updated = readAgentConfigSnapshot({ configPath }); + expect(updated.config.agents?.list).toEqual([ + { id: 'research', verboseDefault: 'off' }, + { id: 'created-by-cli' }, + ]); + }); + + it('rejects stale etags without writing', async () => { + const configPath = await configFixture({ agents: { list: [{ id: 'research' }] } }); + const before = await fsPromises.readFile(configPath, 'utf8'); + const patch = AgentSettingsPatchBodySchema.parse({ + etag: 'stale', + set: { verboseDefault: 'off' }, + }); + + await expect(updateAgentSettings('research', patch, { configPath })).rejects.toMatchObject({ + code: 'config_etag_conflict', + status: 409, + }); + expect(await fsPromises.readFile(configPath, 'utf8')).toBe(before); + }); + + it('rejects duplicate normalized IDs on mutation paths', async () => { + const configPath = await configFixture({ + agents: { list: [{ id: 'Research' }, { id: 'research' }] }, + }); + const patch = AgentSettingsPatchBodySchema.parse({ set: { verboseDefault: 'off' } }); + + await expect(updateAgentSettings('research', patch, { configPath })).rejects.toMatchObject({ + code: 'invalid_agent_config', + status: 422, + }); + }); +}); diff --git a/services/kiloclaw/controller/src/openclaw-agent-config.ts b/services/kiloclaw/controller/src/openclaw-agent-config.ts new file mode 100644 index 0000000000..4ce5cae24e --- /dev/null +++ b/services/kiloclaw/controller/src/openclaw-agent-config.ts @@ -0,0 +1,552 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { z } from 'zod'; +import { atomicWrite } from './atomic-write'; +import { backupConfigFile } from './config-writer'; + +export const OPENCLAW_CONFIG_PATH = '/root/.openclaw/openclaw.json'; +export const DEFAULT_AGENT_ID = 'main'; + +const INVALID_AGENT_ID_CHARS = /[^a-z0-9_-]+/g; +const LEADING_DASHES = /^-+/; +const TRAILING_DASHES = /-+$/; +const VALID_AGENT_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/i; + +const AgentModelSchema = z.union([ + z.string().min(1), + z + .object({ + primary: z.string().min(1).optional(), + fallbacks: z.array(z.string().min(1)).optional(), + }) + .passthrough(), +]); + +const ThinkingDefaultSchema = z.enum([ + 'off', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'adaptive', + 'max', +]); +const VerboseDefaultSchema = z.enum(['off', 'on', 'full']); +const ReasoningDefaultSchema = z.enum(['on', 'off', 'stream']); + +const AgentEntrySchema = z + .object({ + id: z.string().min(1), + name: z.string().optional(), + workspace: z.string().optional(), + agentDir: z.string().optional(), + model: AgentModelSchema.optional(), + thinkingDefault: ThinkingDefaultSchema.optional(), + verboseDefault: VerboseDefaultSchema.optional(), + reasoningDefault: ReasoningDefaultSchema.optional(), + fastModeDefault: z.boolean().optional(), + }) + .passthrough(); + +const AgentDefaultsSchema = z + .object({ + model: AgentModelSchema.optional(), + thinkingDefault: ThinkingDefaultSchema.optional(), + verboseDefault: VerboseDefaultSchema.optional(), + }) + .passthrough(); + +const OpenClawAgentConfigSchema = z + .object({ + agents: z + .object({ + defaults: AgentDefaultsSchema.optional(), + list: z.array(AgentEntrySchema).optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +const EditableModelSchema = z + .object({ + primary: z.string().trim().min(1).optional(), + fallbacks: z.array(z.string().trim().min(1)).optional(), + }) + .strict() + .refine(model => model.primary !== undefined || model.fallbacks !== undefined, { + message: 'Model patch must include primary or fallbacks', + }); + +const EditableSettingsSchema = z + .object({ + model: EditableModelSchema.optional(), + thinkingDefault: ThinkingDefaultSchema.optional(), + verboseDefault: VerboseDefaultSchema.optional(), + reasoningDefault: ReasoningDefaultSchema.optional(), + fastModeDefault: z.boolean().optional(), + }) + .strict(); + +const EditableDefaultsSettingsSchema = z + .object({ + model: EditableModelSchema.optional(), + thinkingDefault: ThinkingDefaultSchema.optional(), + verboseDefault: VerboseDefaultSchema.optional(), + }) + .strict(); + +const EditableUnsetFieldSchema = z.enum([ + 'model', + 'model.primary', + 'model.fallbacks', + 'thinkingDefault', + 'verboseDefault', + 'reasoningDefault', + 'fastModeDefault', +]); + +const EditableDefaultsUnsetFieldSchema = z.enum([ + 'model', + 'model.primary', + 'model.fallbacks', + 'thinkingDefault', + 'verboseDefault', +]); + +export const AgentSettingsPatchBodySchema = z + .object({ + etag: z.string().min(1).optional(), + set: EditableSettingsSchema.default({}), + unset: z.array(EditableUnsetFieldSchema).default([]), + }) + .strict() + .refine(body => Object.keys(body.set).length > 0 || body.unset.length > 0, { + message: 'Patch must set or unset at least one field', + }); + +export const AgentDefaultsPatchBodySchema = z + .object({ + etag: z.string().min(1).optional(), + set: EditableDefaultsSettingsSchema.default({}), + unset: z.array(EditableDefaultsUnsetFieldSchema).default([]), + }) + .strict() + .refine(body => Object.keys(body.set).length > 0 || body.unset.length > 0, { + message: 'Patch must set or unset at least one field', + }); + +export type AgentSettingsPatchBody = z.infer; +export type AgentDefaultsPatchBody = z.infer; +type OpenClawAgentConfig = z.infer; +type AgentEntry = z.infer; +type AgentDefaults = z.infer; +type ModelValue = z.infer; + +type NormalizedModel = { + primary: string | null; + fallbacks: string[]; +}; + +export type AgentSummary = { + id: string; + name: string | null; + configured: boolean; + workspace: string | null; + agentDir: string | null; + model: NormalizedModel & { source: 'agent' | 'defaults' | null }; + rawModel: ModelValue | null; + settings: { + thinkingDefault: string | null; + verboseDefault: string | null; + reasoningDefault: string | null; + fastModeDefault: boolean | null; + }; +}; + +export type AgentConfigSummary = { + defaults: { + model: NormalizedModel | null; + settings: AgentSummary['settings']; + }; + agents: AgentSummary[]; +}; + +export type AgentConfigSnapshot = { + raw: string; + etag: string; + config: OpenClawAgentConfig; +}; + +export class AgentConfigError extends Error { + readonly status: number; + readonly code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.name = 'AgentConfigError'; + this.status = status; + this.code = code; + } +} + +export type AgentConfigOptions = { + configPath?: string; +}; + +const mutationQueues = new Map>(); + +export function computeConfigEtag(raw: string): string { + return crypto.createHash('md5').update(raw).digest('hex'); +} + +export function normalizeAgentId(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return DEFAULT_AGENT_ID; + } + const normalized = trimmed.toLowerCase(); + if (VALID_AGENT_ID.test(trimmed)) { + return normalized; + } + return ( + normalized + .replace(INVALID_AGENT_ID_CHARS, '-') + .replace(LEADING_DASHES, '') + .replace(TRAILING_DASHES, '') + .slice(0, 64) || DEFAULT_AGENT_ID + ); +} + +export function requireAgentId(value: string): string { + if (!value.trim()) { + throw new AgentConfigError(400, 'invalid_agent_id', 'Agent id is required'); + } + return normalizeAgentId(value); +} + +function normalizeModel(model: ModelValue | undefined): NormalizedModel | null { + if (typeof model === 'string') { + return { primary: model.trim() || null, fallbacks: [] }; + } + if (model === undefined) { + return null; + } + return { + primary: model.primary?.trim() || null, + fallbacks: (model.fallbacks ?? []).map(item => item.trim()).filter(Boolean), + }; +} + +function normalizeModelForWrite(model: z.infer): { + primary?: string; + fallbacks?: string[]; +} { + return { + ...(model.primary !== undefined ? { primary: model.primary.trim() } : {}), + ...(model.fallbacks !== undefined + ? { fallbacks: model.fallbacks.map(item => item.trim()).filter(Boolean) } + : {}), + }; +} + +function settingsOf(entry: AgentEntry | AgentDefaults | undefined): AgentSummary['settings'] { + const reasoningDefault = + entry && 'reasoningDefault' in entry && typeof entry.reasoningDefault === 'string' + ? entry.reasoningDefault + : null; + const fastModeDefault = + entry && 'fastModeDefault' in entry && typeof entry.fastModeDefault === 'boolean' + ? entry.fastModeDefault + : null; + return { + thinkingDefault: entry?.thinkingDefault ?? null, + verboseDefault: entry?.verboseDefault ?? null, + reasoningDefault, + fastModeDefault, + }; +} + +function findConfiguredEntry(config: OpenClawAgentConfig, agentId: string): AgentEntry | undefined { + return config.agents?.list?.find(entry => normalizeAgentId(entry.id) === agentId); +} + +function assertUniqueAgentIds(config: OpenClawAgentConfig): void { + const seen = new Set(); + for (const entry of config.agents?.list ?? []) { + const normalized = requireAgentId(entry.id); + if (seen.has(normalized)) { + throw new AgentConfigError(422, 'invalid_agent_config', `Duplicate agent id: ${normalized}`); + } + seen.add(normalized); + } +} + +export function readAgentConfigSnapshot(options: AgentConfigOptions = {}): AgentConfigSnapshot { + const configPath = options.configPath ?? OPENCLAW_CONFIG_PATH; + let raw: string; + try { + raw = fs.readFileSync(configPath, 'utf8'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new AgentConfigError( + 500, + 'agent_config_read_failed', + `Failed to read config: ${message}` + ); + } + + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new AgentConfigError(500, 'invalid_agent_config', 'OpenClaw config is not valid JSON'); + } + const parsed = OpenClawAgentConfigSchema.safeParse(value); + if (!parsed.success) { + throw new AgentConfigError( + 422, + 'invalid_agent_config', + 'OpenClaw agent config shape is invalid' + ); + } + return { raw, etag: computeConfigEtag(raw), config: parsed.data }; +} + +export function summarizeAgentConfig(config: OpenClawAgentConfig): AgentConfigSummary { + const defaults = config.agents?.defaults; + const defaultsModel = normalizeModel(defaults?.model); + const entries = config.agents?.list?.length ? config.agents.list : [{ id: DEFAULT_AGENT_ID }]; + return { + defaults: { + model: defaultsModel, + settings: settingsOf(defaults), + }, + agents: entries.map(entry => { + const id = normalizeAgentId(entry.id); + const ownModel = normalizeModel(entry.model); + const effectiveModel = ownModel ?? defaultsModel ?? { primary: null, fallbacks: [] }; + return { + id, + name: entry.name ?? null, + configured: findConfiguredEntry(config, id) !== undefined, + workspace: entry.workspace ?? null, + agentDir: entry.agentDir ?? null, + model: { + ...effectiveModel, + source: ownModel ? 'agent' : defaultsModel ? 'defaults' : null, + }, + rawModel: entry.model ?? null, + settings: settingsOf(entry), + }; + }), + }; +} + +export function readAgentSummary( + agentId: string, + options: AgentConfigOptions = {} +): { snapshot: AgentConfigSnapshot; agent: AgentSummary } { + const normalized = requireAgentId(agentId); + const snapshot = readAgentConfigSnapshot(options); + const entry = findConfiguredEntry(snapshot.config, normalized); + if (entry === undefined && normalized !== DEFAULT_AGENT_ID) { + throw new AgentConfigError(404, 'agent_not_found', `Agent "${normalized}" not found`); + } + const summarizedEntry = entry ?? { id: DEFAULT_AGENT_ID }; + const agent = summarizeAgentConfig({ + ...snapshot.config, + agents: { ...snapshot.config.agents, list: [summarizedEntry] }, + }).agents[0]; + if (!agent) { + throw new AgentConfigError(500, 'agent_config_read_failed', 'Unable to summarize agent'); + } + return { snapshot, agent: { ...agent, configured: entry !== undefined } }; +} + +function applySettingsPatch( + target: AgentEntry | AgentDefaults, + patch: AgentSettingsPatchBody +): void { + for (const field of patch.unset) { + switch (field) { + case 'model': + delete target.model; + break; + case 'model.primary': + if (typeof target.model === 'string') { + delete target.model; + } else if (target.model !== undefined) { + delete target.model.primary; + } + break; + case 'model.fallbacks': + if (target.model !== undefined && typeof target.model !== 'string') { + delete target.model.fallbacks; + } + break; + case 'thinkingDefault': + delete target.thinkingDefault; + break; + case 'verboseDefault': + delete target.verboseDefault; + break; + case 'reasoningDefault': + delete target.reasoningDefault; + break; + case 'fastModeDefault': + delete target.fastModeDefault; + break; + } + } + + if (patch.set.model !== undefined) { + const existingModel = + typeof target.model === 'string' + ? { primary: target.model } + : target.model === undefined + ? {} + : target.model; + target.model = { ...existingModel, ...normalizeModelForWrite(patch.set.model) }; + } + if (patch.set.thinkingDefault !== undefined) { + target.thinkingDefault = patch.set.thinkingDefault; + } + if (patch.set.verboseDefault !== undefined) { + target.verboseDefault = patch.set.verboseDefault; + } + if (patch.set.reasoningDefault !== undefined) { + target.reasoningDefault = patch.set.reasoningDefault; + } + if (patch.set.fastModeDefault !== undefined) { + target.fastModeDefault = patch.set.fastModeDefault; + } +} + +async function enqueueMutation(configPath: string, operation: () => Promise): Promise { + const previous = mutationQueues.get(configPath) ?? Promise.resolve(); + let complete: (() => void) | undefined; + const currentComplete = new Promise(resolve => { + complete = resolve; + }); + const tail = previous.catch(() => undefined).then(() => currentComplete); + mutationQueues.set(configPath, tail); + await previous.catch(() => undefined); + try { + return await operation(); + } finally { + complete?.(); + if (mutationQueues.get(configPath) === tail) { + mutationQueues.delete(configPath); + } + } +} + +export async function serializeAgentConfigMutation( + operation: () => Promise, + options: AgentConfigOptions = {} +): Promise { + const configPath = options.configPath ?? OPENCLAW_CONFIG_PATH; + return enqueueMutation(configPath, operation); +} + +async function mutateAgentConfig( + etag: string | undefined, + mutate: (config: OpenClawAgentConfig) => T, + options: AgentConfigOptions +): Promise<{ snapshot: AgentConfigSnapshot; result: T }> { + const configPath = options.configPath ?? OPENCLAW_CONFIG_PATH; + return serializeAgentConfigMutation(async () => { + const current = readAgentConfigSnapshot({ configPath }); + assertUniqueAgentIds(current.config); + if (etag !== undefined && current.etag !== etag) { + throw new AgentConfigError(409, 'config_etag_conflict', 'Config changed since last read'); + } + const result = mutate(current.config); + assertUniqueAgentIds(current.config); + + // OpenClaw config mutations use a snapshot hash guard rather than a shared + // file lock. Re-check the source just before this atomic write to reject + // changes observed since our read-modify step. + const latest = readAgentConfigSnapshot({ configPath }); + if (latest.etag !== current.etag) { + throw new AgentConfigError(409, 'config_etag_conflict', 'Config changed during update'); + } + + const serialized = `${JSON.stringify(current.config, null, 2)}\n`; + backupConfigFile(configPath); + atomicWrite(configPath, serialized, undefined, { mode: 0o600 }); + const snapshot = readAgentConfigSnapshot({ configPath }); + return { snapshot, result }; + }, options); +} + +export async function updateAgentSettings( + agentId: string, + patch: AgentSettingsPatchBody, + options: AgentConfigOptions = {} +): Promise<{ snapshot: AgentConfigSnapshot; agent: AgentSummary }> { + const normalized = requireAgentId(agentId); + const { snapshot } = await mutateAgentConfig( + patch.etag, + config => { + let entry = findConfiguredEntry(config, normalized); + if (entry === undefined) { + if (normalized !== DEFAULT_AGENT_ID) { + throw new AgentConfigError(404, 'agent_not_found', `Agent "${normalized}" not found`); + } + config.agents ??= {}; + config.agents.list ??= []; + entry = { id: DEFAULT_AGENT_ID }; + config.agents.list.push(entry); + } + applySettingsPatch(entry, patch); + const validated = AgentEntrySchema.safeParse(entry); + if (!validated.success) { + throw new AgentConfigError(422, 'invalid_config_after_patch', 'Updated agent is invalid'); + } + }, + options + ); + const updatedEntry = findConfiguredEntry(snapshot.config, normalized); + if (updatedEntry === undefined) { + throw new AgentConfigError(500, 'invalid_config_after_patch', 'Updated agent is missing'); + } + const agent = summarizeAgentConfig({ + ...snapshot.config, + agents: { ...snapshot.config.agents, list: [updatedEntry] }, + }).agents[0]; + if (!agent) { + throw new AgentConfigError( + 500, + 'invalid_config_after_patch', + 'Unable to summarize updated agent' + ); + } + return { snapshot, agent }; +} + +export async function updateAgentDefaults( + patch: AgentDefaultsPatchBody, + options: AgentConfigOptions = {} +): Promise<{ snapshot: AgentConfigSnapshot; defaults: AgentConfigSummary['defaults'] }> { + const { snapshot } = await mutateAgentConfig( + patch.etag, + config => { + config.agents ??= {}; + config.agents.defaults ??= {}; + applySettingsPatch(config.agents.defaults, patch); + const validated = AgentDefaultsSchema.safeParse(config.agents.defaults); + if (!validated.success) { + throw new AgentConfigError( + 422, + 'invalid_config_after_patch', + 'Updated defaults are invalid' + ); + } + }, + options + ); + return { snapshot, defaults: summarizeAgentConfig(snapshot.config).defaults }; +} diff --git a/services/kiloclaw/controller/src/routes/config-agents.test.ts b/services/kiloclaw/controller/src/routes/config-agents.test.ts new file mode 100644 index 0000000000..bf47c43299 --- /dev/null +++ b/services/kiloclaw/controller/src/routes/config-agents.test.ts @@ -0,0 +1,227 @@ +import { Hono } from 'hono'; +import { describe, expect, it, vi } from 'vitest'; +import type { AgentConfigSnapshot, AgentSummary } from '../openclaw-agent-config'; +import { AgentConfigError } from '../openclaw-agent-config'; +import { OpenClawAgentCliError } from '../openclaw-agent-cli'; +import { registerAgentConfigRoutes, type AgentRouteDeps } from './config-agents'; + +const snapshot: AgentConfigSnapshot = { + raw: '{}', + etag: 'etag-1', + config: { agents: { list: [{ id: 'research' }] } }, +}; + +const agent: AgentSummary = { + id: 'research', + name: 'Research', + configured: true, + workspace: '/root/.openclaw/workspace-research', + agentDir: '/root/.openclaw/agents/research/agent', + model: { primary: 'kilocode/default', fallbacks: [], source: 'agent' }, + rawModel: { primary: 'kilocode/default' }, + settings: { + thinkingDefault: null, + verboseDefault: null, + reasoningDefault: null, + fastModeDefault: null, + }, +}; + +function createDeps(overrides: Partial = {}): AgentRouteDeps { + return { + readSnapshot: vi.fn(() => snapshot), + readSummary: vi.fn(() => ({ snapshot, agent })), + serializeMutation: async operation => operation(), + summarize: vi.fn(() => ({ + defaults: { + model: null, + settings: { + thinkingDefault: null, + verboseDefault: null, + reasoningDefault: null, + fastModeDefault: null, + }, + }, + agents: [agent], + })), + updateSettings: vi.fn(async () => ({ snapshot, agent })), + updateDefaults: vi.fn(async () => ({ + snapshot, + defaults: { + model: { primary: 'kilocode/default', fallbacks: [] }, + settings: agent.settings, + }, + })), + createViaCli: vi.fn(async () => ({ + agentId: 'research', + name: 'Research', + workspace: '/root/.openclaw/workspace-research', + agentDir: '/root/.openclaw/agents/research/agent', + })), + deleteViaCli: vi.fn(async () => ({ + agentId: 'research', + workspace: '/root/.openclaw/workspace-research', + agentDir: '/root/.openclaw/agents/research/agent', + sessionsDir: '/root/.openclaw/agents/research/sessions', + removedBindings: 1, + removedAllow: 0, + })), + ...overrides, + }; +} + +function makeApp(deps: AgentRouteDeps): Hono { + const app = new Hono(); + registerAgentConfigRoutes(app, deps); + return app; +} + +describe('agent config read routes', () => { + it('returns normalized agent summaries with etag', async () => { + const response = await makeApp(createDeps()).request('/_kilo/config/agents'); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ etag: 'etag-1', agents: [{ id: 'research' }] }); + }); + + it('returns only one normalized agent summary and its etag', async () => { + const response = await makeApp(createDeps()).request('/_kilo/config/agents/research'); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ etag: 'etag-1', agent }); + }); +}); + +describe('agent config mutation routes', () => { + it('creates a basic agent inside mutation serialization then returns its normalized summary', async () => { + let serializedMutations = 0; + const deps = createDeps({ + serializeMutation: async operation => { + serializedMutations += 1; + return operation(); + }, + }); + const response = await makeApp(deps).request('/_kilo/config/agents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Research', workspace: '/root/.openclaw/workspace-research' }), + }); + + expect(response.status).toBe(200); + expect(serializedMutations).toBe(1); + expect(deps.createViaCli).toHaveBeenCalledWith({ + name: 'Research', + workspace: '/root/.openclaw/workspace-research', + }); + expect(await response.json()).toMatchObject({ + ok: true, + etag: 'etag-1', + agent: { id: 'research' }, + }); + }); + + it('updates allowed per-agent settings', async () => { + const deps = createDeps(); + const response = await makeApp(deps).request('/_kilo/config/agents/research', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ set: { verboseDefault: 'off' } }), + }); + + expect(response.status).toBe(200); + expect(deps.updateSettings).toHaveBeenCalledWith( + 'research', + expect.objectContaining({ set: { verboseDefault: 'off' } }) + ); + }); + + it('updates agent defaults on the unambiguous defaults route', async () => { + const deps = createDeps(); + const response = await makeApp(deps).request('/_kilo/config/agent-defaults', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ set: { model: { primary: 'kilocode/default' } } }), + }); + + expect(response.status).toBe(200); + expect(deps.updateDefaults).toHaveBeenCalledOnce(); + }); + + it('rejects settings that OpenClaw does not support on inherited defaults', async () => { + const response = await makeApp(createDeps()).request('/_kilo/config/agent-defaults', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ set: { reasoningDefault: 'on' } }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: 'invalid_agent_request' }); + }); + + it('deletes through serialized CLI execution without claiming filesystem disposition', async () => { + let serializedMutations = 0; + const deps = createDeps({ + serializeMutation: async operation => { + serializedMutations += 1; + return operation(); + }, + }); + const response = await makeApp(deps).request('/_kilo/config/agents/research', { + method: 'DELETE', + }); + + expect(response.status).toBe(200); + expect(serializedMutations).toBe(1); + expect(deps.deleteViaCli).toHaveBeenCalledWith('research'); + expect(await response.json()).toMatchObject({ + ok: true, + agentId: 'research', + filesystemDisposition: 'unverified', + }); + }); + + it('rejects unsupported settings fields and removed target expectation guards', async () => { + const app = makeApp(createDeps()); + const unsupportedFieldResponse = await app.request('/_kilo/config/agents/research', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ set: { workspace: '/tmp/new' } }), + }); + expect(unsupportedFieldResponse.status).toBe(400); + expect(await unsupportedFieldResponse.json()).toMatchObject({ code: 'invalid_agent_request' }); + + const removedGuardResponse = await app.request('/_kilo/config/agents/research', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ expect: { targetHash: 'old' }, set: { verboseDefault: 'off' } }), + }); + expect(removedGuardResponse.status).toBe(400); + expect(await removedGuardResponse.json()).toMatchObject({ code: 'invalid_agent_request' }); + }); + + it('maps native conflict and CLI lifecycle errors', async () => { + const conflict = createDeps({ + updateSettings: vi.fn(async () => { + throw new AgentConfigError(409, 'config_etag_conflict', 'Config changed since last read'); + }), + }); + const conflictResponse = await makeApp(conflict).request('/_kilo/config/agents/research', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ set: { verboseDefault: 'off' } }), + }); + expect(conflictResponse.status).toBe(409); + expect(await conflictResponse.json()).toMatchObject({ code: 'config_etag_conflict' }); + + const reserved = createDeps({ + deleteViaCli: vi.fn(async () => { + throw new OpenClawAgentCliError(400, 'reserved_agent_id', 'The default agent is reserved'); + }), + }); + const reservedResponse = await makeApp(reserved).request('/_kilo/config/agents/main', { + method: 'DELETE', + }); + expect(reservedResponse.status).toBe(400); + expect(await reservedResponse.json()).toMatchObject({ code: 'reserved_agent_id' }); + }); +}); diff --git a/services/kiloclaw/controller/src/routes/config-agents.ts b/services/kiloclaw/controller/src/routes/config-agents.ts new file mode 100644 index 0000000000..faee4622ff --- /dev/null +++ b/services/kiloclaw/controller/src/routes/config-agents.ts @@ -0,0 +1,156 @@ +import type { Context, Hono } from 'hono'; +import { + AgentConfigError, + AgentDefaultsPatchBodySchema, + AgentSettingsPatchBodySchema, + readAgentConfigSnapshot, + readAgentSummary, + serializeAgentConfigMutation, + summarizeAgentConfig, + updateAgentDefaults, + updateAgentSettings, +} from '../openclaw-agent-config'; +import { + BasicAgentCreateBodySchema, + OpenClawAgentCliError, + createAgentViaCli, + deleteAgentViaCli, +} from '../openclaw-agent-cli'; + +export type AgentRouteDeps = { + readSnapshot: typeof readAgentConfigSnapshot; + readSummary: typeof readAgentSummary; + serializeMutation: typeof serializeAgentConfigMutation; + summarize: typeof summarizeAgentConfig; + updateSettings: typeof updateAgentSettings; + updateDefaults: typeof updateAgentDefaults; + createViaCli: typeof createAgentViaCli; + deleteViaCli: typeof deleteAgentViaCli; +}; + +const defaultDeps: AgentRouteDeps = { + readSnapshot: readAgentConfigSnapshot, + readSummary: readAgentSummary, + serializeMutation: serializeAgentConfigMutation, + summarize: summarizeAgentConfig, + updateSettings: updateAgentSettings, + updateDefaults: updateAgentDefaults, + createViaCli: createAgentViaCli, + deleteViaCli: deleteAgentViaCli, +}; + +function errorStatus(status: number): 400 | 404 | 409 | 422 | 500 | 502 | 504 { + switch (status) { + case 400: + case 404: + case 409: + case 422: + case 500: + case 502: + case 504: + return status; + default: + return 500; + } +} + +function respondError(c: Context, error: unknown) { + if (error instanceof AgentConfigError || error instanceof OpenClawAgentCliError) { + return c.json({ code: error.code, error: error.message }, errorStatus(error.status)); + } + const message = error instanceof Error ? error.message : String(error); + console.error('[controller] Agent config route failed:', message); + return c.json( + { code: 'agent_config_failed', error: 'Agent configuration operation failed' }, + 500 + ); +} + +async function readJsonBody(c: Context): Promise { + try { + return await c.req.json(); + } catch { + throw new AgentConfigError(400, 'invalid_agent_request', 'Invalid JSON body'); + } +} + +export function registerAgentConfigRoutes(app: Hono, deps: AgentRouteDeps = defaultDeps): void { + app.get('/_kilo/config/agents', c => { + try { + const snapshot = deps.readSnapshot(); + return c.json({ etag: snapshot.etag, ...deps.summarize(snapshot.config) }); + } catch (error) { + return respondError(c, error); + } + }); + + app.get('/_kilo/config/agents/:agentId', c => { + try { + const { snapshot, agent } = deps.readSummary(c.req.param('agentId')); + return c.json({ etag: snapshot.etag, agent }); + } catch (error) { + return respondError(c, error); + } + }); + + app.post('/_kilo/config/agents', async c => { + try { + const parsed = BasicAgentCreateBodySchema.safeParse(await readJsonBody(c)); + if (!parsed.success) { + return c.json( + { code: 'invalid_agent_request', error: 'Invalid agent create request' }, + 400 + ); + } + const result = await deps.serializeMutation(async () => { + const created = await deps.createViaCli(parsed.data); + const { snapshot, agent } = deps.readSummary(created.agentId); + return { etag: snapshot.etag, agent, created }; + }); + return c.json({ ok: true, ...result }); + } catch (error) { + return respondError(c, error); + } + }); + + app.patch('/_kilo/config/agents/:agentId', async c => { + try { + const parsed = AgentSettingsPatchBodySchema.safeParse(await readJsonBody(c)); + if (!parsed.success) { + return c.json( + { code: 'invalid_agent_request', error: 'Invalid agent update request' }, + 400 + ); + } + const result = await deps.updateSettings(c.req.param('agentId'), parsed.data); + return c.json({ ok: true, etag: result.snapshot.etag, agent: result.agent }); + } catch (error) { + return respondError(c, error); + } + }); + + app.patch('/_kilo/config/agent-defaults', async c => { + try { + const parsed = AgentDefaultsPatchBodySchema.safeParse(await readJsonBody(c)); + if (!parsed.success) { + return c.json( + { code: 'invalid_agent_request', error: 'Invalid agent defaults request' }, + 400 + ); + } + const result = await deps.updateDefaults(parsed.data); + return c.json({ ok: true, etag: result.snapshot.etag, defaults: result.defaults }); + } catch (error) { + return respondError(c, error); + } + }); + + app.delete('/_kilo/config/agents/:agentId', async c => { + try { + const deleted = await deps.serializeMutation(() => deps.deleteViaCli(c.req.param('agentId'))); + return c.json({ ok: true, ...deleted, filesystemDisposition: 'unverified' }); + } catch (error) { + return respondError(c, error); + } + }); +} diff --git a/services/kiloclaw/controller/src/routes/config.test.ts b/services/kiloclaw/controller/src/routes/config.test.ts index 55ce365df1..0a0c9db44f 100644 --- a/services/kiloclaw/controller/src/routes/config.test.ts +++ b/services/kiloclaw/controller/src/routes/config.test.ts @@ -472,6 +472,15 @@ describe('/_kilo/config/read routes', () => { vi.resetAllMocks(); }); + it('rejects agent CRUD requests without auth through config middleware', async () => { + await test({ + route: '/_kilo/config/agents', + expect: { + status: 401, + }, + }); + }); + it('rejects requests without auth', async () => { await test({ route: '/_kilo/config/read', diff --git a/services/kiloclaw/controller/src/routes/config.ts b/services/kiloclaw/controller/src/routes/config.ts index 33555e8c53..560b30ce84 100644 --- a/services/kiloclaw/controller/src/routes/config.ts +++ b/services/kiloclaw/controller/src/routes/config.ts @@ -8,6 +8,7 @@ import type { Supervisor } from '../supervisor'; import { backupConfigFile, writeBaseConfig } from '../config-writer'; import { GOG_SECTION_CONFIG, seedExecApprovalsDefaults, updateToolsMdSection } from '../bootstrap'; import { getBearerToken } from './gateway'; +import { registerAgentConfigRoutes } from './config-agents'; const ReplaceConfigBodySchema = z.object({ config: z.record(z.string(), z.unknown()), @@ -72,6 +73,8 @@ export function registerConfigRoutes( await next(); }); + registerAgentConfigRoutes(app); + // Read the current openclaw.json config from disk. app.get('/_kilo/config/read', c => { try { diff --git a/services/kiloclaw/scripts/controller-entrypoint-smoke-test.sh b/services/kiloclaw/scripts/controller-entrypoint-smoke-test.sh index cb115a0fc1..f0cffc269f 100644 --- a/services/kiloclaw/scripts/controller-entrypoint-smoke-test.sh +++ b/services/kiloclaw/scripts/controller-entrypoint-smoke-test.sh @@ -119,6 +119,52 @@ CODE=$(curl -s -o /dev/null -w "%{http_code}" \ "http://127.0.0.1:${PORT}/_kilo/gateway/status") check "gateway status (bearer auth) -> 200" "200" "$CODE" +echo +echo "--- agent config CRUD endpoints ---" + +TRASH_BIN=$(docker exec "$CID" sh -c 'command -v trash 2>/dev/null || true') +check "trash command omitted from packaged image" "" "$TRASH_BIN" + +CREATE_RESP=$(curl -sS -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"crud-smoke","workspace":"/root/.openclaw/workspace-crud-smoke"}' \ + "http://127.0.0.1:${PORT}/_kilo/config/agents") +CREATE_ID=$(echo "$CREATE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('agent',{}).get('id',''))" 2>/dev/null || echo "") +check "create configured agent" "crud-smoke" "$CREATE_ID" + +LIST_RESP=$(curl -sS \ + -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:${PORT}/_kilo/config/agents") +LIST_HAS_AGENT=$(echo "$LIST_RESP" | python3 -c "import sys,json; print(any(a.get('id') == 'crud-smoke' and a.get('configured') for a in json.load(sys.stdin).get('agents', [])))" 2>/dev/null || echo "") +check "list includes created agent" "True" "$LIST_HAS_AGENT" + +PATCH_RESP=$(curl -sS -X PATCH \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"set":{"model":{"primary":"kilocode/kilo-auto/balanced"}}}' \ + "http://127.0.0.1:${PORT}/_kilo/config/agents/crud-smoke") +PATCH_MODEL=$(echo "$PATCH_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('agent',{}).get('model',{}).get('primary',''))" 2>/dev/null || echo "") +check "patch updates created agent model" "kilocode/kilo-auto/balanced" "$PATCH_MODEL" + +DELETE_RESP=$(curl -sS -X DELETE \ + -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:${PORT}/_kilo/config/agents/crud-smoke") +DELETE_DISPOSITION=$(echo "$DELETE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('filesystemDisposition',''))" 2>/dev/null || echo "") +check "delete reports unverified filesystem disposition" "unverified" "$DELETE_DISPOSITION" + +LIST_AFTER_DELETE=$(curl -sS \ + -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:${PORT}/_kilo/config/agents") +LIST_STILL_HAS_AGENT=$(echo "$LIST_AFTER_DELETE" | python3 -c "import sys,json; print(any(a.get('id') == 'crud-smoke' and a.get('configured') for a in json.load(sys.stdin).get('agents', [])))" 2>/dev/null || echo "") +check "deleted agent is no longer configured" "False" "$LIST_STILL_HAS_AGENT" + +if [ -d "$ROOTDIR/.openclaw/workspace-crud-smoke" ]; then + check "packaged no-trash baseline retains deleted workspace" "1" "1" +else + check "packaged no-trash baseline retains deleted workspace" "1" "0" +fi + assert_kilo_chat_smoke "$CID" "$PORT" "$TOKEN" echo From 9745e28b6cd8cf7ea24ad34329e8e6a9e57eb70b Mon Sep 17 00:00:00 2001 From: syn Date: Tue, 26 May 2026 16:23:28 -0500 Subject: [PATCH 2/5] chore(kiloclaw): enable controller typecheck checks --- services/kiloclaw/controller/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/kiloclaw/controller/package.json b/services/kiloclaw/controller/package.json index ac4c6ec943..ccc3cfb333 100644 --- a/services/kiloclaw/controller/package.json +++ b/services/kiloclaw/controller/package.json @@ -2,6 +2,9 @@ "name": "kiloclaw-controller", "private": true, "type": "module", + "scripts": { + "typecheck": "tsgo --noEmit" + }, "dependencies": { "hono": "catalog:", "zod": "catalog:" From 13e129e22d26de9504a7619368c25bca0edfe316 Mon Sep 17 00:00:00 2001 From: syn Date: Tue, 26 May 2026 16:41:33 -0500 Subject: [PATCH 3/5] fix(kiloclaw): normalize agent CLI result IDs --- .../kiloclaw/controller/src/openclaw-agent-cli.test.ts | 7 ++++--- services/kiloclaw/controller/src/openclaw-agent-cli.ts | 7 +++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/services/kiloclaw/controller/src/openclaw-agent-cli.test.ts b/services/kiloclaw/controller/src/openclaw-agent-cli.test.ts index 64a10d88d0..da3198569a 100644 --- a/services/kiloclaw/controller/src/openclaw-agent-cli.test.ts +++ b/services/kiloclaw/controller/src/openclaw-agent-cli.test.ts @@ -10,7 +10,7 @@ describe('createAgentViaCli', () => { it('uses argv-only non-interactive JSON creation arguments', async () => { const run = vi.fn(async () => ({ stdout: JSON.stringify({ - agentId: 'research', + agentId: 'Research Agent', name: 'Research', workspace: '/root/.openclaw/workspace-research', agentDir: '/root/.openclaw/agents/research/agent', @@ -29,7 +29,7 @@ describe('createAgentViaCli', () => { const result = await createAgentViaCli(body, { run }); - expect(result.agentId).toBe('research'); + expect(result.agentId).toBe('research-agent'); expect(run).toHaveBeenCalledWith([ 'agents', 'add', @@ -61,7 +61,7 @@ describe('deleteAgentViaCli', () => { it('uses forced JSON deletion arguments and parses deletion summary', async () => { const run = vi.fn(async () => ({ stdout: JSON.stringify({ - agentId: 'research', + agentId: 'Research Agent', workspace: '/root/.openclaw/workspace-research', agentDir: '/root/.openclaw/agents/research/agent', sessionsDir: '/root/.openclaw/agents/research/sessions', @@ -74,6 +74,7 @@ describe('deleteAgentViaCli', () => { const result = await deleteAgentViaCli('research', { run }); expect(run).toHaveBeenCalledWith(['agents', 'delete', 'research', '--force', '--json']); + expect(result.agentId).toBe('research-agent'); expect(result.removedBindings).toBe(2); expect(result.removedAllow).toBe(1); }); diff --git a/services/kiloclaw/controller/src/openclaw-agent-cli.ts b/services/kiloclaw/controller/src/openclaw-agent-cli.ts index e3e8c5631a..76a94ce5b0 100644 --- a/services/kiloclaw/controller/src/openclaw-agent-cli.ts +++ b/services/kiloclaw/controller/src/openclaw-agent-cli.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process'; import path from 'node:path'; import { z } from 'zod'; +import { normalizeAgentId } from './openclaw-agent-config'; const AGENT_CLI_TIMEOUT_MS = 30_000; const AGENT_CLI_MAX_OUTPUT_BYTES = 1_048_576; @@ -30,8 +31,10 @@ export const BasicAgentCreateBodySchema = z export type BasicAgentCreateBody = z.infer; +const NormalizedCliAgentIdSchema = z.string().trim().min(1).transform(normalizeAgentId); + const CreateResultSchema = z.object({ - agentId: z.string().min(1), + agentId: NormalizedCliAgentIdSchema, name: z.string().min(1), workspace: z.string().min(1), agentDir: z.string().min(1), @@ -47,7 +50,7 @@ const CreateResultSchema = z.object({ }); const DeleteResultSchema = z.object({ - agentId: z.string().min(1), + agentId: NormalizedCliAgentIdSchema, workspace: z.string().min(1), agentDir: z.string().min(1), sessionsDir: z.string().min(1), From 0ce37e74ac050324b03cd69cbf347b11b27a4605 Mon Sep 17 00:00:00 2001 From: syn Date: Tue, 26 May 2026 19:56:10 -0500 Subject: [PATCH 4/5] test(kiloclaw): validate created agent config in smoke test --- services/kiloclaw/scripts/controller-entrypoint-smoke-test.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/kiloclaw/scripts/controller-entrypoint-smoke-test.sh b/services/kiloclaw/scripts/controller-entrypoint-smoke-test.sh index f0cffc269f..953f1b2bd8 100644 --- a/services/kiloclaw/scripts/controller-entrypoint-smoke-test.sh +++ b/services/kiloclaw/scripts/controller-entrypoint-smoke-test.sh @@ -133,6 +133,10 @@ CREATE_RESP=$(curl -sS -X POST \ CREATE_ID=$(echo "$CREATE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('agent',{}).get('id',''))" 2>/dev/null || echo "") check "create configured agent" "crud-smoke" "$CREATE_ID" +CONFIG_VALIDATE_RESP=$(docker exec "$CID" openclaw config validate --json 2>/dev/null || true) +CONFIG_VALID=$(echo "$CONFIG_VALIDATE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('valid', False))" 2>/dev/null || echo "") +check "created agent leaves OpenClaw config valid" "True" "$CONFIG_VALID" + LIST_RESP=$(curl -sS \ -H "Authorization: Bearer $TOKEN" \ "http://127.0.0.1:${PORT}/_kilo/config/agents") From 8072d48be4c661c7151fcee9c4d0114b89fe3c69 Mon Sep 17 00:00:00 2001 From: syn Date: Wed, 27 May 2026 09:12:12 -0500 Subject: [PATCH 5/5] fix(kiloclaw): harden agent config CRUD inputs --- .specs/kiloclaw-controller.md | 17 ++++++---- .../controller/src/openclaw-agent-cli.test.ts | 10 ++++++ .../controller/src/openclaw-agent-cli.ts | 14 ++++++-- .../src/openclaw-agent-config.test.ts | 32 ++++++++++++++++++- .../controller/src/openclaw-agent-config.ts | 16 ++++++---- 5 files changed, 71 insertions(+), 18 deletions(-) diff --git a/.specs/kiloclaw-controller.md b/.specs/kiloclaw-controller.md index 06f8885829..3306718ac9 100644 --- a/.specs/kiloclaw-controller.md +++ b/.specs/kiloclaw-controller.md @@ -670,13 +670,16 @@ Other values MUST return 400. 4. `PATCH /_kilo/config/agents/:agentId` and `PATCH /_kilo/config/agent-defaults` MUST expose only controller-approved model/settings fields and MUST reject unknown patch fields. 5. Agent/default native updates MUST use guarded read-modify-write behavior that preserves unrelated configuration and sibling agent entries. 6. `POST /_kilo/config/agents` MUST delegate basic creation to non-interactive OpenClaw CLI behavior and MUST return the normalized created agent identifier. -7. `POST /_kilo/config/agents` MAY accept arbitrary absolute workspace paths. Clients MUST NOT assume every configured workspace is exposed by `/_kilo/files/*`. -8. `DELETE /_kilo/config/agents/:agentId` MUST delegate deletion to non-interactive OpenClaw CLI behavior and MUST reject deletion of `main`. -9. The delete response MUST NOT claim verified filesystem deletion or verified file retention. Filesystem disposition is controlled by the installed OpenClaw CLI/runtime behavior and is not represented by the controller response. -10. The following capability hints MUST be advertised when the corresponding CRUD routes are registered: `config.agents.read`, `config.agents.create.basic.cli`, `config.agents.update`, `config.agents.delete.cli`, and `config.agent-defaults.update`. -11. Native updates MUST report stale config etags or config changes observed before commit as `409 config_etag_conflict`. -12. Controller-originated agent create, update, defaults-update, and delete mutations MUST be serialized per config path so a lifecycle CLI mutation cannot be overwritten by a concurrent native controller update. -13. CLI lifecycle operations MUST report known reserved/not-found/conflict validation failures using stable HTTP error codes, and MUST report timeout or malformed/process failures without exposing secret environment values. +7. Controller-accepted CLI creation values MUST NOT be interpreted as additional OpenClaw command options beyond the defined basic-create surface. +8. `POST /_kilo/config/agents` MAY accept arbitrary absolute workspace paths. Clients MUST NOT assume every configured workspace is exposed by `/_kilo/files/*`. +9. Native agent resource lookup and update requests MUST reject non-empty IDs that collapse to the reserved implicit `main` identifier rather than silently targeting `main`. +10. `DELETE /_kilo/config/agents/:agentId` MUST delegate deletion to non-interactive OpenClaw CLI behavior and MUST reject deletion of `main`. +11. The delete response MUST NOT claim verified filesystem deletion or verified file retention. Filesystem disposition is controlled by the installed OpenClaw CLI/runtime behavior and is not represented by the controller response. +12. The following capability hints MUST be advertised when the corresponding CRUD routes are registered: `config.agents.read`, `config.agents.create.basic.cli`, `config.agents.update`, `config.agents.delete.cli`, and `config.agent-defaults.update`. +13. Native updates MUST report stale config etags or config changes observed before commit as `409 config_etag_conflict`. +14. Controller-originated agent create, update, defaults-update, and delete mutations MUST be serialized per config path so a lifecycle CLI mutation cannot be overwritten by a concurrent native controller update. +15. CLI lifecycle operations MUST report known reserved/not-found/conflict validation failures using stable HTTP error codes, and MUST report timeout or malformed/process failures without exposing secret environment values. +16. Controller server errors from agent-config reads MUST NOT expose filesystem error details in HTTP responses. #### Environment (bearer token) diff --git a/services/kiloclaw/controller/src/openclaw-agent-cli.test.ts b/services/kiloclaw/controller/src/openclaw-agent-cli.test.ts index da3198569a..4c34fd210b 100644 --- a/services/kiloclaw/controller/src/openclaw-agent-cli.test.ts +++ b/services/kiloclaw/controller/src/openclaw-agent-cli.test.ts @@ -47,6 +47,16 @@ describe('createAgentViaCli', () => { ]); }); + it('rejects option-like create values before constructing CLI arguments', () => { + for (const body of [ + { name: '--help', workspace: '/tmp/research' }, + { name: 'Research', workspace: '/tmp/research', model: '--config=/tmp/other.json' }, + { name: 'Research', workspace: '/tmp/research', bindings: ['--debug'] }, + ]) { + expect(BasicAgentCreateBodySchema.safeParse(body).success).toBe(false); + } + }); + it('rejects malformed CLI JSON output', async () => { await expect( createAgentViaCli( diff --git a/services/kiloclaw/controller/src/openclaw-agent-cli.ts b/services/kiloclaw/controller/src/openclaw-agent-cli.ts index 76a94ce5b0..fa30c2358a 100644 --- a/services/kiloclaw/controller/src/openclaw-agent-cli.ts +++ b/services/kiloclaw/controller/src/openclaw-agent-cli.ts @@ -6,9 +6,17 @@ import { normalizeAgentId } from './openclaw-agent-config'; const AGENT_CLI_TIMEOUT_MS = 30_000; const AGENT_CLI_MAX_OUTPUT_BYTES = 1_048_576; +const CliValueSchema = z + .string() + .trim() + .min(1) + .refine(value => !value.startsWith('-'), { + message: 'CLI value must not begin with a dash', + }); + export const BasicAgentCreateBodySchema = z .object({ - name: z.string().trim().min(1), + name: CliValueSchema, workspace: z .string() .trim() @@ -24,8 +32,8 @@ export const BasicAgentCreateBodySchema = z message: 'Agent directory must be an absolute path', }) .optional(), - model: z.string().trim().min(1).optional(), - bindings: z.array(z.string().trim().min(1)).optional(), + model: CliValueSchema.optional(), + bindings: z.array(CliValueSchema).optional(), }) .strict(); diff --git a/services/kiloclaw/controller/src/openclaw-agent-config.test.ts b/services/kiloclaw/controller/src/openclaw-agent-config.test.ts index c6d2068766..df46bcde54 100644 --- a/services/kiloclaw/controller/src/openclaw-agent-config.test.ts +++ b/services/kiloclaw/controller/src/openclaw-agent-config.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { AgentConfigError, AgentDefaultsPatchBodySchema, @@ -65,6 +65,36 @@ describe('agent config summaries', () => { expect(readAgentSummary('main', { configPath }).agent.configured).toBe(false); expect(() => readAgentSummary('research', { configPath })).toThrowError(AgentConfigError); }); + + it('rejects resource IDs that collapse to the implicit main agent', async () => { + const configPath = await configFixture({ agents: { defaults: {} } }); + + for (const agentId of ['@@@', '!!!', '----']) { + expect(() => readAgentSummary(agentId, { configPath })).toThrowError( + expect.objectContaining({ code: 'invalid_agent_id', status: 400 }) + ); + } + expect(readAgentSummary('MAIN', { configPath }).agent.id).toBe('main'); + }); + + it('does not expose filesystem details when reading config fails', () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(() => + readAgentConfigSnapshot({ configPath: '/missing/private/config.json' }) + ).toThrowError( + expect.objectContaining({ + code: 'agent_config_read_failed', + status: 500, + message: 'Failed to read agent config', + }) + ); + expect(log).toHaveBeenCalledWith( + '[controller] Failed to read OpenClaw agent config:', + expect.stringContaining('/missing/private/config.json') + ); + log.mockRestore(); + }); }); describe('native agent config mutations', () => { diff --git a/services/kiloclaw/controller/src/openclaw-agent-config.ts b/services/kiloclaw/controller/src/openclaw-agent-config.ts index 4ce5cae24e..ef770befb8 100644 --- a/services/kiloclaw/controller/src/openclaw-agent-config.ts +++ b/services/kiloclaw/controller/src/openclaw-agent-config.ts @@ -220,10 +220,15 @@ export function normalizeAgentId(value: string): string { } export function requireAgentId(value: string): string { - if (!value.trim()) { + const trimmed = value.trim(); + if (!trimmed) { throw new AgentConfigError(400, 'invalid_agent_id', 'Agent id is required'); } - return normalizeAgentId(value); + const normalized = normalizeAgentId(trimmed); + if (normalized === DEFAULT_AGENT_ID && trimmed.toLowerCase() !== DEFAULT_AGENT_ID) { + throw new AgentConfigError(400, 'invalid_agent_id', 'Agent id normalizes to a reserved id'); + } + return normalized; } function normalizeModel(model: ModelValue | undefined): NormalizedModel | null { @@ -290,11 +295,8 @@ export function readAgentConfigSnapshot(options: AgentConfigOptions = {}): Agent raw = fs.readFileSync(configPath, 'utf8'); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new AgentConfigError( - 500, - 'agent_config_read_failed', - `Failed to read config: ${message}` - ); + console.error('[controller] Failed to read OpenClaw agent config:', message); + throw new AgentConfigError(500, 'agent_config_read_failed', 'Failed to read agent config'); } let value: unknown;