Skip to content

Commit a568cc1

Browse files
committed
address review feedback
1 parent 90352c2 commit a568cc1

6 files changed

Lines changed: 332 additions & 85 deletions

File tree

packages/agent/src/adapters/claude/session/options.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,15 +211,21 @@ describe("buildSessionOptions", () => {
211211
expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("claude-haiku-4-5");
212212
});
213213

214-
it("converts gateway model ids from settings to CLI aliases", () => {
214+
it.each([
215+
["claude-opus-4-7", "opus"],
216+
["claude-opus-4-8", "opus"],
217+
["claude-sonnet-4-6", "sonnet"],
218+
["claude-haiku-4-5", "claude-haiku-4-5"],
219+
["inherit", "inherit"],
220+
])("maps settings value %s to env value %s", (settingsValue, expected) => {
215221
const params = makeParams();
216222
vi.spyOn(params.settingsManager, "getSettings").mockReturnValue({
217-
env: { CLAUDE_CODE_SUBAGENT_MODEL: "claude-opus-4-8" },
223+
env: { CLAUDE_CODE_SUBAGENT_MODEL: settingsValue },
218224
});
219225

220226
const options = buildSessionOptions(params);
221227

222-
expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("opus");
228+
expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe(expected);
223229
});
224230
});
225231

packages/agent/src/adapters/claude/session/options.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,11 @@ function buildEnvironment(
200200
// subagent; the merged .claude/settings.json env block (user layer written
201201
// by the Settings UI, stored as a gateway model id) or a pre-set process
202202
// env var wins over the default.
203-
CLAUDE_CODE_SUBAGENT_MODEL: settingsSubagentModel
204-
? toSdkModelId(settingsSubagentModel)
205-
: (process.env.CLAUDE_CODE_SUBAGENT_MODEL ?? DEFAULT_SUBAGENT_MODEL),
203+
CLAUDE_CODE_SUBAGENT_MODEL: toSdkModelId(
204+
settingsSubagentModel ??
205+
process.env.CLAUDE_CODE_SUBAGENT_MODEL ??
206+
DEFAULT_SUBAGENT_MODEL,
207+
),
206208
};
207209
}
208210

packages/agent/src/adapters/claude/session/settings.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,4 +411,27 @@ describe("user settings env vars", () => {
411411
"{not json",
412412
);
413413
});
414+
415+
it("serialises concurrent writes without clobbering", async () => {
416+
await Promise.all([
417+
setUserSettingsEnvVar("A", "1"),
418+
setUserSettingsEnvVar("B", "2"),
419+
setUserSettingsEnvVar("C", "3"),
420+
]);
421+
422+
expect(await readSettings()).toEqual({
423+
env: { A: "1", B: "2", C: "3" },
424+
});
425+
});
426+
427+
it("merges the written env into SettingsManager settings", async () => {
428+
await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", "sonnet");
429+
430+
const manager = new SettingsManager(configDir);
431+
await manager.initialize();
432+
433+
expect(manager.getSettings().env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe(
434+
"sonnet",
435+
);
436+
});
414437
});

packages/agent/src/adapters/claude/session/settings.ts

Lines changed: 77 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,31 @@ function getUserSettingsFilePath(): string {
216216
return path.join(configDir, "settings.json");
217217
}
218218

219+
/**
220+
* Mutex-serialised read-modify-write of a settings file with an atomic
221+
* temp-file + rename, refusing to overwrite unparseable content. Returning
222+
* `existing` unchanged from `mutate` skips the write.
223+
*/
224+
async function updateSettingsFile(
225+
mutex: AsyncMutex,
226+
filePath: string,
227+
mutate: (existing: ClaudeCodeSettings) => ClaudeCodeSettings,
228+
): Promise<ClaudeCodeSettings> {
229+
await mutex.acquire();
230+
try {
231+
const existing = await readSettingsFileForUpdate(filePath);
232+
const next = mutate(existing);
233+
if (next === existing) {
234+
return existing;
235+
}
236+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
237+
await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`);
238+
return next;
239+
} finally {
240+
mutex.release();
241+
}
242+
}
243+
219244
const userSettingsWriteMutex = new AsyncMutex();
220245

221246
export async function getUserSettingsEnvVar(
@@ -230,34 +255,31 @@ export async function getUserSettingsEnvVar(
230255
* (`<CLAUDE_CONFIG_DIR>/settings.json`, app-scoped since the host sets
231256
* CLAUDE_CONFIG_DIR). `buildSessionOptions` reads the merged env at every
232257
* session spawn, so changes apply to new and resumed sessions without any
233-
* per-session threading. `value === undefined` deletes the key. Mirrors
234-
* `addAllowRules`: mutex-serialised, atomic temp-file + rename.
258+
* per-session threading. `value === undefined` deletes the key.
235259
*/
236260
export async function setUserSettingsEnvVar(
237261
key: string,
238262
value: string | undefined,
239263
): Promise<void> {
240-
await userSettingsWriteMutex.acquire();
241-
try {
242-
const filePath = getUserSettingsFilePath();
243-
const existing = await readSettingsFileForUpdate(filePath);
244-
const env = { ...existing.env };
245-
if (value === undefined) {
246-
delete env[key];
247-
} else {
248-
env[key] = value;
249-
}
250-
const next: ClaudeCodeSettings = { ...existing };
251-
if (Object.keys(env).length > 0) {
252-
next.env = env;
253-
} else {
254-
delete next.env;
255-
}
256-
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
257-
await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`);
258-
} finally {
259-
userSettingsWriteMutex.release();
260-
}
264+
await updateSettingsFile(
265+
userSettingsWriteMutex,
266+
getUserSettingsFilePath(),
267+
(existing) => {
268+
const env = { ...existing.env };
269+
if (value === undefined) {
270+
delete env[key];
271+
} else {
272+
env[key] = value;
273+
}
274+
const next: ClaudeCodeSettings = { ...existing };
275+
if (Object.keys(env).length > 0) {
276+
next.env = env;
277+
} else {
278+
delete next.env;
279+
}
280+
return next;
281+
},
282+
);
261283
}
262284

263285
export function getManagedSettingsPath(): string {
@@ -468,27 +490,22 @@ export class SettingsManager {
468490
async addAllowRules(rules: PermissionRuleValue[]): Promise<void> {
469491
if (rules.length === 0) return;
470492
if (!this.initialized) await this.initialize();
471-
await this.writeMutex.acquire();
472-
try {
473-
const filePath = this.getLocalSettingsPath();
474-
const existing = await readSettingsFileForUpdate(filePath);
475-
const permissions: PermissionSettings = {
476-
...(existing.permissions ?? {}),
477-
};
478-
const current = new Set(permissions.allow ?? []);
479-
for (const rule of rules) {
480-
current.add(formatRule(rule));
481-
}
482-
permissions.allow = Array.from(current);
483-
const next: ClaudeCodeSettings = { ...existing, permissions };
484-
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
485-
await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`);
486-
487-
this.localSettings = next;
488-
this.mergeAllSettings();
489-
} finally {
490-
this.writeMutex.release();
491-
}
493+
this.localSettings = await updateSettingsFile(
494+
this.writeMutex,
495+
this.getLocalSettingsPath(),
496+
(existing) => {
497+
const permissions: PermissionSettings = {
498+
...(existing.permissions ?? {}),
499+
};
500+
const current = new Set(permissions.allow ?? []);
501+
for (const rule of rules) {
502+
current.add(formatRule(rule));
503+
}
504+
permissions.allow = Array.from(current);
505+
return { ...existing, permissions };
506+
},
507+
);
508+
this.mergeAllSettings();
492509
}
493510

494511
hasPostHogExecApproval(subTool: string): boolean {
@@ -505,27 +522,22 @@ export class SettingsManager {
505522
async addPostHogExecApproval(subTool: string): Promise<void> {
506523
if (!subTool) return;
507524
if (!this.initialized) await this.initialize();
508-
await this.writeMutex.acquire();
509-
try {
510-
const filePath = this.getLocalSettingsPath();
511-
const existing = await readSettingsFileForUpdate(filePath);
512-
const current = new Set(existing.posthogApprovedExecTools ?? []);
513-
if (current.has(subTool)) {
514-
return;
515-
}
516-
current.add(subTool);
517-
const next: ClaudeCodeSettings = {
518-
...existing,
519-
posthogApprovedExecTools: Array.from(current),
520-
};
521-
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
522-
await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`);
523-
524-
this.localSettings = next;
525-
this.mergeAllSettings();
526-
} finally {
527-
this.writeMutex.release();
528-
}
525+
this.localSettings = await updateSettingsFile(
526+
this.writeMutex,
527+
this.getLocalSettingsPath(),
528+
(existing) => {
529+
const current = new Set(existing.posthogApprovedExecTools ?? []);
530+
if (current.has(subTool)) {
531+
return existing;
532+
}
533+
current.add(subTool);
534+
return {
535+
...existing,
536+
posthogApprovedExecTools: Array.from(current),
537+
};
538+
},
539+
);
540+
this.mergeAllSettings();
529541
}
530542

531543
async setCwd(cwd: string): Promise<void> {

0 commit comments

Comments
 (0)