mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
Merge pull request #74 from TeigenZhang/refactor/envoverrides-tmux-export
refactor: pass envOverrides via tmux export instead of disk write
This commit is contained in:
@@ -84,6 +84,42 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
if (keysToRemove.length === 0) return;
|
||||||
|
|
||||||
|
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
||||||
|
if (!existsSync(settingsPath)) return;
|
||||||
|
|
||||||
|
let existing: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return; // Malformed — don't rewrite it
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = existing.env as Record<string, string> | 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.
|
* Updates env vars in .claude/settings.local.json for the given case path.
|
||||||
* Merges with existing env field; removes vars set to empty string.
|
* Merges with existing env field; removes vars set to empty string.
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export interface CreateSessionOptions {
|
|||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
|
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EFFORT_LEVEL). Ephemeral — not written to disk. */
|
||||||
|
envOverrides?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options for respawning a dead pane. */
|
/** Options for respawning a dead pane. */
|
||||||
@@ -77,6 +79,8 @@ export interface RespawnPaneOptions {
|
|||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
/** Resume a previous Claude conversation when respawning */
|
/** Resume a previous Claude conversation when respawning */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
|
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||||
|
envOverrides?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+10
-2
@@ -152,7 +152,7 @@ export class SessionManager extends EventEmitter {
|
|||||||
await session.start();
|
await session.start();
|
||||||
|
|
||||||
this.sessions.set(session.id, session);
|
this.sessions.set(session.id, session);
|
||||||
this.store.setSession(session.id, session.toState());
|
this.updateSessionState(session);
|
||||||
|
|
||||||
this.emit('sessionStarted', session);
|
this.emit('sessionStarted', session);
|
||||||
return session;
|
return session;
|
||||||
@@ -247,7 +247,15 @@ export class SessionManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateSessionState(session: Session): void {
|
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). */
|
/** Gets all sessions from persistent storage (including stopped). */
|
||||||
|
|||||||
+39
-2
@@ -273,6 +273,10 @@ export class Session extends EventEmitter {
|
|||||||
private _openCodeConfig: OpenCodeConfig | undefined;
|
private _openCodeConfig: OpenCodeConfig | undefined;
|
||||||
private _resumeSessionId: string | 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<string, string> | undefined;
|
||||||
|
|
||||||
// Session color for visual differentiation
|
// Session color for visual differentiation
|
||||||
private _color: import('./types.js').SessionColor = 'default';
|
private _color: import('./types.js').SessionColor = 'default';
|
||||||
|
|
||||||
@@ -332,6 +336,8 @@ export class Session extends EventEmitter {
|
|||||||
openCodeConfig?: OpenCodeConfig;
|
openCodeConfig?: OpenCodeConfig;
|
||||||
/** Resume a previous Claude conversation (used after server reboot) */
|
/** Resume a previous Claude conversation (used after server reboot) */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
|
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
||||||
|
envOverrides?: Record<string, string>;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@@ -379,6 +385,11 @@ export class Session extends EventEmitter {
|
|||||||
this._openCodeConfig = config.openCodeConfig;
|
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)
|
// Initialize task tracker and forward events (store handlers for cleanup)
|
||||||
this._taskTracker = new TaskTracker();
|
this._taskTracker = new TaskTracker();
|
||||||
this._taskTrackerHandlers = {
|
this._taskTrackerHandlers = {
|
||||||
@@ -789,9 +800,29 @@ export class Session extends EventEmitter {
|
|||||||
cliLatestVersion: this._cliLatestVersion || undefined,
|
cliLatestVersion: this._cliLatestVersion || undefined,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
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<string, string> | undefined {
|
||||||
|
if (!this._envOverrides) return undefined;
|
||||||
|
const safe: Record<string, string> = {};
|
||||||
|
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() {
|
toDetailedState() {
|
||||||
return {
|
return {
|
||||||
...this.toLightDetailedState(),
|
...this.toLightDetailedState(),
|
||||||
@@ -957,6 +988,7 @@ export class Session extends EventEmitter {
|
|||||||
allowedTools: this._allowedTools,
|
allowedTools: this._allowedTools,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
|
envOverrides: this._envOverrides,
|
||||||
},
|
},
|
||||||
createSessionOptions: {
|
createSessionOptions: {
|
||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
@@ -969,6 +1001,7 @@ export class Session extends EventEmitter {
|
|||||||
allowedTools: this._allowedTools,
|
allowedTools: this._allowedTools,
|
||||||
openCodeConfig: this._openCodeConfig,
|
openCodeConfig: this._openCodeConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
|
envOverrides: this._envOverrides,
|
||||||
},
|
},
|
||||||
spawnErrLabel: 'mux attachment',
|
spawnErrLabel: 'mux attachment',
|
||||||
});
|
});
|
||||||
@@ -1044,7 +1077,8 @@ export class Session extends EventEmitter {
|
|||||||
cols: 120,
|
cols: 120,
|
||||||
rows: 40,
|
rows: 40,
|
||||||
cwd: this.workingDir,
|
cwd: this.workingDir,
|
||||||
env: buildClaudeEnv(this.id),
|
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||||
|
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||||
});
|
});
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
||||||
@@ -1289,6 +1323,7 @@ export class Session extends EventEmitter {
|
|||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
mode: 'shell',
|
mode: 'shell',
|
||||||
niceConfig: this._niceConfig,
|
niceConfig: this._niceConfig,
|
||||||
|
envOverrides: this._envOverrides,
|
||||||
},
|
},
|
||||||
createSessionOptions: {
|
createSessionOptions: {
|
||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
@@ -1296,6 +1331,7 @@ export class Session extends EventEmitter {
|
|||||||
mode: 'shell',
|
mode: 'shell',
|
||||||
name: this._name,
|
name: this._name,
|
||||||
niceConfig: this._niceConfig,
|
niceConfig: this._niceConfig,
|
||||||
|
envOverrides: this._envOverrides,
|
||||||
},
|
},
|
||||||
spawnErrLabel: 'shell mux attachment',
|
spawnErrLabel: 'shell mux attachment',
|
||||||
});
|
});
|
||||||
@@ -1431,7 +1467,8 @@ export class Session extends EventEmitter {
|
|||||||
cols: 120,
|
cols: 120,
|
||||||
rows: 40,
|
rows: 40,
|
||||||
cwd: this.workingDir,
|
cwd: this.workingDir,
|
||||||
env: buildClaudeEnv(this.id),
|
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||||
|
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||||
});
|
});
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
||||||
|
|||||||
@@ -399,6 +399,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
/**
|
/**
|
||||||
* Build the array of environment export commands shared by createSession() and respawnPane().
|
* Build the array of environment export commands shared by createSession() and respawnPane().
|
||||||
* Includes locale, mux markers, session identity, and API URL.
|
* 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[] {
|
private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] {
|
||||||
const exports = [
|
const exports = [
|
||||||
@@ -415,6 +419,35 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return exports;
|
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<string, string>): 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.
|
* 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.
|
* 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 {
|
|||||||
allowedTools,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
|
envOverrides,
|
||||||
} = options;
|
} = options;
|
||||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||||
|
|
||||||
@@ -545,6 +579,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
this._configureOpenCode(muxName, openCodeConfig);
|
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)
|
// Replace the shell with the actual command (no echo in terminal)
|
||||||
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
@@ -685,6 +723,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
allowedTools,
|
allowedTools,
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
|
envOverrides,
|
||||||
} = options;
|
} = options;
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
@@ -716,6 +755,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
this._configureOpenCode(muxName, openCodeConfig);
|
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)}`, {
|
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1032,6 +1032,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const enabledItems = config.generatedPlan?.filter(i => i.enabled);
|
const enabledItems = config.generatedPlan?.filter(i => i.enabled);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const envOverrides = this.buildEnvOverrides(
|
||||||
|
this.getCaseSettings(config.caseName),
|
||||||
|
this.loadAppSettingsFromStorage()
|
||||||
|
);
|
||||||
const res = await fetch('/api/ralph-loop/start', {
|
const res = await fetch('/api/ralph-loop/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -1042,6 +1046,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
maxIterations: config.maxIterations || null,
|
maxIterations: config.maxIterations || null,
|
||||||
enableRespawn: config.enableRespawn,
|
enableRespawn: config.enableRespawn,
|
||||||
planItems: enabledItems?.length ? enabledItems : undefined,
|
planItems: enabledItems?.length ? enabledItems : undefined,
|
||||||
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|||||||
@@ -12,6 +12,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
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
|
// Quick Start
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -319,14 +335,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Build env overrides from global + case settings (case overrides global)
|
// Build env overrides from global + case settings (case overrides global)
|
||||||
const caseSettings = this.getCaseSettings(caseName);
|
const caseSettings = this.getCaseSettings(caseName);
|
||||||
const globalSettings = this.loadAppSettingsFromStorage();
|
const globalSettings = this.loadAppSettingsFromStorage();
|
||||||
const envOverrides = {};
|
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
|
||||||
if (caseSettings.agentTeams || globalSettings.agentTeamsEnabled) {
|
|
||||||
envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
|
|
||||||
}
|
|
||||||
const thinkingEffort = globalSettings.thinkingEffort;
|
|
||||||
if (thinkingEffort) {
|
|
||||||
envOverrides.CLAUDE_CODE_EFFORT_LEVEL = thinkingEffort;
|
|
||||||
}
|
|
||||||
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
|
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
|
||||||
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
|
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
|
||||||
const modelOverride = useOpus1m ? 'opus[1m]' : '';
|
const modelOverride = useOpus1m ? 'opus[1m]' : '';
|
||||||
@@ -530,6 +539,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Quick-start with opencode mode (auto-allow tools by default)
|
// 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', {
|
const res = await fetch('/api/quick-start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -537,6 +547,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
caseName,
|
caseName,
|
||||||
mode: 'opencode',
|
mode: 'opencode',
|
||||||
openCodeConfig: { autoAllowTools: true },
|
openCodeConfig: { autoAllowTools: true },
|
||||||
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|||||||
@@ -953,11 +953,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
const name = `w${startNumber}-${dirName}`;
|
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', {
|
const createRes = await fetch('/api/sessions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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();
|
const createData = await createRes.json();
|
||||||
if (!createData.success) throw new Error(createData.error);
|
if (!createData.success) throw new Error(createData.error);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { RespawnController } from '../../respawn-controller.js';
|
|||||||
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.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 { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.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(
|
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems, envOverrides } =
|
||||||
RalphLoopStartSchema,
|
parseBody(RalphLoopStartSchema, req.body);
|
||||||
req.body
|
|
||||||
);
|
|
||||||
|
|
||||||
const casePath = join(CASES_DIR, caseName);
|
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
|
// Create session
|
||||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||||
const rlModelConfig = await ctx.getModelConfig();
|
const rlModelConfig = await ctx.getModelConfig();
|
||||||
@@ -311,6 +314,7 @@ export function registerRalphRoutes(
|
|||||||
model: rlModelConfig?.defaultModel || undefined,
|
model: rlModelConfig?.defaultModel || undefined,
|
||||||
claudeMode: rlClaudeModeConfig.claudeMode,
|
claudeMode: rlClaudeModeConfig.claudeMode,
|
||||||
allowedTools: rlClaudeModeConfig.allowedTools,
|
allowedTools: rlClaudeModeConfig.allowedTools,
|
||||||
|
envOverrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure Ralph tracker
|
// Configure Ralph tracker
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import {
|
|||||||
validatePathWithinBase,
|
validatePathWithinBase,
|
||||||
} from '../route-helpers.js';
|
} from '../route-helpers.js';
|
||||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.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 { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { imageWatcher } from '../../image-watcher.js';
|
import { imageWatcher } from '../../image-watcher.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.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
|
// envOverrides flow through Session → tmux setenv (ephemeral, per-session).
|
||||||
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
|
//
|
||||||
await updateCaseEnvVars(workingDir, body.envOverrides);
|
// 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
|
// Write model override to .claude/settings.local.json if provided
|
||||||
@@ -239,6 +251,7 @@ export function registerSessionRoutes(
|
|||||||
allowedTools: claudeModeConfig.allowedTools,
|
allowedTools: claudeModeConfig.allowedTools,
|
||||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||||
resumeSessionId: validatedResumeId,
|
resumeSessionId: validatedResumeId,
|
||||||
|
envOverrides: body.envOverrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.addSession(session);
|
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()) {
|
if (!prompt.trim()) {
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
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.addSession(session);
|
||||||
ctx.store.incrementSessionsCreated();
|
ctx.store.incrementSessionsCreated();
|
||||||
ctx.persistSessionState(session);
|
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
|
// Check OpenCode availability if requested
|
||||||
if (mode === 'opencode') {
|
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
|
// Create a new session with the case as working directory
|
||||||
// Apply global Nice priority config and model config from settings
|
// Apply global Nice priority config and model config from settings
|
||||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||||
@@ -983,6 +1011,7 @@ export function registerSessionRoutes(
|
|||||||
claudeMode: qsClaudeModeConfig.claudeMode,
|
claudeMode: qsClaudeModeConfig.claudeMode,
|
||||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||||
|
envOverrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export const QuickStartSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
|
envOverrides: safeEnvOverridesSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== Hook Events ==========
|
// ========== Hook Events ==========
|
||||||
@@ -417,6 +418,7 @@ export const FlickerFilterSchema = z.object({
|
|||||||
export const QuickRunSchema = z.object({
|
export const QuickRunSchema = z.object({
|
||||||
prompt: z.string().min(1).max(100000),
|
prompt: z.string().min(1).max(100000),
|
||||||
workingDir: safePathSchema.optional(),
|
workingDir: safePathSchema.optional(),
|
||||||
|
envOverrides: safeEnvOverridesSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** POST /api/scheduled */
|
/** POST /api/scheduled */
|
||||||
@@ -539,6 +541,7 @@ export const RalphLoopStartSchema = z.object({
|
|||||||
completionPhrase: z.string().max(100).default('COMPLETE'),
|
completionPhrase: z.string().max(100).default('COMPLETE'),
|
||||||
maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
|
maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
|
||||||
enableRespawn: z.boolean().default(false),
|
enableRespawn: z.boolean().default(false),
|
||||||
|
envOverrides: safeEnvOverridesSchema,
|
||||||
planItems: z
|
planItems: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
+10
-2
@@ -40,7 +40,7 @@ import { execSync } from 'node:child_process';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { Session, type BackgroundTask } from '../session.js';
|
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 { RespawnController, RespawnConfig } from '../respawn-controller.js';
|
||||||
import type { TerminalMultiplexer } from '../mux-interface.js';
|
import type { TerminalMultiplexer } from '../mux-interface.js';
|
||||||
import { createMultiplexer } from '../mux-factory.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 */
|
/** Persists full session state including respawn config to state.json */
|
||||||
private _persistSessionStateNow(session: Session): void {
|
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);
|
const controller = this.respawnControllers.get(session.id);
|
||||||
if (controller) {
|
if (controller) {
|
||||||
const config = controller.getConfig();
|
const config = controller.getConfig();
|
||||||
@@ -1631,6 +1635,9 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
// Create a session object for this mux session
|
// Create a session object for this mux session
|
||||||
const recoveryClaudeMode = await this.getClaudeModeConfig();
|
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<string, string> })?.__envOverrides;
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
id: muxSession.sessionId, // Preserve the original session ID
|
id: muxSession.sessionId, // Preserve the original session ID
|
||||||
workingDir: muxSession.workingDir,
|
workingDir: muxSession.workingDir,
|
||||||
@@ -1641,6 +1648,7 @@ export class WebServer extends EventEmitter {
|
|||||||
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
|
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
|
||||||
claudeMode: recoveryClaudeMode.claudeMode,
|
claudeMode: recoveryClaudeMode.claudeMode,
|
||||||
allowedTools: recoveryClaudeMode.allowedTools,
|
allowedTools: recoveryClaudeMode.allowedTools,
|
||||||
|
envOverrides: savedEnvOverrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ vi.mock('../src/session.js', () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEnvOverridesForPersist() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
getOutput() {
|
getOutput() {
|
||||||
return 'mock output';
|
return 'mock output';
|
||||||
}
|
}
|
||||||
@@ -147,19 +151,14 @@ describe('SessionManager', () => {
|
|||||||
it('should persist session to store', async () => {
|
it('should persist session to store', async () => {
|
||||||
const session = await manager.createSession('/tmp/test');
|
const session = await manager.createSession('/tmp/test');
|
||||||
|
|
||||||
expect(mockState.store.setSession).toHaveBeenCalledWith(
|
expect(mockState.store.setSession).toHaveBeenCalledWith(session.id, expect.any(Object));
|
||||||
session.id,
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when max sessions reached', async () => {
|
it('should throw when max sessions reached', async () => {
|
||||||
mockState.store.state.config.maxConcurrentSessions = 1;
|
mockState.store.state.config.maxConcurrentSessions = 1;
|
||||||
await manager.createSession('/tmp/test1');
|
await manager.createSession('/tmp/test1');
|
||||||
|
|
||||||
await expect(manager.createSession('/tmp/test2')).rejects.toThrow(
|
await expect(manager.createSession('/tmp/test2')).rejects.toThrow(/Maximum concurrent sessions/);
|
||||||
/Maximum concurrent sessions/
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should forward session output events', async () => {
|
it('should forward session output events', async () => {
|
||||||
@@ -325,9 +324,7 @@ describe('SessionManager', () => {
|
|||||||
|
|
||||||
describe('sendToSession', () => {
|
describe('sendToSession', () => {
|
||||||
it('should throw for non-existent session', async () => {
|
it('should throw for non-existent session', async () => {
|
||||||
await expect(manager.sendToSession('non-existent', 'test')).rejects.toThrow(
|
await expect(manager.sendToSession('non-existent', 'test')).rejects.toThrow(/Session non-existent not found/);
|
||||||
/Session non-existent not found/
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should send input to session', async () => {
|
it('should send input to session', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user