diff --git a/cloudflare-gastown/container/src/control-server.ts b/cloudflare-gastown/container/src/control-server.ts index 70ba58c62..a93a9a9a3 100644 --- a/cloudflare-gastown/container/src/control-server.ts +++ b/cloudflare-gastown/container/src/control-server.ts @@ -123,13 +123,36 @@ app.post('/dashboard-context', async c => { // Hot-swap the container-scoped JWT on the running process. Called by // the TownDO alarm to push a fresh token before the current one expires. // Updates process.env so all subsequent API calls use the new token. +// +// Also accepts an optional `kilocodeToken` field to hot-swap the +// KILOCODE_TOKEN used by the SDK / LLM gateway. When provided, +// process.env.KILOCODE_TOKEN is updated so long-lived agents pick up +// the fresh value without a container restart. app.post('/refresh-token', async c => { const body: unknown = await c.req.json().catch(() => null); - if (!body || typeof body !== 'object' || !('token' in body) || typeof body.token !== 'string') { - return c.json({ error: 'Missing or invalid token field' }, 400); + const parsed = z + .object({ + token: z.string().optional(), + kilocodeToken: z.string().optional(), + }) + .refine(d => d.token || d.kilocodeToken, { + message: 'Must provide at least one of: token, kilocodeToken', + }) + .safeParse(body); + + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid request body' }, 400); + } + + const { token, kilocodeToken } = parsed.data; + if (token) { + process.env.GASTOWN_CONTAINER_TOKEN = token; + console.log('[control-server] Container token refreshed'); + } + if (kilocodeToken) { + process.env.KILOCODE_TOKEN = kilocodeToken; + console.log('[control-server] KILOCODE_TOKEN refreshed'); } - process.env.GASTOWN_CONTAINER_TOKEN = body.token; - console.log('[control-server] Container token refreshed'); return c.json({ refreshed: true }); }); @@ -220,6 +243,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..2f1c166cf 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -599,6 +599,10 @@ export class TownDO extends DurableObject { * Push config-derived env vars to the running container. Called after * updateTownConfig so that settings changes take effect without a * container restart. New agent processes inherit the updated values. + * + * KILOCODE_TOKEN is pushed to the running process via the /refresh-token + * endpoint (not just setEnvVar) so long-lived agents pick up the fresh + * value immediately. */ async syncConfigToContainer(): Promise { const townId = this.townId; @@ -629,6 +633,16 @@ export class TownDO extends DurableObject { console.warn(`[Town.do] syncConfigToContainer: ${key} sync failed:`, err); } } + + // Push KILOCODE_TOKEN to the running process so long-lived agents + // (especially the mayor) pick up the fresh token immediately. + if (townConfig.kilocode_token) { + try { + await dispatch.pushKilocodeTokenToContainer(this.env, townId, townConfig.kilocode_token); + } catch (err) { + console.warn('[Town.do] syncConfigToContainer: KILOCODE_TOKEN push failed:', err); + } + } } // ══════════════════════════════════════════════════════════════════ diff --git a/cloudflare-gastown/src/dos/town/container-dispatch.ts b/cloudflare-gastown/src/dos/town/container-dispatch.ts index 4932e92a5..ed90b59f6 100644 --- a/cloudflare-gastown/src/dos/town/container-dispatch.ts +++ b/cloudflare-gastown/src/dos/town/container-dispatch.ts @@ -185,6 +185,51 @@ export async function forceRefreshContainerToken( return token; } +/** + * Push a fresh KILOCODE_TOKEN to the running container process via + * POST /refresh-token. Also persists via setEnvVar for next boot. + * + * Best-effort: tolerates a downed container (the token will be picked + * up on next boot via setEnvVar). Propagates non-network errors so + * callers can log or retry. + */ +export async function pushKilocodeTokenToContainer( + env: Env, + townId: string, + token: string +): Promise { + const container = getTownContainerStub(env, townId); + + // Persist for next boot + try { + await container.setEnvVar('KILOCODE_TOKEN', token); + } catch (err) { + console.warn( + `${TOWN_LOG} pushKilocodeTokenToContainer: setEnvVar failed:`, + err instanceof Error ? err.message : err + ); + } + + // Push to running process + try { + const resp = await container.fetch('http://container/refresh-token', { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kilocodeToken: token }), + }); + if (!resp.ok) { + console.warn(`${TOWN_LOG} pushKilocodeTokenToContainer: container returned ${resp.status}`); + } + } catch (err) { + // If the container isn't running, the token will be in envVars when + // it boots. Only propagate non-network errors. + const isContainerDown = + err instanceof TypeError || (err instanceof Error && err.message.includes('fetch')); + if (!isContainerDown) throw err; + } +} + /** Build the initial prompt for an agent from its bead. */ export function buildPrompt(params: { beadTitle: string; diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index 8169c0d7c..9da52aac5 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -1018,6 +1018,23 @@ export const gastownRouter = router({ } const townStub = getTownDOStub(ctx.env, input.townId); await townStub.forceRefreshContainerToken(); + + // Also push KILOCODE_TOKEN — this is what actually authenticates + // GT tool calls and is the main reason users hit 401s. + // Only remint if the caller is the town owner (they have the + // correct api_token_pepper). For org towns where a non-owner + // member triggers the refresh, push the existing token instead + // of overwriting the owner's identity. + const user = userFromCtx(ctx); + const townConfig = await townStub.getTownConfig(); + const credentialUserId = townConfig.owner_user_id ?? user.id; + if (credentialUserId === user.id) { + const newKilocodeToken = await mintKilocodeToken(ctx.env, user); + await townStub.updateTownConfig({ kilocode_token: newKilocodeToken }); + } + // syncConfigToContainer pushes KILOCODE_TOKEN (new or existing) + // to the running container process. + await townStub.syncConfigToContainer(); }), // ── Events ──────────────────────────────────────────────────────────