Skip to content
Open
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
32 changes: 28 additions & 4 deletions cloudflare-gastown/container/src/control-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});

Expand Down Expand Up @@ -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];
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
14 changes: 14 additions & 0 deletions cloudflare-gastown/src/dos/Town.do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,10 @@ export class TownDO extends DurableObject<Env> {
* 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<void> {
const townId = this.townId;
Expand Down Expand Up @@ -629,6 +633,16 @@ export class TownDO extends DurableObject<Env> {
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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Clearing kilocode_token leaves the old credential active

updateTownConfig can persist an empty string here, and every other config-backed env var in this method uses delete semantics when the value is cleared. This branch skips KILOCODE_TOKEN entirely when the field becomes falsy, so the stale value remains in TownContainerDO.envVars and gets reused by future agent starts instead of reflecting the cleared config.

try {
await dispatch.pushKilocodeTokenToContainer(this.env, townId, townConfig.kilocode_token);
} catch (err) {
console.warn('[Town.do] syncConfigToContainer: KILOCODE_TOKEN push failed:', err);
}
}
}

// ══════════════════════════════════════════════════════════════════
Expand Down
45 changes: 45 additions & 0 deletions cloudflare-gastown/src/dos/town/container-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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;
Expand Down
17 changes: 17 additions & 0 deletions cloudflare-gastown/src/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand Down
Loading