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
1 change: 1 addition & 0 deletions cloudflare-gastown/container/src/control-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
1 change: 1 addition & 0 deletions cloudflare-gastown/container/src/process-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
for (const [key, value] of Object.entries(agent.startupEnv)) {
Expand Down
85 changes: 85 additions & 0 deletions cloudflare-gastown/src/dos/Town.do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -616,6 +618,7 @@ export class TownDO extends DurableObject<Env> {
['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) {
Expand Down Expand Up @@ -3014,6 +3017,16 @@ export class TownDO extends DurableObject<Env> {
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 ────────
Expand Down Expand Up @@ -3299,6 +3312,78 @@ export class TownDO extends DurableObject<Env> {
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<void> {
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);
}
Expand Down
7 changes: 7 additions & 0 deletions cloudflare-gastown/src/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand Down
Loading