diff --git a/services/gastown/container/src/control-server.ts b/services/gastown/container/src/control-server.ts index b56eb97e64..9aef9defbd 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -43,11 +43,39 @@ const TownConfigHeader = z.record(z.string(), z.unknown()); // Used as a fallback by code that runs outside a request context (e.g. background tasks). let lastKnownTownConfig: Record | null = null; +// Track which custom env var keys were applied last sync so removed keys can be cleared. +let lastAppliedEnvVarKeys = new Set(); + +// Env keys managed by the control plane that custom env_vars must never override. +// If a custom key collides with a reserved key, the infra value wins and the +// custom value is silently ignored — matching the !(key in env) guard in buildAgentEnv. +const RESERVED_ENV_KEYS = new Set([ + 'KILOCODE_TOKEN', + 'GIT_TOKEN', + 'GITHUB_TOKEN', + 'GITLAB_TOKEN', + 'GITLAB_INSTANCE_URL', + 'GITHUB_CLI_PAT', + 'GH_TOKEN', + 'GASTOWN_GIT_AUTHOR_NAME', + 'GASTOWN_GIT_AUTHOR_EMAIL', + 'GASTOWN_DISABLE_AI_COAUTHOR', + 'GASTOWN_ORGANIZATION_ID', + 'GASTOWN_CONTAINER_TOKEN', + 'GASTOWN_SESSION_TOKEN', + 'GASTOWN_API_URL', +]); + /** Get the latest town config delivered via X-Town-Config header. */ export function getCurrentTownConfig(): Record | null { return lastKnownTownConfig; } +/** Get the set of custom env var keys applied in the last sync. */ +export function getLastAppliedEnvVarKeys(): Set { + return lastAppliedEnvVarKeys; +} + /** * Sync config-derived env vars from the last-known town config into * process.env. Safe to call at any time — no-ops when no config is cached. @@ -102,6 +130,27 @@ function syncTownConfigToProcessEnv(): void { } else { delete process.env.GASTOWN_ORGANIZATION_ID; } + + // Apply custom env_vars from the town config. Reserved infra keys are + // skipped so the control-plane values always take precedence — matching the + // !(key in env) guard in buildAgentEnv. + const rawEnvVars = cfg.env_vars; + const customEnvVars: Record = + rawEnvVars !== null && typeof rawEnvVars === 'object' && !Array.isArray(rawEnvVars) + ? (rawEnvVars as Record) + : {}; + const newCustomKeys = new Set(Object.keys(customEnvVars)); + // Remove keys that were present in the previous sync but are gone now. + // Skip reserved keys — deleting those would wipe a control-plane value. + for (const key of lastAppliedEnvVarKeys) { + if (!newCustomKeys.has(key) && !RESERVED_ENV_KEYS.has(key)) delete process.env[key]; + } + // Apply current custom env vars, skipping reserved keys. + for (const [key, value] of Object.entries(customEnvVars)) { + if (RESERVED_ENV_KEYS.has(key)) continue; + process.env[key] = String(value); + } + lastAppliedEnvVarKeys = newCustomKeys; } export const app = new Hono(); diff --git a/services/gastown/container/src/process-manager.ts b/services/gastown/container/src/process-manager.ts index 77aa4bb411..659a2c2dea 100644 --- a/services/gastown/container/src/process-manager.ts +++ b/services/gastown/container/src/process-manager.ts @@ -12,6 +12,7 @@ import * as fs from 'node:fs/promises'; import type { ManagedAgent, StartAgentRequest } from './types'; import { reportAgentCompleted, reportMayorWaiting } from './completion-reporter'; import { buildKiloConfigContent } from './agent-runner'; +import { getCurrentTownConfig, getLastAppliedEnvVarKeys } from './control-server'; import { log } from './logger'; const MANAGER_LOG = '[process-manager]'; @@ -1264,6 +1265,33 @@ export async function updateAgentModel( if (live) hotSwapEnv[key] = live; } + // Overlay custom env_vars from the town config so hot-swap picks up + // values that were added/changed after the initial dispatch. Infra + // keys in LIVE_ENV_KEYS always take precedence (they were already + // populated from process.env above), so custom vars cannot override. + const freshConfig = getCurrentTownConfig(); + const freshEnvVars = freshConfig?.env_vars; + const freshCustomKeySet = new Set(); + if (freshEnvVars !== null && typeof freshEnvVars === 'object' && !Array.isArray(freshEnvVars)) { + for (const [key, value] of Object.entries(freshEnvVars as Record)) { + if (LIVE_ENV_KEYS.has(key)) continue; + freshCustomKeySet.add(key); + if (value !== undefined && value !== null) { + hotSwapEnv[key] = String(value); + } else { + delete hotSwapEnv[key]; + } + } + } + // Remove stale custom env vars — keys that were applied in a previous + // sync but are no longer in the town config. Without this, startupEnv + // keeps carrying deleted custom keys through every hot-swap. + for (const key of getLastAppliedEnvVarKeys()) { + if (!freshCustomKeySet.has(key) && !LIVE_ENV_KEYS.has(key)) { + delete hotSwapEnv[key]; + } + } + // Re-derive GH_TOKEN from live values using the same priority chain // as buildAgentEnv: GITHUB_CLI_PAT > GIT_TOKEN > GITHUB_TOKEN. // syncConfigToContainer updates these on process.env, but buildAgentEnv diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 97398f20e9..695c87ea77 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -797,6 +797,53 @@ export class TownDO extends DurableObject { } } + // Persist custom env_vars to DO storage so they survive container restarts. + // Compare against the previously-persisted set of keys to clear removed ones. + // Reserved infra keys are never overwritten or deleted — infra values always win. + const RESERVED_ENV_KEYS = new Set([ + 'KILOCODE_TOKEN', + 'GIT_TOKEN', + 'GITHUB_TOKEN', + 'GITLAB_TOKEN', + 'GITLAB_INSTANCE_URL', + 'GITHUB_CLI_PAT', + 'GH_TOKEN', + 'GASTOWN_GIT_AUTHOR_NAME', + 'GASTOWN_GIT_AUTHOR_EMAIL', + 'GASTOWN_DISABLE_AI_COAUTHOR', + 'GASTOWN_ORGANIZATION_ID', + 'GASTOWN_CONTAINER_TOKEN', + 'GASTOWN_SESSION_TOKEN', + 'GASTOWN_API_URL', + ]); + const CUSTOM_ENV_KEYS_STORAGE_KEY = 'container:custom_env_var_keys'; + const prevCustomKeys: string[] = + (await this.ctx.storage.get(CUSTOM_ENV_KEYS_STORAGE_KEY)) ?? []; + const newCustomKeys = Object.keys(townConfig.env_vars).filter( + key => !RESERVED_ENV_KEYS.has(key) + ); + const newCustomKeySet = new Set(newCustomKeys); + + for (const key of prevCustomKeys) { + if (RESERVED_ENV_KEYS.has(key)) continue; + if (!newCustomKeySet.has(key)) { + try { + await container.deleteEnvVar(key); + } catch (err) { + console.warn(`[Town.do] syncConfigToContainer: delete custom ${key} failed:`, err); + } + } + } + for (const [key, value] of Object.entries(townConfig.env_vars)) { + if (RESERVED_ENV_KEYS.has(key)) continue; + try { + await container.setEnvVar(key, value); + } catch (err) { + console.warn(`[Town.do] syncConfigToContainer: set custom ${key} failed:`, err); + } + } + await this.ctx.storage.put(CUSTOM_ENV_KEYS_STORAGE_KEY, newCustomKeys); + // Phase 2: Push to the running container's process.env via the // /sync-config endpoint. The X-Town-Config header delivers the // full config; the endpoint applies CONFIG_ENV_MAP to process.env.