Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .specs/kiloclaw-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,11 +651,36 @@ 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. 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)

| Method | Path | Description |
Expand Down
3 changes: 3 additions & 0 deletions services/kiloclaw/controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"name": "kiloclaw-controller",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"hono": "catalog:",
"zod": "catalog:"
Expand Down
12 changes: 12 additions & 0 deletions services/kiloclaw/controller/src/endpoint-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 5 additions & 0 deletions services/kiloclaw/controller/src/endpoint-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
105 changes: 105 additions & 0 deletions services/kiloclaw/controller/src/openclaw-agent-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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 Agent',
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-agent');
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 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(
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 Agent',
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.agentId).toBe('research-agent');
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 });
});
});
187 changes: 187 additions & 0 deletions services/kiloclaw/controller/src/openclaw-agent-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
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;

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: CliValueSchema,
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: CliValueSchema.optional(),
bindings: z.array(CliValueSchema).optional(),
})
.strict();

export type BasicAgentCreateBody = z.infer<typeof BasicAgentCreateBodySchema>;

const NormalizedCliAgentIdSchema = z.string().trim().min(1).transform(normalizeAgentId);

const CreateResultSchema = z.object({
agentId: NormalizedCliAgentIdSchema,
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: NormalizedCliAgentIdSchema,
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<typeof CreateResultSchema>;
export type DeleteAgentCliResult = z.infer<typeof DeleteResultSchema>;

type CliProcessResult = {
stdout: string;
stderr: string;
};

export type OpenClawAgentCliDeps = {
run: (args: string[]) => Promise<CliProcessResult>;
};

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<T>(stdout: string, schema: z.ZodType<T>): 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<CreateAgentCliResult> {
const args = [
'agents',
'add',
body.name,
Comment thread
pandemicsyn marked this conversation as resolved.
'--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<DeleteAgentCliResult> {
const result = await deps.run(['agents', 'delete', agentId, '--force', '--json']);
return parseCliJson(result.stdout, DeleteResultSchema);
}
Loading
Loading