Skip to content

Commit 0fd0c33

Browse files
authored
Fix kiloclaw kilocode provider discovery (#3048)
* fix(kiloclaw): always keep kilocode provider entry so bundled plugin loads * fix(kiloclaw): drop as/non-null assertions in kilocode provider cleanup * fix(kiloclaw): scrub stale plaintext apiKey from kilocode provider entry
1 parent a1139f7 commit 0fd0c33

3 files changed

Lines changed: 128 additions & 65 deletions

File tree

.specs/kiloclaw-controller.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -335,14 +335,21 @@ patches to `openclaw.json`. The patches MUST include:
335335
2. Gateway auth token from `OPENCLAW_GATEWAY_TOKEN`.
336336
3. `allowInsecureAuth` when `AUTO_APPROVE_DEVICES=true`.
337337
4. Allowed origins from `OPENCLAW_ALLOWED_ORIGINS`.
338-
5. Stale kilocode provider migration (remove entries with old base
339-
URLs).
340-
6. KiloCode API base URL override from `KILOCODE_API_BASE_URL`.
338+
5. KiloCode provider entry MUST always be present at
339+
`models.providers.kilocode` with `baseUrl`
340+
`https://api.kilo.ai/api/gateway/`, `api` `openai-completions`, and
341+
`models` set to `[]`. The bundled openclaw kilocode plugin only loads
342+
when this entry is present; without it, live model discovery never
343+
runs and `kilo-auto/*` is unreachable. Stale entries written with
344+
the old `/api/openrouter/` base URL MUST be dropped before the entry
345+
is rebuilt.
346+
6. KiloCode API base URL override from `KILOCODE_API_BASE_URL` (local
347+
dev tunnel).
341348
7. Org-scoped KiloCode provider configuration from
342349
`KILOCODE_ORGANIZATION_ID`: MUST set the
343-
`X-KiloCode-OrganizationId` header and MUST set the provider's
344-
`models` array to `[]` so OpenClaw uses live gateway model discovery
345-
rather than a stale static catalog.
350+
`X-KiloCode-OrganizationId` header on the kilocode provider entry.
351+
When `KILOCODE_ORGANIZATION_ID` is unset, the controller MUST
352+
remove any stale `X-KiloCode-OrganizationId` header from the entry.
346353
8. Default model from `KILOCODE_DEFAULT_MODEL`.
347354
9. Agent default user timezone from `KILOCLAW_USER_TIMEZONE`.
348355
10. Remove `agents.defaults.models` allowlist (KiloClaw users see all

services/kiloclaw/controller/src/config-writer.test.ts

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ function minimalEnv(): Record<string, string | undefined> {
8282
}
8383

8484
describe('generateBaseConfig', () => {
85-
it('generates config with gateway and exec defaults, no kilocode provider entry', () => {
85+
it('generates config with gateway, exec defaults, and a kilocode provider entry that triggers live discovery', () => {
8686
const { deps } = fakeDeps();
8787
const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps);
8888

@@ -93,8 +93,11 @@ describe('generateBaseConfig', () => {
9393
expect(config.gateway.auth.token).toBe('test-gw-token');
9494
expect(config.gateway.controlUi.allowInsecureAuth).toBe(true);
9595

96-
// No kilocode provider entry in production — built-in provider takes over
97-
expect(config.models).toBeUndefined();
96+
// The bundled kilocode plugin only loads when this entry is present.
97+
// Empty `models` lets live gateway discovery own the catalog.
98+
expect(config.models.providers.kilocode.baseUrl).toBe('https://api.kilo.ai/api/gateway/');
99+
expect(config.models.providers.kilocode.api).toBe('openai-completions');
100+
expect(config.models.providers.kilocode.models).toEqual([]);
98101

99102
// No default model override when env var not set, and no memorySearch
100103
// schema introduced when the feature is off and absent from existing config.
@@ -311,7 +314,7 @@ describe('generateBaseConfig', () => {
311314
expect(config.gateway.port).toBe(3001);
312315
});
313316

314-
it('removes stale kilocode provider with /api/openrouter/ baseUrl', () => {
317+
it('removes stale kilocode openrouter entry and rebuilds it pointed at the production gateway', () => {
315318
const existing = JSON.stringify({
316319
models: {
317320
providers: {
@@ -327,16 +330,70 @@ describe('generateBaseConfig', () => {
327330
const { deps } = fakeDeps(existing);
328331
const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps);
329332

330-
// Stale provider deleted, models object cleaned up
331-
expect(config.models).toBeUndefined();
333+
// Stale entry replaced — old apiKey and models dropped, baseUrl pointed
334+
// at the production gateway so the bundled plugin can load.
335+
expect(config.models.providers.kilocode.baseUrl).toBe('https://api.kilo.ai/api/gateway/');
336+
expect(config.models.providers.kilocode.api).toBe('openai-completions');
337+
expect(config.models.providers.kilocode.models).toEqual([]);
338+
expect(config.models.providers.kilocode.apiKey).toBeUndefined();
339+
});
340+
341+
// Regression: an earlier migration deleted the kilocode provider entry on
342+
// personal (non-org) instances, expecting the bundled openclaw kilocode
343+
// plugin to auto-activate from KILOCODE_API_KEY alone. It does not — the
344+
// plugin only loads when an explicit provider entry is present, so without
345+
// it `kilo-auto/balanced` and the rest of the dynamic catalog were never
346+
// discovered and the agent failed with "Unknown model".
347+
it('keeps kilocode provider entry on personal instances (no KILOCODE_ORGANIZATION_ID) so the bundled plugin loads', () => {
348+
const { deps } = fakeDeps();
349+
const env = {
350+
...minimalEnv(),
351+
KILOCODE_DEFAULT_MODEL: 'kilocode/kilo-auto/balanced',
352+
};
353+
const config = generateBaseConfig(env, '/tmp/openclaw.json', deps);
354+
355+
expect(config.models.providers.kilocode.baseUrl).toBe('https://api.kilo.ai/api/gateway/');
356+
expect(config.models.providers.kilocode.api).toBe('openai-completions');
357+
expect(config.models.providers.kilocode.models).toEqual([]);
358+
expect(config.models.providers.kilocode.headers?.['X-KiloCode-OrganizationId']).toBeUndefined();
359+
expect(config.agents.defaults.model.primary).toBe('kilocode/kilo-auto/balanced');
360+
});
361+
362+
it('preserves kilocode provider with production /api/gateway/ baseUrl and clears stale models', () => {
363+
const existing = JSON.stringify({
364+
models: {
365+
providers: {
366+
kilocode: {
367+
baseUrl: 'https://api.kilo.ai/api/gateway/',
368+
api: 'openai-completions',
369+
models: [{ id: 'kilo/auto', name: 'Kilo Auto' }],
370+
},
371+
},
372+
},
373+
});
374+
const { deps } = fakeDeps(existing);
375+
const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps);
376+
377+
// Entry preserved (so the plugin loads), stale onboard-written models
378+
// cleared so live discovery owns the catalog.
379+
expect(config.models.providers.kilocode.baseUrl).toBe('https://api.kilo.ai/api/gateway/');
380+
expect(config.models.providers.kilocode.api).toBe('openai-completions');
381+
expect(config.models.providers.kilocode.models).toEqual([]);
332382
});
333383

334-
it('removes stale kilocode provider with production /api/gateway/ baseUrl', () => {
384+
// Auth must come from `KILOCODE_API_KEY` env, never from a literal `apiKey`
385+
// field on disk. The previous deletion-based migration was incidentally
386+
// scrubbing the field; this test pins that the new normalization keeps that
387+
// scrub so a stale plaintext credential from a legacy onboard run cannot
388+
// survive across boots.
389+
it('scrubs a stale plaintext apiKey from the kilocode provider entry', () => {
335390
const existing = JSON.stringify({
336391
models: {
337392
providers: {
338393
kilocode: {
339394
baseUrl: 'https://api.kilo.ai/api/gateway/',
395+
api: 'openai-completions',
396+
apiKey: 'sk-stale-plaintext',
340397
models: [],
341398
},
342399
},
@@ -345,7 +402,8 @@ describe('generateBaseConfig', () => {
345402
const { deps } = fakeDeps(existing);
346403
const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps);
347404

348-
expect(config.models).toBeUndefined();
405+
expect(config.models.providers.kilocode.apiKey).toBeUndefined();
406+
expect(config.models.providers.kilocode.baseUrl).toBe('https://api.kilo.ai/api/gateway/');
349407
});
350408

351409
it('keeps gateway provider for org-scoped instances but clears static models', () => {
@@ -396,7 +454,7 @@ describe('generateBaseConfig', () => {
396454
expect(config.models.providers.kilocode.baseUrl).toBe('https://api.kilo.ai/api/gateway/');
397455
});
398456

399-
it('preserves non-kilocode providers when removing stale kilocode entry', () => {
457+
it('preserves non-kilocode providers when rebuilding stale kilocode openrouter entry', () => {
400458
const existing = JSON.stringify({
401459
models: {
402460
providers: {
@@ -414,9 +472,9 @@ describe('generateBaseConfig', () => {
414472
const { deps } = fakeDeps(existing);
415473
const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps);
416474

417-
// kilocode removed, openai preserved
418-
expect(config.models.providers.kilocode).toBeUndefined();
475+
// openai preserved, kilocode rebuilt with production gateway URL
419476
expect(config.models.providers.openai.baseUrl).toBe('https://api.openai.com/v1');
477+
expect(config.models.providers.kilocode.baseUrl).toBe('https://api.kilo.ai/api/gateway/');
420478
});
421479

422480
it('creates kilocode provider with baseUrl and models: [] when KILOCODE_API_BASE_URL is set', () => {
@@ -428,7 +486,7 @@ describe('generateBaseConfig', () => {
428486
expect(config.models.providers.kilocode.models).toEqual([]);
429487
});
430488

431-
it('preserves existing models array when overriding baseUrl', () => {
489+
it('clears stale models when overriding baseUrl, since live discovery owns the catalog', () => {
432490
const existing = JSON.stringify({
433491
models: {
434492
providers: {
@@ -443,9 +501,10 @@ describe('generateBaseConfig', () => {
443501
const env = { ...minimalEnv(), KILOCODE_API_BASE_URL: 'https://new-tunnel.example.com/' };
444502
const config = generateBaseConfig(env, '/tmp/openclaw.json', deps);
445503

446-
// baseUrl updated, existing models preserved
504+
// baseUrl updated, stale onboard-written models cleared so live
505+
// discovery populates the catalog from the new endpoint.
447506
expect(config.models.providers.kilocode.baseUrl).toBe('https://new-tunnel.example.com/');
448-
expect(config.models.providers.kilocode.models).toEqual([{ id: 'kept/model', name: 'Kept' }]);
507+
expect(config.models.providers.kilocode.models).toEqual([]);
449508
});
450509

451510
it('sets X-KiloCode-OrganizationId header when KILOCODE_ORGANIZATION_ID is set', () => {
@@ -465,8 +524,10 @@ describe('generateBaseConfig', () => {
465524
const { deps } = fakeDeps();
466525
const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps);
467526

468-
// No kilocode provider entry created when neither baseUrl nor orgId is set
469-
expect(config.models).toBeUndefined();
527+
// Personal instance: kilocode entry still present (the bundled plugin
528+
// requires it to load), but no org header attached.
529+
expect(config.models.providers.kilocode.baseUrl).toBe('https://api.kilo.ai/api/gateway/');
530+
expect(config.models.providers.kilocode.headers?.['X-KiloCode-OrganizationId']).toBeUndefined();
470531
});
471532

472533
it('preserves existing kilocode baseUrl and headers when adding org header', () => {
@@ -517,7 +578,8 @@ describe('generateBaseConfig', () => {
517578
// Other headers and config preserved
518579
expect(config.models.providers.kilocode.headers['X-Custom']).toBe('preserved');
519580
expect(config.models.providers.kilocode.baseUrl).toBe('https://tunnel.example.com/');
520-
expect(config.models.providers.kilocode.models).toEqual([{ id: 'kept/model', name: 'Kept' }]);
581+
// models cleared so live discovery from the (preserved) baseUrl owns the catalog
582+
expect(config.models.providers.kilocode.models).toEqual([]);
521583
});
522584

523585
it('removes agents.defaults.models allowlist left by openclaw onboard', () => {

services/kiloclaw/controller/src/config-writer.ts

Lines changed: 36 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -226,69 +226,63 @@ export function generateBaseConfig(
226226
);
227227
}
228228

229-
// Migration: remove stale manually-managed kilocode provider config.
230-
// OpenClaw 2026.2.24+ has a built-in kilocode provider that activates when
231-
// KILOCODE_API_KEY is in the environment. Stale config entries with the old
232-
// /api/openrouter/ URL or the production /api/gateway/ URL conflict with it.
233-
// For the production /api/gateway/ URL, skip removal when KILOCODE_ORGANIZATION_ID
234-
// is set: org-scoped instances need an explicit provider entry (with the production
235-
// baseUrl) to carry the org header. Its model list is cleared below so live gateway
236-
// discovery still controls the catalog. The /api/openrouter/ cleanup always runs
237-
// since that URL is unconditionally broken.
238-
if (config.models?.providers?.kilocode) {
239-
const staleBaseUrl: string = config.models.providers.kilocode.baseUrl || '';
240-
const isOpenrouterUrl = staleBaseUrl.includes('/api/openrouter/');
241-
const isGatewayUrl = staleBaseUrl === 'https://api.kilo.ai/api/gateway/';
242-
if (isOpenrouterUrl || (isGatewayUrl && !env.KILOCODE_ORGANIZATION_ID)) {
243-
delete config.models.providers.kilocode;
244-
console.log(`Removed stale kilocode provider config (baseUrl: ${staleBaseUrl})`);
245-
if (Object.keys(config.models.providers).length === 0) {
246-
delete config.models.providers;
247-
}
248-
if (Object.keys(config.models).length === 0) {
249-
delete config.models;
250-
}
251-
}
252-
}
229+
// KiloCode provider entry. The bundled openclaw kilocode plugin only loads
230+
// when an explicit `models.providers.kilocode` entry exists in the config —
231+
// without it, the plugin's catalog hook never runs and live gateway model
232+
// discovery never populates `kilo-auto/*` and the rest of the dynamic
233+
// catalog. So we always include this entry (production baseUrl, empty
234+
// `models` so live discovery owns the catalog).
235+
//
236+
// Cleanup: the old `/api/openrouter/` URL is unconditionally broken; if a
237+
// stale entry pointing at it survives from a previous boot, drop it before
238+
// we rebuild.
239+
const existingProviders = config.models?.providers;
240+
const existingBaseUrl: string = existingProviders?.kilocode?.baseUrl ?? '';
241+
if (existingProviders && existingBaseUrl.includes('/api/openrouter/')) {
242+
delete existingProviders.kilocode;
243+
console.log(`Removed stale kilocode provider config (baseUrl: ${existingBaseUrl})`);
244+
}
245+
246+
config.models = config.models ?? {};
247+
config.models.providers = config.models.providers ?? {};
248+
config.models.providers.kilocode = config.models.providers.kilocode ?? {};
249+
config.models.providers.kilocode.baseUrl =
250+
config.models.providers.kilocode.baseUrl ?? 'https://api.kilo.ai/api/gateway/';
251+
config.models.providers.kilocode.api =
252+
config.models.providers.kilocode.api ?? 'openai-completions';
253+
// Empty array keeps the provider schema-valid while letting live gateway
254+
// discovery populate the catalog. Stale model entries written by an older
255+
// `openclaw onboard` are intentionally cleared here.
256+
config.models.providers.kilocode.models = [];
257+
// Auth must come from `KILOCODE_API_KEY` env (env-backed SecretRef in
258+
// `auth-profiles.json` for new installs). A literal `apiKey` in
259+
// `openclaw.json` is never the source of truth on kiloclaw, but the
260+
// previous deletion-based migration was incidentally scrubbing the field.
261+
// Preserve that scrub explicitly so any pre-existing plaintext key from a
262+
// legacy onboard run does not linger on disk.
263+
delete config.models.providers.kilocode.apiKey;
253264

254265
// KiloCode provider base URL override (local dev only).
255266
// OpenClaw's native kilocode provider hardcodes https://api.kilo.ai/api/gateway/.
256267
// In local dev, Fly machines need to route through a Cloudflare tunnel, so we
257268
// override the base URL when KILOCODE_API_BASE_URL is set.
258269
if (env.KILOCODE_API_BASE_URL) {
259-
config.models = config.models ?? {};
260-
config.models.providers = config.models.providers ?? {};
261-
config.models.providers.kilocode = config.models.providers.kilocode ?? {};
262270
config.models.providers.kilocode.baseUrl = env.KILOCODE_API_BASE_URL;
263-
// Provider entries require a models array per OpenClaw's strict zod schema.
264-
// Empty array is valid — the built-in kilocode provider fills in its catalog.
265-
config.models.providers.kilocode.models = config.models.providers.kilocode.models ?? [];
266271
console.log(`Overriding kilocode base URL: ${env.KILOCODE_API_BASE_URL}`);
267272
}
268273

269274
// Pass org scope to KiloCode provider as request header when available.
270275
// This is used by OpenClaw provider requests (not Kilo CLI).
271276
// Header name matches ORGANIZATION_ID_HEADER in src/lib/constants.ts.
272277
if (env.KILOCODE_ORGANIZATION_ID) {
273-
config.models = config.models ?? {};
274-
config.models.providers = config.models.providers ?? {};
275-
config.models.providers.kilocode = config.models.providers.kilocode ?? {};
276-
// Explicit provider entries require a baseUrl per OpenClaw's strict schema.
277-
// When KILOCODE_API_BASE_URL already set the URL above, preserve it;
278-
// otherwise default to the production gateway URL.
279-
config.models.providers.kilocode.baseUrl =
280-
config.models.providers.kilocode.baseUrl ?? 'https://api.kilo.ai/api/gateway/';
281278
config.models.providers.kilocode.headers = config.models.providers.kilocode.headers ?? {};
282279
config.models.providers.kilocode.headers['X-KiloCode-OrganizationId'] =
283280
env.KILOCODE_ORGANIZATION_ID;
284-
// Empty array keeps the provider schema-valid while allowing OpenClaw to
285-
// populate the full Kilo Gateway catalog through live model discovery.
286-
config.models.providers.kilocode.models = [];
287281
console.log('Configured KiloCode organization header from KILOCODE_ORGANIZATION_ID');
288282
} else {
289283
// Remove stale org header from previous boots (e.g., instance was transferred
290284
// from org to personal, or org was deleted).
291-
delete config.models?.providers?.kilocode?.headers?.['X-KiloCode-OrganizationId'];
285+
delete config.models.providers.kilocode.headers?.['X-KiloCode-OrganizationId'];
292286
}
293287

294288
// User-selected default model override.

0 commit comments

Comments
 (0)