@fitlab-ai/agent-infra 0.6.2-alpha.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -777,7 +777,7 @@ The generated `.agents/.airc.json` file is the central contract between the boot
777
777
  "project": "my-project",
778
778
  "org": "my-org",
779
779
  "language": "en",
780
- "templateVersion": "v0.6.1",
780
+ "templateVersion": "v0.6.2",
781
781
  "templates": {
782
782
  "sources": [
783
783
  { "type": "local", "path": "~/private-templates" }
package/README.zh-CN.md CHANGED
@@ -753,7 +753,7 @@ import-issue #42 从 GitHub Issue 导入任务
753
753
  "project": "my-project",
754
754
  "org": "my-org",
755
755
  "language": "en",
756
- "templateVersion": "v0.6.1",
756
+ "templateVersion": "v0.6.2",
757
757
  "templates": {
758
758
  "sources": [
759
759
  { "type": "local", "path": "~/private-templates" }
@@ -18,7 +18,7 @@ import { hostJoin, toEnginePath, volumeArg } from "../engines/wsl2-paths.js";
18
18
  import { validateSelinuxDisableEnv } from "../engines/selinux.js";
19
19
  import { resolveBuildUid } from "../engines/native.js";
20
20
  import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
21
- import { assertClaudeCredentialsAvailable, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
21
+ import { prepareClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
22
22
  const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
23
23
  const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
24
24
  const SANDBOX_ALIAS_BLOCK_END = '# <<< agent-infra managed aliases <<<';
@@ -825,11 +825,13 @@ export async function create(args) {
825
825
  assertBranchAvailable(config.repoRoot, branch, { allowedWorktrees: worktreeCandidates });
826
826
  const tools = resolveTools(effectiveConfig);
827
827
  const resolvedTools = resolveToolDirs(effectiveConfig, tools, branch);
828
- // Fail fast before any filesystem/docker side effects so a missing
829
- // Claude Code credential blob doesn't leave the user with a stale
830
- // worktree, docker image, or temporary Dockerfile they need to manually
831
- // clean up.
832
- assertClaudeCredentialsAvailable(effectiveConfig.home, effectiveConfig.project, resolvedTools);
828
+ // Fatal credential states still fail before filesystem/docker side effects.
829
+ // A genuinely missing Claude Code credential only removes Claude Code's
830
+ // sandbox config and credential mounts for this create run.
831
+ const credentialOutcome = prepareClaudeCredentials(effectiveConfig.home, effectiveConfig.project, resolvedTools);
832
+ const effectiveResolvedTools = credentialOutcome.status === 'SKIPPED'
833
+ ? resolvedTools.filter(({ tool }) => tool.id !== 'claude-code')
834
+ : resolvedTools;
833
835
  const container = containerName(effectiveConfig, branch);
834
836
  const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
835
837
  const shareCommon = shareCommonDir(effectiveConfig);
@@ -840,6 +842,11 @@ export async function create(args) {
840
842
  const engine = detectEngine(effectiveConfig);
841
843
  p.intro(pc.cyan('AI Sandbox'));
842
844
  p.log.info(`Project: ${pc.bold(effectiveConfig.project)} | Branch: ${pc.bold(branch)} | Base: ${pc.bold(baseBranch || 'HEAD')}`);
845
+ if (credentialOutcome.status === 'SKIPPED') {
846
+ p.log.warn('Claude Code credentials not found on host - creating this sandbox WITHOUT Claude Code credentials.\n'
847
+ + ' Claude Code is still installed in the image but will not be authenticated.\n'
848
+ + ' To enable it: run "claude" once on the host to complete login, then re-run "ai sandbox create".');
849
+ }
843
850
  try {
844
851
  p.log.step('Checking container engine...');
845
852
  await ensureDocker(effectiveConfig, (detail) => {
@@ -913,7 +920,7 @@ export async function create(args) {
913
920
  {
914
921
  title: 'Preparing tool state',
915
922
  task: async () => {
916
- for (const { tool, dir } of resolvedTools) {
923
+ for (const { tool, dir } of effectiveResolvedTools) {
917
924
  fs.mkdirSync(dir, { recursive: true });
918
925
  for (const { hostPath, sandboxName } of tool.hostPreSeedFiles ?? []) {
919
926
  const destination = path.join(dir, sandboxName);
@@ -946,7 +953,7 @@ export async function create(args) {
946
953
  fs.writeFileSync(filePath, content, 'utf8');
947
954
  }
948
955
  }
949
- return `${resolvedTools.length} tool config directories ready`;
956
+ return `${effectiveResolvedTools.length} tool config directories ready`;
950
957
  }
951
958
  },
952
959
  {
@@ -978,31 +985,32 @@ export async function create(args) {
978
985
  const cachedGpg = needsGpg
979
986
  ? readGpgCache(effectiveConfig.home, effectiveConfig.project, undefined, signingKey)
980
987
  : null;
981
- const envFile = buildContainerEnvFile(resolvedTools, engine);
988
+ const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
982
989
  let hostShellConfig;
983
990
  try {
984
- const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
991
+ const claudeCodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'claude-code');
985
992
  if (claudeCodeEntry) {
986
993
  ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
987
994
  ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
988
- // Credential availability is asserted up-front in create() so we
989
- // know the shared credentials file already exists at this point.
995
+ // prepareClaudeCredentials wrote the shared credentials file
996
+ // before this point. If credentials were missing, the
997
+ // claude-code entry was removed from effectiveResolvedTools.
990
998
  }
991
- const codexEntry = resolvedTools.find(({ tool }) => tool.id === 'codex');
999
+ const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
992
1000
  if (codexEntry) {
993
1001
  ensureCodexModelInheritance(codexEntry.dir, effectiveConfig.home);
994
1002
  ensureCodexWorkspaceTrust(codexEntry.dir);
995
1003
  }
996
- const geminiEntry = resolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
1004
+ const geminiEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
997
1005
  if (geminiEntry) {
998
1006
  ensureGeminiWorkspaceTrust(geminiEntry.dir);
999
1007
  }
1000
- const opencodeEntry = resolvedTools.find(({ tool }) => tool.id === 'opencode');
1008
+ const opencodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'opencode');
1001
1009
  if (opencodeEntry) {
1002
1010
  // The TUI reads <toolDir>/opencode.json via OPENCODE_CONFIG pinned in tools.js.
1003
1011
  ensureOpenCodeModelInheritance(opencodeEntry.dir, effectiveConfig.home);
1004
1012
  }
1005
- const toolVolumes = resolvedTools.flatMap(({ tool, dir }) => [
1013
+ const toolVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => [
1006
1014
  '-v',
1007
1015
  volumeArg(engine, dir, tool.containerMount)
1008
1016
  ]);
@@ -1017,7 +1025,7 @@ export async function create(args) {
1017
1025
  '-v',
1018
1026
  volumeArg(engine, hostPath, containerPath, ':ro')
1019
1027
  ]);
1020
- const liveMountVolumes = resolvedTools.flatMap(({ tool }) => (tool.hostLiveMounts ?? [])
1028
+ const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) => (tool.hostLiveMounts ?? [])
1021
1029
  .filter(({ hostPath }) => fs.existsSync(hostPath))
1022
1030
  .flatMap(({ hostPath, containerSubpath }) => [
1023
1031
  '-v',
@@ -1105,7 +1113,7 @@ export async function create(args) {
1105
1113
  : 'Host GPG keys unavailable; using stripped git config fallback...');
1106
1114
  }
1107
1115
  }
1108
- for (const { tool } of resolvedTools) {
1116
+ for (const { tool } of effectiveResolvedTools) {
1109
1117
  for (const command of tool.postSetupCmds ?? []) {
1110
1118
  runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
1111
1119
  }
@@ -1143,7 +1151,7 @@ export async function create(args) {
1143
1151
  }
1144
1152
  }
1145
1153
  p.outro(pc.green('Sandbox ready'));
1146
- const toolHints = resolvedTools.map(({ tool, dir }) => {
1154
+ const toolHints = effectiveResolvedTools.map(({ tool, dir }) => {
1147
1155
  const hasLiveMount = (tool.hostLiveMounts ?? []).some(({ hostPath }) => fs.existsSync(hostPath));
1148
1156
  const hint = hasLiveMount
1149
1157
  ? 'Live-mounted auth/config files stay in sync with the host.'
@@ -395,43 +395,62 @@ export function formatRemaining(expiresAt) {
395
395
  const minutes = totalMinutes % 60;
396
396
  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
397
397
  }
398
- export function assertClaudeCredentialsAvailable(home, project, resolvedTools, extractFn = extractClaudeCredentialsBlob, writeFn = writeClaudeCredentialsFile, inspectFn = inspectClaudeKeychainStatus) {
398
+ export function prepareClaudeCredentials(home, project, resolvedTools, extractFn = extractClaudeCredentialsBlob, writeFn = writeClaudeCredentialsFile, inspectFn = inspectClaudeKeychainStatus) {
399
399
  const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
400
400
  if (!claudeCodeEntry) {
401
- return;
401
+ return { status: 'NOT_APPLICABLE' };
402
402
  }
403
403
  let blob = null;
404
404
  const hasCustomInspectFn = inspectFn !== inspectClaudeKeychainStatus;
405
405
  const hasCustomExtractFn = extractFn !== extractClaudeCredentialsBlob;
406
406
  if (hasCustomInspectFn || !hasCustomExtractFn) {
407
407
  const inspection = inspectFn(home);
408
- if (inspection.status === 'KEYCHAIN_LOCKED') {
409
- throw new Error([
410
- 'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
411
- '',
412
- buildLockedGuidance()
413
- ].join('\n'));
408
+ switch (inspection.status) {
409
+ case 'OK':
410
+ blob = inspection.blob;
411
+ break;
412
+ case 'MISSING':
413
+ return { status: 'SKIPPED' };
414
+ case 'KEYCHAIN_LOCKED':
415
+ throw new Error([
416
+ 'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
417
+ '',
418
+ buildLockedGuidance()
419
+ ].join('\n'));
420
+ case 'STALE_ACCESS':
421
+ throw new Error([
422
+ 'Claude Code credentials on host are invalid or expired.',
423
+ '',
424
+ 'The sandbox needs valid Claude Code OAuth credentials so the container can use Claude Code.',
425
+ '',
426
+ 'To fix:',
427
+ ' 1. On the host, run "claude" once and complete the OAuth login flow.',
428
+ ' 2. Verify with "claude /status" that you see your subscription.',
429
+ ' 3. Re-run "ai sandbox create".'
430
+ ].join('\n'));
431
+ case 'KEYCHAIN_ERROR':
432
+ throw new Error([
433
+ 'Claude Code credentials could not be read from the host keychain.',
434
+ '',
435
+ inspection.detail ? `Detail: ${inspection.detail}` : 'Detail: unknown keychain error',
436
+ '',
437
+ 'To fix:',
438
+ ' 1. Unlock or repair the host keychain, then re-run "ai sandbox create".',
439
+ ' 2. For SSH / CI, set AGENT_INFRA_CLAUDE_CREDENTIALS_FILE to an absolute path containing a valid credentials blob.'
440
+ ].join('\n'));
441
+ default: {
442
+ const _exhaustive = inspection;
443
+ throw new Error(`Unhandled Claude Code credential inspection status: ${_exhaustive.status}`);
444
+ }
414
445
  }
415
- blob = inspection.status === 'OK' ? inspection.blob : null;
416
446
  }
417
447
  else {
418
448
  blob = extractFn(home);
419
- }
420
- if (!blob) {
421
- throw new Error([
422
- 'Claude Code credentials not found on host.',
423
- '',
424
- 'The sandbox needs your Claude Code OAuth credentials so the container can use Claude Code.',
425
- '',
426
- 'To fix:',
427
- ' 1. On the host, run "claude" once and complete the OAuth login flow.',
428
- ' 2. Verify with "claude /status" that you see your subscription.',
429
- ' 3. Re-run "ai sandbox create".',
430
- '',
431
- 'Alternatively, if you do not need Claude Code in this sandbox,',
432
- 'remove "claude-code" from the "sandbox.tools" array in .agents/.airc.json.'
433
- ].join('\n'));
449
+ if (!blob) {
450
+ return { status: 'SKIPPED' };
451
+ }
434
452
  }
435
453
  writeFn(home, project, blob);
454
+ return { status: 'OK' };
436
455
  }
437
456
  //# sourceMappingURL=credentials.js.map
package/dist/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.6.2-alpha.1",
3
+ "version": "0.6.2",
4
4
  "type": "module"
5
5
  }
@@ -43,7 +43,7 @@ import { validateSelinuxDisableEnv } from '../engines/selinux.ts';
43
43
  import { resolveBuildUid } from '../engines/native.ts';
44
44
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
45
45
  import {
46
- assertClaudeCredentialsAvailable,
46
+ prepareClaudeCredentials,
47
47
  redactCommandError,
48
48
  validateClaudeCredentialsEnvOverride
49
49
  } from '../credentials.ts';
@@ -1100,15 +1100,17 @@ export async function create(args: string[]): Promise<void> {
1100
1100
  assertBranchAvailable(config.repoRoot, branch, { allowedWorktrees: worktreeCandidates });
1101
1101
  const tools = resolveTools(effectiveConfig);
1102
1102
  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(
1108
1107
  effectiveConfig.home,
1109
1108
  effectiveConfig.project,
1110
1109
  resolvedTools
1111
1110
  );
1111
+ const effectiveResolvedTools = credentialOutcome.status === 'SKIPPED'
1112
+ ? resolvedTools.filter(({ tool }) => tool.id !== 'claude-code')
1113
+ : resolvedTools;
1112
1114
  const container = containerName(effectiveConfig, branch);
1113
1115
  const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
1114
1116
  const shareCommon = shareCommonDir(effectiveConfig);
@@ -1122,6 +1124,13 @@ export async function create(args: string[]): Promise<void> {
1122
1124
  p.log.info(
1123
1125
  `Project: ${pc.bold(effectiveConfig.project)} | Branch: ${pc.bold(branch)} | Base: ${pc.bold(baseBranch || 'HEAD')}`
1124
1126
  );
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
+ }
1125
1134
 
1126
1135
  try {
1127
1136
  p.log.step('Checking container engine...');
@@ -1206,7 +1215,7 @@ export async function create(args: string[]): Promise<void> {
1206
1215
  {
1207
1216
  title: 'Preparing tool state',
1208
1217
  task: async () => {
1209
- for (const { tool, dir } of resolvedTools) {
1218
+ for (const { tool, dir } of effectiveResolvedTools) {
1210
1219
  fs.mkdirSync(dir, { recursive: true });
1211
1220
 
1212
1221
  for (const { hostPath, sandboxName } of tool.hostPreSeedFiles ?? []) {
@@ -1243,7 +1252,7 @@ export async function create(args: string[]): Promise<void> {
1243
1252
  }
1244
1253
  }
1245
1254
 
1246
- return `${resolvedTools.length} tool config directories ready`;
1255
+ return `${effectiveResolvedTools.length} tool config directories ready`;
1247
1256
  }
1248
1257
  },
1249
1258
  {
@@ -1283,31 +1292,32 @@ export async function create(args: string[]): Promise<void> {
1283
1292
  signingKey
1284
1293
  )
1285
1294
  : null;
1286
- const envFile = buildContainerEnvFile(resolvedTools, engine);
1295
+ const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
1287
1296
  let hostShellConfig: HostShellConfig;
1288
1297
  try {
1289
- const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
1298
+ const claudeCodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'claude-code');
1290
1299
  if (claudeCodeEntry) {
1291
1300
  ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
1292
1301
  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.
1295
1305
  }
1296
- const codexEntry = resolvedTools.find(({ tool }) => tool.id === 'codex');
1306
+ const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
1297
1307
  if (codexEntry) {
1298
1308
  ensureCodexModelInheritance(codexEntry.dir, effectiveConfig.home);
1299
1309
  ensureCodexWorkspaceTrust(codexEntry.dir);
1300
1310
  }
1301
- const geminiEntry = resolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
1311
+ const geminiEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
1302
1312
  if (geminiEntry) {
1303
1313
  ensureGeminiWorkspaceTrust(geminiEntry.dir);
1304
1314
  }
1305
- const opencodeEntry = resolvedTools.find(({ tool }) => tool.id === 'opencode');
1315
+ const opencodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'opencode');
1306
1316
  if (opencodeEntry) {
1307
1317
  // The TUI reads <toolDir>/opencode.json via OPENCODE_CONFIG pinned in tools.js.
1308
1318
  ensureOpenCodeModelInheritance(opencodeEntry.dir, effectiveConfig.home);
1309
1319
  }
1310
- const toolVolumes = resolvedTools.flatMap(({ tool, dir }) => [
1320
+ const toolVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => [
1311
1321
  '-v',
1312
1322
  volumeArg(engine, dir, tool.containerMount)
1313
1323
  ]);
@@ -1322,7 +1332,7 @@ export async function create(args: string[]): Promise<void> {
1322
1332
  '-v',
1323
1333
  volumeArg(engine, hostPath, containerPath, ':ro')
1324
1334
  ]);
1325
- const liveMountVolumes = resolvedTools.flatMap(({ tool }) =>
1335
+ const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) =>
1326
1336
  (tool.hostLiveMounts ?? [])
1327
1337
  .filter(({ hostPath }) => fs.existsSync(hostPath))
1328
1338
  .flatMap(({ hostPath, containerSubpath }) => [
@@ -1435,7 +1445,7 @@ export async function create(args: string[]): Promise<void> {
1435
1445
  }
1436
1446
  }
1437
1447
 
1438
- for (const { tool } of resolvedTools) {
1448
+ for (const { tool } of effectiveResolvedTools) {
1439
1449
  for (const command of tool.postSetupCmds ?? []) {
1440
1450
  runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
1441
1451
  }
@@ -1477,7 +1487,7 @@ export async function create(args: string[]): Promise<void> {
1477
1487
 
1478
1488
  p.outro(pc.green('Sandbox ready'));
1479
1489
 
1480
- const toolHints = resolvedTools.map(({ tool, dir }) => {
1490
+ const toolHints = effectiveResolvedTools.map(({ tool, dir }) => {
1481
1491
  const hasLiveMount = (tool.hostLiveMounts ?? []).some(({ hostPath }) => fs.existsSync(hostPath));
1482
1492
  const hint = hasLiveMount
1483
1493
  ? 'Live-mounted auth/config files stay in sync with the host.'
@@ -92,6 +92,11 @@ type ResolvedTool = {
92
92
  };
93
93
  };
94
94
 
95
+ export type ClaudeCredentialOutcome =
96
+ | { status: 'OK' }
97
+ | { status: 'SKIPPED' }
98
+ | { status: 'NOT_APPLICABLE' };
99
+
95
100
  type CredentialPayload = {
96
101
  claudeAiOauth?: CredentialPayload;
97
102
  scopes?: unknown;
@@ -584,17 +589,17 @@ export function formatRemaining(expiresAt: unknown): string {
584
589
  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
585
590
  }
586
591
 
587
- export function assertClaudeCredentialsAvailable(
592
+ export function prepareClaudeCredentials(
588
593
  home: string,
589
594
  project: string,
590
595
  resolvedTools: ResolvedTool[],
591
596
  extractFn: (home: string) => string | null = extractClaudeCredentialsBlob,
592
597
  writeFn: (home: string, project: string, blob: string) => void = writeClaudeCredentialsFile,
593
598
  inspectFn: (home: string) => CredentialInspection = inspectClaudeKeychainStatus
594
- ): void {
599
+ ): ClaudeCredentialOutcome {
595
600
  const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
596
601
  if (!claudeCodeEntry) {
597
- return;
602
+ return { status: 'NOT_APPLICABLE' };
598
603
  }
599
604
 
600
605
  let blob: string | null = null;
@@ -602,33 +607,51 @@ export function assertClaudeCredentialsAvailable(
602
607
  const hasCustomExtractFn = extractFn !== extractClaudeCredentialsBlob;
603
608
  if (hasCustomInspectFn || !hasCustomExtractFn) {
604
609
  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
+ }
611
647
  }
612
- blob = inspection.status === 'OK' ? inspection.blob : null;
613
648
  } else {
614
649
  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
+ }
631
653
  }
632
654
 
633
655
  writeFn(home, project, blob);
656
+ return { status: 'OK' };
634
657
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.6.2-alpha.1",
3
+ "version": "0.6.2",
4
4
  "description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Gemini CLI, and OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -149,13 +149,13 @@ gh api "repos/$upstream_repo/issues/{issue-number}" -X PATCH \
149
149
 
150
150
  Failure is non-blocking.
151
151
 
152
- ### 6.5 Set Issue Fields (Optional)
152
+ ### 7. Set Issue Fields (Optional)
153
153
 
154
154
  If `has_push=true`, read `.agents/rules/issue-fields.md` and follow Flow A to write any applicable non-empty `priority`, `effort`, `start_date`, and `target_date` values from `task.md`.
155
155
 
156
156
  Field write failures are non-blocking.
157
157
 
158
- ### 7. Write Back task.md
158
+ ### 8. Write Back task.md
159
159
 
160
160
  Update task.md:
161
161
 
@@ -164,7 +164,7 @@ Update task.md:
164
164
 
165
165
  > Do NOT append an Activity Log entry here. The Issue creation event is already captured by the GitHub Issue itself and by the frontmatter `issue_number` field; the Activity Log only records the single `create-task` skill execution anchor (`Task Created`), written by the caller SKILL step 3.
166
166
 
167
- ### 8. Return the Result
167
+ ### 9. Return the Result
168
168
 
169
169
  Hand the following back to the caller `create-task`:
170
170
 
@@ -149,13 +149,13 @@ gh api "repos/$upstream_repo/issues/{issue-number}" -X PATCH \
149
149
 
150
150
  设置失败不阻断流程。
151
151
 
152
- ### 6.5 设置 Issue 字段(可选)
152
+ ### 7. 设置 Issue 字段(可选)
153
153
 
154
154
  如果 `has_push=true`,读取 `.agents/rules/issue-fields.md`,按流程 A 写入 `task.md` 中适用且非空的 `priority`、`effort`、`start_date` 和 `target_date`。
155
155
 
156
156
  字段写入失败不阻断流程。
157
157
 
158
- ### 7. 回写 task.md
158
+ ### 8. 回写 task.md
159
159
 
160
160
  更新 task.md:
161
161
 
@@ -164,7 +164,7 @@ gh api "repos/$upstream_repo/issues/{issue-number}" -X PATCH \
164
164
 
165
165
  > 不要在此追加 Activity Log 条目。Issue 创建事件已由 GitHub Issue 自身和 frontmatter `issue_number` 承载;Activity Log 仅记录 `create-task` skill 一次执行的整体锚点(`Task Created`),由调用方 SKILL 步骤 3 写入。
166
166
 
167
- ### 8. 返回结果
167
+ ### 9. 返回结果
168
168
 
169
169
  把以下信息回传给调用方 `create-task`:
170
170
 
@@ -49,6 +49,8 @@ If `task.md` contains these source fields, also read the corresponding source in
49
49
 
50
50
  ### 4. Perform Requirements Analysis
51
51
 
52
+ Before analysis begins: if `start_date` in the frontmatter is empty, write today's date immediately (command: `date +%F`, format `YYYY-MM-DD`); keep any existing value. Before writing, read `.agents/rules/version-stamp.md` and refresh `updated_at` / `agent_infra_version` at the same time.
53
+
52
54
  Follow the `analysis` step in `.agents/workflows/feature-development.yaml`:
53
55
 
54
56
  **Required tasks** (analysis only, no business code changes):
@@ -126,6 +128,7 @@ If task.md contains a valid `issue_number`, perform these sync actions (skip and
126
128
  - Set `status: pending-design-work` by following issue-sync.md
127
129
  - Create or update the task comment marker defined in `.agents/rules/issue-sync.md` (follow the task.md comment sync rule in issue-sync.md)
128
130
  - Publish the `{analysis-artifact}` comment
131
+ - Read `.agents/rules/issue-fields.md` and follow Flow A to sync every non-empty Issue field (`priority`/`effort`/`start_date`/`target_date`) from `task.md` to the Issue (idempotent; skip without blocking when `has_push=false` or the fetch/write fails)
129
132
 
130
133
  ### 7. Verification Gate
131
134
 
@@ -49,6 +49,8 @@ description: "分析任务并输出需求分析文档"
49
49
 
50
50
  ### 4. 执行需求分析
51
51
 
52
+ 开始分析前:若 frontmatter 的 `start_date` 为空,立即写入当日日期(命令 `date +%F`,格式 `YYYY-MM-DD`);已有值则保留。写入前先读取 `.agents/rules/version-stamp.md`,并同步刷新 `updated_at` / `agent_infra_version`。
53
+
52
54
  遵循 `.agents/workflows/feature-development.yaml` 中的 `analysis` 步骤:
53
55
 
54
56
  **必要任务**(仅分析,不编写业务代码):
@@ -126,6 +128,7 @@ date "+%Y-%m-%d %H:%M:%S%:z"
126
128
  - 按 issue-sync.md 设置 `status: pending-design-work`
127
129
  - 创建或更新 `.agents/rules/issue-sync.md` 中定义的 task 评论标记(按 issue-sync.md 的 task.md 评论同步规则)
128
130
  - 发布 `{analysis-artifact}` 评论
131
+ - 读取 `.agents/rules/issue-fields.md`,按流程 A 把 `task.md` 中所有非空的 Issue 字段(`priority`/`effort`/`start_date`/`target_date`)同步到 Issue(幂等;`has_push=false` 或取数/写入失败时跳过,不阻断)
129
132
 
130
133
  ### 7. 完成校验
131
134
 
@@ -98,6 +98,7 @@ If a valid `issue_number` exists:
98
98
  - Backfill checked `## Requirements` items to the Issue body by following the requirements-checkbox sync steps in issue-sync.md
99
99
  - Do not set any `status:` label — status labels are automatically cleared when the Issue is closed
100
100
  - Finally create or update the summary comment marked with the summary marker defined in `.agents/rules/issue-sync.md`
101
+ - Read `.agents/rules/issue-fields.md` and follow Flow A to sync every non-empty Issue field (`priority`/`effort`/`start_date`/`target_date`) from `task.md` to the Issue (idempotent; skip without blocking when `has_push=false` or the fetch/write fails)
101
102
 
102
103
  ### 7. Verification Gate
103
104
 
@@ -98,6 +98,7 @@ ls .agents/workspace/completed/{task-id}/task.md
98
98
  - 按 issue-sync.md 的需求复选框同步步骤,兜底同步 `## 需求` 中已勾选的条目到 Issue body
99
99
  - 不要设置 `status:` label — Issue 关闭后 status label 会被自动清除
100
100
  - 最后创建或更新 `.agents/rules/issue-sync.md` 中定义的 summary 评论标记对应的 summary 评论
101
+ - 读取 `.agents/rules/issue-fields.md`,按流程 A 把 `task.md` 中所有非空的 Issue 字段(`priority`/`effort`/`start_date`/`target_date`)同步到 Issue(幂等;`has_push=false` 或取数/写入失败时跳过,不阻断)
101
102
 
102
103
  ### 7. 完成校验
103
104
 
@@ -24,6 +24,7 @@
24
24
  "verify_in_labels_match_pr": true,
25
25
  "verify_pr_type_label": true,
26
26
  "verify_pr_assignee": true,
27
+ "verify_issue_fields": true,
27
28
  "verify_milestone": true,
28
29
  "expected_pr_comment_marker_key": "prSummary"
29
30
  }
@@ -78,8 +78,8 @@ created_at: {YYYY-MM-DD HH:mm:ss±HH:MM}
78
78
  updated_at: {YYYY-MM-DD HH:mm:ss±HH:MM}
79
79
  agent_infra_version: {agent_infra_version}
80
80
  created_by: human
81
- priority: # optional; Urgent | High | Medium | Low
82
- effort: # optional; High | Medium | Low
81
+ priority: # required; inferred by the AI from the title/description; Urgent | High | Medium | Low
82
+ effort: # required; inferred by the AI from the title/description; High | Medium | Low
83
83
  start_date: # optional; YYYY-MM-DD
84
84
  target_date: # optional; YYYY-MM-DD
85
85
  current_step: requirement-analysis
@@ -87,7 +87,7 @@ assigned_to: {current AI agent}
87
87
  ```
88
88
 
89
89
  Note: `created_by` is `human` because the task comes from the user's description.
90
- Optional Issue field metadata may be left empty at task creation; do not invent dates.
90
+ priority / effort are required: the AI infers them from the task title and description (candidates in `.agents/rules/issue-fields.md`; normalize localized input). Leave start_date / target_date empty at creation - analyze-task / plan-task fill them later; do not invent dates.
91
91
 
92
92
  ### 3. Update Task Status
93
93
 
@@ -78,8 +78,8 @@ created_at: {YYYY-MM-DD HH:mm:ss±HH:MM}
78
78
  updated_at: {YYYY-MM-DD HH:mm:ss±HH:MM}
79
79
  agent_infra_version: {agent_infra_version}
80
80
  created_by: human
81
- priority: # 可选;Urgent | High | Medium | Low
82
- effort: # 可选;High | Medium | Low
81
+ priority: # 必填;由 AI 从标题/描述推断;Urgent | High | Medium | Low
82
+ effort: # 必填;由 AI 从标题/描述推断;High | Medium | Low
83
83
  start_date: # 可选;YYYY-MM-DD
84
84
  target_date: # 可选;YYYY-MM-DD
85
85
  current_step: requirement-analysis
@@ -87,7 +87,7 @@ assigned_to: {当前 AI 代理}
87
87
  ```
88
88
 
89
89
  注意:`created_by` 为 `human`,因为任务来源于用户的描述。
90
- 可选 Issue 字段元数据在创建任务时可留空;不要臆测日期。
90
+ priority / effort 必填:由 AI 从任务标题与描述推断后填入(候选值见 `.agents/rules/issue-fields.md`;中文输入按本地化映射规范化)。start_date / target_date 创建时保持留空,由 analyze-task / plan-task 阶段填入;不要臆测日期。
91
91
 
92
92
  ### 3. 更新任务状态
93
93
 
@@ -91,6 +91,7 @@ Update `.agents/workspace/active/{task-id}/task.md`:
91
91
  - `assigned_to`: {current AI agent}
92
92
  - `updated_at`: {current time}
93
93
  - `agent_infra_version`: value from `.agents/rules/version-stamp.md`
94
+ - If `target_date` is empty, write an estimated completion date based on the effort estimate (`YYYY-MM-DD`); leave it empty without blocking when no reasonable estimate exists; keep any existing value
94
95
  - Record the plan artifact for this round: `{plan-artifact}` (Round `{plan-round}`)
95
96
  - If the task template contains a `## Design` section, update it to link to `{plan-artifact}`
96
97
  - Mark technical-design as complete in workflow progress and include the actual round when the task template supports it
@@ -104,6 +105,7 @@ If task.md contains a valid `issue_number`, perform these sync actions (skip and
104
105
  - Set `status: pending-design-work` by following issue-sync.md
105
106
  - Create or update the task comment marker defined in `.agents/rules/issue-sync.md` (follow the task.md comment sync rule in issue-sync.md)
106
107
  - Publish the `{plan-artifact}` comment
108
+ - Read `.agents/rules/issue-fields.md` and follow Flow A to sync every non-empty Issue field (`priority`/`effort`/`start_date`/`target_date`) from `task.md` to the Issue (idempotent; skip without blocking when `has_push=false` or the fetch/write fails)
107
109
 
108
110
  ### 8. Verification Gate
109
111
 
@@ -91,6 +91,7 @@ date "+%Y-%m-%d %H:%M:%S%:z"
91
91
  - `assigned_to`:{当前 AI 代理}
92
92
  - `updated_at`:{当前时间}
93
93
  - `agent_infra_version`:按 `.agents/rules/version-stamp.md` 取值
94
+ - 若 `target_date` 为空,基于工作量评估写入预估完成日(`YYYY-MM-DD`);无法合理预估时保持留空、不阻塞;已有值则保留
94
95
  - 记录本轮方案产物:`{plan-artifact}`(Round `{plan-round}`)
95
96
  - 如任务模板包含 `## 设计` 段落,更新为指向 `{plan-artifact}` 的链接
96
97
  - 在工作流进度中标记 technical-design 为已完成,并注明实际轮次(如果任务模板支持)
@@ -104,6 +105,7 @@ date "+%Y-%m-%d %H:%M:%S%:z"
104
105
  - 按 issue-sync.md 设置 `status: pending-design-work`
105
106
  - 创建或更新 `.agents/rules/issue-sync.md` 中定义的 task 评论标记(按 issue-sync.md 的 task.md 评论同步规则)
106
107
  - 发布 `{plan-artifact}` 评论
108
+ - 读取 `.agents/rules/issue-fields.md`,按流程 A 把 `task.md` 中所有非空的 Issue 字段(`priority`/`effort`/`start_date`/`target_date`)同步到 Issue(幂等;`has_push=false` 或取数/写入失败时跳过,不阻断)
107
109
 
108
110
  ### 8. 完成校验
109
111