From 8925f76aefa0fa7ee17e6cf9d94c2e79dd0f12fc Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Mon, 1 Jun 2026 12:38:49 -0600 Subject: [PATCH 1/3] fix(gastown): stop sending secrets in town config headers --- .../gastown/container/src/agent-runner.ts | 3 +- .../gastown/container/src/control-server.ts | 119 ++++++++++-------- .../gastown/container/src/process-manager.ts | 2 +- services/gastown/src/dos/Town.do.ts | 40 ++---- services/gastown/src/dos/town/config.test.ts | 79 +++++++++++- services/gastown/src/dos/town/config.ts | 101 +++++++++++---- .../src/dos/town/container-dispatch.ts | 14 ++- 7 files changed, 245 insertions(+), 113 deletions(-) diff --git a/services/gastown/container/src/agent-runner.ts b/services/gastown/container/src/agent-runner.ts index 30ac6330ca..ae6c059666 100644 --- a/services/gastown/container/src/agent-runner.ts +++ b/services/gastown/container/src/agent-runner.ts @@ -143,7 +143,8 @@ GASTOWN_AGENT_ID="${env.GASTOWN_AGENT_ID}" GASTOWN_RIG_ID="${env.GASTOWN_RIG_ID}" GASTOWN_TOWN_ID="${env.GASTOWN_TOWN_ID}"`); - // Fall back to X-Town-Config for KILOCODE_TOKEN if not in request or process.env + // Fall back to cached config for KILOCODE_TOKEN if not in request or process.env. + // New requests should provide this through envVars rather than X-Town-Config. if (!env.KILOCODE_TOKEN) { const townConfig = getCurrentTownConfig(); const tokenFromConfig = diff --git a/services/gastown/container/src/control-server.ts b/services/gastown/container/src/control-server.ts index 761feefbaa..22698c4c30 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -41,13 +41,18 @@ import type { const MAX_TICKETS = 1000; const streamTickets = new Map(); -// Minimal Zod schema for the town config delivered via X-Town-Config header. +// Minimal Zod schema for the non-secret town config delivered via X-Town-Config header. // Uses z.record() so any string-keyed object is accepted and future keys are preserved. const TownConfigHeader = z.record(z.string(), z.unknown()); +const SyncConfigRequest = z.object({ + envVars: z.record(z.string(), z.string()).optional(), +}); + // Last-known-good town config. Updated on every request that carries the header. // Used as a fallback by code that runs outside a request context (e.g. background tasks). let lastKnownTownConfig: Record | null = null; +let lastBodySyncedEnvVars: Record | null = null; // Track which custom env var keys were applied last sync so removed keys can be cleared. let lastAppliedEnvVarKeys = new Set(); @@ -76,7 +81,20 @@ export const RESERVED_ENV_KEYS = new Set([ 'GASTOWN_RIG_ID', ]); -/** Get the latest town config delivered via X-Town-Config header. */ +const CONFIG_SYNC_ENV_KEYS = new Set([ + 'KILOCODE_TOKEN', + 'GIT_TOKEN', + 'GITLAB_TOKEN', + 'GITLAB_INSTANCE_URL', + 'GITHUB_CLI_PAT', + 'GASTOWN_GIT_AUTHOR_NAME', + 'GASTOWN_GIT_AUTHOR_EMAIL', + 'GASTOWN_DISABLE_AI_COAUTHOR', + 'GASTOWN_ORGANIZATION_ID', + 'GASTOWN_API_URL', +]); + +/** Get the latest non-secret town config delivered via X-Town-Config header. */ export function getCurrentTownConfig(): Record | null { return lastKnownTownConfig; } @@ -87,18 +105,40 @@ export function getLastAppliedEnvVarKeys(): Set { } /** - * 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. + * Sync config-derived env vars into process.env. Secret-bearing values arrive + * in the JSON body because Cloudflare event logs can record request headers. */ -function syncTownConfigToProcessEnv(): void { +function syncTownConfigToProcessEnv(envVars?: Record): void { + if (envVars) { + for (const key of CONFIG_SYNC_ENV_KEYS) { + if (!(key in envVars)) delete process.env[key]; + } + for (const [key, value] of Object.entries(envVars)) { + if (RESERVED_ENV_KEYS.has(key) && !CONFIG_SYNC_ENV_KEYS.has(key)) continue; + process.env[key] = value; + } + + const customKeys = Object.keys(envVars).filter(key => !RESERVED_ENV_KEYS.has(key)); + const newCustomKeys = new Set(customKeys); + const customEnvVars = Object.fromEntries(customKeys.map(key => [key, envVars[key]])); + for (const key of lastAppliedEnvVarKeys) { + if (!newCustomKeys.has(key) && !RESERVED_ENV_KEYS.has(key)) delete process.env[key]; + } + lastAppliedEnvVarKeys = newCustomKeys; + lastBodySyncedEnvVars = customEnvVars; + lastKnownTownConfig = { + ...(lastKnownTownConfig ?? {}), + env_vars: customEnvVars, + }; + return; + } + const cfg = getCurrentTownConfig(); if (!cfg) return; const CONFIG_ENV_MAP: Array<[string, string]> = [ - ['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]; @@ -109,23 +149,6 @@ function syncTownConfigToProcessEnv(): void { } } - const gitAuth = cfg.git_auth; - if (typeof gitAuth === 'object' && gitAuth !== null) { - const auth = gitAuth as Record; - for (const [authKey, envKey] of [ - ['github_token', 'GIT_TOKEN'], - ['gitlab_token', 'GITLAB_TOKEN'], - ['gitlab_instance_url', 'GITLAB_INSTANCE_URL'], - ] as const) { - const val = auth[authKey]; - if (typeof val === 'string' && val) { - process.env[envKey] = val; - } else { - delete process.env[envKey]; - } - } - } - if (cfg.disable_ai_coauthor) { process.env.GASTOWN_DISABLE_AI_COAUTHOR = '1'; } else { @@ -140,32 +163,11 @@ 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(); -// Parse and validate town config from X-Town-Config header (sent by TownDO on +// Parse and validate non-secret town config from X-Town-Config header (sent by TownDO on // every request). The validated config is stored in a module-level cache // accessible via getCurrentTownConfig(). app.use('*', async (c, next) => { @@ -175,11 +177,14 @@ app.use('*', async (c, next) => { const raw: unknown = JSON.parse(configHeader); const result = TownConfigHeader.safeParse(raw); if (result.success) { - lastKnownTownConfig = result.data; - const hasToken = - typeof result.data.kilocode_token === 'string' && result.data.kilocode_token.length > 0; + const headerConfig = { ...result.data }; + delete headerConfig.env_vars; + lastKnownTownConfig = { + ...headerConfig, + ...(lastBodySyncedEnvVars ? { env_vars: lastBodySyncedEnvVars } : {}), + }; console.log( - `[control-server] X-Town-Config received: hasKilocodeToken=${hasToken} keys=${Object.keys(result.data).join(',')}` + `[control-server] X-Town-Config received: keys=${Object.keys(result.data).join(',')}` ); } else { console.warn( @@ -302,12 +307,17 @@ app.post('/refresh-token', async c => { }); // POST /sync-config -// Push config-derived env vars from X-Town-Config into process.env on +// Push config-derived env vars from the JSON body into process.env on // the running container. Called by TownDO.syncConfigToContainer() after // persisting env vars to DO storage, so the live process picks up // changes (e.g. refreshed KILOCODE_TOKEN) without a container restart. app.post('/sync-config', async c => { - syncTownConfigToProcessEnv(); + const body: unknown = await c.req.json().catch(() => ({})); + const parsed = SyncConfigRequest.safeParse(body); + if (!parsed.success) { + return c.json({ error: 'Invalid request body', issues: parsed.error.issues }, 400); + } + syncTownConfigToProcessEnv(parsed.data.envVars); return c.json({ synced: true }); }); @@ -422,9 +432,8 @@ app.patch('/agents/:agentId/model', async c => { process.env.GASTOWN_ORGANIZATION_ID = parsed.data.organizationId; } - // Sync config-derived env vars from X-Town-Config into process.env so - // the SDK server restart picks up fresh tokens and git identity. - // The middleware already parsed the header into lastKnownTownConfig. + // Sync non-secret config fields from X-Town-Config into process.env. + // Secret-bearing values are refreshed through /sync-config or request envVars. syncTownConfigToProcessEnv(); await updateAgentModel( diff --git a/services/gastown/container/src/process-manager.ts b/services/gastown/container/src/process-manager.ts index 4d35c350c0..a6e973743d 100644 --- a/services/gastown/container/src/process-manager.ts +++ b/services/gastown/container/src/process-manager.ts @@ -1530,7 +1530,7 @@ export async function sendMessage(agentId: string, prompt: string): Promise { // Resolve a fresh GitHub token here too — this method runs both at // initial config push and on every config change, so the persisted // GIT_TOKEN must be live rather than the stale value stored in - // git_auth.github_token from rig creation. The container's - // syncTownConfigToProcessEnv path reads `git_auth.github_token` - // from the X-Town-Config header on every request, so the in-process - // GIT_TOKEN follows the same source-of-truth as the persisted one. + // git_auth.github_token from rig creation. const githubToken = await scm.resolveGitHubTokenString({ env: this.env, townId, @@ -983,6 +980,7 @@ export class TownDO extends DurableObject { ['GASTOWN_GIT_AUTHOR_EMAIL', townConfig.git_author_email], ['GASTOWN_DISABLE_AI_COAUTHOR', townConfig.disable_ai_coauthor ? '1' : undefined], ['KILOCODE_TOKEN', townConfig.kilocode_token], + ['GASTOWN_ORGANIZATION_ID', townConfig.organization_id], ]; for (const [key, value] of envMapping) { @@ -1000,32 +998,14 @@ 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 customEnvVars = config.getContainerCustomEnvVars(townConfig); + const newCustomKeys = Object.keys(customEnvVars); 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); @@ -1034,8 +1014,7 @@ export class TownDO extends DurableObject { } } } - for (const [key, value] of Object.entries(townConfig.env_vars)) { - if (RESERVED_ENV_KEYS.has(key)) continue; + for (const [key, value] of Object.entries(customEnvVars)) { try { await container.setEnvVar(key, value); } catch (err) { @@ -1045,20 +1024,23 @@ export class TownDO extends DurableObject { 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. + // /sync-config endpoint. Secret-bearing values are sent in the JSON + // body, not X-Town-Config, because request headers can appear in + // Cloudflare event logs. try { const containerConfig = await config.buildContainerConfig( this.ctx.storage, this.env, this.townId ); + const envVars = await config.buildContainerEnvVars(this.ctx.storage, this.env, this.townId); await container.fetch('http://container/sync-config', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Town-Config': JSON.stringify(containerConfig), }, + body: JSON.stringify({ envVars }), }); } catch (err) { // Best-effort — container may not be running yet. @@ -4951,7 +4933,7 @@ export class TownDO extends DurableObject { // Always include X-Town-Config so the container populates // lastKnownTownConfig on startup — before any /agents/start arrives. - // This ensures org context and credentials are available immediately + // This ensures non-secret org context is available immediately // after a container restart when the first request is a model update // (PATCH /model) rather than a new agent start. const containerConfig = await config.buildContainerConfig( diff --git a/services/gastown/src/dos/town/config.test.ts b/services/gastown/src/dos/town/config.test.ts index 47ab900244..9ba17d4c9b 100644 --- a/services/gastown/src/dos/town/config.test.ts +++ b/services/gastown/src/dos/town/config.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { TownConfigSchema } from '../../types'; -import { getTownConfig, resolveModel } from './config'; +import { buildContainerConfig, buildContainerEnvVars, getTownConfig, resolveModel } from './config'; const HARDCODED_FALLBACK = 'anthropic/claude-sonnet-4.6'; @@ -183,3 +183,80 @@ describe('getTownConfig seeding behavior', () => { expect(config.refinery).toBeUndefined(); }); }); + +describe('container config payloads', () => { + it('keeps secret-bearing values out of X-Town-Config payloads', async () => { + const storage = makeFakeStorage( + new Map([ + [ + 'town:config', + TownConfigSchema.parse({ + env_vars: { + CUSTOM_SECRET: 'custom-secret-value', + GH_TOKEN: 'reserved-custom-value', + }, + git_auth: { + github_token: 'github-secret-value', + gitlab_token: 'gitlab-secret-value', + gitlab_instance_url: 'https://gitlab.example.com', + platform_integration_id: 'integration-id', + }, + github_cli_pat: 'github-cli-secret-value', + kilocode_token: 'kilocode-secret-value', + organization_id: 'org_123', + }), + ], + ]) + ); + + const config = await buildContainerConfig(storage, {} as Env, 'town-1'); + const serialized = JSON.stringify(config); + + expect(serialized).not.toContain('github-secret-value'); + expect(serialized).not.toContain('gitlab-secret-value'); + expect(serialized).not.toContain('github-cli-secret-value'); + expect(serialized).not.toContain('kilocode-secret-value'); + expect(serialized).not.toContain('custom-secret-value'); + expect(serialized).not.toContain('reserved-custom-value'); + expect(config).toMatchObject({ + git_auth: { + gitlab_instance_url: 'https://gitlab.example.com', + platform_integration_id: 'integration-id', + }, + organization_id: 'org_123', + }); + }); + + it('keeps container env vars available outside the logged header path', async () => { + const storage = makeFakeStorage( + new Map([ + [ + 'town:config', + TownConfigSchema.parse({ + env_vars: { + CUSTOM_SECRET: 'custom-secret-value', + GH_TOKEN: 'reserved-custom-value', + }, + git_auth: { + github_token: 'github-secret-value', + gitlab_token: 'gitlab-secret-value', + }, + github_cli_pat: 'github-cli-secret-value', + kilocode_token: 'kilocode-secret-value', + }), + ], + ]) + ); + + const envVars = await buildContainerEnvVars(storage, {} as Env, 'town-1'); + + expect(envVars).toMatchObject({ + CUSTOM_SECRET: 'custom-secret-value', + GIT_TOKEN: 'github-cli-secret-value', + GITLAB_TOKEN: 'gitlab-secret-value', + GITHUB_CLI_PAT: 'github-cli-secret-value', + KILOCODE_TOKEN: 'kilocode-secret-value', + }); + expect(envVars.GH_TOKEN).toBeUndefined(); + }); +}); diff --git a/services/gastown/src/dos/town/config.ts b/services/gastown/src/dos/town/config.ts index 85b3ca4715..55d6e3ae42 100644 --- a/services/gastown/src/dos/town/config.ts +++ b/services/gastown/src/dos/town/config.ts @@ -16,6 +16,25 @@ const NEW_TOWN_DEFAULTS_SEEDED_KEY = 'town:config:newDefaultsSeeded'; const TOWN_LOG = '[Town.do]'; +const RESERVED_CONTAINER_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', + 'GASTOWN_TOWN_ID', + 'GASTOWN_RIG_ID', +]); + /** * Defaults that were introduced for NEW towns in #2725 but that must NOT * be retroactively applied to existing persisted configs (doing so would @@ -293,26 +312,13 @@ export function resolveRigConfig( }; } -/** - * Build the ContainerConfig payload for X-Town-Config header. - * Sent with every fetch() to the container. - * - * The container's `syncTownConfigToProcessEnv` reads `git_auth.github_token` - * from this payload on every request and writes it to `process.env.GIT_TOKEN`, - * which the SDK server's `gh` CLI inherits via `GH_TOKEN`. To prevent serving - * an expired installation token (TTL ~1h) we resolve through `resolveGitHubToken` - * so a configured platform integration always returns a fresh value. - * - * `townId` is required so we can always perform the integration lookup. - * Making it optional was a foot-gun — a forgotten arg silently re-introduces - * the stale-token bug this function exists to prevent. - */ -export async function buildContainerConfig( +export async function buildContainerEnvVars( storage: DurableObjectStorage, env: Env, townId: string -): Promise> { +): Promise> { const config = await getTownConfig(storage); + const envVars = getContainerCustomEnvVars(config); let resolvedGithubToken = config.git_auth?.github_token; try { @@ -324,21 +330,72 @@ export async function buildContainerConfig( if (fresh) resolvedGithubToken = fresh; } catch (err) { console.warn( - `${TOWN_LOG} buildContainerConfig: resolveGitHubTokenString failed; falling back to stored token`, + `${TOWN_LOG} buildContainerEnvVars: resolveGitHubTokenString failed; falling back to stored token`, err ); } + if (resolvedGithubToken) { + envVars.GIT_TOKEN = resolvedGithubToken; + } + if (config.git_auth?.gitlab_token) { + envVars.GITLAB_TOKEN = config.git_auth.gitlab_token; + } + if (config.git_auth?.gitlab_instance_url) { + envVars.GITLAB_INSTANCE_URL = config.git_auth.gitlab_instance_url; + } + if (config.github_cli_pat) { + envVars.GITHUB_CLI_PAT = config.github_cli_pat; + } + if (config.git_author_name) { + envVars.GASTOWN_GIT_AUTHOR_NAME = config.git_author_name; + } + if (config.git_author_email) { + envVars.GASTOWN_GIT_AUTHOR_EMAIL = config.git_author_email; + } + if (config.disable_ai_coauthor) { + envVars.GASTOWN_DISABLE_AI_COAUTHOR = '1'; + } + if (config.kilocode_token) { + envVars.KILOCODE_TOKEN = config.kilocode_token; + } + if (config.organization_id) { + envVars.GASTOWN_ORGANIZATION_ID = config.organization_id; + } + if (env.GASTOWN_API_URL) { + envVars.GASTOWN_API_URL = env.GASTOWN_API_URL; + } + + return envVars; +} + +export function getContainerCustomEnvVars(config: TownConfig): Record { + return Object.fromEntries( + Object.entries(config.env_vars).filter(([key]) => !RESERVED_CONTAINER_ENV_KEYS.has(key)) + ); +} + +/** + * Build the non-secret ContainerConfig payload for X-Town-Config. + * + * Cloudflare event logs can record request headers, so this payload must never + * contain credentials or user-defined env vars. Secrets are delivered through + * request bodies or persisted container env vars instead. + */ +export async function buildContainerConfig( + storage: DurableObjectStorage, + env: Env, + _townId: string +): Promise> { + const config = await getTownConfig(storage); + return { - env_vars: config.env_vars, default_model: resolveModel(config, null, ''), small_model: resolveSmallModel(config), git_auth: { - ...config.git_auth, - github_token: resolvedGithubToken, + gitlab_instance_url: config.git_auth?.gitlab_instance_url, + platform_integration_id: config.git_auth?.platform_integration_id, }, - kilocode_token: config.kilocode_token, - github_cli_pat: config.github_cli_pat, git_author_name: config.git_author_name, git_author_email: config.git_author_email, disable_ai_coauthor: config.disable_ai_coauthor, diff --git a/services/gastown/src/dos/town/container-dispatch.ts b/services/gastown/src/dos/town/container-dispatch.ts index 84c31be162..00d1cfed5e 100644 --- a/services/gastown/src/dos/town/container-dispatch.ts +++ b/services/gastown/src/dos/town/container-dispatch.ts @@ -8,7 +8,13 @@ import { signAgentJWT, signContainerJWT } from '../../util/jwt.util'; import { buildPolecatSystemPrompt } from '../../prompts/polecat-system.prompt'; import { buildMayorSystemPrompt } from '../../prompts/mayor-system.prompt'; import type { TownConfig, RigOverrideConfig } from '../../types'; -import { buildContainerConfig, resolveModel, resolveSmallModel, resolveRigConfig } from './config'; +import { + buildContainerConfig, + getContainerCustomEnvVars, + resolveModel, + resolveSmallModel, + resolveRigConfig, +} from './config'; import { writeEvent } from '../../util/analytics.util'; import { resolveGitHubTokenString } from './town-scm'; @@ -341,7 +347,7 @@ export function branchForConvoyAgent( /** * Signal the container to start an agent process. - * Attaches current town config via X-Town-Config header. + * Attaches non-secret town config via X-Town-Config header. */ export async function startAgentInContainer( env: Env, @@ -408,7 +414,7 @@ export async function startAgentInContainer( } // Build env vars from town config - const envVars: Record = { ...(params.townConfig.env_vars ?? {}) }; + const envVars: Record = getContainerCustomEnvVars(params.townConfig); // Map git_auth tokens. Resolve GitHub token through resolveGitHubTokenString so // we mint a fresh installation token when a platform integration is @@ -630,7 +636,7 @@ export async function startMergeInContainer( return false; } - const envVars: Record = { ...(params.townConfig.env_vars ?? {}) }; + const envVars: Record = getContainerCustomEnvVars(params.townConfig); // Resolve GitHub token through resolveGitHubTokenString so a configured // platform integration mints a fresh installation token for the // merge process. See startAgentInContainer for the rationale. From d0f37192d07926de729a55a343db75d9c25fe1c1 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Mon, 1 Jun 2026 14:43:21 -0600 Subject: [PATCH 2/3] fix(gastown): centralize container env key invariants --- .../gastown/container/src/control-server.ts | 40 ++------------- services/gastown/container/src/env-keys.ts | 35 +++++++++++++ services/gastown/src/dos/town/config.test.ts | 50 ++++++++++++++++++- services/gastown/src/dos/town/config.ts | 20 +------- 4 files changed, 89 insertions(+), 56 deletions(-) create mode 100644 services/gastown/container/src/env-keys.ts diff --git a/services/gastown/container/src/control-server.ts b/services/gastown/container/src/control-server.ts index 22698c4c30..92f43028ff 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -24,6 +24,7 @@ import { log } from './logger'; import { startHeartbeat, stopHeartbeat, notifyContainerReady } from './heartbeat'; import { pushContext as pushDashboardContext } from './dashboard-context'; import { mergeBranch, setupRigBrowseWorktree } from './git-manager'; +import { CONFIG_SYNC_ENV_KEYS, RESERVED_ENV_KEYS } from './env-keys'; import { StartAgentRequest, SendMessageRequest, @@ -41,6 +42,8 @@ import type { const MAX_TICKETS = 1000; const streamTickets = new Map(); +export { RESERVED_ENV_KEYS }; + // Minimal Zod schema for the non-secret town config delivered via X-Town-Config header. // Uses z.record() so any string-keyed object is accepted and future keys are preserved. const TownConfigHeader = z.record(z.string(), z.unknown()); @@ -57,43 +60,6 @@ let lastBodySyncedEnvVars: 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. -export 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', - // Runtime routing vars read by pending-nudge routes and plugin clients — - // must never be overwritten by user-supplied env_vars. - 'GASTOWN_TOWN_ID', - 'GASTOWN_RIG_ID', -]); - -const CONFIG_SYNC_ENV_KEYS = new Set([ - 'KILOCODE_TOKEN', - 'GIT_TOKEN', - 'GITLAB_TOKEN', - 'GITLAB_INSTANCE_URL', - 'GITHUB_CLI_PAT', - 'GASTOWN_GIT_AUTHOR_NAME', - 'GASTOWN_GIT_AUTHOR_EMAIL', - 'GASTOWN_DISABLE_AI_COAUTHOR', - 'GASTOWN_ORGANIZATION_ID', - 'GASTOWN_API_URL', -]); - /** Get the latest non-secret town config delivered via X-Town-Config header. */ export function getCurrentTownConfig(): Record | null { return lastKnownTownConfig; diff --git a/services/gastown/container/src/env-keys.ts b/services/gastown/container/src/env-keys.ts new file mode 100644 index 0000000000..a8ed0c2c74 --- /dev/null +++ b/services/gastown/container/src/env-keys.ts @@ -0,0 +1,35 @@ +// 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. +export 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', + // Runtime routing vars read by pending-nudge routes and plugin clients — + // must never be overwritten by user-supplied env_vars. + 'GASTOWN_TOWN_ID', + 'GASTOWN_RIG_ID', +]); + +export const CONFIG_SYNC_ENV_KEYS = new Set([ + 'KILOCODE_TOKEN', + 'GIT_TOKEN', + 'GITLAB_TOKEN', + 'GITLAB_INSTANCE_URL', + 'GITHUB_CLI_PAT', + 'GASTOWN_GIT_AUTHOR_NAME', + 'GASTOWN_GIT_AUTHOR_EMAIL', + 'GASTOWN_DISABLE_AI_COAUTHOR', + 'GASTOWN_ORGANIZATION_ID', + 'GASTOWN_API_URL', +]); diff --git a/services/gastown/src/dos/town/config.test.ts b/services/gastown/src/dos/town/config.test.ts index 9ba17d4c9b..a822e4db65 100644 --- a/services/gastown/src/dos/town/config.test.ts +++ b/services/gastown/src/dos/town/config.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from 'vitest'; +import { CONFIG_SYNC_ENV_KEYS } from '../../../container/src/env-keys'; import { TownConfigSchema } from '../../types'; -import { buildContainerConfig, buildContainerEnvVars, getTownConfig, resolveModel } from './config'; +import { + RESERVED_CONTAINER_ENV_KEYS, + buildContainerConfig, + buildContainerEnvVars, + getTownConfig, + resolveModel, +} from './config'; const HARDCODED_FALLBACK = 'anthropic/claude-sonnet-4.6'; @@ -259,4 +266,45 @@ describe('container config payloads', () => { }); expect(envVars.GH_TOKEN).toBeUndefined(); }); + + it('only emits reserved env vars that the container sync path applies', async () => { + const storage = makeFakeStorage( + new Map([ + [ + 'town:config', + TownConfigSchema.parse({ + env_vars: { + CUSTOM_SECRET: 'custom-secret-value', + GASTOWN_CONTAINER_TOKEN: 'reserved-custom-value', + GASTOWN_SESSION_TOKEN: 'reserved-custom-value', + GASTOWN_TOWN_ID: 'reserved-custom-value', + GASTOWN_RIG_ID: 'reserved-custom-value', + }, + git_auth: { + github_token: 'github-secret-value', + gitlab_token: 'gitlab-secret-value', + gitlab_instance_url: 'https://gitlab.example.com', + }, + github_cli_pat: 'github-cli-secret-value', + git_author_name: 'Kilo Bot', + git_author_email: 'kilo@example.com', + disable_ai_coauthor: true, + kilocode_token: 'kilocode-secret-value', + organization_id: 'org_123', + }), + ], + ]) + ); + + const envVars = await buildContainerEnvVars( + storage, + { GASTOWN_API_URL: 'https://gastown.example.com' } as unknown as Env, + 'town-1' + ); + const emittedReservedKeys = Object.keys(envVars).filter(key => + RESERVED_CONTAINER_ENV_KEYS.has(key) + ); + + expect(emittedReservedKeys.sort()).toEqual([...CONFIG_SYNC_ENV_KEYS].sort()); + }); }); diff --git a/services/gastown/src/dos/town/config.ts b/services/gastown/src/dos/town/config.ts index 55d6e3ae42..3bf5f81759 100644 --- a/services/gastown/src/dos/town/config.ts +++ b/services/gastown/src/dos/town/config.ts @@ -9,6 +9,7 @@ import { type MergeStrategy, type RigOverrideConfig, } from '../../types'; +import { RESERVED_ENV_KEYS } from '../../../container/src/env-keys'; import { resolveGitHubTokenString } from './town-scm'; const CONFIG_KEY = 'town:config'; @@ -16,24 +17,7 @@ const NEW_TOWN_DEFAULTS_SEEDED_KEY = 'town:config:newDefaultsSeeded'; const TOWN_LOG = '[Town.do]'; -const RESERVED_CONTAINER_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', - 'GASTOWN_TOWN_ID', - 'GASTOWN_RIG_ID', -]); +export const RESERVED_CONTAINER_ENV_KEYS = RESERVED_ENV_KEYS; /** * Defaults that were introduced for NEW towns in #2725 but that must NOT From 7383558bfd3305269119489a58b431d67fff1083 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Mon, 1 Jun 2026 14:50:09 -0600 Subject: [PATCH 3/3] fix(gastown): preserve api url during config sync --- .../container/src/control-server.test.ts | 80 +++++++++++++++++++ .../gastown/container/src/control-server.ts | 3 + 2 files changed, 83 insertions(+) create mode 100644 services/gastown/container/src/control-server.test.ts diff --git a/services/gastown/container/src/control-server.test.ts b/services/gastown/container/src/control-server.test.ts new file mode 100644 index 0000000000..1c88edbd2a --- /dev/null +++ b/services/gastown/container/src/control-server.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('./agent-runner', () => ({ + runAgent: vi.fn(), + resolveGitCredentials: vi.fn(), + writeMayorSystemPromptToAgentsMd: vi.fn(), +})); + +vi.mock('./process-manager', () => ({ + stopAgent: vi.fn(), + sendMessage: vi.fn(), + updateAgentModel: vi.fn(), + getAgentStatus: vi.fn(), + activeAgentCount: vi.fn(() => 0), + activeServerCount: vi.fn(() => 0), + getUptime: vi.fn(() => 0), + getStartTime: vi.fn(() => '2026-01-01T00:00:00.000Z'), + getMayorReadyAt: vi.fn(() => null), + stopAll: vi.fn(), + drainAll: vi.fn(), + isDraining: vi.fn(() => false), + getAgentEvents: vi.fn(), + registerEventSink: vi.fn(), + refreshTokenForAllAgents: vi.fn(), + listAgents: vi.fn(() => []), + awaitHydration: vi.fn(), +})); + +vi.mock('./logger', () => ({ + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock('./heartbeat', () => ({ + startHeartbeat: vi.fn(), + stopHeartbeat: vi.fn(), + notifyContainerReady: vi.fn(), +})); + +vi.mock('./dashboard-context', () => ({ + pushContext: vi.fn(), +})); + +vi.mock('./git-manager', () => ({ + mergeBranch: vi.fn(), + setupRigBrowseWorktree: vi.fn(), +})); + +const { app } = await import('./control-server'); + +describe('/sync-config', () => { + it('preserves startup GASTOWN_API_URL when the sync payload omits it', async () => { + const previousApiUrl = process.env.GASTOWN_API_URL; + const previousToken = process.env.KILOCODE_TOKEN; + const previousGitToken = process.env.GIT_TOKEN; + process.env.GASTOWN_API_URL = 'https://startup.example.com'; + process.env.KILOCODE_TOKEN = 'old-token'; + + try { + const response = await app.request('/sync-config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ envVars: { GIT_TOKEN: 'new-token' } }), + }); + + expect(response.status).toBe(200); + expect(process.env.GASTOWN_API_URL).toBe('https://startup.example.com'); + expect(process.env.KILOCODE_TOKEN).toBeUndefined(); + expect(process.env.GIT_TOKEN).toBe('new-token'); + } finally { + if (previousApiUrl !== undefined) process.env.GASTOWN_API_URL = previousApiUrl; + else delete process.env.GASTOWN_API_URL; + + if (previousToken !== undefined) process.env.KILOCODE_TOKEN = previousToken; + else delete process.env.KILOCODE_TOKEN; + + if (previousGitToken !== undefined) process.env.GIT_TOKEN = previousGitToken; + else delete process.env.GIT_TOKEN; + } + }); +}); diff --git a/services/gastown/container/src/control-server.ts b/services/gastown/container/src/control-server.ts index 92f43028ff..db093329ec 100644 --- a/services/gastown/container/src/control-server.ts +++ b/services/gastown/container/src/control-server.ts @@ -52,6 +52,8 @@ const SyncConfigRequest = z.object({ envVars: z.record(z.string(), z.string()).optional(), }); +const CONFIG_SYNC_ENV_KEYS_PRESERVE_WHEN_ABSENT = new Set(['GASTOWN_API_URL']); + // Last-known-good town config. Updated on every request that carries the header. // Used as a fallback by code that runs outside a request context (e.g. background tasks). let lastKnownTownConfig: Record | null = null; @@ -77,6 +79,7 @@ export function getLastAppliedEnvVarKeys(): Set { function syncTownConfigToProcessEnv(envVars?: Record): void { if (envVars) { for (const key of CONFIG_SYNC_ENV_KEYS) { + if (CONFIG_SYNC_ENV_KEYS_PRESERVE_WHEN_ABSENT.has(key)) continue; if (!(key in envVars)) delete process.env[key]; } for (const [key, value] of Object.entries(envVars)) {