Skip to content

Commit d07b59d

Browse files
authored
Merge pull request #74 from TeigenZhang/refactor/envoverrides-tmux-export
refactor: pass envOverrides via tmux export instead of disk write
2 parents 79d7117 + a5a7e0c commit d07b59d

13 files changed

Lines changed: 232 additions & 38 deletions

src/hooks-config.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,42 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
8484
};
8585
}
8686

87+
/**
88+
* Remove a subset of env keys from .claude/settings.local.json.env if present.
89+
* Used during the disk→tmux-setenv migration: when the caller is actively setting
90+
* a fresh value for a Codeman-managed key, any stale disk entry for THAT KEY is
91+
* superseded and should be removed. Keys NOT in `keysToRemove` are left alone
92+
* (they may be user-managed). No-op if the file/keys don't exist.
93+
*/
94+
export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly string[]): Promise<void> {
95+
if (keysToRemove.length === 0) return;
96+
97+
const settingsPath = join(casePath, '.claude', 'settings.local.json');
98+
if (!existsSync(settingsPath)) return;
99+
100+
let existing: Record<string, unknown>;
101+
try {
102+
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
103+
} catch {
104+
return; // Malformed — don't rewrite it
105+
}
106+
107+
const env = existing.env as Record<string, string> | undefined;
108+
if (!env) return;
109+
110+
let changed = false;
111+
for (const key of keysToRemove) {
112+
if (key in env) {
113+
delete env[key];
114+
changed = true;
115+
}
116+
}
117+
if (!changed) return;
118+
119+
existing.env = env;
120+
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
121+
}
122+
87123
/**
88124
* Updates env vars in .claude/settings.local.json for the given case path.
89125
* Merges with existing env field; removes vars set to empty string.

src/mux-interface.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export interface CreateSessionOptions {
6363
openCodeConfig?: OpenCodeConfig;
6464
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
6565
resumeSessionId?: string;
66+
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EFFORT_LEVEL). Ephemeral — not written to disk. */
67+
envOverrides?: Record<string, string>;
6668
}
6769

6870
/** Options for respawning a dead pane. */
@@ -77,6 +79,8 @@ export interface RespawnPaneOptions {
7779
openCodeConfig?: OpenCodeConfig;
7880
/** Resume a previous Claude conversation when respawning */
7981
resumeSessionId?: string;
82+
/** Extra env vars exported before launching the CLI (preserved across respawns). */
83+
envOverrides?: Record<string, string>;
8084
}
8185

8286
/**

src/session-manager.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export class SessionManager extends EventEmitter {
152152
await session.start();
153153

154154
this.sessions.set(session.id, session);
155-
this.store.setSession(session.id, session.toState());
155+
this.updateSessionState(session);
156156

157157
this.emit('sessionStarted', session);
158158
return session;
@@ -247,7 +247,15 @@ export class SessionManager extends EventEmitter {
247247
}
248248

249249
private updateSessionState(session: Session): void {
250-
this.store.setSession(session.id, session.toState());
250+
// envOverrides is intentionally NOT on SessionState (API safety). For disk
251+
// persistence we augment the stored object with __envOverrides so reboot
252+
// recovery can restore them without leaking through any API serializer.
253+
// The key uses the reserved `__` prefix so it is visibly "internal" to any
254+
// future reader of state.json.
255+
const state = session.toState();
256+
const envOverrides = session.getEnvOverridesForPersist();
257+
const toStore = envOverrides ? { ...state, __envOverrides: envOverrides } : state;
258+
this.store.setSession(session.id, toStore as SessionState);
251259
}
252260

253261
/** Gets all sessions from persistent storage (including stopped). */

src/session.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,10 @@ export class Session extends EventEmitter {
273273
private _openCodeConfig: OpenCodeConfig | undefined;
274274
private _resumeSessionId: string | undefined;
275275

276+
// Ephemeral env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL). Exported by tmux at spawn,
277+
// preserved across respawns via persisted state. Not written to .claude/settings.local.json.
278+
private _envOverrides: Record<string, string> | undefined;
279+
276280
// Session color for visual differentiation
277281
private _color: import('./types.js').SessionColor = 'default';
278282

@@ -332,6 +336,8 @@ export class Session extends EventEmitter {
332336
openCodeConfig?: OpenCodeConfig;
333337
/** Resume a previous Claude conversation (used after server reboot) */
334338
resumeSessionId?: string;
339+
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
340+
envOverrides?: Record<string, string>;
335341
}
336342
) {
337343
super();
@@ -379,6 +385,11 @@ export class Session extends EventEmitter {
379385
this._openCodeConfig = config.openCodeConfig;
380386
}
381387

388+
// Apply env overrides (exported at spawn, not persisted to disk)
389+
if (config.envOverrides && Object.keys(config.envOverrides).length > 0) {
390+
this._envOverrides = { ...config.envOverrides };
391+
}
392+
382393
// Initialize task tracker and forward events (store handlers for cleanup)
383394
this._taskTracker = new TaskTracker();
384395
this._taskTrackerHandlers = {
@@ -789,9 +800,29 @@ export class Session extends EventEmitter {
789800
cliLatestVersion: this._cliLatestVersion || undefined,
790801
openCodeConfig: this._openCodeConfig,
791802
resumeSessionId: this._resumeSessionId,
803+
// envOverrides intentionally NOT on the public SessionState type — they must not
804+
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
805+
// can carry secrets). For disk persistence, session-manager calls
806+
// getEnvOverridesForPersist() and writes alongside state.
792807
};
793808
}
794809

810+
/**
811+
* Returns a subset of env overrides safe for disk persistence (state.json).
812+
* Only non-sensitive `CLAUDE_CODE_*` keys are included. `OPENCODE_*` keys are
813+
* filtered out because the schema permits them and they can carry secrets
814+
* (e.g., OPENCODE_API_KEY); secrets must not land in `~/.codeman/state.json`.
815+
* Must NOT be included in any API-bound serializer — see toState() comment.
816+
*/
817+
getEnvOverridesForPersist(): Record<string, string> | undefined {
818+
if (!this._envOverrides) return undefined;
819+
const safe: Record<string, string> = {};
820+
for (const [key, value] of Object.entries(this._envOverrides)) {
821+
if (key.startsWith('CLAUDE_CODE_')) safe[key] = value;
822+
}
823+
return Object.keys(safe).length > 0 ? safe : undefined;
824+
}
825+
795826
toDetailedState() {
796827
return {
797828
...this.toLightDetailedState(),
@@ -957,6 +988,7 @@ export class Session extends EventEmitter {
957988
allowedTools: this._allowedTools,
958989
openCodeConfig: this._openCodeConfig,
959990
resumeSessionId: this._resumeSessionId,
991+
envOverrides: this._envOverrides,
960992
},
961993
createSessionOptions: {
962994
sessionId: this.id,
@@ -969,6 +1001,7 @@ export class Session extends EventEmitter {
9691001
allowedTools: this._allowedTools,
9701002
openCodeConfig: this._openCodeConfig,
9711003
resumeSessionId: this._resumeSessionId,
1004+
envOverrides: this._envOverrides,
9721005
},
9731006
spawnErrLabel: 'mux attachment',
9741007
});
@@ -1044,7 +1077,8 @@ export class Session extends EventEmitter {
10441077
cols: 120,
10451078
rows: 40,
10461079
cwd: this.workingDir,
1047-
env: buildClaudeEnv(this.id),
1080+
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
1081+
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
10481082
});
10491083
} catch (spawnErr) {
10501084
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
@@ -1289,13 +1323,15 @@ export class Session extends EventEmitter {
12891323
workingDir: this.workingDir,
12901324
mode: 'shell',
12911325
niceConfig: this._niceConfig,
1326+
envOverrides: this._envOverrides,
12921327
},
12931328
createSessionOptions: {
12941329
sessionId: this.id,
12951330
workingDir: this.workingDir,
12961331
mode: 'shell',
12971332
name: this._name,
12981333
niceConfig: this._niceConfig,
1334+
envOverrides: this._envOverrides,
12991335
},
13001336
spawnErrLabel: 'shell mux attachment',
13011337
});
@@ -1431,7 +1467,8 @@ export class Session extends EventEmitter {
14311467
cols: 120,
14321468
rows: 40,
14331469
cwd: this.workingDir,
1434-
env: buildClaudeEnv(this.id),
1470+
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
1471+
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
14351472
});
14361473
} catch (spawnErr) {
14371474
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);

src/tmux-manager.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
399399
/**
400400
* Build the array of environment export commands shared by createSession() and respawnPane().
401401
* Includes locale, mux markers, session identity, and API URL.
402+
*
403+
* User-supplied envOverrides are NOT inlined here — they go through applyEnvOverrides()
404+
* via `tmux setenv` so secret values (e.g., OPENCODE_API_KEY) never appear in the bash
405+
* command line (visible in `ps`). This also sidesteps shell-metachar injection via keys.
402406
*/
403407
private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] {
404408
const exports = [
@@ -415,6 +419,35 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
415419
return exports;
416420
}
417421

422+
/**
423+
* Apply user-supplied env overrides to a tmux session via `tmux setenv`.
424+
* Values stay off the bash command line (not visible in `ps`), and are inherited
425+
* by new panes — including `respawn-pane`. Persists at tmux-session level, so
426+
* Codeman server restarts don't lose the setting as long as the tmux session lives.
427+
*
428+
* Key validation is strict (`/^[A-Z_][A-Z0-9_]*$/`) as defense-in-depth against
429+
* shell-metachar injection even if upstream schema check is bypassed.
430+
*/
431+
private applyEnvOverrides(muxName: string, envOverrides?: Record<string, string>): void {
432+
if (!envOverrides) return;
433+
const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/;
434+
for (const [key, value] of Object.entries(envOverrides)) {
435+
if (!value) continue; // Skip empty — nothing to set
436+
if (!VALID_KEY.test(key)) {
437+
console.warn(`[TmuxManager] Skipping invalid env override key: ${JSON.stringify(key)}`);
438+
continue;
439+
}
440+
try {
441+
execSync(`tmux setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
442+
timeout: EXEC_TIMEOUT_MS,
443+
stdio: ['pipe', 'pipe', 'pipe'],
444+
});
445+
} catch (err) {
446+
console.warn(`[TmuxManager] Failed to set env override ${key}:`, err);
447+
}
448+
}
449+
}
450+
418451
/**
419452
* Resolve the CLI binary directory and return the PATH export prefix string.
420453
* Returns '' if no override is needed (shell mode) or the binary dir is not found.
@@ -458,6 +491,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
458491
allowedTools,
459492
openCodeConfig,
460493
resumeSessionId,
494+
envOverrides,
461495
} = options;
462496
const muxName = `codeman-${sessionId.slice(0, 8)}`;
463497

@@ -545,6 +579,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
545579
this._configureOpenCode(muxName, openCodeConfig);
546580
}
547581

582+
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
583+
// so secret values stay off the bash command line. Must run before respawn-pane.
584+
this.applyEnvOverrides(muxName, envOverrides);
585+
548586
// Replace the shell with the actual command (no echo in terminal)
549587
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
550588
timeout: EXEC_TIMEOUT_MS,
@@ -685,6 +723,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
685723
allowedTools,
686724
openCodeConfig,
687725
resumeSessionId,
726+
envOverrides,
688727
} = options;
689728
const session = this.sessions.get(sessionId);
690729
if (!session) return null;
@@ -716,6 +755,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
716755
this._configureOpenCode(muxName, openCodeConfig);
717756
}
718757

758+
// Re-apply user env overrides before respawn so the new shell inherits them.
759+
this.applyEnvOverrides(muxName, envOverrides);
760+
719761
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
720762
timeout: EXEC_TIMEOUT_MS,
721763
});

src/web/public/ralph-wizard.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,10 @@ Object.assign(CodemanApp.prototype, {
10321032
const enabledItems = config.generatedPlan?.filter(i => i.enabled);
10331033

10341034
try {
1035+
const envOverrides = this.buildEnvOverrides(
1036+
this.getCaseSettings(config.caseName),
1037+
this.loadAppSettingsFromStorage()
1038+
);
10351039
const res = await fetch('/api/ralph-loop/start', {
10361040
method: 'POST',
10371041
headers: { 'Content-Type': 'application/json' },
@@ -1042,6 +1046,7 @@ Object.assign(CodemanApp.prototype, {
10421046
maxIterations: config.maxIterations || null,
10431047
enableRespawn: config.enableRespawn,
10441048
planItems: enabledItems?.length ? enabledItems : undefined,
1049+
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
10451050
}),
10461051
});
10471052
const data = await res.json();

src/web/public/session-ui.js

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@
1212
*/
1313

1414
Object.assign(CodemanApp.prototype, {
15+
/**
16+
* Build envOverrides payload from case + global settings.
17+
* Single source of truth for the server-side tmux setenv values.
18+
* Keys omitted when value is default/falsy — backend treats unset as "no override".
19+
*/
20+
buildEnvOverrides(caseSettings, globalSettings) {
21+
const env = {};
22+
if (caseSettings?.agentTeams || globalSettings?.agentTeamsEnabled) {
23+
env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
24+
}
25+
if (globalSettings?.thinkingEffort) {
26+
env.CLAUDE_CODE_EFFORT_LEVEL = globalSettings.thinkingEffort;
27+
}
28+
return env;
29+
},
30+
1531
// ═══════════════════════════════════════════════════════════════
1632
// Quick Start
1733
// ═══════════════════════════════════════════════════════════════
@@ -319,14 +335,7 @@ Object.assign(CodemanApp.prototype, {
319335
// Build env overrides from global + case settings (case overrides global)
320336
const caseSettings = this.getCaseSettings(caseName);
321337
const globalSettings = this.loadAppSettingsFromStorage();
322-
const envOverrides = {};
323-
if (caseSettings.agentTeams || globalSettings.agentTeamsEnabled) {
324-
envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
325-
}
326-
const thinkingEffort = globalSettings.thinkingEffort;
327-
if (thinkingEffort) {
328-
envOverrides.CLAUDE_CODE_EFFORT_LEVEL = thinkingEffort;
329-
}
338+
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
330339
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
331340
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
332341
const modelOverride = useOpus1m ? 'opus[1m]' : '';
@@ -530,13 +539,15 @@ Object.assign(CodemanApp.prototype, {
530539
}
531540

532541
// Quick-start with opencode mode (auto-allow tools by default)
542+
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
533543
const res = await fetch('/api/quick-start', {
534544
method: 'POST',
535545
headers: { 'Content-Type': 'application/json' },
536546
body: JSON.stringify({
537547
caseName,
538548
mode: 'opencode',
539549
openCodeConfig: { autoAllowTools: true },
550+
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
540551
})
541552
});
542553
const data = await res.json();

src/web/public/terminal-ui.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -953,11 +953,21 @@ Object.assign(CodemanApp.prototype, {
953953
}
954954
const name = `w${startNumber}-${dirName}`;
955955

956-
// Create session with resumeSessionId
956+
// Create session with resumeSessionId — include envOverrides so resumed
957+
// conversations inherit current UI settings (effort, agent teams, etc.).
958+
// Match by path (not basename) so linked/renamed cases still resolve correctly.
959+
const matchingCase = (this.cases || []).find((c) => c.path === workingDir);
960+
const caseName = matchingCase?.name || workingDir.split('/').pop() || '';
961+
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
957962
const createRes = await fetch('/api/sessions', {
958963
method: 'POST',
959964
headers: { 'Content-Type': 'application/json' },
960-
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId }),
965+
body: JSON.stringify({
966+
workingDir,
967+
name,
968+
resumeSessionId: sessionId,
969+
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
970+
}),
961971
});
962972
const createData = await createRes.json();
963973
if (!createData.success) throw new Error(createData.error);

0 commit comments

Comments
 (0)