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
2 changes: 2 additions & 0 deletions kiloclaw/controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Supervisor } from './supervisor';
import { registerHealthRoute } from './routes/health';
import { registerGatewayRoutes } from './routes/gateway';
import { registerConfigRoutes } from './routes/config';
import { registerEnvRoutes } from './routes/env';
import { registerGmailPushRoute } from './routes/gmail-push';
import { CONTROLLER_COMMIT, CONTROLLER_VERSION } from './version';
import { writeKiloCliConfig } from './kilo-cli-config';
Expand Down Expand Up @@ -182,6 +183,7 @@ export async function startController(env: NodeJS.ProcessEnv = process.env): Pro
registerHealthRoute(app, supervisor, config.expectedToken);
registerGatewayRoutes(app, supervisor, config.expectedToken);
registerConfigRoutes(app, supervisor, config.expectedToken);
registerEnvRoutes(app, supervisor, config.expectedToken);
registerGmailPushRoute(app, gmailWatchSupervisor, config.expectedToken);
app.all(
'*',
Expand Down
184 changes: 184 additions & 0 deletions kiloclaw/controller/src/routes/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Hono } from 'hono';
import { registerEnvRoutes } from './env';
import type { Supervisor } from '../supervisor';

function createMockSupervisor(state: 'running' | 'stopped' = 'running'): Supervisor {
return {
start: vi.fn(async () => true),
stop: vi.fn(async () => true),
restart: vi.fn(async () => true),
shutdown: vi.fn(async () => undefined),
signal: vi.fn(() => true),
getState: vi.fn(() => state),
getStats: vi.fn(() => ({
state,
pid: 100,
uptime: 50,
restarts: 3,
lastExit: null,
})),
};
}

function authHeaders(token = 'test-token'): HeadersInit {
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
}

describe('/_kilo/env/patch', () => {
const originalApiKey = process.env.KILOCODE_API_KEY;

beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
// Restore original env to avoid pollution between tests
if (originalApiKey === undefined) {
delete process.env.KILOCODE_API_KEY;
} else {
process.env.KILOCODE_API_KEY = originalApiKey;
}
});

it('rejects requests without auth', async () => {
const app = new Hono();
registerEnvRoutes(app, createMockSupervisor(), 'test-token');

const resp = await app.request('/_kilo/env/patch', {
method: 'POST',
body: JSON.stringify({ KILOCODE_API_KEY: 'new-key' }),
headers: { 'Content-Type': 'application/json' },
});
expect(resp.status).toBe(401);
});

it('rejects requests with wrong token', async () => {
const app = new Hono();
registerEnvRoutes(app, createMockSupervisor(), 'test-token');

const resp = await app.request('/_kilo/env/patch', {
method: 'POST',
body: JSON.stringify({ KILOCODE_API_KEY: 'new-key' }),
headers: authHeaders('wrong-token'),
});
expect(resp.status).toBe(401);
});

it('rejects invalid JSON body', async () => {
const app = new Hono();
registerEnvRoutes(app, createMockSupervisor(), 'test-token');

const resp = await app.request('/_kilo/env/patch', {
method: 'POST',
body: 'not json',
headers: authHeaders(),
});
expect(resp.status).toBe(400);
expect(await resp.json()).toEqual({ error: 'Invalid JSON body' });
});

it('rejects non-object body (array)', async () => {
const app = new Hono();
registerEnvRoutes(app, createMockSupervisor(), 'test-token');

const resp = await app.request('/_kilo/env/patch', {
method: 'POST',
body: JSON.stringify([1, 2]),
headers: authHeaders(),
});
expect(resp.status).toBe(400);
expect(await resp.json()).toEqual({ error: 'Body must be a JSON object' });
});

it('rejects empty object', async () => {
const app = new Hono();
registerEnvRoutes(app, createMockSupervisor(), 'test-token');

const resp = await app.request('/_kilo/env/patch', {
method: 'POST',
body: JSON.stringify({}),
headers: authHeaders(),
});
expect(resp.status).toBe(400);
expect(await resp.json()).toEqual({ error: 'Body must contain at least one key' });
});

it('rejects keys not in the allowlist', async () => {
const app = new Hono();
registerEnvRoutes(app, createMockSupervisor(), 'test-token');

const resp = await app.request('/_kilo/env/patch', {
method: 'POST',
body: JSON.stringify({ PATH: '/usr/bin' }),
headers: authHeaders(),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error: string };
expect(body.error).toContain("'PATH' is not patchable");
});

it('rejects non-string values', async () => {
const app = new Hono();
registerEnvRoutes(app, createMockSupervisor(), 'test-token');

const resp = await app.request('/_kilo/env/patch', {
method: 'POST',
body: JSON.stringify({ KILOCODE_API_KEY: 123 }),
headers: authHeaders(),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error: string };
expect(body.error).toContain("'KILOCODE_API_KEY' must be a string");
});

it('updates process.env and signals SIGUSR1', async () => {
const app = new Hono();
const supervisor = createMockSupervisor('running');
registerEnvRoutes(app, supervisor, 'test-token');

const resp = await app.request('/_kilo/env/patch', {
method: 'POST',
body: JSON.stringify({ KILOCODE_API_KEY: 'fresh-jwt-token' }),
headers: authHeaders(),
});

expect(resp.status).toBe(200);
expect(await resp.json()).toEqual({ ok: true, signaled: true });

expect(process.env.KILOCODE_API_KEY).toBe('fresh-jwt-token');
expect(supervisor.signal).toHaveBeenCalledWith('SIGUSR1');
});

it('returns signaled: false when gateway is not running', async () => {
const app = new Hono();
const supervisor = createMockSupervisor('stopped');
registerEnvRoutes(app, supervisor, 'test-token');

const resp = await app.request('/_kilo/env/patch', {
method: 'POST',
body: JSON.stringify({ KILOCODE_API_KEY: 'fresh-jwt-token' }),
headers: authHeaders(),
});

expect(resp.status).toBe(200);
expect(await resp.json()).toEqual({ ok: true, signaled: false });

// Env is still updated even if not signaled
expect(process.env.KILOCODE_API_KEY).toBe('fresh-jwt-token');
expect(supervisor.signal).not.toHaveBeenCalled();
});

it('does not leak through to catch-all proxy', async () => {
const app = new Hono();
registerEnvRoutes(app, createMockSupervisor(), 'test-token');
app.all('*', c => c.json({ proxied: true }));

const resp = await app.request('/_kilo/env/patch', {
method: 'POST',
});
// Should hit the auth middleware, not the proxy
expect(resp.status).toBe(401);
expect(await resp.json()).toEqual({ error: 'Unauthorized' });
});
});
59 changes: 59 additions & 0 deletions kiloclaw/controller/src/routes/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Hono } from 'hono';
import { timingSafeTokenEqual } from '../auth';
import type { Supervisor } from '../supervisor';
import { getBearerToken } from './gateway';

const PATCHABLE_KEYS = new Set(['KILOCODE_API_KEY']);

export function registerEnvRoutes(app: Hono, supervisor: Supervisor, expectedToken: string): void {
app.use('/_kilo/env/*', async (c, next) => {
const token = getBearerToken(c.req.header('authorization'));
if (!timingSafeTokenEqual(token, expectedToken)) {
return c.json({ error: 'Unauthorized' }, 401);
}
await next();
});

app.post('/_kilo/env/patch', async c => {
let patch: unknown;
try {
patch = await c.req.json();
} catch {
return c.json({ error: 'Invalid JSON body' }, 400);
}

if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
return c.json({ error: 'Body must be a JSON object' }, 400);
}

const entries = Object.entries(patch as Record<string, unknown>);
if (entries.length === 0) {
return c.json({ error: 'Body must contain at least one key' }, 400);
}

const validated: Record<string, string> = {};
for (const [key, value] of entries) {
if (!PATCHABLE_KEYS.has(key)) {
return c.json({ error: `Key '${key}' is not patchable` }, 400);
}
if (typeof value !== 'string') {
return c.json({ error: `Value for '${key}' must be a string` }, 400);
}
validated[key] = value;
}

for (const [key, value] of Object.entries(validated)) {
process.env[key] = value;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The hot-patched key leaves KILO_API_KEY stale when the Kilo CLI feature is enabled

start-openclaw.sh aliases KILOCODE_API_KEY into KILO_API_KEY before launching the controller, but this route only updates process.env.KILOCODE_API_KEY. After SIGUSR1, the supervised gateway child respawns from the controller's current environment, so it still inherits the old KILO_API_KEY and the Kilo CLI auth plugin keeps using the expired token.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ill fix up the cli in a follow up.

}

const signaled = supervisor.getState() === 'running' && supervisor.signal('SIGUSR1');

console.log(
'[controller] Env patched:',
entries.map(([k]) => k).join(', '),
'signaled:',
signaled
);
return c.json({ ok: true, signaled });
});
}
33 changes: 33 additions & 0 deletions kiloclaw/scripts/controller-smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,38 @@ curl -s -o /dev/null -w "%{http_code}\n" \
echo "user traffic without proxy token (REQUIRE_PROXY_TOKEN=true) -> expect 401:"
curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:${PORT}/"

echo
echo "--- env patch endpoint ---"

echo "env patch (no auth) -> expect 401:"
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST -H "Content-Type: application/json" \
-d '{"KILOCODE_API_KEY":"fresh-key"}' \
"http://127.0.0.1:${PORT}/_kilo/env/patch"

echo "env patch (valid auth, patchable key) -> expect 200:"
curl -sS -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"KILOCODE_API_KEY":"fresh-key"}' \
"http://127.0.0.1:${PORT}/_kilo/env/patch"
echo

echo "env patch (non-patchable key) -> expect 400:"
curl -sS -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"PATH":"/usr/bin"}' \
"http://127.0.0.1:${PORT}/_kilo/env/patch"
echo

echo "env patch (empty body) -> expect 400:"
curl -sS -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}' \
"http://127.0.0.1:${PORT}/_kilo/env/patch"
echo

echo "container logs:"
docker logs --tail 80 "$CID"
36 changes: 36 additions & 0 deletions kiloclaw/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { getProactiveRefreshThresholdMs, PROACTIVE_REFRESH_THRESHOLD_MS } from './config';

describe('getProactiveRefreshThresholdMs', () => {
it('returns default when no override', () => {
expect(getProactiveRefreshThresholdMs(undefined)).toBe(PROACTIVE_REFRESH_THRESHOLD_MS);
});

it('returns default for empty string', () => {
expect(getProactiveRefreshThresholdMs('')).toBe(PROACTIVE_REFRESH_THRESHOLD_MS);
});

it('converts hours to milliseconds', () => {
expect(getProactiveRefreshThresholdMs('24')).toBe(24 * 60 * 60 * 1000);
});

it('handles fractional hours', () => {
expect(getProactiveRefreshThresholdMs('0.5')).toBe(30 * 60 * 1000);
});

it('returns default for non-numeric string', () => {
expect(getProactiveRefreshThresholdMs('abc')).toBe(PROACTIVE_REFRESH_THRESHOLD_MS);
});

it('returns default for zero', () => {
expect(getProactiveRefreshThresholdMs('0')).toBe(PROACTIVE_REFRESH_THRESHOLD_MS);
});

it('returns default for negative value', () => {
expect(getProactiveRefreshThresholdMs('-5')).toBe(PROACTIVE_REFRESH_THRESHOLD_MS);
});

it('accepts large values for testing', () => {
expect(getProactiveRefreshThresholdMs('8760')).toBe(365 * 24 * 60 * 60 * 1000);
});
});
17 changes: 17 additions & 0 deletions kiloclaw/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,20 @@ export const HEALTH_PROBE_INTERVAL_MS = 3_000;

/** Auto-destroy provisioned instances that never started after this duration */
export const STALE_PROVISION_THRESHOLD_MS = 8 * 60 * 60 * 1000; // 8 hours

/** Proactive API key refresh: default trigger when key expires within this window. */
export const PROACTIVE_REFRESH_THRESHOLD_MS = 3 * 24 * 60 * 60 * 1000; // 3 days

/**
* Read the proactive refresh threshold from an env override, falling back to
* the hardcoded default. The env var is in hours for ease of use in wrangler vars.
*/
export function getProactiveRefreshThresholdMs(envOverrideHours?: string): number {
if (envOverrideHours) {
const hours = Number(envOverrideHours);
if (!Number.isNaN(hours) && hours > 0) {
return hours * 60 * 60 * 1000;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Thresholds at or above the token lifetime trigger refresh on every alarm

mintFreshApiKey() always issues 30-day tokens (KILOCODE_API_KEY_EXPIRY_SECONDS). Because this helper accepts any positive hour value, setting PROACTIVE_REFRESH_THRESHOLD_HOURS to 720 or larger means a newly minted key is still inside the threshold on the next 5-minute reconcile, so the worker will mint and push a fresh token forever. Clamp the override below the token lifetime or fall back to the default before returning it.

}
}
return PROACTIVE_REFRESH_THRESHOLD_MS;
}
5 changes: 5 additions & 0 deletions kiloclaw/src/durable-objects/gateway-controller-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export const ControllerVersionResponseSchema = z.object({
openclawCommit: z.string().nullable().optional(),
});

export const EnvPatchResponseSchema = z.object({
ok: z.boolean(),
signaled: z.boolean(),
});

export class GatewayControllerError extends Error {
readonly status: number;
readonly code: string | null;
Expand Down
Loading
Loading