From aafda679e9beecf6a864640e624febf063b4b2cd Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 13 Apr 2026 14:41:32 +0000 Subject: [PATCH 1/3] fix(gastown): propagate custom env_vars to running containers on settings save Three gaps fixed: 1. syncTownConfigToProcessEnv() now applies custom env_vars from town config to process.env, with tracking of previously-applied keys so removed vars are deleted from process.env. 2. syncConfigToContainer() now persists custom env_vars to TownContainerDO storage (via container.setEnvVar/deleteEnvVar) so they survive container restarts. Previously-persisted custom keys are tracked in DO storage and cleaned up on removal. 3. updateAgentModel() hot-swap now overlays fresh custom env_vars from getCurrentTownConfig() over the stale startupEnv snapshot. Infra keys in LIVE_ENV_KEYS always take precedence. --- .../gastown/container/src/control-server.ts | 21 +++++++++++++++ .../gastown/container/src/process-manager.ts | 18 +++++++++++++ services/gastown/src/dos/Town.do.ts | 26 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/services/gastown/container/src/control-server.ts b/services/gastown/container/src/control-server.ts index b56eb97e64..64413b1089 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -43,6 +43,9 @@ 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(); + /** Get the latest town config delivered via X-Town-Config header. */ export function getCurrentTownConfig(): Record | null { return lastKnownTownConfig; @@ -102,6 +105,24 @@ function syncTownConfigToProcessEnv(): void { } else { delete process.env.GASTOWN_ORGANIZATION_ID; } + + // Apply custom env_vars from the town config. Infra keys above always take + // precedence — custom keys are applied after so they cannot override them. + 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. + for (const key of lastAppliedEnvVarKeys) { + if (!newCustomKeys.has(key)) delete process.env[key]; + } + // Apply current custom env vars. + for (const [key, value] of Object.entries(customEnvVars)) { + 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..d6e2bdb7a8 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 } from './control-server'; import { log } from './logger'; const MANAGER_LOG = '[process-manager]'; @@ -1264,6 +1265,23 @@ 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; + 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; + if (value !== undefined && value !== null) { + hotSwapEnv[key] = String(value); + } else { + 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..b21acaa1d5 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -797,6 +797,32 @@ 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. + 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); + const newCustomKeySet = new Set(newCustomKeys); + + for (const key of prevCustomKeys) { + 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)) { + 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. From f18a98e28fe3e9abfc725c79c64e77a5aaba8652 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 13 Apr 2026 15:06:41 +0000 Subject: [PATCH 2/3] fix(gastown): guard custom env_vars against reserved key override - control-server: export getLastAppliedEnvVarKeys() for process-manager - process-manager: delete stale custom keys from hotSwapEnv on hot-swap - Town.do: skip RESERVED_ENV_KEYS when setting custom env_vars on container Addresses 3 review warnings about custom env_vars overriding infra keys. --- .../gastown/container/src/control-server.ts | 36 ++++++++++++++++--- .../gastown/container/src/process-manager.ts | 12 ++++++- services/gastown/src/dos/Town.do.ts | 22 +++++++++++- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/services/gastown/container/src/control-server.ts b/services/gastown/container/src/control-server.ts index 64413b1089..9aef9defbd 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -46,11 +46,36 @@ 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. @@ -106,8 +131,9 @@ function syncTownConfigToProcessEnv(): void { delete process.env.GASTOWN_ORGANIZATION_ID; } - // Apply custom env_vars from the town config. Infra keys above always take - // precedence — custom keys are applied after so they cannot override them. + // 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) @@ -115,11 +141,13 @@ function syncTownConfigToProcessEnv(): void { : {}; 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)) delete process.env[key]; + if (!newCustomKeys.has(key) && !RESERVED_ENV_KEYS.has(key)) delete process.env[key]; } - // Apply current custom env vars. + // 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; diff --git a/services/gastown/container/src/process-manager.ts b/services/gastown/container/src/process-manager.ts index d6e2bdb7a8..659a2c2dea 100644 --- a/services/gastown/container/src/process-manager.ts +++ b/services/gastown/container/src/process-manager.ts @@ -12,7 +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 } from './control-server'; +import { getCurrentTownConfig, getLastAppliedEnvVarKeys } from './control-server'; import { log } from './logger'; const MANAGER_LOG = '[process-manager]'; @@ -1271,9 +1271,11 @@ export async function updateAgentModel( // 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 { @@ -1281,6 +1283,14 @@ export async function updateAgentModel( } } } + // 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. diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index b21acaa1d5..85f7936561 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -799,10 +799,29 @@ 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); + const newCustomKeys = Object.keys(townConfig.env_vars).filter( + key => !RESERVED_ENV_KEYS.has(key) + ); const newCustomKeySet = new Set(newCustomKeys); for (const key of prevCustomKeys) { @@ -815,6 +834,7 @@ export class TownDO extends DurableObject { } } 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) { From 354f7f916b8fa2413446f17fdfae311e462b67da Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 13 Apr 2026 15:13:45 +0000 Subject: [PATCH 3/3] fix: skip reserved env keys in prevCustomKeys cleanup loop prevCustomKeys may contain reserved keys persisted by the previous implementation (before the RESERVED_ENV_KEYS filter was added). Without this guard the cleanup loop would delete managed infra values like KILOCODE_TOKEN that were just written by envMapping. --- services/gastown/src/dos/Town.do.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 85f7936561..695c87ea77 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -825,6 +825,7 @@ export class TownDO extends DurableObject { 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);