Skip to content

Commit 50dc1c7

Browse files
authored
feat(kiloclaw): add agent config CRUD routes (#3508)
* feat(kiloclaw): add agent config CRUD routes * chore(kiloclaw): enable controller typecheck checks * fix(kiloclaw): normalize agent CLI result IDs * test(kiloclaw): validate created agent config in smoke test * fix(kiloclaw): harden agent config CRUD inputs
1 parent 6807bdf commit 50dc1c7

13 files changed

Lines changed: 1584 additions & 0 deletions

.specs/kiloclaw-controller.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,11 +651,36 @@ Version capability hint rules:
651651
| POST | `/_kilo/config/restore/base` | Regenerate config from env vars, signal gateway reload |
652652
| POST | `/_kilo/config/replace` | Atomically replace openclaw.json (etag concurrency) |
653653
| POST | `/_kilo/config/patch` | Deep-merge a JSON patch into openclaw.json |
654+
| GET | `/_kilo/config/agents` | List normalized configured-agent summaries and effective defaults |
655+
| GET | `/_kilo/config/agents/:agentId` | Read one normalized configured-agent summary |
656+
| POST | `/_kilo/config/agents` | Create a basic agent through non-interactive OpenClaw CLI behavior |
657+
| PATCH | `/_kilo/config/agents/:agentId` | Update approved agent model/settings fields |
658+
| DELETE | `/_kilo/config/agents/:agentId` | Delete a configured agent through non-interactive OpenClaw CLI behavior |
659+
| PATCH | `/_kilo/config/agent-defaults` | Update approved inherited agent-default fields |
654660
| POST | `/_kilo/config/tools-md/google-workspace` | Enable/disable managed Google Workspace `TOOLS.md` section |
655661

656662
The restore endpoint only accepts `base` as the version parameter.
657663
Other values MUST return 400.
658664

665+
##### Agent configuration CRUD
666+
667+
1. All agent configuration endpoints MUST require bearer-token authentication.
668+
2. `GET /_kilo/config/agents` MUST return configured agent summaries, effective defaults, and an etag representing the read config snapshot.
669+
3. Agent reads MAY represent the implicit default `main` agent as `configured: false` when no explicit list entry exists for it.
670+
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.
671+
5. Agent/default native updates MUST use guarded read-modify-write behavior that preserves unrelated configuration and sibling agent entries.
672+
6. `POST /_kilo/config/agents` MUST delegate basic creation to non-interactive OpenClaw CLI behavior and MUST return the normalized created agent identifier.
673+
7. Controller-accepted CLI creation values MUST NOT be interpreted as additional OpenClaw command options beyond the defined basic-create surface.
674+
8. `POST /_kilo/config/agents` MAY accept arbitrary absolute workspace paths. Clients MUST NOT assume every configured workspace is exposed by `/_kilo/files/*`.
675+
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`.
676+
10. `DELETE /_kilo/config/agents/:agentId` MUST delegate deletion to non-interactive OpenClaw CLI behavior and MUST reject deletion of `main`.
677+
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.
678+
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`.
679+
13. Native updates MUST report stale config etags or config changes observed before commit as `409 config_etag_conflict`.
680+
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.
681+
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.
682+
16. Controller server errors from agent-config reads MUST NOT expose filesystem error details in HTTP responses.
683+
659684
#### Environment (bearer token)
660685

661686
| Method | Path | Description |

services/kiloclaw/controller/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
"name": "kiloclaw-controller",
33
"private": true,
44
"type": "module",
5+
"scripts": {
6+
"typecheck": "tsgo --noEmit"
7+
},
58
"dependencies": {
69
"hono": "catalog:",
710
"zod": "catalog:"

services/kiloclaw/controller/src/endpoint-capabilities.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ describe('getControllerEndpointCapabilities', () => {
1616
expect(capabilities).toEqual([...new Set(CONTROLLER_ENDPOINT_CAPABILITIES)].sort());
1717
});
1818

19+
it('advertises operation-specific agent CRUD capabilities', () => {
20+
expect(getControllerEndpointCapabilities()).toEqual(
21+
expect.arrayContaining([
22+
'config.agents.read',
23+
'config.agents.create.basic.cli',
24+
'config.agents.update',
25+
'config.agents.delete.cli',
26+
'config.agent-defaults.update',
27+
])
28+
);
29+
});
30+
1931
it('includes conditional Kilo Chat capabilities only when requested', () => {
2032
const defaultCapabilities = getControllerEndpointCapabilities();
2133
const kiloChatCapabilities = getControllerEndpointCapabilities({

services/kiloclaw/controller/src/endpoint-capabilities.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ export const CONTROLLER_ENDPOINT_CAPABILITIES = [
55
'config.restore',
66
'config.replace',
77
'config.patch',
8+
'config.agents.read',
9+
'config.agents.create.basic.cli',
10+
'config.agents.update',
11+
'config.agents.delete.cli',
12+
'config.agent-defaults.update',
813
'config.tools-md.google-workspace',
914
'env.patch',
1015
'doctor.run',
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import {
3+
BasicAgentCreateBodySchema,
4+
OpenClawAgentCliError,
5+
createAgentViaCli,
6+
deleteAgentViaCli,
7+
} from './openclaw-agent-cli';
8+
9+
describe('createAgentViaCli', () => {
10+
it('uses argv-only non-interactive JSON creation arguments', async () => {
11+
const run = vi.fn(async () => ({
12+
stdout: JSON.stringify({
13+
agentId: 'Research Agent',
14+
name: 'Research',
15+
workspace: '/root/.openclaw/workspace-research',
16+
agentDir: '/root/.openclaw/agents/research/agent',
17+
model: 'kilocode/default',
18+
bindings: { added: [], updated: [], skipped: [], conflicts: [] },
19+
}),
20+
stderr: '',
21+
}));
22+
const body = BasicAgentCreateBodySchema.parse({
23+
name: 'Research',
24+
workspace: '/root/.openclaw/workspace-research',
25+
agentDir: '/root/.openclaw/agents/research/agent',
26+
model: 'kilocode/default',
27+
bindings: ['discord:team'],
28+
});
29+
30+
const result = await createAgentViaCli(body, { run });
31+
32+
expect(result.agentId).toBe('research-agent');
33+
expect(run).toHaveBeenCalledWith([
34+
'agents',
35+
'add',
36+
'Research',
37+
'--workspace',
38+
'/root/.openclaw/workspace-research',
39+
'--agent-dir',
40+
'/root/.openclaw/agents/research/agent',
41+
'--model',
42+
'kilocode/default',
43+
'--bind',
44+
'discord:team',
45+
'--non-interactive',
46+
'--json',
47+
]);
48+
});
49+
50+
it('rejects option-like create values before constructing CLI arguments', () => {
51+
for (const body of [
52+
{ name: '--help', workspace: '/tmp/research' },
53+
{ name: 'Research', workspace: '/tmp/research', model: '--config=/tmp/other.json' },
54+
{ name: 'Research', workspace: '/tmp/research', bindings: ['--debug'] },
55+
]) {
56+
expect(BasicAgentCreateBodySchema.safeParse(body).success).toBe(false);
57+
}
58+
});
59+
60+
it('rejects malformed CLI JSON output', async () => {
61+
await expect(
62+
createAgentViaCli(
63+
BasicAgentCreateBodySchema.parse({ name: 'Research', workspace: '/tmp/research' }),
64+
{ run: async () => ({ stdout: 'not-json', stderr: '' }) }
65+
)
66+
).rejects.toMatchObject({ code: 'openclaw_cli_failed', status: 502 });
67+
});
68+
});
69+
70+
describe('deleteAgentViaCli', () => {
71+
it('uses forced JSON deletion arguments and parses deletion summary', async () => {
72+
const run = vi.fn(async () => ({
73+
stdout: JSON.stringify({
74+
agentId: 'Research Agent',
75+
workspace: '/root/.openclaw/workspace-research',
76+
agentDir: '/root/.openclaw/agents/research/agent',
77+
sessionsDir: '/root/.openclaw/agents/research/sessions',
78+
removedBindings: 2,
79+
removedAllow: 1,
80+
}),
81+
stderr: '',
82+
}));
83+
84+
const result = await deleteAgentViaCli('research', { run });
85+
86+
expect(run).toHaveBeenCalledWith(['agents', 'delete', 'research', '--force', '--json']);
87+
expect(result.agentId).toBe('research-agent');
88+
expect(result.removedBindings).toBe(2);
89+
expect(result.removedAllow).toBe(1);
90+
});
91+
92+
it('propagates typed CLI operation failures', async () => {
93+
await expect(
94+
deleteAgentViaCli('main', {
95+
run: async () => {
96+
throw new OpenClawAgentCliError(
97+
400,
98+
'reserved_agent_id',
99+
'The default agent is reserved'
100+
);
101+
},
102+
})
103+
).rejects.toMatchObject({ code: 'reserved_agent_id', status: 400 });
104+
});
105+
});
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { execFile } from 'node:child_process';
2+
import path from 'node:path';
3+
import { z } from 'zod';
4+
import { normalizeAgentId } from './openclaw-agent-config';
5+
6+
const AGENT_CLI_TIMEOUT_MS = 30_000;
7+
const AGENT_CLI_MAX_OUTPUT_BYTES = 1_048_576;
8+
9+
const CliValueSchema = z
10+
.string()
11+
.trim()
12+
.min(1)
13+
.refine(value => !value.startsWith('-'), {
14+
message: 'CLI value must not begin with a dash',
15+
});
16+
17+
export const BasicAgentCreateBodySchema = z
18+
.object({
19+
name: CliValueSchema,
20+
workspace: z
21+
.string()
22+
.trim()
23+
.min(1)
24+
.refine(value => path.isAbsolute(value), {
25+
message: 'Workspace must be an absolute path',
26+
}),
27+
agentDir: z
28+
.string()
29+
.trim()
30+
.min(1)
31+
.refine(value => path.isAbsolute(value), {
32+
message: 'Agent directory must be an absolute path',
33+
})
34+
.optional(),
35+
model: CliValueSchema.optional(),
36+
bindings: z.array(CliValueSchema).optional(),
37+
})
38+
.strict();
39+
40+
export type BasicAgentCreateBody = z.infer<typeof BasicAgentCreateBodySchema>;
41+
42+
const NormalizedCliAgentIdSchema = z.string().trim().min(1).transform(normalizeAgentId);
43+
44+
const CreateResultSchema = z.object({
45+
agentId: NormalizedCliAgentIdSchema,
46+
name: z.string().min(1),
47+
workspace: z.string().min(1),
48+
agentDir: z.string().min(1),
49+
model: z.string().optional(),
50+
bindings: z
51+
.object({
52+
added: z.array(z.string()),
53+
updated: z.array(z.string()),
54+
skipped: z.array(z.string()),
55+
conflicts: z.array(z.string()),
56+
})
57+
.optional(),
58+
});
59+
60+
const DeleteResultSchema = z.object({
61+
agentId: NormalizedCliAgentIdSchema,
62+
workspace: z.string().min(1),
63+
agentDir: z.string().min(1),
64+
sessionsDir: z.string().min(1),
65+
removedBindings: z.number().int().min(0),
66+
removedAllow: z.number().int().min(0),
67+
});
68+
69+
export type CreateAgentCliResult = z.infer<typeof CreateResultSchema>;
70+
export type DeleteAgentCliResult = z.infer<typeof DeleteResultSchema>;
71+
72+
type CliProcessResult = {
73+
stdout: string;
74+
stderr: string;
75+
};
76+
77+
export type OpenClawAgentCliDeps = {
78+
run: (args: string[]) => Promise<CliProcessResult>;
79+
};
80+
81+
export class OpenClawAgentCliError extends Error {
82+
readonly status: number;
83+
readonly code: string;
84+
85+
constructor(status: number, code: string, message: string) {
86+
super(message);
87+
this.name = 'OpenClawAgentCliError';
88+
this.status = status;
89+
this.code = code;
90+
}
91+
}
92+
93+
const defaultDeps: OpenClawAgentCliDeps = {
94+
run: args =>
95+
new Promise((resolve, reject) => {
96+
execFile(
97+
'openclaw',
98+
args,
99+
{
100+
env: process.env,
101+
timeout: AGENT_CLI_TIMEOUT_MS,
102+
maxBuffer: AGENT_CLI_MAX_OUTPUT_BYTES,
103+
encoding: 'utf8',
104+
},
105+
(error, stdout, stderr) => {
106+
if (error) {
107+
if ('killed' in error && error.killed === true) {
108+
reject(
109+
new OpenClawAgentCliError(
110+
504,
111+
'openclaw_cli_timeout',
112+
'OpenClaw agent command timed out'
113+
)
114+
);
115+
return;
116+
}
117+
reject(mapCliFailure(`${stderr}\n${error.message}`));
118+
return;
119+
}
120+
resolve({ stdout, stderr });
121+
}
122+
);
123+
}),
124+
};
125+
126+
function mapCliFailure(output: string): OpenClawAgentCliError {
127+
if (/cannot be deleted|is reserved/i.test(output)) {
128+
return new OpenClawAgentCliError(400, 'reserved_agent_id', 'The default agent is reserved');
129+
}
130+
if (/already exists/i.test(output)) {
131+
return new OpenClawAgentCliError(409, 'agent_exists', 'Agent already exists');
132+
}
133+
if (/not found/i.test(output)) {
134+
return new OpenClawAgentCliError(404, 'agent_not_found', 'Agent not found');
135+
}
136+
return new OpenClawAgentCliError(502, 'openclaw_cli_failed', 'OpenClaw agent command failed');
137+
}
138+
139+
function parseCliJson<T>(stdout: string, schema: z.ZodType<T>): T {
140+
let parsed: unknown;
141+
try {
142+
parsed = JSON.parse(stdout.trim());
143+
} catch {
144+
throw new OpenClawAgentCliError(
145+
502,
146+
'openclaw_cli_failed',
147+
'OpenClaw agent command returned invalid JSON'
148+
);
149+
}
150+
const result = schema.safeParse(parsed);
151+
if (!result.success) {
152+
throw new OpenClawAgentCliError(
153+
502,
154+
'openclaw_cli_failed',
155+
'OpenClaw agent command returned an invalid response'
156+
);
157+
}
158+
return result.data;
159+
}
160+
161+
export async function createAgentViaCli(
162+
body: BasicAgentCreateBody,
163+
deps: OpenClawAgentCliDeps = defaultDeps
164+
): Promise<CreateAgentCliResult> {
165+
const args = [
166+
'agents',
167+
'add',
168+
body.name,
169+
'--workspace',
170+
body.workspace,
171+
...(body.agentDir ? ['--agent-dir', body.agentDir] : []),
172+
...(body.model ? ['--model', body.model] : []),
173+
...(body.bindings ?? []).flatMap(binding => ['--bind', binding]),
174+
'--non-interactive',
175+
'--json',
176+
];
177+
const result = await deps.run(args);
178+
return parseCliJson(result.stdout, CreateResultSchema);
179+
}
180+
181+
export async function deleteAgentViaCli(
182+
agentId: string,
183+
deps: OpenClawAgentCliDeps = defaultDeps
184+
): Promise<DeleteAgentCliResult> {
185+
const result = await deps.run(['agents', 'delete', agentId, '--force', '--json']);
186+
return parseCliJson(result.stdout, DeleteResultSchema);
187+
}

0 commit comments

Comments
 (0)