Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions base-action/src/setup-claude-code-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ import { $ } from "bun";
import { homedir } from "os";
import { readFile } from "fs/promises";

// Env vars that resolve a model alias (sonnet/opus/haiku) to a concrete
// model id or proxy preset. The Agent SDK's top-level call inherits these
// from process.env, but spawned sub-call subprocesses (Task tool, sub-agents)
// have historically dropped them and fallen back to the SDK's hardcoded
// default — e.g. routing to literal `claude-sonnet-4-6` even when the user
// opted into a non-Anthropic preset via ANTHROPIC_DEFAULT_SONNET_MODEL.
// Mirroring these into settings.env guarantees the CLI propagates them to
// every session, including sub-calls, via the documented settings layer.
// See: https://github.com/anthropics/claude-code-action/issues/1258
const MODEL_ALIAS_ENV_VARS = [
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
] as const;

export async function setupClaudeCodeSettings(
settingsInput?: string,
homeDir?: string,
Expand Down Expand Up @@ -63,6 +78,31 @@ export async function setupClaudeCodeSettings(
settings.enableAllProjectMcpServers = true;
console.log(`Updated settings with enableAllProjectMcpServers: true`);

// Forward model-alias env vars into settings.env so the CLI propagates them
// to every session (top-level + sub-calls). User-provided settings.env
// entries take precedence; we only fill in keys the user didn't already set.
const userEnv =
typeof settings.env === "object" && settings.env !== null
? (settings.env as Record<string, string>)
: undefined;
const mergedEnv: Record<string, string> = { ...(userEnv ?? {}) };
const injectedKeys: string[] = [];
for (const key of MODEL_ALIAS_ENV_VARS) {
const value = process.env[key];
if (value && !(key in mergedEnv)) {
mergedEnv[key] = value;
injectedKeys.push(key);
}
}
if (injectedKeys.length > 0 || userEnv) {
settings.env = mergedEnv;
if (injectedKeys.length > 0) {
console.log(
`Forwarded model-alias env vars to settings.env: ${injectedKeys.join(", ")}`,
);
}
}

await $`echo ${JSON.stringify(settings, null, 2)} > ${settingsPath}`.quiet();
console.log(`Settings saved successfully`);
}
103 changes: 103 additions & 0 deletions base-action/test/setup-claude-code-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,107 @@ describe("setupClaudeCodeSettings", () => {
expect(settings.newKey).toBe("newValue");
expect(settings.model).toBe("claude-opus-4-1-20250805");
});

describe("model-alias env var forwarding (issue #1258)", () => {
const MODEL_ENV_VARS = [
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
] as const;

let originalEnv: Record<string, string | undefined>;

beforeEach(() => {
originalEnv = {};
for (const key of MODEL_ENV_VARS) {
originalEnv[key] = process.env[key];
delete process.env[key];
}
});

afterEach(() => {
for (const key of MODEL_ENV_VARS) {
if (originalEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = originalEnv[key];
}
}
});

test("should mirror ANTHROPIC_DEFAULT_*_MODEL env vars into settings.env", async () => {
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL =
"@preset/minimax-minimax-m2-7-no-thinking";
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = "@preset/some-opus-preset";
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = "@preset/some-haiku-preset";

await setupClaudeCodeSettings(undefined, testHomeDir);

const settingsContent = await readFile(settingsPath, "utf-8");
const settings = JSON.parse(settingsContent);

expect(settings.env).toEqual({
ANTHROPIC_DEFAULT_SONNET_MODEL:
"@preset/minimax-minimax-m2-7-no-thinking",
ANTHROPIC_DEFAULT_OPUS_MODEL: "@preset/some-opus-preset",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "@preset/some-haiku-preset",
});
});

test("should forward only the env vars that are set", async () => {
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = "sonnet-preset";

await setupClaudeCodeSettings(undefined, testHomeDir);

const settingsContent = await readFile(settingsPath, "utf-8");
const settings = JSON.parse(settingsContent);

expect(settings.env).toEqual({
ANTHROPIC_DEFAULT_SONNET_MODEL: "sonnet-preset",
});
});

test("should not create settings.env when no model-alias env vars are set", async () => {
await setupClaudeCodeSettings(undefined, testHomeDir);

const settingsContent = await readFile(settingsPath, "utf-8");
const settings = JSON.parse(settingsContent);

expect(settings.env).toBeUndefined();
});

test("should preserve user-provided settings.env keys (user wins on conflict)", async () => {
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = "sonnet-from-env";
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = "opus-from-env";

const userSettings = JSON.stringify({
env: {
ANTHROPIC_DEFAULT_SONNET_MODEL: "sonnet-from-user-settings",
CUSTOM_VAR: "custom-value",
},
});

await setupClaudeCodeSettings(userSettings, testHomeDir);

const settingsContent = await readFile(settingsPath, "utf-8");
const settings = JSON.parse(settingsContent);

expect(settings.env).toEqual({
ANTHROPIC_DEFAULT_SONNET_MODEL: "sonnet-from-user-settings",
ANTHROPIC_DEFAULT_OPUS_MODEL: "opus-from-env",
CUSTOM_VAR: "custom-value",
});
});

test("should ignore empty env var values", async () => {
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = "";

await setupClaudeCodeSettings(undefined, testHomeDir);

const settingsContent = await readFile(settingsPath, "utf-8");
const settings = JSON.parse(settingsContent);

expect(settings.env).toBeUndefined();
});
});
});