Skip to content

Commit 3c81825

Browse files
CodeCasterXclaudecodex
authored
fix(cli): 宿主无 Claude Code 凭证时 warn-skip 而不是硬抛错 (#360)
* fix(sandbox): skip Claude Code when host creds missing - Replace assertClaudeCredentialsAvailable with prepareClaudeCredentials returning {OK|SKIPPED|NOT_APPLICABLE}; only inspect MISSING degrades, while STALE_ACCESS / KEYCHAIN_LOCKED / KEYCHAIN_ERROR still throw with state-specific guidance. - In create(), filter claude-code from resolvedTools on SKIPPED so its config/credential mount is skipped, while tools (image build + version check) stays intact; emit a prominent warning before ensureDocker so the message is visible even without Docker. - Cover the new outcome matrix with unit + integration tests; preserve the zero-temp-Dockerfile leak assertion. Closes #357 Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Codex <noreply@openai.com> * fix(test): short-circuit create test at ensureDocker The integration test added in e3e95fe used DOCKER_EXIT_FOR_RUN so the CLI would walk the full create flow (image build, worktree setup, tool state preparation) before failing at "docker run". On slow runners (Windows CI, overlay-fs sandboxes) those preliminary steps take >30s, blowing past spawnSync's timeout and leaving result.signal as 'SIGTERM'. The test only needs to prove the credential gate emits the warning and the CLI exits non-zero — none of the work after ensureDocker matters. - Add DOCKER_EXIT_FOR_INFO support to the fake docker fixture so the first call ensureDocker makes ("docker info") can be made to exit non-zero on demand, mirroring the existing DOCKER_EXIT_FOR_RUN switch. - Switch the test to DOCKER_EXIT_FOR_INFO so the CLI exits immediately after p.log.warn(), before any worktree/fs work. Local runtime drops from ~30s to ~150ms; Windows CI passes. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com>
1 parent 127013b commit 3c81825

5 files changed

Lines changed: 139 additions & 83 deletions

File tree

lib/sandbox/commands/create.ts

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import { validateSelinuxDisableEnv } from '../engines/selinux.ts';
4343
import { resolveBuildUid } from '../engines/native.ts';
4444
import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
4545
import {
46-
assertClaudeCredentialsAvailable,
46+
prepareClaudeCredentials,
4747
redactCommandError,
4848
validateClaudeCredentialsEnvOverride
4949
} from '../credentials.ts';
@@ -1100,15 +1100,17 @@ export async function create(args: string[]): Promise<void> {
11001100
assertBranchAvailable(config.repoRoot, branch, { allowedWorktrees: worktreeCandidates });
11011101
const tools = resolveTools(effectiveConfig);
11021102
const resolvedTools = resolveToolDirs(effectiveConfig, tools, branch);
1103-
// Fail fast before any filesystem/docker side effects so a missing
1104-
// Claude Code credential blob doesn't leave the user with a stale
1105-
// worktree, docker image, or temporary Dockerfile they need to manually
1106-
// clean up.
1107-
assertClaudeCredentialsAvailable(
1103+
// Fatal credential states still fail before filesystem/docker side effects.
1104+
// A genuinely missing Claude Code credential only removes Claude Code's
1105+
// sandbox config and credential mounts for this create run.
1106+
const credentialOutcome = prepareClaudeCredentials(
11081107
effectiveConfig.home,
11091108
effectiveConfig.project,
11101109
resolvedTools
11111110
);
1111+
const effectiveResolvedTools = credentialOutcome.status === 'SKIPPED'
1112+
? resolvedTools.filter(({ tool }) => tool.id !== 'claude-code')
1113+
: resolvedTools;
11121114
const container = containerName(effectiveConfig, branch);
11131115
const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
11141116
const shareCommon = shareCommonDir(effectiveConfig);
@@ -1122,6 +1124,13 @@ export async function create(args: string[]): Promise<void> {
11221124
p.log.info(
11231125
`Project: ${pc.bold(effectiveConfig.project)} | Branch: ${pc.bold(branch)} | Base: ${pc.bold(baseBranch || 'HEAD')}`
11241126
);
1127+
if (credentialOutcome.status === 'SKIPPED') {
1128+
p.log.warn(
1129+
'Claude Code credentials not found on host - creating this sandbox WITHOUT Claude Code credentials.\n'
1130+
+ ' Claude Code is still installed in the image but will not be authenticated.\n'
1131+
+ ' To enable it: run "claude" once on the host to complete login, then re-run "ai sandbox create".'
1132+
);
1133+
}
11251134

11261135
try {
11271136
p.log.step('Checking container engine...');
@@ -1206,7 +1215,7 @@ export async function create(args: string[]): Promise<void> {
12061215
{
12071216
title: 'Preparing tool state',
12081217
task: async () => {
1209-
for (const { tool, dir } of resolvedTools) {
1218+
for (const { tool, dir } of effectiveResolvedTools) {
12101219
fs.mkdirSync(dir, { recursive: true });
12111220

12121221
for (const { hostPath, sandboxName } of tool.hostPreSeedFiles ?? []) {
@@ -1243,7 +1252,7 @@ export async function create(args: string[]): Promise<void> {
12431252
}
12441253
}
12451254

1246-
return `${resolvedTools.length} tool config directories ready`;
1255+
return `${effectiveResolvedTools.length} tool config directories ready`;
12471256
}
12481257
},
12491258
{
@@ -1283,31 +1292,32 @@ export async function create(args: string[]): Promise<void> {
12831292
signingKey
12841293
)
12851294
: null;
1286-
const envFile = buildContainerEnvFile(resolvedTools, engine);
1295+
const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
12871296
let hostShellConfig: HostShellConfig;
12881297
try {
1289-
const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
1298+
const claudeCodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'claude-code');
12901299
if (claudeCodeEntry) {
12911300
ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
12921301
ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
1293-
// Credential availability is asserted up-front in create() so we
1294-
// know the shared credentials file already exists at this point.
1302+
// prepareClaudeCredentials wrote the shared credentials file
1303+
// before this point. If credentials were missing, the
1304+
// claude-code entry was removed from effectiveResolvedTools.
12951305
}
1296-
const codexEntry = resolvedTools.find(({ tool }) => tool.id === 'codex');
1306+
const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
12971307
if (codexEntry) {
12981308
ensureCodexModelInheritance(codexEntry.dir, effectiveConfig.home);
12991309
ensureCodexWorkspaceTrust(codexEntry.dir);
13001310
}
1301-
const geminiEntry = resolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
1311+
const geminiEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
13021312
if (geminiEntry) {
13031313
ensureGeminiWorkspaceTrust(geminiEntry.dir);
13041314
}
1305-
const opencodeEntry = resolvedTools.find(({ tool }) => tool.id === 'opencode');
1315+
const opencodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'opencode');
13061316
if (opencodeEntry) {
13071317
// The TUI reads <toolDir>/opencode.json via OPENCODE_CONFIG pinned in tools.js.
13081318
ensureOpenCodeModelInheritance(opencodeEntry.dir, effectiveConfig.home);
13091319
}
1310-
const toolVolumes = resolvedTools.flatMap(({ tool, dir }) => [
1320+
const toolVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => [
13111321
'-v',
13121322
volumeArg(engine, dir, tool.containerMount)
13131323
]);
@@ -1322,7 +1332,7 @@ export async function create(args: string[]): Promise<void> {
13221332
'-v',
13231333
volumeArg(engine, hostPath, containerPath, ':ro')
13241334
]);
1325-
const liveMountVolumes = resolvedTools.flatMap(({ tool }) =>
1335+
const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) =>
13261336
(tool.hostLiveMounts ?? [])
13271337
.filter(({ hostPath }) => fs.existsSync(hostPath))
13281338
.flatMap(({ hostPath, containerSubpath }) => [
@@ -1435,7 +1445,7 @@ export async function create(args: string[]): Promise<void> {
14351445
}
14361446
}
14371447

1438-
for (const { tool } of resolvedTools) {
1448+
for (const { tool } of effectiveResolvedTools) {
14391449
for (const command of tool.postSetupCmds ?? []) {
14401450
runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
14411451
}
@@ -1477,7 +1487,7 @@ export async function create(args: string[]): Promise<void> {
14771487

14781488
p.outro(pc.green('Sandbox ready'));
14791489

1480-
const toolHints = resolvedTools.map(({ tool, dir }) => {
1490+
const toolHints = effectiveResolvedTools.map(({ tool, dir }) => {
14811491
const hasLiveMount = (tool.hostLiveMounts ?? []).some(({ hostPath }) => fs.existsSync(hostPath));
14821492
const hint = hasLiveMount
14831493
? 'Live-mounted auth/config files stay in sync with the host.'

lib/sandbox/credentials.ts

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ type ResolvedTool = {
9292
};
9393
};
9494

95+
export type ClaudeCredentialOutcome =
96+
| { status: 'OK' }
97+
| { status: 'SKIPPED' }
98+
| { status: 'NOT_APPLICABLE' };
99+
95100
type CredentialPayload = {
96101
claudeAiOauth?: CredentialPayload;
97102
scopes?: unknown;
@@ -584,51 +589,69 @@ export function formatRemaining(expiresAt: unknown): string {
584589
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
585590
}
586591

587-
export function assertClaudeCredentialsAvailable(
592+
export function prepareClaudeCredentials(
588593
home: string,
589594
project: string,
590595
resolvedTools: ResolvedTool[],
591596
extractFn: (home: string) => string | null = extractClaudeCredentialsBlob,
592597
writeFn: (home: string, project: string, blob: string) => void = writeClaudeCredentialsFile,
593598
inspectFn: (home: string) => CredentialInspection = inspectClaudeKeychainStatus
594-
): void {
599+
): ClaudeCredentialOutcome {
595600
const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
596601
if (!claudeCodeEntry) {
597-
return;
602+
return { status: 'NOT_APPLICABLE' };
598603
}
599604

600605
let blob: string | null = null;
601606
const hasCustomInspectFn = inspectFn !== inspectClaudeKeychainStatus;
602607
const hasCustomExtractFn = extractFn !== extractClaudeCredentialsBlob;
603608
if (hasCustomInspectFn || !hasCustomExtractFn) {
604609
const inspection = inspectFn(home);
605-
if (inspection.status === 'KEYCHAIN_LOCKED') {
606-
throw new Error([
607-
'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
608-
'',
609-
buildLockedGuidance()
610-
].join('\n'));
610+
switch (inspection.status) {
611+
case 'OK':
612+
blob = inspection.blob;
613+
break;
614+
case 'MISSING':
615+
return { status: 'SKIPPED' };
616+
case 'KEYCHAIN_LOCKED':
617+
throw new Error([
618+
'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
619+
'',
620+
buildLockedGuidance()
621+
].join('\n'));
622+
case 'STALE_ACCESS':
623+
throw new Error([
624+
'Claude Code credentials on host are invalid or expired.',
625+
'',
626+
'The sandbox needs valid Claude Code OAuth credentials so the container can use Claude Code.',
627+
'',
628+
'To fix:',
629+
' 1. On the host, run "claude" once and complete the OAuth login flow.',
630+
' 2. Verify with "claude /status" that you see your subscription.',
631+
' 3. Re-run "ai sandbox create".'
632+
].join('\n'));
633+
case 'KEYCHAIN_ERROR':
634+
throw new Error([
635+
'Claude Code credentials could not be read from the host keychain.',
636+
'',
637+
inspection.detail ? `Detail: ${inspection.detail}` : 'Detail: unknown keychain error',
638+
'',
639+
'To fix:',
640+
' 1. Unlock or repair the host keychain, then re-run "ai sandbox create".',
641+
' 2. For SSH / CI, set AGENT_INFRA_CLAUDE_CREDENTIALS_FILE to an absolute path containing a valid credentials blob.'
642+
].join('\n'));
643+
default: {
644+
const _exhaustive: never = inspection;
645+
throw new Error(`Unhandled Claude Code credential inspection status: ${(_exhaustive as { status: string }).status}`);
646+
}
611647
}
612-
blob = inspection.status === 'OK' ? inspection.blob : null;
613648
} else {
614649
blob = extractFn(home);
615-
}
616-
617-
if (!blob) {
618-
throw new Error([
619-
'Claude Code credentials not found on host.',
620-
'',
621-
'The sandbox needs your Claude Code OAuth credentials so the container can use Claude Code.',
622-
'',
623-
'To fix:',
624-
' 1. On the host, run "claude" once and complete the OAuth login flow.',
625-
' 2. Verify with "claude /status" that you see your subscription.',
626-
' 3. Re-run "ai sandbox create".',
627-
'',
628-
'Alternatively, if you do not need Claude Code in this sandbox,',
629-
'remove "claude-code" from the "sandbox.tools" array in .agents/.airc.json.'
630-
].join('\n'));
650+
if (!blob) {
651+
return { status: 'SKIPPED' };
652+
}
631653
}
632654

633655
writeFn(home, project, blob);
656+
return { status: 'OK' };
634657
}

tests/cli/sandbox-credentials.test.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -257,21 +257,22 @@ test("credential path helpers and writer manage the shared credential file", asy
257257
}
258258
});
259259

260-
test("assertClaudeCredentialsAvailable writes credentials only when claude-code is enabled", async () => {
260+
test("prepareClaudeCredentials writes credentials only when claude-code is enabled", async () => {
261261
const credentials = await loadFreshEsm<typeof import("../../lib/sandbox/credentials.ts")>("lib/sandbox/credentials.js");
262262
const writes: Array<[string, string, string]> = [];
263263

264-
credentials.assertClaudeCredentialsAvailable(
264+
const outcome = credentials.prepareClaudeCredentials(
265265
"/Users/demo",
266266
"agent-infra",
267267
[{ tool: { id: "claude-code" } }],
268268
() => "valid-blob",
269269
(home: string, project: string, blob: string) => writes.push([home, project, blob])
270270
);
271+
assert.deepEqual(outcome, { status: "OK" });
271272
assert.deepEqual(writes, [["/Users/demo", "agent-infra", "valid-blob"]]);
272273

273274
let extractCalled = false;
274-
credentials.assertClaudeCredentialsAvailable(
275+
const missingToolOutcome = credentials.prepareClaudeCredentials(
275276
"/Users/demo",
276277
"agent-infra",
277278
[{ tool: { id: "codex" } }],
@@ -281,25 +282,55 @@ test("assertClaudeCredentialsAvailable writes credentials only when claude-code
281282
},
282283
() => {}
283284
);
285+
assert.deepEqual(missingToolOutcome, { status: "NOT_APPLICABLE" });
284286
assert.equal(extractCalled, false);
285287
});
286288

287-
test("assertClaudeCredentialsAvailable throws a readable error when credentials are missing", async () => {
289+
test("prepareClaudeCredentials returns SKIPPED and writes nothing when credentials are missing", async () => {
288290
const credentials = await loadFreshEsm<typeof import("../../lib/sandbox/credentials.ts")>("lib/sandbox/credentials.js");
291+
let writeCalled = false;
289292

290-
assert.throws(() => credentials.assertClaudeCredentialsAvailable(
293+
const outcome = credentials.prepareClaudeCredentials(
291294
"/Users/demo",
292295
"agent-infra",
293296
[{ tool: { id: "claude-code" } }],
294297
() => null,
295-
() => {}
296-
), /Claude Code credentials not found on host/);
298+
() => {
299+
writeCalled = true;
300+
},
301+
() => ({ status: "MISSING" })
302+
);
303+
304+
assert.deepEqual(outcome, { status: "SKIPPED" });
305+
assert.equal(writeCalled, false);
306+
});
307+
308+
test("prepareClaudeCredentials rejects stale and keychain error states", async () => {
309+
const credentials = await loadFreshEsm<typeof import("../../lib/sandbox/credentials.ts")>("lib/sandbox/credentials.js");
310+
311+
for (const inspection of [
312+
{ status: "STALE_ACCESS" as const },
313+
{ status: "KEYCHAIN_ERROR" as const, detail: "security failed" }
314+
]) {
315+
let writeCalled = false;
316+
assert.throws(() => credentials.prepareClaudeCredentials(
317+
"/Users/demo",
318+
"agent-infra",
319+
[{ tool: { id: "claude-code" } }],
320+
() => null,
321+
() => {
322+
writeCalled = true;
323+
},
324+
() => inspection
325+
), /Claude Code credentials/);
326+
assert.equal(writeCalled, false);
327+
}
297328
});
298329

299-
test("assertClaudeCredentialsAvailable reports keychain locked guidance", async () => {
330+
test("prepareClaudeCredentials reports keychain locked guidance", async () => {
300331
const credentials = await loadFreshEsm<typeof import("../../lib/sandbox/credentials.ts")>("lib/sandbox/credentials.js");
301332

302-
assert.throws(() => credentials.assertClaudeCredentialsAvailable(
333+
assert.throws(() => credentials.prepareClaudeCredentials(
303334
"/Users/demo",
304335
"agent-infra",
305336
[{ tool: { id: "claude-code" } }],

tests/cli/sandbox.test.ts

Lines changed: 18 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -394,45 +394,34 @@ test("sandbox rm defaults local branch deletion confirmation to yes", () => {
394394
);
395395
});
396396

397-
test("sandbox create fails before preparing a temporary Dockerfile when Claude credentials are missing", () => {
397+
test("sandbox create warns and continues past missing Claude credentials", () => {
398398
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-sandbox-create-no-credentials-"));
399-
const repoDir = path.join(tmpDir, "repo");
400-
const homeDir = path.join(tmpDir, "home");
401399
const project = `sandbox-no-leak-${process.pid}-${Date.now()}`;
402400
const dockerfilePrefix = `${project}-sandbox-`;
403401
const existingEntries = new Set(
404402
fs.readdirSync(os.tmpdir()).filter((entry) => entry.startsWith(dockerfilePrefix))
405403
);
406404

407405
try {
408-
fs.mkdirSync(repoDir, { recursive: true });
409-
fs.mkdirSync(homeDir, { recursive: true });
410-
execSync("git init", { cwd: repoDir, env: gitSafeEnv(), stdio: "pipe" });
411-
fs.mkdirSync(path.join(repoDir, ".agents"), { recursive: true });
412-
fs.writeFileSync(
413-
path.join(repoDir, ".agents", ".airc.json"),
414-
JSON.stringify({ project, org: "fitlab-ai" }, null, 2) + "\n",
415-
"utf8"
416-
);
406+
const fixture = writeSandboxEngineFixture(tmpDir, { project });
417407

418-
let commandError: (Error & { stderr?: string | Buffer }) | null = null;
419-
try {
420-
execFileSync(
421-
process.execPath,
422-
cliArgs("sandbox", "create", "feature/no-credentials"),
423-
{
424-
cwd: repoDir,
425-
env: gitSafeEnv({ HOME: homeDir }),
426-
encoding: "utf8",
427-
stdio: ["ignore", "pipe", "pipe"]
428-
}
429-
);
430-
} catch (error) {
431-
commandError = error as Error & { stderr?: string | Buffer };
432-
}
408+
const result = spawnSandboxCli(
409+
fixture,
410+
tmpDir,
411+
["create", "feature/no-credentials"],
412+
{
413+
// Fail at ensureDocker (the first docker call) so the test exits
414+
// quickly on slow CI runners (e.g. Windows). The credential gate
415+
// runs before ensureDocker, so a missing claude credential will
416+
// emit its warning to stderr first.
417+
DOCKER_EXIT_FOR_INFO: "1",
418+
AGENT_INFRA_CLAUDE_CREDENTIALS_FILE: path.join(tmpDir, "missing-claude-credentials.json")
419+
}
420+
);
433421

434-
assert.ok(commandError);
435-
assert.match(String(commandError.stderr ?? ""), /Claude Code credentials not found on host/);
422+
assert.equal(result.signal, null);
423+
assert.notEqual(result.status, 0);
424+
assert.match(`${result.stdout}\n${result.stderr}`, /WITHOUT Claude Code credentials/);
436425

437426
const leakedEntries = fs.readdirSync(os.tmpdir()).filter((entry) => (
438427
entry.startsWith(dockerfilePrefix) && !existingEntries.has(entry)

0 commit comments

Comments
 (0)