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
49 changes: 49 additions & 0 deletions services/gastown/container/src/control-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null = null;

// Track which custom env var keys were applied last sync so removed keys can be cleared.
let lastAppliedEnvVarKeys = new Set<string>();

// 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<string, unknown> | null {
return lastKnownTownConfig;
}

/** Get the set of custom env var keys applied in the last sync. */
export function getLastAppliedEnvVarKeys(): Set<string> {
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.
Expand Down Expand Up @@ -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<string, string> =
rawEnvVars !== null && typeof rawEnvVars === 'object' && !Array.isArray(rawEnvVars)
? (rawEnvVars as Record<string, string>)
: {};
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);
Comment thread
jrf0110 marked this conversation as resolved.
}
lastAppliedEnvVarKeys = newCustomKeys;
}

export const app = new Hono();
Expand Down
28 changes: 28 additions & 0 deletions services/gastown/container/src/process-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]';
Expand Down Expand Up @@ -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<string>();
if (freshEnvVars !== null && typeof freshEnvVars === 'object' && !Array.isArray(freshEnvVars)) {
for (const [key, value] of Object.entries(freshEnvVars as Record<string, unknown>)) {
Comment thread
jrf0110 marked this conversation as resolved.
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
Expand Down
47 changes: 47 additions & 0 deletions services/gastown/src/dos/Town.do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,53 @@ export class TownDO extends DurableObject<Env> {
}
}

// 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<string[]>(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);
Comment thread
jrf0110 marked this conversation as resolved.
} 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);
Comment thread
jrf0110 marked this conversation as resolved.
} 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.
Expand Down