@fitlab-ai/agent-infra 0.5.9 → 0.5.10

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