@fitlab-ai/agent-infra 0.5.9 → 0.6.0

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.
Files changed (100) hide show
  1. package/README.md +200 -8
  2. package/README.zh-CN.md +176 -8
  3. package/bin/{cli.js → cli.ts} +23 -19
  4. package/dist/bin/cli.js +116 -0
  5. package/dist/lib/defaults.json +61 -0
  6. package/dist/lib/init.js +238 -0
  7. package/dist/lib/log.js +18 -0
  8. package/dist/lib/merge.js +747 -0
  9. package/dist/lib/paths.js +18 -0
  10. package/dist/lib/prompt.js +85 -0
  11. package/dist/lib/render.js +139 -0
  12. package/dist/lib/sandbox/commands/create.js +1173 -0
  13. package/dist/lib/sandbox/commands/enter.js +98 -0
  14. package/dist/lib/sandbox/commands/ls.js +93 -0
  15. package/dist/lib/sandbox/commands/rebuild.js +101 -0
  16. package/dist/lib/sandbox/commands/refresh.js +85 -0
  17. package/dist/lib/sandbox/commands/rm.js +226 -0
  18. package/dist/lib/sandbox/commands/vm.js +144 -0
  19. package/dist/lib/sandbox/config.js +85 -0
  20. package/dist/lib/sandbox/constants.js +104 -0
  21. package/dist/lib/sandbox/credentials.js +437 -0
  22. package/dist/lib/sandbox/dockerfile.js +76 -0
  23. package/dist/lib/sandbox/dotfiles.js +170 -0
  24. package/dist/lib/sandbox/engine.js +155 -0
  25. package/dist/lib/sandbox/engines/colima.js +64 -0
  26. package/dist/lib/sandbox/engines/docker-desktop.js +27 -0
  27. package/dist/lib/sandbox/engines/index.js +25 -0
  28. package/dist/lib/sandbox/engines/native.js +96 -0
  29. package/dist/lib/sandbox/engines/orbstack.js +63 -0
  30. package/dist/lib/sandbox/engines/selinux.js +48 -0
  31. package/dist/lib/sandbox/engines/wsl2-paths.js +47 -0
  32. package/dist/lib/sandbox/engines/wsl2.js +57 -0
  33. package/dist/lib/sandbox/index.js +70 -0
  34. package/dist/lib/sandbox/runtimes/ai-tools.dockerfile +39 -0
  35. package/dist/lib/sandbox/runtimes/base.dockerfile +178 -0
  36. package/dist/lib/sandbox/runtimes/java17.dockerfile +3 -0
  37. package/dist/lib/sandbox/runtimes/java21.dockerfile +3 -0
  38. package/dist/lib/sandbox/runtimes/node20.dockerfile +3 -0
  39. package/dist/lib/sandbox/runtimes/node22.dockerfile +3 -0
  40. package/dist/lib/sandbox/runtimes/python3.dockerfile +3 -0
  41. package/dist/lib/sandbox/shell.js +148 -0
  42. package/dist/lib/sandbox/task-resolver.js +35 -0
  43. package/dist/lib/sandbox/tools.js +115 -0
  44. package/dist/lib/update.js +186 -0
  45. package/dist/lib/version.js +5 -0
  46. package/dist/package.json +5 -0
  47. package/lib/{init.js → init.ts} +64 -20
  48. package/lib/{log.js → log.ts} +4 -4
  49. package/lib/{merge.js → merge.ts} +129 -63
  50. package/lib/paths.ts +18 -0
  51. package/lib/{prompt.js → prompt.ts} +12 -12
  52. package/lib/{render.js → render.ts} +30 -17
  53. package/lib/sandbox/commands/create.ts +1507 -0
  54. package/lib/sandbox/commands/enter.ts +115 -0
  55. package/lib/sandbox/commands/{ls.js → ls.ts} +41 -10
  56. package/lib/sandbox/commands/rebuild.ts +135 -0
  57. package/lib/sandbox/commands/refresh.ts +128 -0
  58. package/lib/sandbox/commands/{rm.js → rm.ts} +71 -21
  59. package/lib/sandbox/commands/{vm.js → vm.ts} +62 -15
  60. package/lib/sandbox/config.ts +133 -0
  61. package/lib/sandbox/{constants.js → constants.ts} +41 -17
  62. package/lib/sandbox/credentials.ts +634 -0
  63. package/lib/sandbox/{dockerfile.js → dockerfile.ts} +13 -6
  64. package/lib/sandbox/dotfiles.ts +236 -0
  65. package/lib/sandbox/engine.ts +231 -0
  66. package/lib/sandbox/engines/colima.ts +81 -0
  67. package/lib/sandbox/engines/docker-desktop.ts +36 -0
  68. package/lib/sandbox/engines/index.ts +74 -0
  69. package/lib/sandbox/engines/native.ts +131 -0
  70. package/lib/sandbox/engines/orbstack.ts +78 -0
  71. package/lib/sandbox/engines/selinux.ts +66 -0
  72. package/lib/sandbox/engines/wsl2-paths.ts +65 -0
  73. package/lib/sandbox/engines/wsl2.ts +74 -0
  74. package/lib/sandbox/{index.js → index.ts} +17 -8
  75. package/lib/sandbox/runtimes/ai-tools.dockerfile +14 -1
  76. package/lib/sandbox/runtimes/base.dockerfile +116 -1
  77. package/lib/sandbox/shell.ts +186 -0
  78. package/lib/sandbox/{task-resolver.js → task-resolver.ts} +6 -6
  79. package/lib/sandbox/{tools.js → tools.ts} +33 -29
  80. package/lib/{update.js → update.ts} +33 -10
  81. package/package.json +22 -12
  82. package/templates/.agents/rules/create-issue.github.en.md +2 -4
  83. package/templates/.agents/rules/create-issue.github.zh-CN.md +2 -4
  84. package/templates/.agents/rules/issue-pr-commands.github.en.md +29 -0
  85. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +29 -0
  86. package/templates/.agents/scripts/{platform-adapters/find-existing-task.github.js → find-existing-task.js} +22 -79
  87. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +26 -41
  88. package/templates/.agents/skills/create-task/SKILL.en.md +1 -1
  89. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
  90. package/templates/.agents/skills/import-issue/SKILL.en.md +6 -8
  91. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +6 -8
  92. package/lib/paths.js +0 -9
  93. package/lib/sandbox/commands/create.js +0 -1174
  94. package/lib/sandbox/commands/enter.js +0 -79
  95. package/lib/sandbox/commands/rebuild.js +0 -102
  96. package/lib/sandbox/config.js +0 -84
  97. package/lib/sandbox/engine.js +0 -256
  98. package/lib/sandbox/shell.js +0 -122
  99. package/templates/.agents/scripts/platform-adapters/find-existing-task.js +0 -5
  100. /package/lib/{version.js → version.ts} +0 -0
@@ -0,0 +1,634 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { hostJoin } from './engines/wsl2-paths.ts';
6
+
7
+ type ExecFn = (file: string, args: string[], options?: Record<string, unknown>) => string | Buffer | void;
8
+ type ReadFn = (targetPath: string) => string;
9
+ type ExistsFn = (targetPath: string) => boolean;
10
+ type EnvFn = () => NodeJS.ProcessEnv;
11
+
12
+ type SecurityClassification = 'OK' | 'LOCKED' | 'NOT_FOUND' | 'OTHER';
13
+
14
+ type SecurityResult =
15
+ | { ok: true; stdout: string; stderr: ''; classification: 'OK' }
16
+ | { ok: false; stdout: ''; stderr: string; classification: Exclude<SecurityClassification, 'OK'> };
17
+
18
+ type CredentialStatus = 'OK'
19
+ | 'MISSING'
20
+ | 'STALE_ACCESS'
21
+ | 'KEYCHAIN_LOCKED'
22
+ | 'KEYCHAIN_ERROR'
23
+ | 'KEYCHAIN_WRITE_FAILED';
24
+
25
+ type CredentialInspection =
26
+ | { status: 'OK'; blob: string; expiresAt: unknown }
27
+ | { status: Exclude<CredentialStatus, 'OK' | 'KEYCHAIN_WRITE_FAILED'>; blob?: undefined; expiresAt?: unknown; detail?: string };
28
+
29
+ type CredentialEndpoint = CredentialInspection & {
30
+ name: string;
31
+ project?: string;
32
+ };
33
+
34
+ type OkCredentialEndpoint = CredentialEndpoint & {
35
+ status: 'OK';
36
+ blob: string;
37
+ };
38
+ type ExpiringOkCredentialEndpoint = OkCredentialEndpoint & {
39
+ expiresAt: number;
40
+ };
41
+
42
+ type Warning = {
43
+ source?: string;
44
+ classification?: string;
45
+ message?: string;
46
+ };
47
+
48
+ type WriteResult =
49
+ | { ok: true; classification?: undefined; error?: undefined }
50
+ | { ok: false; classification: Exclude<SecurityClassification, 'OK'>; error: string };
51
+
52
+ type InspectOptions = {
53
+ readFn?: ReadFn;
54
+ existsFn?: ExistsFn;
55
+ envFn?: EnvFn;
56
+ };
57
+
58
+ type WriteHostOptions = {
59
+ execFn?: ExecFn;
60
+ mkdirFn?: typeof fs.mkdirSync;
61
+ chmodFn?: typeof fs.chmodSync;
62
+ writeFileFn?: typeof fs.writeFileSync;
63
+ renameFn?: typeof fs.renameSync;
64
+ rmFn?: typeof fs.rmSync;
65
+ randomFn?: () => string;
66
+ envFn?: EnvFn;
67
+ };
68
+
69
+ type ReconcileOptions = InspectOptions & {
70
+ execFn?: ExecFn;
71
+ writeFn?: (home: string, project: string, blob: string) => void;
72
+ writeHostFn?: (home: string, blob: string, options?: WriteHostOptions) => WriteResult;
73
+ discoverFn?: (home: string) => string[];
74
+ projects?: string[] | null;
75
+ singleProject?: string | null;
76
+ inspection?: CredentialInspection | null;
77
+ };
78
+ type ReconcileResult = {
79
+ status: CredentialStatus;
80
+ authoritative: string | null;
81
+ expiresAt: unknown;
82
+ hostWritten: boolean;
83
+ filesWritten: string[];
84
+ fileErrors: Array<{ project: string; error: string }>;
85
+ warnings: Warning[];
86
+ detail?: string | null;
87
+ };
88
+
89
+ type ResolvedTool = {
90
+ tool: {
91
+ id: string;
92
+ };
93
+ };
94
+
95
+ type CredentialPayload = {
96
+ claudeAiOauth?: CredentialPayload;
97
+ scopes?: unknown;
98
+ accessToken?: unknown;
99
+ refreshToken?: unknown;
100
+ expiresAt?: unknown;
101
+ };
102
+
103
+ const LOCKED_PATTERN = /errSecInteractionNotAllowed|User interaction is not allowed/i;
104
+ const NOT_FOUND_PATTERN = /errSecItemNotFound|specified item could not be found/i;
105
+ const REDACTION_PATTERNS = [
106
+ { pattern: /\{[^{}]*"claudeAiOauth"[\s\S]*?\}\s*\}/g, replacement: '[REDACTED credentials blob]' },
107
+ { pattern: /sk-ant-[A-Za-z0-9_-]{20,}/g, replacement: '[REDACTED claude token]' },
108
+ { pattern: /gh[psoru]_[A-Za-z0-9]{30,}/g, replacement: '[REDACTED github token]' },
109
+ { pattern: /Bearer\s+[A-Za-z0-9._~+/=-]{20,}/gi, replacement: 'Bearer [REDACTED]' }
110
+ ];
111
+
112
+ function errorMessage(error: unknown): string {
113
+ return error instanceof Error ? error.message : 'unknown error';
114
+ }
115
+
116
+ export function redactCommandError(text: unknown): string {
117
+ if (!text || typeof text !== 'string') {
118
+ return '';
119
+ }
120
+
121
+ return REDACTION_PATTERNS.reduce(
122
+ (result, { pattern, replacement }) => result.replace(pattern, replacement),
123
+ text
124
+ );
125
+ }
126
+
127
+ export const redactSecurityOutput = redactCommandError;
128
+
129
+ function extractStderrSafely(error: unknown): string {
130
+ const stderr = typeof error === 'object' && error !== null && 'stderr' in error
131
+ ? error.stderr
132
+ : undefined;
133
+ if (Buffer.isBuffer(stderr)) {
134
+ return stderr.toString('utf8');
135
+ }
136
+ if (typeof stderr === 'string') {
137
+ return stderr;
138
+ }
139
+ return '';
140
+ }
141
+
142
+ function classifySecurityFailure(text: string): Exclude<SecurityClassification, 'OK'> {
143
+ if (LOCKED_PATTERN.test(text)) {
144
+ return 'LOCKED';
145
+ }
146
+ if (NOT_FOUND_PATTERN.test(text)) {
147
+ return 'NOT_FOUND';
148
+ }
149
+ return 'OTHER';
150
+ }
151
+
152
+ function runSecurity(args: string[], options: Record<string, unknown> = {}, execFn: ExecFn = execFileSync): SecurityResult {
153
+ try {
154
+ const stdout = execFn('security', args, {
155
+ encoding: 'utf8',
156
+ stdio: ['ignore', 'pipe', 'pipe'],
157
+ ...options
158
+ });
159
+ const text = typeof stdout === 'string'
160
+ ? stdout
161
+ : (Buffer.isBuffer(stdout) ? stdout.toString('utf8') : '');
162
+ return { ok: true, stdout: text, stderr: '', classification: 'OK' };
163
+ } catch (error) {
164
+ const stderr = redactCommandError(extractStderrSafely(error));
165
+ return {
166
+ ok: false,
167
+ stdout: '',
168
+ stderr,
169
+ classification: classifySecurityFailure(stderr)
170
+ };
171
+ }
172
+ }
173
+
174
+ export function buildLockedGuidance(): string {
175
+ return [
176
+ 'macOS keychain is locked (errSecInteractionNotAllowed).',
177
+ 'Options to recover:',
178
+ ' 1. Unlock the keychain on the host:',
179
+ ' security unlock-keychain ~/Library/Keychains/login.keychain-db',
180
+ ' Then re-run "ai sandbox refresh".',
181
+ ' 2. Bypass the keychain via an environment variable (recommended for SSH / CI):',
182
+ ' macOS stores Claude Code credentials in the keychain by default, so',
183
+ ' the env-override file must be seeded once before use.',
184
+ ' On a session where the keychain is unlocked, run:',
185
+ ' security unlock-keychain ~/Library/Keychains/login.keychain-db',
186
+ ' umask 077 && mkdir -p "$HOME/.agent-infra" && \\',
187
+ ' security find-generic-password -s "Claude Code-credentials" -w \\',
188
+ ' > "$HOME/.agent-infra/claude-credentials.json"',
189
+ ' chmod 600 "$HOME/.agent-infra/claude-credentials.json"',
190
+ ' Then on the SSH / CI side:',
191
+ ' export AGENT_INFRA_CLAUDE_CREDENTIALS_FILE="$HOME/.agent-infra/claude-credentials.json"',
192
+ ' ai sandbox refresh',
193
+ ' Subsequent reads/writes use that file instead of the keychain.'
194
+ ].join('\n');
195
+ }
196
+
197
+ export function claudeCredentialsEnvOverride(env: NodeJS.ProcessEnv = process.env): { path: string; source: string } | null {
198
+ const raw = env?.AGENT_INFRA_CLAUDE_CREDENTIALS_FILE;
199
+ if (!raw || typeof raw !== 'string') {
200
+ return null;
201
+ }
202
+ return { path: raw, source: 'AGENT_INFRA_CLAUDE_CREDENTIALS_FILE' };
203
+ }
204
+
205
+ export function validateClaudeCredentialsEnvOverride(env: NodeJS.ProcessEnv = process.env): void {
206
+ const raw = env?.AGENT_INFRA_CLAUDE_CREDENTIALS_FILE;
207
+ if (raw === undefined || raw === '') {
208
+ return;
209
+ }
210
+ if (typeof raw !== 'string' || !path.isAbsolute(raw)) {
211
+ throw new Error(
212
+ 'Invalid AGENT_INFRA_CLAUDE_CREDENTIALS_FILE value. Expected an absolute file path.'
213
+ );
214
+ }
215
+ }
216
+
217
+ // Reconcile treats the freshest valid endpoint as authoritative so sandbox
218
+ // token rotations can flow back to the host credential store.
219
+ function validateClaudeCredentialsBlob(raw: unknown, blob: string | null = null): CredentialInspection {
220
+ const trimmed = typeof raw === 'string' ? raw.trim() : '';
221
+ if (!trimmed) {
222
+ return { status: 'MISSING' };
223
+ }
224
+
225
+ let parsed: CredentialPayload;
226
+ try {
227
+ parsed = JSON.parse(trimmed) as CredentialPayload;
228
+ } catch {
229
+ return { status: 'STALE_ACCESS' };
230
+ }
231
+
232
+ const payload = parsed?.claudeAiOauth ?? parsed;
233
+ const scopes = Array.isArray(payload?.scopes) ? payload.scopes : [];
234
+ const hasRequiredScopes = scopes.includes('user:profile')
235
+ && scopes.includes('user:sessions:claude_code');
236
+ if (!payload?.accessToken || !payload?.refreshToken || !hasRequiredScopes) {
237
+ return { status: 'STALE_ACCESS' };
238
+ }
239
+
240
+ return {
241
+ status: 'OK',
242
+ blob: blob ?? trimmed,
243
+ expiresAt: typeof payload?.expiresAt === 'number' ? payload.expiresAt : null
244
+ };
245
+ }
246
+
247
+ export function readClaudeCredentialsFile(
248
+ filePath: string,
249
+ readFn: ReadFn = (targetPath) => fs.readFileSync(targetPath, 'utf8'),
250
+ existsFn: ExistsFn = fs.existsSync
251
+ ): CredentialInspection {
252
+ if (!existsFn(filePath)) {
253
+ return { status: 'MISSING' };
254
+ }
255
+
256
+ try {
257
+ const raw = readFn(filePath);
258
+ return validateClaudeCredentialsBlob(raw, raw);
259
+ } catch {
260
+ return { status: 'STALE_ACCESS' };
261
+ }
262
+ }
263
+
264
+ export function inspectClaudeKeychainStatus(
265
+ home: string,
266
+ execFn: ExecFn = execFileSync,
267
+ options: InspectOptions = {}
268
+ ): CredentialInspection {
269
+ const {
270
+ readFn,
271
+ existsFn,
272
+ envFn = () => process.env
273
+ } = options;
274
+ const override = claudeCredentialsEnvOverride(envFn());
275
+ if (override) {
276
+ return readClaudeCredentialsFile(override.path, readFn, existsFn);
277
+ }
278
+
279
+ if (process.platform === 'darwin') {
280
+ const result = runSecurity([
281
+ 'find-generic-password',
282
+ '-a',
283
+ path.basename(home),
284
+ '-s',
285
+ 'Claude Code-credentials',
286
+ '-w'
287
+ ], {
288
+ stdio: ['ignore', 'pipe', 'pipe']
289
+ }, execFn);
290
+ if (result.ok) {
291
+ return validateClaudeCredentialsBlob(result.stdout);
292
+ }
293
+ if (result.classification === 'NOT_FOUND') {
294
+ return { status: 'MISSING' };
295
+ }
296
+ if (result.classification === 'LOCKED') {
297
+ return { status: 'KEYCHAIN_LOCKED', detail: result.stderr };
298
+ }
299
+ return { status: 'KEYCHAIN_ERROR', detail: result.stderr };
300
+ }
301
+
302
+ const credentialsPath = path.join(home, '.claude', '.credentials.json');
303
+ return readClaudeCredentialsFile(credentialsPath, readFn, existsFn);
304
+ }
305
+
306
+ export function inspectClaudeMountFile(home: string, project: string, options: InspectOptions = {}): CredentialInspection {
307
+ return readClaudeCredentialsFile(
308
+ claudeCredentialsPath(home, project),
309
+ options.readFn,
310
+ options.existsFn
311
+ );
312
+ }
313
+
314
+ export function extractClaudeCredentialsBlob(home: string, execFn: ExecFn = execFileSync): string | null {
315
+ const inspection = inspectClaudeKeychainStatus(home, execFn);
316
+ return inspection.status === 'OK' ? inspection.blob : null;
317
+ }
318
+
319
+ export function claudeCredentialsDir(home: string, project: string): string {
320
+ return hostJoin(home, '.agent-infra', 'credentials', project, 'claude-code');
321
+ }
322
+
323
+ export function claudeCredentialsPath(home: string, project: string): string {
324
+ return hostJoin(claudeCredentialsDir(home, project), '.credentials.json');
325
+ }
326
+
327
+ export function writeClaudeCredentialsFile(home: string, project: string, blob: string): void {
328
+ const dir = claudeCredentialsDir(home, project);
329
+ const filePath = claudeCredentialsPath(home, project);
330
+
331
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
332
+ fs.chmodSync(dir, 0o700);
333
+ fs.writeFileSync(filePath, blob, { mode: 0o600 });
334
+ fs.chmodSync(filePath, 0o600);
335
+ }
336
+
337
+ export function discoverProjects(home: string): string[] {
338
+ const credentialsRoot = hostJoin(home, '.agent-infra', 'credentials');
339
+ if (!fs.existsSync(credentialsRoot)) {
340
+ return [];
341
+ }
342
+
343
+ return fs.readdirSync(credentialsRoot, { withFileTypes: true })
344
+ .filter((entry) => entry.isDirectory())
345
+ .map((entry) => entry.name)
346
+ .filter((project) => fs.existsSync(claudeCredentialsPath(home, project)));
347
+ }
348
+
349
+ export function writeClaudeCredentialsToHost(home: string, blob: string, options: WriteHostOptions = {}): WriteResult {
350
+ const {
351
+ execFn = execFileSync,
352
+ mkdirFn = fs.mkdirSync,
353
+ chmodFn = fs.chmodSync,
354
+ writeFileFn = fs.writeFileSync,
355
+ renameFn = fs.renameSync,
356
+ rmFn = fs.rmSync,
357
+ randomFn = () => randomBytes(6).toString('hex'),
358
+ envFn = () => process.env
359
+ } = options;
360
+ const override = claudeCredentialsEnvOverride(envFn());
361
+
362
+ if (!override && process.platform === 'darwin') {
363
+ const result = runSecurity([
364
+ 'add-generic-password',
365
+ '-U',
366
+ '-a',
367
+ path.basename(home),
368
+ '-s',
369
+ 'Claude Code-credentials',
370
+ '-w',
371
+ blob
372
+ ], {
373
+ stdio: ['ignore', 'ignore', 'pipe']
374
+ }, execFn);
375
+ if (result.ok) {
376
+ return { ok: true };
377
+ }
378
+ if (result.classification === 'LOCKED') {
379
+ return { ok: false, classification: 'LOCKED', error: buildLockedGuidance() };
380
+ }
381
+ return {
382
+ ok: false,
383
+ classification: result.classification,
384
+ error: `security command failed: ${result.stderr || 'unknown error'}`
385
+ };
386
+ }
387
+
388
+ const targetPath = override?.path ?? path.join(home, '.claude', '.credentials.json');
389
+ const dir = path.dirname(targetPath);
390
+ const tmpPath = `${targetPath}.tmp.${process.pid}.${randomFn()}`;
391
+
392
+ try {
393
+ mkdirFn(dir, { recursive: true, mode: 0o700 });
394
+ chmodFn(dir, 0o700);
395
+ writeFileFn(tmpPath, blob, { mode: 0o600 });
396
+ chmodFn(tmpPath, 0o600);
397
+ renameFn(tmpPath, targetPath);
398
+ return { ok: true };
399
+ } catch (error) {
400
+ try {
401
+ rmFn(tmpPath, { force: true });
402
+ } catch {
403
+ // Best-effort cleanup only.
404
+ }
405
+ return {
406
+ ok: false,
407
+ classification: 'OTHER',
408
+ error: redactCommandError(errorMessage(error))
409
+ };
410
+ }
411
+ }
412
+
413
+ export function formatCredentialWarnings(warnings: Array<string | Warning> = []): string {
414
+ return warnings
415
+ .map((warning) => (typeof warning === 'string' ? warning : warning?.message))
416
+ .filter(Boolean)
417
+ .join('; ');
418
+ }
419
+
420
+ function endpointNameForFile(project: string): string {
421
+ return `file:${project}`;
422
+ }
423
+
424
+ function chooseAuthoritativeEndpoint(endpoints: CredentialEndpoint[]): OkCredentialEndpoint | null {
425
+ const okEndpoints = endpoints.filter((endpoint): endpoint is OkCredentialEndpoint => endpoint.status === 'OK');
426
+ if (okEndpoints.length === 0) {
427
+ return null;
428
+ }
429
+
430
+ const hostEndpoint = okEndpoints.find((endpoint) => endpoint.name === 'host');
431
+ if (hostEndpoint && typeof hostEndpoint.expiresAt !== 'number') {
432
+ return hostEndpoint;
433
+ }
434
+
435
+ const withExpiresAt = okEndpoints.filter(
436
+ (endpoint): endpoint is ExpiringOkCredentialEndpoint => typeof endpoint.expiresAt === 'number'
437
+ );
438
+ if (withExpiresAt.length > 0) {
439
+ return withExpiresAt.reduce((best, endpoint) => (
440
+ endpoint.expiresAt > best.expiresAt ? endpoint : best
441
+ ));
442
+ }
443
+
444
+ return okEndpoints.find((endpoint) => endpoint.name === 'host') ?? okEndpoints[0] ?? null;
445
+ }
446
+
447
+ function shouldWriteEndpoint(authoritative: OkCredentialEndpoint, target: CredentialEndpoint): boolean {
448
+ if (target.status !== 'OK') {
449
+ return true;
450
+ }
451
+
452
+ if (typeof authoritative.expiresAt === 'number' && typeof target.expiresAt === 'number') {
453
+ return authoritative.expiresAt > target.expiresAt;
454
+ }
455
+
456
+ // Both endpoints are OK but expiresAt is not comparable (one or both non-numeric,
457
+ // or values are equal at the same millisecond). Stay conservative and refuse to
458
+ // write — a real rotation will produce a strictly larger expiresAt next time.
459
+ // This guards against leaner host blobs (e.g. stored without subscriptionType)
460
+ // overwriting a richer mount blob solely on the basis of byte differences.
461
+ return false;
462
+ }
463
+
464
+ export function reconcileClaudeCredentials(home: string, options: ReconcileOptions = {}): ReconcileResult {
465
+ const {
466
+ execFn = execFileSync,
467
+ writeFn = writeClaudeCredentialsFile,
468
+ writeHostFn = writeClaudeCredentialsToHost,
469
+ readFn,
470
+ existsFn,
471
+ discoverFn = discoverProjects,
472
+ projects = null,
473
+ singleProject = null,
474
+ inspection = null,
475
+ envFn = () => process.env
476
+ } = options;
477
+
478
+ const effectiveProjects = singleProject
479
+ ? [singleProject]
480
+ : (projects ?? discoverFn(home));
481
+ const hostInspection = inspection ?? inspectClaudeKeychainStatus(home, execFn, { readFn, existsFn, envFn });
482
+ const hostEndpoint = { name: 'host', ...hostInspection };
483
+ const fileEndpoints = effectiveProjects.map((project) => ({
484
+ name: endpointNameForFile(project),
485
+ project,
486
+ ...inspectClaudeMountFile(home, project, { readFn, existsFn })
487
+ }));
488
+ const endpoints = [hostEndpoint, ...fileEndpoints];
489
+ const authoritative = chooseAuthoritativeEndpoint(endpoints);
490
+
491
+ if (!authoritative) {
492
+ const hasStaleEndpoint = endpoints.some((endpoint) => endpoint.status === 'STALE_ACCESS');
493
+ const unavailableStatus = ['KEYCHAIN_LOCKED', 'KEYCHAIN_ERROR'].includes(hostEndpoint.status)
494
+ ? hostEndpoint.status
495
+ : null;
496
+ return {
497
+ status: unavailableStatus ?? (hasStaleEndpoint ? 'STALE_ACCESS' : 'MISSING'),
498
+ authoritative: null,
499
+ expiresAt: null,
500
+ hostWritten: false,
501
+ filesWritten: [],
502
+ fileErrors: [],
503
+ warnings: [],
504
+ detail: 'detail' in hostEndpoint ? hostEndpoint.detail ?? null : null
505
+ };
506
+ }
507
+
508
+ const warnings: Warning[] = [];
509
+ const filesWritten: string[] = [];
510
+ const fileErrors: Array<{ project: string; error: string }> = [];
511
+ let hostWritten = false;
512
+ let hostWriteFailed = false;
513
+
514
+ if (authoritative.name !== 'host' && shouldWriteEndpoint(authoritative, hostEndpoint)) {
515
+ const result = writeHostFn(home, authoritative.blob, { execFn, envFn });
516
+ if (result?.ok) {
517
+ hostWritten = true;
518
+ } else {
519
+ hostWriteFailed = true;
520
+ warnings.push({
521
+ source: 'host-keychain',
522
+ classification: result?.classification ?? 'OTHER',
523
+ message: result?.error ?? 'unknown error'
524
+ });
525
+ }
526
+ }
527
+
528
+ for (const endpoint of fileEndpoints) {
529
+ if (endpoint.name === authoritative.name || !shouldWriteEndpoint(authoritative, endpoint)) {
530
+ continue;
531
+ }
532
+
533
+ try {
534
+ writeFn(home, endpoint.project, authoritative.blob);
535
+ filesWritten.push(endpoint.project);
536
+ } catch (error) {
537
+ fileErrors.push({ project: endpoint.project, error: errorMessage(error) });
538
+ }
539
+ }
540
+
541
+ return {
542
+ status: hostWriteFailed ? 'KEYCHAIN_WRITE_FAILED' : 'OK',
543
+ authoritative: authoritative.name,
544
+ expiresAt: authoritative.expiresAt ?? null,
545
+ hostWritten,
546
+ filesWritten,
547
+ fileErrors,
548
+ warnings
549
+ };
550
+ }
551
+
552
+ export function syncClaudeCredentialsFromKeychain(home: string, project: string, options: ReconcileOptions = {}) {
553
+ const result = reconcileClaudeCredentials(home, {
554
+ ...options,
555
+ singleProject: project
556
+ });
557
+ if (result.status !== 'OK' && result.status !== 'KEYCHAIN_WRITE_FAILED') {
558
+ return {
559
+ status: result.status,
560
+ written: false
561
+ };
562
+ }
563
+
564
+ return {
565
+ status: result.status === 'KEYCHAIN_WRITE_FAILED' ? 'OK' : result.status,
566
+ written: result.filesWritten.includes(project),
567
+ expiresAt: result.expiresAt
568
+ };
569
+ }
570
+
571
+ export function formatRemaining(expiresAt: unknown): string {
572
+ if (typeof expiresAt !== 'number') {
573
+ return 'unknown';
574
+ }
575
+
576
+ const ms = expiresAt - Date.now();
577
+ if (ms <= 0) {
578
+ return 'EXPIRED';
579
+ }
580
+
581
+ const totalMinutes = Math.floor(ms / 60_000);
582
+ const hours = Math.floor(totalMinutes / 60);
583
+ const minutes = totalMinutes % 60;
584
+ return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
585
+ }
586
+
587
+ export function assertClaudeCredentialsAvailable(
588
+ home: string,
589
+ project: string,
590
+ resolvedTools: ResolvedTool[],
591
+ extractFn: (home: string) => string | null = extractClaudeCredentialsBlob,
592
+ writeFn: (home: string, project: string, blob: string) => void = writeClaudeCredentialsFile,
593
+ inspectFn: (home: string) => CredentialInspection = inspectClaudeKeychainStatus
594
+ ): void {
595
+ const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
596
+ if (!claudeCodeEntry) {
597
+ return;
598
+ }
599
+
600
+ let blob: string | null = null;
601
+ const hasCustomInspectFn = inspectFn !== inspectClaudeKeychainStatus;
602
+ const hasCustomExtractFn = extractFn !== extractClaudeCredentialsBlob;
603
+ if (hasCustomInspectFn || !hasCustomExtractFn) {
604
+ 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'));
611
+ }
612
+ blob = inspection.status === 'OK' ? inspection.blob : null;
613
+ } else {
614
+ 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'));
631
+ }
632
+
633
+ writeFn(home, project, blob);
634
+ }
@@ -9,19 +9,26 @@ const RUNTIMES_DIR = path.join(
9
9
  'runtimes'
10
10
  );
11
11
 
12
- function listRuntimeFragments() {
12
+ type DockerfileConfig = {
13
+ repoRoot: string;
14
+ project: string;
15
+ dockerfile: string | null;
16
+ runtimes: string[];
17
+ };
18
+
19
+ function listRuntimeFragments(): string[] {
13
20
  return fs.readdirSync(RUNTIMES_DIR)
14
21
  .filter((file) => file.endsWith('.dockerfile'))
15
22
  .map((file) => file.replace(/\.dockerfile$/, ''));
16
23
  }
17
24
 
18
- export function availableRuntimes() {
25
+ export function availableRuntimes(): string[] {
19
26
  return listRuntimeFragments()
20
27
  .filter((name) => name !== 'base' && name !== 'ai-tools')
21
28
  .sort();
22
29
  }
23
30
 
24
- function dockerfileContent(config) {
31
+ function dockerfileContent(config: DockerfileConfig): string {
25
32
  if (config.dockerfile) {
26
33
  const customPath = path.resolve(config.repoRoot, config.dockerfile);
27
34
  if (!fs.existsSync(customPath)) {
@@ -52,14 +59,14 @@ function dockerfileContent(config) {
52
59
  return `${content}\n`;
53
60
  }
54
61
 
55
- export function dockerfileSignature(config) {
62
+ export function dockerfileSignature(config: DockerfileConfig): string {
56
63
  return createHash('sha256')
57
64
  .update(dockerfileContent(config))
58
65
  .digest('hex')
59
66
  .slice(0, 12);
60
67
  }
61
68
 
62
- export function prepareDockerfile(config) {
69
+ export function prepareDockerfile(config: DockerfileConfig): { path: string; signature: string; cleanup: () => void } {
63
70
  if (config.dockerfile) {
64
71
  const customPath = path.resolve(config.repoRoot, config.dockerfile);
65
72
  if (!fs.existsSync(customPath)) {
@@ -86,7 +93,7 @@ export function prepareDockerfile(config) {
86
93
  };
87
94
  }
88
95
 
89
- export function composeDockerfile(config) {
96
+ export function composeDockerfile(config: DockerfileConfig): string {
90
97
  const content = dockerfileContent(config);
91
98
 
92
99
  const tempPath = path.join(os.tmpdir(), `${config.project}-sandbox.Dockerfile`);