-
Notifications
You must be signed in to change notification settings - Fork 11
feat(kiloclaw): proactively refresh API keys approaching expiry #1049
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e851c24
9fe1c4b
7a4d151
61b76ca
b39307b
da73452
96bec11
a1637db
584f907
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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' }); | ||
| }); | ||
| }); |
| 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; | ||
| } | ||
|
|
||
| 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 }); | ||
| }); | ||
| } | ||
| 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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||
| } | ||
| } | ||
| return PROACTIVE_REFRESH_THRESHOLD_MS; | ||
| } | ||
There was a problem hiding this comment.
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_KEYstale when the Kilo CLI feature is enabledstart-openclaw.shaliasesKILOCODE_API_KEYintoKILO_API_KEYbefore launching the controller, but this route only updatesprocess.env.KILOCODE_API_KEY. AfterSIGUSR1, the supervised gateway child respawns from the controller's current environment, so it still inherits the oldKILO_API_KEYand the Kilo CLI auth plugin keeps using the expired token.There was a problem hiding this comment.
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.