diff --git a/cloudflare-gastown/container/src/control-server.ts b/cloudflare-gastown/container/src/control-server.ts index 70ba58c62..5834a1b81 100644 --- a/cloudflare-gastown/container/src/control-server.ts +++ b/cloudflare-gastown/container/src/control-server.ts @@ -220,6 +220,7 @@ app.patch('/agents/:agentId/model', async c => { ['github_cli_pat', 'GITHUB_CLI_PAT'], ['git_author_name', 'GASTOWN_GIT_AUTHOR_NAME'], ['git_author_email', 'GASTOWN_GIT_AUTHOR_EMAIL'], + ['kilocode_token', 'KILOCODE_TOKEN'], ]; for (const [cfgKey, envKey] of CONFIG_ENV_MAP) { const val = cfg[cfgKey]; diff --git a/cloudflare-gastown/container/src/process-manager.ts b/cloudflare-gastown/container/src/process-manager.ts index f0008e320..6b9d390ba 100644 --- a/cloudflare-gastown/container/src/process-manager.ts +++ b/cloudflare-gastown/container/src/process-manager.ts @@ -843,6 +843,7 @@ export async function updateAgentModel( 'GASTOWN_GIT_AUTHOR_NAME', 'GASTOWN_GIT_AUTHOR_EMAIL', 'GASTOWN_DISABLE_AI_COAUTHOR', + 'KILOCODE_TOKEN', ]); const hotSwapEnv: Record = {}; for (const [key, value] of Object.entries(agent.startupEnv)) { diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index b3eeaa158..5124e0a0d 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -56,6 +56,8 @@ import { query } from '../util/query.util'; import { getAgentDOStub } from './Agent.do'; import { getTownContainerStub } from './TownContainer.do'; +import { generateKiloApiToken } from '../util/kilo-token.util'; +import { resolveSecret } from '../util/secret.util'; import { writeEvent, type GastownEventData } from '../util/analytics.util'; import { logger, withLogTags } from '../util/log.util'; import { BeadPriority } from '../types'; @@ -616,6 +618,7 @@ export class TownDO extends DurableObject { ['GASTOWN_GIT_AUTHOR_NAME', townConfig.git_author_name], ['GASTOWN_GIT_AUTHOR_EMAIL', townConfig.git_author_email], ['GASTOWN_DISABLE_AI_COAUTHOR', townConfig.disable_ai_coauthor ? '1' : undefined], + ['KILOCODE_TOKEN', townConfig.kilocode_token], ]; for (const [key, value] of envMapping) { @@ -3014,6 +3017,16 @@ export class TownDO extends DurableObject { error: err instanceof Error ? err.message : String(err), }); } + + // Proactively remint KILOCODE_TOKEN before it expires (30-day + // expiry, checked daily, refreshed within 7 days of expiry). + try { + await this.refreshKilocodeTokenIfExpiring(); + } catch (err) { + logger.warn('alarm: refreshKilocodeTokenIfExpiring failed', { + error: err instanceof Error ? err.message : String(err), + }); + } } // ── Pre-phase: Observe container status for working agents ──────── @@ -3299,6 +3312,78 @@ export class TownDO extends DurableObject { this.lastContainerTokenRefreshAt = now; } + /** + * Proactively remint KILOCODE_TOKEN when it's approaching expiry. + * Throttled to once per day — the 30-day token is refreshed when + * within 7 days of expiry, providing ample safety margin. + * + * Decodes the existing JWT payload to extract user identity (no + * signature verification needed — we're just reading the claims to + * re-sign with the same data). + */ + private lastKilocodeTokenCheckAt = 0; + private async refreshKilocodeTokenIfExpiring(): Promise { + const CHECK_INTERVAL_MS = 24 * 60 * 60_000; // once per day + const REFRESH_WINDOW_SECONDS = 7 * 24 * 60 * 60; // 7 days + const now = Date.now(); + if (now - this.lastKilocodeTokenCheckAt < CHECK_INTERVAL_MS) return; + this.lastKilocodeTokenCheckAt = now; + + const townConfig = await this.getTownConfig(); + const token = townConfig.kilocode_token; + if (!token) return; + + // Decode JWT payload (base64url, no verification) + const parts = token.split('.'); + const encodedPayload = parts[1]; + if (!encodedPayload) return; + const payloadSchema = z.object({ + exp: z.number().optional(), + kiloUserId: z.string().optional(), + apiTokenPepper: z.string().nullable().optional(), + }); + let rawPayload: unknown; + try { + rawPayload = JSON.parse(atob(encodedPayload.replace(/-/g, '+').replace(/_/g, '/'))); + } catch { + return; + } + const parsed = payloadSchema.safeParse(rawPayload); + if (!parsed.success) return; + const payload = parsed.data; + + const exp = payload.exp; + if (!exp) return; + + const nowSeconds = Math.floor(now / 1000); + if (exp - nowSeconds > REFRESH_WINDOW_SECONDS) return; + + // Token expires within 7 days — remint it + const userId = payload.kiloUserId; + if (!userId) return; + + if (!this.env.NEXTAUTH_SECRET) { + logger.warn('refreshKilocodeTokenIfExpiring: NEXTAUTH_SECRET not configured'); + return; + } + const secret = await resolveSecret(this.env.NEXTAUTH_SECRET); + if (!secret) { + logger.warn('refreshKilocodeTokenIfExpiring: failed to resolve NEXTAUTH_SECRET'); + return; + } + + const newToken = await generateKiloApiToken( + { id: userId, api_token_pepper: payload.apiTokenPepper ?? null }, + secret + ); + await this.updateTownConfig({ kilocode_token: newToken }); + await this.syncConfigToContainer(); + logger.info('refreshKilocodeTokenIfExpiring: reminted KILOCODE_TOKEN proactively', { + userId, + oldExp: new Date(exp * 1000).toISOString(), + }); + } + private hasActiveWork(): boolean { return scheduling.hasActiveWork(this.sql); } diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index 8169c0d7c..c1551d2d7 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -1018,6 +1018,13 @@ export const gastownRouter = router({ } const townStub = getTownDOStub(ctx.env, input.townId); await townStub.forceRefreshContainerToken(); + + // Also remint and push KILOCODE_TOKEN — this is what actually + // authenticates GT tool calls and is the main reason users hit 401s. + const user = userFromCtx(ctx); + const newKilocodeToken = await mintKilocodeToken(ctx.env, user); + await townStub.updateTownConfig({ kilocode_token: newKilocodeToken }); + await townStub.syncConfigToContainer(); }), // ── Events ──────────────────────────────────────────────────────────