@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.
- package/README.md +200 -8
- package/README.zh-CN.md +176 -8
- package/bin/{cli.js → cli.ts} +23 -19
- package/dist/bin/cli.js +116 -0
- package/dist/lib/defaults.json +61 -0
- package/dist/lib/init.js +238 -0
- package/dist/lib/log.js +18 -0
- package/dist/lib/merge.js +747 -0
- package/dist/lib/paths.js +18 -0
- package/dist/lib/prompt.js +85 -0
- package/dist/lib/render.js +139 -0
- package/dist/lib/sandbox/commands/create.js +1173 -0
- package/dist/lib/sandbox/commands/enter.js +98 -0
- package/dist/lib/sandbox/commands/ls.js +93 -0
- package/dist/lib/sandbox/commands/rebuild.js +101 -0
- package/dist/lib/sandbox/commands/refresh.js +85 -0
- package/dist/lib/sandbox/commands/rm.js +226 -0
- package/dist/lib/sandbox/commands/vm.js +144 -0
- package/dist/lib/sandbox/config.js +85 -0
- package/dist/lib/sandbox/constants.js +104 -0
- package/dist/lib/sandbox/credentials.js +437 -0
- package/dist/lib/sandbox/dockerfile.js +76 -0
- package/dist/lib/sandbox/dotfiles.js +170 -0
- package/dist/lib/sandbox/engine.js +155 -0
- package/dist/lib/sandbox/engines/colima.js +64 -0
- package/dist/lib/sandbox/engines/docker-desktop.js +27 -0
- package/dist/lib/sandbox/engines/index.js +25 -0
- package/dist/lib/sandbox/engines/native.js +96 -0
- package/dist/lib/sandbox/engines/orbstack.js +63 -0
- package/dist/lib/sandbox/engines/selinux.js +48 -0
- package/dist/lib/sandbox/engines/wsl2-paths.js +47 -0
- package/dist/lib/sandbox/engines/wsl2.js +57 -0
- package/dist/lib/sandbox/index.js +70 -0
- package/dist/lib/sandbox/runtimes/ai-tools.dockerfile +39 -0
- package/dist/lib/sandbox/runtimes/base.dockerfile +178 -0
- package/dist/lib/sandbox/runtimes/java17.dockerfile +3 -0
- package/dist/lib/sandbox/runtimes/java21.dockerfile +3 -0
- package/dist/lib/sandbox/runtimes/node20.dockerfile +3 -0
- package/dist/lib/sandbox/runtimes/node22.dockerfile +3 -0
- package/dist/lib/sandbox/runtimes/python3.dockerfile +3 -0
- package/dist/lib/sandbox/shell.js +148 -0
- package/dist/lib/sandbox/task-resolver.js +35 -0
- package/dist/lib/sandbox/tools.js +115 -0
- package/dist/lib/update.js +186 -0
- package/dist/lib/version.js +5 -0
- package/dist/package.json +5 -0
- package/lib/{init.js → init.ts} +64 -20
- package/lib/{log.js → log.ts} +4 -4
- package/lib/{merge.js → merge.ts} +129 -63
- package/lib/paths.ts +18 -0
- package/lib/{prompt.js → prompt.ts} +12 -12
- package/lib/{render.js → render.ts} +30 -17
- package/lib/sandbox/commands/create.ts +1507 -0
- package/lib/sandbox/commands/enter.ts +115 -0
- package/lib/sandbox/commands/{ls.js → ls.ts} +41 -10
- package/lib/sandbox/commands/rebuild.ts +135 -0
- package/lib/sandbox/commands/refresh.ts +128 -0
- package/lib/sandbox/commands/{rm.js → rm.ts} +71 -21
- package/lib/sandbox/commands/{vm.js → vm.ts} +62 -15
- package/lib/sandbox/config.ts +133 -0
- package/lib/sandbox/{constants.js → constants.ts} +41 -17
- package/lib/sandbox/credentials.ts +634 -0
- package/lib/sandbox/{dockerfile.js → dockerfile.ts} +13 -6
- package/lib/sandbox/dotfiles.ts +236 -0
- package/lib/sandbox/engine.ts +231 -0
- package/lib/sandbox/engines/colima.ts +81 -0
- package/lib/sandbox/engines/docker-desktop.ts +36 -0
- package/lib/sandbox/engines/index.ts +74 -0
- package/lib/sandbox/engines/native.ts +131 -0
- package/lib/sandbox/engines/orbstack.ts +78 -0
- package/lib/sandbox/engines/selinux.ts +66 -0
- package/lib/sandbox/engines/wsl2-paths.ts +65 -0
- package/lib/sandbox/engines/wsl2.ts +74 -0
- package/lib/sandbox/{index.js → index.ts} +17 -8
- package/lib/sandbox/runtimes/ai-tools.dockerfile +14 -1
- package/lib/sandbox/runtimes/base.dockerfile +116 -1
- package/lib/sandbox/shell.ts +186 -0
- package/lib/sandbox/{task-resolver.js → task-resolver.ts} +6 -6
- package/lib/sandbox/{tools.js → tools.ts} +33 -29
- package/lib/{update.js → update.ts} +33 -10
- package/package.json +22 -12
- package/templates/.agents/rules/create-issue.github.en.md +2 -4
- package/templates/.agents/rules/create-issue.github.zh-CN.md +2 -4
- package/templates/.agents/rules/issue-pr-commands.github.en.md +29 -0
- package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +29 -0
- package/templates/.agents/scripts/{platform-adapters/find-existing-task.github.js → find-existing-task.js} +22 -79
- package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +26 -41
- package/templates/.agents/skills/create-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +6 -8
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +6 -8
- package/lib/paths.js +0 -9
- package/lib/sandbox/commands/create.js +0 -1174
- package/lib/sandbox/commands/enter.js +0 -79
- package/lib/sandbox/commands/rebuild.js +0 -102
- package/lib/sandbox/config.js +0 -84
- package/lib/sandbox/engine.js +0 -256
- package/lib/sandbox/shell.js +0 -122
- package/templates/.agents/scripts/platform-adapters/find-existing-task.js +0 -5
- /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
|
-
|
|
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`);
|