Skip to content

Commit b493e06

Browse files
CodeCasterXcodex
andauthored
feat(sandbox): inherit Claude settings (#556)
- Merge Claude Code provider env from host settings. - Preserve sandbox overrides with first-write semantics. - Cover DeepSeek example mappings and invalid env cases. - Document the settings inheritance boundary. Co-authored-by: Codex <noreply@openai.com>
1 parent a27a70d commit b493e06

4 files changed

Lines changed: 250 additions & 9 deletions

File tree

docs/en/sandbox.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ When you run the sandbox from a remote Mac over SSH, use `ai cp <ssh-alias>` on
2222

2323
`ai sandbox exec` and `ai sandbox refresh` reconcile Claude Code credentials in both directions across the host credential store and every sandbox project copy under `~/.agent-infra/credentials/*`. When a long-running sandbox refreshes OAuth tokens first, the next entry or refresh command writes the freshest valid copy back to the host Keychain or `~/.claude/.credentials.json`; when the host is fresher, it updates the project copies. If every copy is stale, `ai sandbox refresh` probes `claude /status` and asks you to log in only when the probe cannot recover credentials.
2424

25+
When Claude Code is enabled, `ai sandbox create` also merges model and API provider settings from the host `~/.claude/settings.json` into the sandbox Claude Code settings. Existing sandbox values take precedence, so local sandbox overrides are preserved. Credentials still use the dedicated credentials channel above; provider environment settings are copied only as Claude Code settings values.
26+
2527
## Host-sandbox file exchange
2628

2729
`ai sandbox create` mounts two writable directories for dropping files between

docs/zh-CN/sandbox.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
`ai sandbox exec``ai sandbox refresh` 会在宿主机凭证存储与 `~/.agent-infra/credentials/*` 下的所有沙箱项目副本之间做双向 reconcile。长时间运行的沙箱如果先刷新了 OAuth token,下一次进入或刷新命令会把最新有效副本回写到宿主 Keychain 或 `~/.claude/.credentials.json`;宿主机更新时也会继续覆盖项目副本。如果所有副本都已失效,`ai sandbox refresh` 会尝试 `claude /status` 探活,只有探活无法恢复时才提示重新登录。
2424

25+
启用 Claude Code 时,`ai sandbox create` 还会把宿主机 `~/.claude/settings.json` 中的模型和 API provider 设置合并到沙箱内的 Claude Code settings。已有的沙箱值优先,因此沙箱内的本地覆盖会被保留。凭证仍使用上面的专用 credentials 通道;provider 环境设置只会作为 Claude Code settings 值复制。
26+
2527
## 宿主-沙箱文件交换
2628

2729
`ai sandbox create` 会自动挂载两个可读写目录,方便宿主与容器之间互相 drop 文件,不污染 git 工作树:

lib/sandbox/commands/create.ts

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,63 @@ function readHostJsonSafe(filePath: string): JsonObject | null {
740740
}
741741
}
742742

743+
const CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS = [
744+
'model',
745+
'fallbackModel',
746+
'availableModels',
747+
'modelOverrides',
748+
'enforceAvailableModels',
749+
'advisorModel',
750+
'apiKeyHelper',
751+
'effortLevel'
752+
];
753+
754+
function isJsonObjectRecord(value: unknown): value is JsonObject {
755+
return value !== null && typeof value === 'object' && !Array.isArray(value);
756+
}
757+
758+
function mergeMissingStringEnvFields(target: JsonObject, source: JsonObject): boolean {
759+
if (!isJsonObjectRecord(source.env)) {
760+
return false;
761+
}
762+
if (Object.hasOwn(target, 'env') && !isJsonObjectRecord(target.env)) {
763+
return false;
764+
}
765+
766+
let targetEnv = target.env as JsonObject | undefined;
767+
let changed = false;
768+
for (const [key, value] of Object.entries(source.env)) {
769+
if (typeof value !== 'string' || value === '') {
770+
continue;
771+
}
772+
if (!targetEnv) {
773+
targetEnv = {};
774+
target.env = targetEnv;
775+
}
776+
if (!Object.hasOwn(targetEnv, key)) {
777+
targetEnv[key] = value;
778+
changed = true;
779+
}
780+
}
781+
return changed;
782+
}
783+
784+
function mergeMissingTopLevelSettings(target: JsonObject, source: JsonObject): boolean {
785+
let changed = false;
786+
for (const key of CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS) {
787+
if (!Object.hasOwn(source, key) || Object.hasOwn(target, key)) {
788+
continue;
789+
}
790+
const value = source[key];
791+
if (value === null || value === undefined || value === '') {
792+
continue;
793+
}
794+
target[key] = value;
795+
changed = true;
796+
}
797+
return changed;
798+
}
799+
743800
export function ensureClaudeOnboarding(toolDir: string, hostHomeDir?: string): void {
744801
const claudeJsonPath = path.join(toolDir, '.claude.json');
745802
let data: JsonObject & {
@@ -810,7 +867,7 @@ export function ensureClaudeOnboarding(toolDir: string, hostHomeDir?: string): v
810867

811868
export function ensureClaudeSettings(toolDir: string, hostHomeDir?: string): void {
812869
const settingsPath = path.join(toolDir, 'settings.json');
813-
let data: JsonObject & { skipDangerousModePermissionPrompt?: boolean; effortLevel?: string } = {};
870+
let data: JsonObject & { skipDangerousModePermissionPrompt?: boolean } = {};
814871
if (fs.existsSync(settingsPath)) {
815872
try {
816873
data = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as typeof data;
@@ -825,14 +882,9 @@ export function ensureClaudeSettings(toolDir: string, hostHomeDir?: string): voi
825882
}
826883
if (hostHomeDir) {
827884
const hostSettings = readHostJsonSafe(path.join(hostHomeDir, '.claude', 'settings.json'));
828-
if (
829-
hostSettings
830-
&& typeof hostSettings.effortLevel === 'string'
831-
&& hostSettings.effortLevel !== ''
832-
&& !Object.hasOwn(data, 'effortLevel')
833-
) {
834-
data.effortLevel = hostSettings.effortLevel;
835-
changed = true;
885+
if (hostSettings) {
886+
changed = mergeMissingStringEnvFields(data, hostSettings) || changed;
887+
changed = mergeMissingTopLevelSettings(data, hostSettings) || changed;
836888
}
837889
}
838890
if (changed) {

tests/integration/cli/sandbox-inheritance.test.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,191 @@ test("ensureClaudeSettings skips empty host effort level", async () => {
445445
}
446446
});
447447

448+
test("ensureClaudeSettings inherits host provider env and model settings", async () => {
449+
const sandboxCreate = await loadFreshEsm<SandboxCreateModule>("lib/sandbox/commands/create.js");
450+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-claude-settings-provider-"));
451+
const hostHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-claude-host-provider-"));
452+
453+
try {
454+
fs.mkdirSync(path.join(hostHome, ".claude"), { recursive: true });
455+
fs.writeFileSync(
456+
path.join(hostHome, ".claude", "settings.json"),
457+
JSON.stringify({
458+
env: {
459+
ANTHROPIC_BASE_URL: "https://api.deepseek.example/anthropic",
460+
ANTHROPIC_AUTH_TOKEN: "test-token-redacted",
461+
ANTHROPIC_MODEL: "deepseek-v4-pro[1m]",
462+
ANTHROPIC_OPUS_MODEL: "deepseek-v4-pro[1m]",
463+
ANTHROPIC_SONNET_MODEL: "deepseek-v4-flash",
464+
ANTHROPIC_HAIKU_MODEL: "deepseek-v4-flash",
465+
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-v4-flash",
466+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
467+
},
468+
model: "deepseek-v4-pro[1m]",
469+
fallbackModel: "deepseek-v4-flash",
470+
availableModels: ["deepseek-v4-pro[1m]", "deepseek-v4-flash"],
471+
modelOverrides: {
472+
opus: "deepseek-v4-pro[1m]",
473+
sonnet: "deepseek-v4-flash",
474+
haiku: "deepseek-v4-flash",
475+
subagent: "deepseek-v4-flash"
476+
},
477+
enforceAvailableModels: true,
478+
advisorModel: "deepseek-v4-flash",
479+
apiKeyHelper: "printf test-token-redacted",
480+
theme: "dark"
481+
}),
482+
"utf8"
483+
);
484+
485+
sandboxCreate.ensureClaudeSettings(tmpDir, hostHome);
486+
const data = JSON.parse(fs.readFileSync(path.join(tmpDir, "settings.json"), "utf8"));
487+
assert.deepEqual(data.env, {
488+
ANTHROPIC_BASE_URL: "https://api.deepseek.example/anthropic",
489+
ANTHROPIC_AUTH_TOKEN: "test-token-redacted",
490+
ANTHROPIC_MODEL: "deepseek-v4-pro[1m]",
491+
ANTHROPIC_OPUS_MODEL: "deepseek-v4-pro[1m]",
492+
ANTHROPIC_SONNET_MODEL: "deepseek-v4-flash",
493+
ANTHROPIC_HAIKU_MODEL: "deepseek-v4-flash",
494+
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-v4-flash",
495+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
496+
});
497+
assert.equal(data.model, "deepseek-v4-pro[1m]");
498+
assert.equal(data.fallbackModel, "deepseek-v4-flash");
499+
assert.deepEqual(data.availableModels, ["deepseek-v4-pro[1m]", "deepseek-v4-flash"]);
500+
assert.deepEqual(data.modelOverrides, {
501+
opus: "deepseek-v4-pro[1m]",
502+
sonnet: "deepseek-v4-flash",
503+
haiku: "deepseek-v4-flash",
504+
subagent: "deepseek-v4-flash"
505+
});
506+
assert.equal(data.enforceAvailableModels, true);
507+
assert.equal(data.advisorModel, "deepseek-v4-flash");
508+
assert.equal(data.apiKeyHelper, "printf test-token-redacted");
509+
assert.equal(Object.hasOwn(data, "theme"), false);
510+
assert.equal(data.skipDangerousModePermissionPrompt, true);
511+
} finally {
512+
fs.rmSync(tmpDir, { recursive: true, force: true });
513+
fs.rmSync(hostHome, { recursive: true, force: true });
514+
}
515+
});
516+
517+
test("ensureClaudeSettings preserves sandbox provider fields and fills missing env", async () => {
518+
const sandboxCreate = await loadFreshEsm<SandboxCreateModule>("lib/sandbox/commands/create.js");
519+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-claude-settings-provider-keep-"));
520+
const hostHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-claude-host-provider-keep-"));
521+
522+
try {
523+
fs.mkdirSync(path.join(hostHome, ".claude"), { recursive: true });
524+
fs.writeFileSync(
525+
path.join(hostHome, ".claude", "settings.json"),
526+
JSON.stringify({
527+
env: {
528+
ANTHROPIC_MODEL: "host-model",
529+
ANTHROPIC_SMALL_FAST_MODEL: "host-fast-model"
530+
},
531+
model: "host-top-level-model",
532+
fallbackModel: "host-fallback-model"
533+
}),
534+
"utf8"
535+
);
536+
fs.writeFileSync(
537+
path.join(tmpDir, "settings.json"),
538+
JSON.stringify({
539+
skipDangerousModePermissionPrompt: true,
540+
env: {
541+
ANTHROPIC_MODEL: "sandbox-model"
542+
},
543+
model: "sandbox-top-level-model"
544+
}),
545+
"utf8"
546+
);
547+
548+
sandboxCreate.ensureClaudeSettings(tmpDir, hostHome);
549+
const data = JSON.parse(fs.readFileSync(path.join(tmpDir, "settings.json"), "utf8"));
550+
assert.deepEqual(data.env, {
551+
ANTHROPIC_MODEL: "sandbox-model",
552+
ANTHROPIC_SMALL_FAST_MODEL: "host-fast-model"
553+
});
554+
assert.equal(data.model, "sandbox-top-level-model");
555+
assert.equal(data.fallbackModel, "host-fallback-model");
556+
} finally {
557+
fs.rmSync(tmpDir, { recursive: true, force: true });
558+
fs.rmSync(hostHome, { recursive: true, force: true });
559+
}
560+
});
561+
562+
test("ensureClaudeSettings skips invalid host env values and non-object sandbox env", async () => {
563+
const sandboxCreate = await loadFreshEsm<SandboxCreateModule>("lib/sandbox/commands/create.js");
564+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-claude-settings-provider-invalid-"));
565+
const hostHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-claude-host-provider-invalid-"));
566+
567+
try {
568+
fs.mkdirSync(path.join(hostHome, ".claude"), { recursive: true });
569+
fs.writeFileSync(
570+
path.join(hostHome, ".claude", "settings.json"),
571+
JSON.stringify({
572+
env: {
573+
ANTHROPIC_BASE_URL: "https://api.example.test",
574+
ANTHROPIC_AUTH_TOKEN: "",
575+
ANTHROPIC_MODEL: ["not", "a", "string"],
576+
ANTHROPIC_SMALL_FAST_MODEL: false
577+
}
578+
}),
579+
"utf8"
580+
);
581+
fs.writeFileSync(
582+
path.join(tmpDir, "settings.json"),
583+
JSON.stringify({
584+
skipDangerousModePermissionPrompt: true,
585+
env: "keep-local-env-value"
586+
}),
587+
"utf8"
588+
);
589+
590+
sandboxCreate.ensureClaudeSettings(tmpDir, hostHome);
591+
const data = JSON.parse(fs.readFileSync(path.join(tmpDir, "settings.json"), "utf8"));
592+
assert.equal(data.env, "keep-local-env-value");
593+
} finally {
594+
fs.rmSync(tmpDir, { recursive: true, force: true });
595+
fs.rmSync(hostHome, { recursive: true, force: true });
596+
}
597+
});
598+
599+
test("ensureClaudeSettings skips non-object host env without defaulting effort env", async () => {
600+
const sandboxCreate = await loadFreshEsm<SandboxCreateModule>("lib/sandbox/commands/create.js");
601+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-claude-settings-host-env-invalid-"));
602+
const hostHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-claude-host-env-invalid-"));
603+
604+
try {
605+
fs.mkdirSync(path.join(hostHome, ".claude"), { recursive: true });
606+
fs.writeFileSync(path.join(hostHome, ".claude", "settings.json"), JSON.stringify({ env: "invalid" }), "utf8");
607+
sandboxCreate.ensureClaudeSettings(tmpDir, hostHome);
608+
const data = JSON.parse(fs.readFileSync(path.join(tmpDir, "settings.json"), "utf8"));
609+
assert.equal(Object.hasOwn(data, "env"), false);
610+
} finally {
611+
fs.rmSync(tmpDir, { recursive: true, force: true });
612+
fs.rmSync(hostHome, { recursive: true, force: true });
613+
}
614+
});
615+
616+
test("ensureClaudeSettings skips malformed host settings", async () => {
617+
const sandboxCreate = await loadFreshEsm<SandboxCreateModule>("lib/sandbox/commands/create.js");
618+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-claude-settings-malformed-host-"));
619+
const hostHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-claude-host-malformed-settings-"));
620+
621+
try {
622+
fs.mkdirSync(path.join(hostHome, ".claude"), { recursive: true });
623+
fs.writeFileSync(path.join(hostHome, ".claude", "settings.json"), "{not-json:test-token-redacted", "utf8");
624+
sandboxCreate.ensureClaudeSettings(tmpDir, hostHome);
625+
const data = JSON.parse(fs.readFileSync(path.join(tmpDir, "settings.json"), "utf8"));
626+
assert.deepEqual(data, { skipDangerousModePermissionPrompt: true });
627+
} finally {
628+
fs.rmSync(tmpDir, { recursive: true, force: true });
629+
fs.rmSync(hostHome, { recursive: true, force: true });
630+
}
631+
});
632+
448633
test("ensureCodexModelInheritance creates config with host model fields", async () => {
449634
const sandboxCreate = await loadFreshEsm<SandboxCreateModule>("lib/sandbox/commands/create.js");
450635
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-codex-model-"));

0 commit comments

Comments
 (0)