From aba60a876ed9c9bd920fa8c8569e845d356584f3 Mon Sep 17 00:00:00 2001 From: syn Date: Tue, 10 Mar 2026 16:48:53 -0500 Subject: [PATCH 1/4] Set error log when key mint fails --- kiloclaw/src/config.ts | 3 + .../durable-objects/kiloclaw-instance.test.ts | 171 +++++++++++++++++- .../src/durable-objects/kiloclaw-instance.ts | 59 +++++- kiloclaw/src/index.test.ts | 48 +++++ kiloclaw/src/index.ts | 1 + packages/worker-utils/src/index.ts | 4 +- packages/worker-utils/src/kilo-token.test.ts | 91 +++++++++- packages/worker-utils/src/kilo-token.ts | 52 +++++- 8 files changed, 420 insertions(+), 9 deletions(-) create mode 100644 kiloclaw/src/index.test.ts diff --git a/kiloclaw/src/config.ts b/kiloclaw/src/config.ts index cfe1366fa3..fca3f30223 100644 --- a/kiloclaw/src/config.ts +++ b/kiloclaw/src/config.ts @@ -25,6 +25,9 @@ export const KILOCLAW_AUTH_COOKIE_MAX_AGE = 60 * 60 * 24; /** Expected JWT token version -- must match cloud's JWT_TOKEN_VERSION */ export const KILO_TOKEN_VERSION = 3; +/** API key max age for gateway credentials minted by the worker */ +export const KILOCODE_API_KEY_EXPIRY_SECONDS = 30 * 24 * 60 * 60; + /** Default Fly Machine guest spec (shared-cpu-2x, 3GB) */ export const DEFAULT_MACHINE_GUEST = { cpus: 2, diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts index 31019038ca..14305aaabf 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts @@ -12,7 +12,7 @@ * - Alarm cadence varies by status */ -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; // -- Mock cloudflare:workers -- // Must be before the DO import so vitest hoists it. @@ -67,6 +67,10 @@ vi.mock('../lib/image-version', async () => { vi.mock('../db', () => ({ getWorkerDb: vi.fn(() => ({})), getActiveInstance: vi.fn().mockResolvedValue(null), + findPepperByUserId: vi.fn().mockResolvedValue({ + id: 'user-1', + api_token_pepper: 'pepper-1', + }), markInstanceDestroyed: vi.fn().mockResolvedValue(undefined), })); @@ -88,7 +92,9 @@ import { KiloClawInstance } from './kiloclaw-instance'; import * as flyClient from '../fly/client'; import { FlyApiError } from '../fly/client'; import * as db from '../db'; +import * as gatewayEnv from '../gateway/env'; import { resolveLatestVersion } from '../lib/image-version'; +import { verifyKiloToken } from '@kilocode/worker-utils'; import { ALARM_INTERVAL_RUNNING_MS, ALARM_INTERVAL_DESTROYING_MS, @@ -152,12 +158,14 @@ function createFakeEnv() { FLY_APP_NAME: 'test-app', FLY_REGION: 'us,eu', GATEWAY_TOKEN_SECRET: 'test-secret', + NEXTAUTH_SECRET: 'test-nextauth-secret-at-least-32-chars', + WORKER_ENV: 'development', KILOCLAW_INSTANCE: {} as unknown, KILOCLAW_APP: { idFromName: vi.fn().mockReturnValue('fake-do-id'), get: vi.fn().mockReturnValue(appStub), } as unknown, - HYPERDRIVE: { connectionString: '' } as unknown, + HYPERDRIVE: { connectionString: 'postgresql://fake' } as unknown, KV_CLAW_CACHE: { get: vi.fn().mockResolvedValue(null), put: vi.fn().mockResolvedValue(undefined), @@ -249,6 +257,10 @@ beforeEach(() => { ); }); +afterEach(() => { + vi.useRealTimers(); +}); + describe('two-phase destroy', () => { it('clears all state when both Fly deletes succeed', async () => { const { instance, storage } = createInstance(); @@ -807,6 +819,161 @@ describe('status guards', () => { }); }); +describe('buildUserEnvVars API key refresh', () => { + async function callBuildUserEnvVars(instance: KiloClawInstance) { + await (instance as unknown as { loadState: () => Promise }).loadState(); + return await (instance as unknown as { + buildUserEnvVars: () => Promise<{ + envVars: Record; + minSecretsVersion: number; + }>; + }).buildUserEnvVars(); + } + + beforeEach(() => { + (gatewayEnv.buildEnvVars as Mock).mockClear(); + (db.findPepperByUserId as Mock).mockResolvedValue({ + id: 'user-1', + api_token_pepper: 'pepper-1', + }); + }); + + it('mints a fresh key, persists it, and passes it to buildEnvVars', async () => { + const { instance, storage } = createInstance(); + await seedProvisioned(storage, { + kilocodeApiKey: 'stale-key', + kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', + }); + + const result = await callBuildUserEnvVars(instance); + + expect(result.minSecretsVersion).toBe(1); + expect(db.findPepperByUserId).toHaveBeenCalledTimes(1); + expect(gatewayEnv.buildEnvVars).toHaveBeenCalledTimes(1); + + const options = (gatewayEnv.buildEnvVars as Mock).mock.calls[0][3] as { + kilocodeApiKey?: string; + }; + expect(options.kilocodeApiKey).toBeTypeOf('string'); + expect(options.kilocodeApiKey).not.toBe('stale-key'); + expect(storage._store.get('kilocodeApiKey')).toBe(options.kilocodeApiKey); + expect(storage._store.get('kilocodeApiKeyExpiresAt')).toBeTypeOf('string'); + + const payload = await verifyKiloToken( + options.kilocodeApiKey!, + 'test-nextauth-secret-at-least-32-chars' + ); + expect(payload.kiloUserId).toBe('user-1'); + expect(payload.apiTokenPepper).toBe('pepper-1'); + expect(payload.env).toBe('development'); + }); + + it('falls back to the stored key when Hyperdrive is unavailable', async () => { + const env = createFakeEnv(); + env.HYPERDRIVE = { connectionString: '' } as never; + const { instance, storage } = createInstance(createFakeStorage(), env); + await seedProvisioned(storage, { + kilocodeApiKey: 'stored-key', + kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', + }); + + await callBuildUserEnvVars(instance); + + expect(db.findPepperByUserId).not.toHaveBeenCalled(); + const options = (gatewayEnv.buildEnvVars as Mock).mock.calls[0][3] as { + kilocodeApiKey?: string; + }; + expect(options.kilocodeApiKey).toBe('stored-key'); + expect(storage._store.get('kilocodeApiKey')).toBe('stored-key'); + }); + + it('falls back to the stored key and logs when the user is missing', async () => { + const { instance, storage } = createInstance(); + await seedProvisioned(storage, { + kilocodeApiKey: 'stored-key', + kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', + }); + (db.findPepperByUserId as Mock).mockResolvedValueOnce(null); + + await callBuildUserEnvVars(instance); + + expect(console.warn).toHaveBeenCalledWith('[DO] mintFreshApiKey: user not found in DB'); + const options = (gatewayEnv.buildEnvVars as Mock).mock.calls[0][3] as { + kilocodeApiKey?: string; + }; + expect(options.kilocodeApiKey).toBe('stored-key'); + }); + + it('falls back to the stored key and logs when the DB lookup throws', async () => { + const { instance, storage } = createInstance(); + await seedProvisioned(storage, { + kilocodeApiKey: 'stored-key', + kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', + }); + const err = new Error('db down'); + (db.findPepperByUserId as Mock).mockRejectedValueOnce(err); + + await callBuildUserEnvVars(instance); + + expect(console.warn).toHaveBeenCalledWith( + '[DO] buildUserEnvVars: failed to mint fresh API key, using stored key:', + err + ); + const options = (gatewayEnv.buildEnvVars as Mock).mock.calls[0][3] as { + kilocodeApiKey?: string; + }; + expect(options.kilocodeApiKey).toBe('stored-key'); + }); + + it('falls back to the stored key and logs when minting times out', async () => { + vi.useFakeTimers(); + + const { instance, storage } = createInstance(); + await seedProvisioned(storage, { + kilocodeApiKey: 'stored-key', + kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', + }); + (db.findPepperByUserId as Mock).mockImplementationOnce( + () => new Promise(() => undefined) + ); + + const buildPromise = callBuildUserEnvVars(instance); + await vi.advanceTimersByTimeAsync(5_000); + await buildPromise; + + const warningCall = (console.warn as Mock).mock.calls.find( + (call: unknown[]) => + call[0] === '[DO] buildUserEnvVars: failed to mint fresh API key, using stored key:' && + call[1] instanceof Error && + call[1].message === 'API key mint timed out' + ); + expect(warningCall).toBeDefined(); + + const options = (gatewayEnv.buildEnvVars as Mock).mock.calls[0][3] as { + kilocodeApiKey?: string; + }; + expect(options.kilocodeApiKey).toBe('stored-key'); + }); + + it('rejects env building when NEXTAUTH_SECRET is missing', async () => { + const env = { + ...createFakeEnv(), + NEXTAUTH_SECRET: undefined, + } as unknown as ReturnType; + const { instance, storage } = createInstance(createFakeStorage(), env); + await seedProvisioned(storage, { + kilocodeApiKey: 'stored-key', + kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', + }); + + await expect(callBuildUserEnvVars(instance)).rejects.toThrow( + 'Cannot build env vars: NEXTAUTH_SECRET missing' + ); + expect(db.findPepperByUserId).not.toHaveBeenCalled(); + expect(gatewayEnv.buildEnvVars).not.toHaveBeenCalled(); + }); +}); + describe('alarm cadence', () => { it('schedules fast alarm for running instances', async () => { const { instance, storage } = createInstance(); diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.ts index e5faf41245..063091ad0a 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.ts @@ -22,10 +22,11 @@ */ import { DurableObject } from 'cloudflare:workers'; +import { signKiloToken, withTimeout } from '@kilocode/worker-utils'; import type { KiloClawEnv } from '../types'; import { sandboxIdFromUserId } from '../auth/sandbox-id'; import { deriveGatewayToken } from '../auth/gateway-token'; -import { getWorkerDb, getActiveInstance, markInstanceDestroyed } from '../db'; +import { findPepperByUserId, getWorkerDb, getActiveInstance, markInstanceDestroyed } from '../db'; import { buildEnvVars } from '../gateway/env'; import { PersistedStateSchema, @@ -48,6 +49,7 @@ import { HEALTH_PROBE_INTERVAL_MS, STALE_PROVISION_THRESHOLD_MS, OPENCLAW_BUILTIN_DEFAULT_MODEL, + KILOCODE_API_KEY_EXPIRY_SECONDS, } from '../config'; import type { FlyClientConfig } from '../fly/client'; import type { FlyMachineConfig, FlyVolumeSnapshot } from '../fly/types'; @@ -114,6 +116,8 @@ function storageUpdate(update: Partial): Partial return update; } +const MINT_TIMEOUT_MS = 5_000; + // ============================================================================ // Structured reconciliation logging // ============================================================================ @@ -2768,6 +2772,28 @@ export class KiloClawInstance extends DurableObject { } } + private async mintFreshApiKey(secret: string): Promise<{ token: string; expiresAt: string } | null> { + const connectionString = this.env.HYPERDRIVE?.connectionString; + if (!this.userId || !connectionString) { + return null; + } + + const db = getWorkerDb(connectionString); + const user = await findPepperByUserId(db, this.userId); + if (!user) { + console.warn('[DO] mintFreshApiKey: user not found in DB'); + return null; + } + + return signKiloToken({ + userId: user.id, + pepper: user.api_token_pepper, + secret, + expiresInSeconds: KILOCODE_API_KEY_EXPIRY_SECONDS, + env: this.env.WORKER_ENV, + }); + } + private async buildUserEnvVars(): Promise<{ envVars: Record; minSecretsVersion: number; @@ -2778,6 +2804,35 @@ export class KiloClawInstance extends DurableObject { if (!this.userId) { throw new Error('Cannot build env vars: userId missing'); } + if (!this.env.NEXTAUTH_SECRET) { + throw new Error('Cannot build env vars: NEXTAUTH_SECRET missing'); + } + const nextAuthSecret = this.env.NEXTAUTH_SECRET; + + let kilocodeApiKey = this.kilocodeApiKey ?? undefined; + if (this.userId && this.env.HYPERDRIVE?.connectionString) { + try { + const freshKey = await withTimeout( + this.mintFreshApiKey(nextAuthSecret), + MINT_TIMEOUT_MS, + 'API key mint timed out' + ); + if (freshKey) { + kilocodeApiKey = freshKey.token; + this.kilocodeApiKey = freshKey.token; + this.kilocodeApiKeyExpiresAt = freshKey.expiresAt; + await this.ctx.storage.put( + storageUpdate({ + kilocodeApiKey: freshKey.token, + kilocodeApiKeyExpiresAt: freshKey.expiresAt, + }) + ); + console.log('[DO] buildUserEnvVars: minted fresh API key, expires:', freshKey.expiresAt); + } + } catch (err) { + console.warn('[DO] buildUserEnvVars: failed to mint fresh API key, using stored key:', err); + } + } const { env: plainEnv, sensitive } = await buildEnvVars( this.env, @@ -2786,7 +2841,7 @@ export class KiloClawInstance extends DurableObject { { envVars: this.envVars ?? undefined, encryptedSecrets: this.encryptedSecrets ?? undefined, - kilocodeApiKey: this.kilocodeApiKey ?? undefined, + kilocodeApiKey, kilocodeDefaultModel: this.kilocodeDefaultModel ?? undefined, channels: this.channels ?? undefined, } diff --git a/kiloclaw/src/index.test.ts b/kiloclaw/src/index.test.ts new file mode 100644 index 0000000000..453ff8c29c --- /dev/null +++ b/kiloclaw/src/index.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('cloudflare:workers', () => ({ + DurableObject: class FakeDurableObject {}, +})); + +vi.mock('./lib/image-version', async () => { + const actual = await vi.importActual('./lib/image-version'); + return { + ...actual, + registerVersionIfNeeded: vi.fn().mockResolvedValue(undefined), + }; +}); + +import worker from './index'; + +describe('platform route env validation', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('rejects platform routes when NEXTAUTH_SECRET is missing', async () => { + const response = await worker.fetch( + new Request('https://example.com/api/platform/provision', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-api-key': 'secret-123', + }, + body: JSON.stringify({ userId: 'user-1' }), + }), + { + INTERNAL_API_SECRET: 'secret-123', + HYPERDRIVE: { connectionString: 'postgresql://fake' }, + GATEWAY_TOKEN_SECRET: 'gateway-secret', + FLY_API_TOKEN: 'fly-token', + } as never, + { waitUntil: vi.fn() } as never + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ error: 'Configuration error' }); + expect(console.error).toHaveBeenCalledWith( + '[CONFIG] Platform route missing bindings:', + 'NEXTAUTH_SECRET' + ); + }); +}); diff --git a/kiloclaw/src/index.ts b/kiloclaw/src/index.ts index b63673a191..4824b4fb69 100644 --- a/kiloclaw/src/index.ts +++ b/kiloclaw/src/index.ts @@ -82,6 +82,7 @@ async function requireEnvVars(c: Context, next: Next) { const missing: string[] = []; if (!c.env.INTERNAL_API_SECRET) missing.push('INTERNAL_API_SECRET'); if (!c.env.HYPERDRIVE?.connectionString) missing.push('HYPERDRIVE'); + if (!c.env.NEXTAUTH_SECRET) missing.push('NEXTAUTH_SECRET'); if (!c.env.GATEWAY_TOKEN_SECRET) missing.push('GATEWAY_TOKEN_SECRET'); if (!c.env.FLY_API_TOKEN) missing.push('FLY_API_TOKEN'); if (missing.length > 0) { diff --git a/packages/worker-utils/src/index.ts b/packages/worker-utils/src/index.ts index 8a4a06fc7a..3e7878a600 100644 --- a/packages/worker-utils/src/index.ts +++ b/packages/worker-utils/src/index.ts @@ -23,5 +23,5 @@ export { createNotFoundHandler } from './not-found-handler.js'; export type { Owner, MCPServerConfig } from './types.js'; -export { verifyKiloToken, kiloTokenPayload } from './kilo-token.js'; -export type { KiloTokenPayload } from './kilo-token.js'; +export { signKiloToken, verifyKiloToken, kiloTokenPayload } from './kilo-token.js'; +export type { KiloTokenPayload, SignKiloTokenExtra } from './kilo-token.js'; diff --git a/packages/worker-utils/src/kilo-token.test.ts b/packages/worker-utils/src/kilo-token.test.ts index 28cef5e02a..962aea00df 100644 --- a/packages/worker-utils/src/kilo-token.test.ts +++ b/packages/worker-utils/src/kilo-token.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { SignJWT } from 'jose'; -import { verifyKiloToken } from './kilo-token.js'; +import { kiloTokenPayload, signKiloToken, verifyKiloToken } from './kilo-token.js'; const SECRET = 'test-secret-at-least-32-characters-long'; @@ -16,6 +16,93 @@ async function sign(payload: Record, secret = SECRET, expiresIn .sign(encode(secret)); } +afterEach(() => { + vi.useRealTimers(); +}); + +describe('signKiloToken', () => { + it('round-trips through verifyKiloToken with known extra claims', async () => { + const { token } = await signKiloToken({ + userId: 'user-123', + pepper: 'pepper-123', + secret: SECRET, + expiresInSeconds: 60, + env: 'development', + extra: { + botId: 'bot-1', + internalApiUse: true, + gastownAccess: true, + }, + }); + + const payload = await verifyKiloToken(token, SECRET); + + expect(payload).toMatchObject({ + kiloUserId: 'user-123', + apiTokenPepper: 'pepper-123', + version: 3, + env: 'development', + botId: 'bot-1', + internalApiUse: true, + gastownAccess: true, + }); + }); + + it('returns an ISO expiresAt derived from expiresInSeconds', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-10T12:00:00.000Z')); + + const { expiresAt } = await signKiloToken({ + userId: 'user-123', + pepper: null, + secret: SECRET, + expiresInSeconds: 90, + }); + + expect(expiresAt).toBe('2026-03-10T12:01:30.000Z'); + }); + + it('includes env when provided and omits it when absent', async () => { + const withEnv = await signKiloToken({ + userId: 'user-with-env', + pepper: null, + secret: SECRET, + expiresInSeconds: 60, + env: 'production', + }); + const withoutEnv = await signKiloToken({ + userId: 'user-no-env', + pepper: null, + secret: SECRET, + expiresInSeconds: 60, + }); + + const payloadWithEnv = await verifyKiloToken(withEnv.token, SECRET); + const payloadWithoutEnv = await verifyKiloToken(withoutEnv.token, SECRET); + + expect(payloadWithEnv.env).toBe('production'); + expect(payloadWithoutEnv.env).toBeUndefined(); + }); + + it('produces payloads accepted by the closed schema', async () => { + const { token } = await signKiloToken({ + userId: 'user-schema', + pepper: 'pepper-schema', + secret: SECRET, + expiresInSeconds: 60, + extra: { + organizationId: 'org-1', + organizationRole: 'owner', + tokenSource: 'cloud-agent', + }, + }); + + const payload = await verifyKiloToken(token, SECRET); + + expect(kiloTokenPayload.parse(payload)).toEqual(payload); + }); +}); + describe('verifyKiloToken', () => { it('accepts a valid version-3 token', async () => { const token = await sign({ version: 3, kiloUserId: 'user-123' }); diff --git a/packages/worker-utils/src/kilo-token.ts b/packages/worker-utils/src/kilo-token.ts index 6aa7ebb8fa..3177c01103 100644 --- a/packages/worker-utils/src/kilo-token.ts +++ b/packages/worker-utils/src/kilo-token.ts @@ -1,4 +1,4 @@ -import { jwtVerify } from 'jose'; +import { SignJWT, jwtVerify } from 'jose'; import { z } from 'zod'; /** @@ -30,6 +30,56 @@ export const kiloTokenPayload = z.object({ export type KiloTokenPayload = z.infer; +const KILO_TOKEN_VERSION = 3; + +/** + * Optional claims beyond the core fields (userId, pepper, version, env). + * Derived from KiloTokenPayload so sign and verify stay in sync. + */ +export type SignKiloTokenExtra = Pick< + KiloTokenPayload, + | 'isAdmin' + | 'gastownAccess' + | 'botId' + | 'organizationId' + | 'organizationRole' + | 'internalApiUse' + | 'createdOnPlatform' + | 'tokenSource' + | 'deviceAuthRequestCode' +>; + +export async function signKiloToken(params: { + userId: string; + pepper: string | null; + secret: string; + expiresInSeconds: number; + env?: string; + extra?: SignKiloTokenExtra; +}): Promise<{ token: string; expiresAt: string }> { + const now = Math.floor(Date.now() / 1000); + const exp = now + params.expiresInSeconds; + + const payload: Record = { + kiloUserId: params.userId, + apiTokenPepper: params.pepper, + version: KILO_TOKEN_VERSION, + ...params.extra, + }; + + if (params.env) { + payload.env = params.env; + } + + const token = await new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(now) + .setExpirationTime(exp) + .sign(new TextEncoder().encode(params.secret)); + + return { token, expiresAt: new Date(exp * 1000).toISOString() }; +} + /** * Verify a Kilo user JWT (HS256, version 3). * From f350aea60b357bf6cc188ce6cf33999db54f75e8 Mon Sep 17 00:00:00 2001 From: syn Date: Tue, 10 Mar 2026 16:52:40 -0500 Subject: [PATCH 2/4] Fix buildUserEnvVars logging issue --- .../durable-objects/kiloclaw-instance.test.ts | 18 +++++++++--------- .../src/durable-objects/kiloclaw-instance.ts | 4 +++- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts index 14305aaabf..ad794ad121 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts @@ -822,12 +822,14 @@ describe('status guards', () => { describe('buildUserEnvVars API key refresh', () => { async function callBuildUserEnvVars(instance: KiloClawInstance) { await (instance as unknown as { loadState: () => Promise }).loadState(); - return await (instance as unknown as { - buildUserEnvVars: () => Promise<{ - envVars: Record; - minSecretsVersion: number; - }>; - }).buildUserEnvVars(); + return await ( + instance as unknown as { + buildUserEnvVars: () => Promise<{ + envVars: Record; + minSecretsVersion: number; + }>; + } + ).buildUserEnvVars(); } beforeEach(() => { @@ -933,9 +935,7 @@ describe('buildUserEnvVars API key refresh', () => { kilocodeApiKey: 'stored-key', kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', }); - (db.findPepperByUserId as Mock).mockImplementationOnce( - () => new Promise(() => undefined) - ); + (db.findPepperByUserId as Mock).mockImplementationOnce(() => new Promise(() => undefined)); const buildPromise = callBuildUserEnvVars(instance); await vi.advanceTimersByTimeAsync(5_000); diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.ts index 063091ad0a..d252e66309 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.ts @@ -2772,7 +2772,9 @@ export class KiloClawInstance extends DurableObject { } } - private async mintFreshApiKey(secret: string): Promise<{ token: string; expiresAt: string } | null> { + private async mintFreshApiKey( + secret: string + ): Promise<{ token: string; expiresAt: string } | null> { const connectionString = this.env.HYPERDRIVE?.connectionString; if (!this.userId || !connectionString) { return null; From 79cdc0d8919f38be8d1a7ae197eaa6d8511f6e37 Mon Sep 17 00:00:00 2001 From: syn Date: Tue, 10 Mar 2026 17:30:14 -0500 Subject: [PATCH 3/4] Improve kilocode env var handling --- kiloclaw/src/config.ts | 5 +- .../durable-objects/kiloclaw-instance.test.ts | 53 ++++++++++++++++--- .../src/durable-objects/kiloclaw-instance.ts | 19 +++++++ packages/worker-utils/src/index.ts | 2 +- packages/worker-utils/src/kilo-token.test.ts | 24 +++++++-- packages/worker-utils/src/kilo-token.ts | 11 ++-- 6 files changed, 97 insertions(+), 17 deletions(-) diff --git a/kiloclaw/src/config.ts b/kiloclaw/src/config.ts index fca3f30223..ee468b179d 100644 --- a/kiloclaw/src/config.ts +++ b/kiloclaw/src/config.ts @@ -1,3 +1,5 @@ +export { KILO_TOKEN_VERSION } from '@kilocode/worker-utils'; + /** * Configuration constants for KiloClaw */ @@ -22,9 +24,6 @@ export const KILOCLAW_AUTH_COOKIE = 'kiloclaw-auth'; /** Cookie max age: 24 hours */ export const KILOCLAW_AUTH_COOKIE_MAX_AGE = 60 * 60 * 24; -/** Expected JWT token version -- must match cloud's JWT_TOKEN_VERSION */ -export const KILO_TOKEN_VERSION = 3; - /** API key max age for gateway credentials minted by the worker */ export const KILOCODE_API_KEY_EXPIRY_SECONDS = 30 * 24 * 60 * 60; diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts index ad794ad121..34deeb66bd 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts @@ -844,7 +844,7 @@ describe('buildUserEnvVars API key refresh', () => { const { instance, storage } = createInstance(); await seedProvisioned(storage, { kilocodeApiKey: 'stale-key', - kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', + kilocodeApiKeyExpiresAt: '2026-12-01T00:00:00.000Z', }); const result = await callBuildUserEnvVars(instance); @@ -876,7 +876,7 @@ describe('buildUserEnvVars API key refresh', () => { const { instance, storage } = createInstance(createFakeStorage(), env); await seedProvisioned(storage, { kilocodeApiKey: 'stored-key', - kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', + kilocodeApiKeyExpiresAt: '2026-12-01T00:00:00.000Z', }); await callBuildUserEnvVars(instance); @@ -889,11 +889,30 @@ describe('buildUserEnvVars API key refresh', () => { expect(storage._store.get('kilocodeApiKey')).toBe('stored-key'); }); + it('rejects when Hyperdrive is unavailable and the stored key is expired', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-10T12:00:00.000Z')); + + const env = createFakeEnv(); + env.HYPERDRIVE = { connectionString: '' } as never; + const { instance, storage } = createInstance(createFakeStorage(), env); + await seedProvisioned(storage, { + kilocodeApiKey: 'stored-key', + kilocodeApiKeyExpiresAt: '2026-03-10T11:59:59.000Z', + }); + + await expect(callBuildUserEnvVars(instance)).rejects.toThrow( + 'Cannot build env vars: stored KiloCode API key expired and fresh mint unavailable' + ); + expect(db.findPepperByUserId).not.toHaveBeenCalled(); + expect(gatewayEnv.buildEnvVars).not.toHaveBeenCalled(); + }); + it('falls back to the stored key and logs when the user is missing', async () => { const { instance, storage } = createInstance(); await seedProvisioned(storage, { kilocodeApiKey: 'stored-key', - kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', + kilocodeApiKeyExpiresAt: '2026-12-01T00:00:00.000Z', }); (db.findPepperByUserId as Mock).mockResolvedValueOnce(null); @@ -910,7 +929,7 @@ describe('buildUserEnvVars API key refresh', () => { const { instance, storage } = createInstance(); await seedProvisioned(storage, { kilocodeApiKey: 'stored-key', - kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', + kilocodeApiKeyExpiresAt: '2026-12-01T00:00:00.000Z', }); const err = new Error('db down'); (db.findPepperByUserId as Mock).mockRejectedValueOnce(err); @@ -927,13 +946,35 @@ describe('buildUserEnvVars API key refresh', () => { expect(options.kilocodeApiKey).toBe('stored-key'); }); + it('rejects when minting fails and the stored key is expired', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-10T12:00:00.000Z')); + + const { instance, storage } = createInstance(); + await seedProvisioned(storage, { + kilocodeApiKey: 'stored-key', + kilocodeApiKeyExpiresAt: '2026-03-10T11:59:59.000Z', + }); + const err = new Error('db down'); + (db.findPepperByUserId as Mock).mockRejectedValueOnce(err); + + await expect(callBuildUserEnvVars(instance)).rejects.toThrow( + 'Cannot build env vars: stored KiloCode API key expired and fresh mint unavailable' + ); + expect(console.warn).toHaveBeenCalledWith( + '[DO] buildUserEnvVars: failed to mint fresh API key, using stored key:', + err + ); + expect(gatewayEnv.buildEnvVars).not.toHaveBeenCalled(); + }); + it('falls back to the stored key and logs when minting times out', async () => { vi.useFakeTimers(); const { instance, storage } = createInstance(); await seedProvisioned(storage, { kilocodeApiKey: 'stored-key', - kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', + kilocodeApiKeyExpiresAt: '2026-12-01T00:00:00.000Z', }); (db.findPepperByUserId as Mock).mockImplementationOnce(() => new Promise(() => undefined)); @@ -963,7 +1004,7 @@ describe('buildUserEnvVars API key refresh', () => { const { instance, storage } = createInstance(createFakeStorage(), env); await seedProvisioned(storage, { kilocodeApiKey: 'stored-key', - kilocodeApiKeyExpiresAt: '2026-01-01T00:00:00.000Z', + kilocodeApiKeyExpiresAt: '2026-12-01T00:00:00.000Z', }); await expect(callBuildUserEnvVars(instance)).rejects.toThrow( diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.ts index d252e66309..4dd6d01b16 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.ts @@ -2796,6 +2796,19 @@ export class KiloClawInstance extends DurableObject { }); } + private hasExpiredStoredApiKey(): boolean { + if (!this.kilocodeApiKey || !this.kilocodeApiKeyExpiresAt) { + return false; + } + + const expiresAtMs = Date.parse(this.kilocodeApiKeyExpiresAt); + if (Number.isNaN(expiresAtMs)) { + return false; + } + + return expiresAtMs <= Date.now(); + } + private async buildUserEnvVars(): Promise<{ envVars: Record; minSecretsVersion: number; @@ -2836,6 +2849,12 @@ export class KiloClawInstance extends DurableObject { } } + if (this.hasExpiredStoredApiKey()) { + throw new Error( + 'Cannot build env vars: stored KiloCode API key expired and fresh mint unavailable' + ); + } + const { env: plainEnv, sensitive } = await buildEnvVars( this.env, this.sandboxId, diff --git a/packages/worker-utils/src/index.ts b/packages/worker-utils/src/index.ts index 3e7878a600..0b3933a918 100644 --- a/packages/worker-utils/src/index.ts +++ b/packages/worker-utils/src/index.ts @@ -23,5 +23,5 @@ export { createNotFoundHandler } from './not-found-handler.js'; export type { Owner, MCPServerConfig } from './types.js'; -export { signKiloToken, verifyKiloToken, kiloTokenPayload } from './kilo-token.js'; +export { signKiloToken, verifyKiloToken, kiloTokenPayload, KILO_TOKEN_VERSION } from './kilo-token.js'; export type { KiloTokenPayload, SignKiloTokenExtra } from './kilo-token.js'; diff --git a/packages/worker-utils/src/kilo-token.test.ts b/packages/worker-utils/src/kilo-token.test.ts index 962aea00df..989fd37a2b 100644 --- a/packages/worker-utils/src/kilo-token.test.ts +++ b/packages/worker-utils/src/kilo-token.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { SignJWT } from 'jose'; -import { kiloTokenPayload, signKiloToken, verifyKiloToken } from './kilo-token.js'; +import { + kiloTokenPayload, + KILO_TOKEN_VERSION, + signKiloToken, + verifyKiloToken, + type SignKiloTokenExtra, +} from './kilo-token.js'; const SECRET = 'test-secret-at-least-32-characters-long'; @@ -40,7 +46,7 @@ describe('signKiloToken', () => { expect(payload).toMatchObject({ kiloUserId: 'user-123', apiTokenPepper: 'pepper-123', - version: 3, + version: KILO_TOKEN_VERSION, env: 'development', botId: 'bot-1', internalApiUse: true, @@ -48,6 +54,18 @@ describe('signKiloToken', () => { }); }); + it('rejects runtime extras outside the closed schema', async () => { + await expect( + signKiloToken({ + userId: 'user-123', + pepper: null, + secret: SECRET, + expiresInSeconds: 60, + extra: { unknownClaim: true } as unknown as SignKiloTokenExtra, + }) + ).rejects.toThrow(); + }); + it('returns an ISO expiresAt derived from expiresInSeconds', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-03-10T12:00:00.000Z')); @@ -108,7 +126,7 @@ describe('verifyKiloToken', () => { const token = await sign({ version: 3, kiloUserId: 'user-123' }); const payload = await verifyKiloToken(token, SECRET); expect(payload.kiloUserId).toBe('user-123'); - expect(payload.version).toBe(3); + expect(payload.version).toBe(KILO_TOKEN_VERSION); }); it('passthrough preserves extra claims', async () => { diff --git a/packages/worker-utils/src/kilo-token.ts b/packages/worker-utils/src/kilo-token.ts index 3177c01103..cc444775cf 100644 --- a/packages/worker-utils/src/kilo-token.ts +++ b/packages/worker-utils/src/kilo-token.ts @@ -1,6 +1,8 @@ import { SignJWT, jwtVerify } from 'jose'; import { z } from 'zod'; +export const KILO_TOKEN_VERSION = 3; + /** * All known fields that can appear in a Kilo user JWT, sourced from * generateApiToken() / generateOrganizationApiToken() in src/lib/tokens.ts. @@ -8,7 +10,7 @@ import { z } from 'zod'; */ export const kiloTokenPayload = z.object({ // Core — always present - version: z.literal(3), + version: z.literal(KILO_TOKEN_VERSION), kiloUserId: z.string().min(1), // Present in generateApiToken / generateOrganizationApiToken, absent in generateInternalServiceToken apiTokenPepper: z.string().nullable().optional(), @@ -29,8 +31,7 @@ export const kiloTokenPayload = z.object({ }); export type KiloTokenPayload = z.infer; - -const KILO_TOKEN_VERSION = 3; +const signKiloTokenPayload = kiloTokenPayload.omit({ iat: true, exp: true }).strict(); /** * Optional claims beyond the core fields (userId, pepper, version, env). @@ -71,7 +72,9 @@ export async function signKiloToken(params: { payload.env = params.env; } - const token = await new SignJWT(payload) + const validatedPayload = signKiloTokenPayload.parse(payload); + + const token = await new SignJWT(validatedPayload) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt(now) .setExpirationTime(exp) From f0a077eb26c970b39e820fadb8cf8138d43114ce Mon Sep 17 00:00:00 2001 From: syn Date: Tue, 10 Mar 2026 17:43:26 -0500 Subject: [PATCH 4/4] Fix expired kilocode API key --- packages/worker-utils/src/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/worker-utils/src/index.ts b/packages/worker-utils/src/index.ts index 0b3933a918..22b89a2142 100644 --- a/packages/worker-utils/src/index.ts +++ b/packages/worker-utils/src/index.ts @@ -23,5 +23,10 @@ export { createNotFoundHandler } from './not-found-handler.js'; export type { Owner, MCPServerConfig } from './types.js'; -export { signKiloToken, verifyKiloToken, kiloTokenPayload, KILO_TOKEN_VERSION } from './kilo-token.js'; +export { + signKiloToken, + verifyKiloToken, + kiloTokenPayload, + KILO_TOKEN_VERSION, +} from './kilo-token.js'; export type { KiloTokenPayload, SignKiloTokenExtra } from './kilo-token.js';