@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,437 @@
|
|
|
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
|
+
const LOCKED_PATTERN = /errSecInteractionNotAllowed|User interaction is not allowed/i;
|
|
7
|
+
const NOT_FOUND_PATTERN = /errSecItemNotFound|specified item could not be found/i;
|
|
8
|
+
const REDACTION_PATTERNS = [
|
|
9
|
+
{ pattern: /\{[^{}]*"claudeAiOauth"[\s\S]*?\}\s*\}/g, replacement: '[REDACTED credentials blob]' },
|
|
10
|
+
{ pattern: /sk-ant-[A-Za-z0-9_-]{20,}/g, replacement: '[REDACTED claude token]' },
|
|
11
|
+
{ pattern: /gh[psoru]_[A-Za-z0-9]{30,}/g, replacement: '[REDACTED github token]' },
|
|
12
|
+
{ pattern: /Bearer\s+[A-Za-z0-9._~+/=-]{20,}/gi, replacement: 'Bearer [REDACTED]' }
|
|
13
|
+
];
|
|
14
|
+
function errorMessage(error) {
|
|
15
|
+
return error instanceof Error ? error.message : 'unknown error';
|
|
16
|
+
}
|
|
17
|
+
export function redactCommandError(text) {
|
|
18
|
+
if (!text || typeof text !== 'string') {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
return REDACTION_PATTERNS.reduce((result, { pattern, replacement }) => result.replace(pattern, replacement), text);
|
|
22
|
+
}
|
|
23
|
+
export const redactSecurityOutput = redactCommandError;
|
|
24
|
+
function extractStderrSafely(error) {
|
|
25
|
+
const stderr = typeof error === 'object' && error !== null && 'stderr' in error
|
|
26
|
+
? error.stderr
|
|
27
|
+
: undefined;
|
|
28
|
+
if (Buffer.isBuffer(stderr)) {
|
|
29
|
+
return stderr.toString('utf8');
|
|
30
|
+
}
|
|
31
|
+
if (typeof stderr === 'string') {
|
|
32
|
+
return stderr;
|
|
33
|
+
}
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
function classifySecurityFailure(text) {
|
|
37
|
+
if (LOCKED_PATTERN.test(text)) {
|
|
38
|
+
return 'LOCKED';
|
|
39
|
+
}
|
|
40
|
+
if (NOT_FOUND_PATTERN.test(text)) {
|
|
41
|
+
return 'NOT_FOUND';
|
|
42
|
+
}
|
|
43
|
+
return 'OTHER';
|
|
44
|
+
}
|
|
45
|
+
function runSecurity(args, options = {}, execFn = execFileSync) {
|
|
46
|
+
try {
|
|
47
|
+
const stdout = execFn('security', args, {
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
50
|
+
...options
|
|
51
|
+
});
|
|
52
|
+
const text = typeof stdout === 'string'
|
|
53
|
+
? stdout
|
|
54
|
+
: (Buffer.isBuffer(stdout) ? stdout.toString('utf8') : '');
|
|
55
|
+
return { ok: true, stdout: text, stderr: '', classification: 'OK' };
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
const stderr = redactCommandError(extractStderrSafely(error));
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
stdout: '',
|
|
62
|
+
stderr,
|
|
63
|
+
classification: classifySecurityFailure(stderr)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function buildLockedGuidance() {
|
|
68
|
+
return [
|
|
69
|
+
'macOS keychain is locked (errSecInteractionNotAllowed).',
|
|
70
|
+
'Options to recover:',
|
|
71
|
+
' 1. Unlock the keychain on the host:',
|
|
72
|
+
' security unlock-keychain ~/Library/Keychains/login.keychain-db',
|
|
73
|
+
' Then re-run "ai sandbox refresh".',
|
|
74
|
+
' 2. Bypass the keychain via an environment variable (recommended for SSH / CI):',
|
|
75
|
+
' macOS stores Claude Code credentials in the keychain by default, so',
|
|
76
|
+
' the env-override file must be seeded once before use.',
|
|
77
|
+
' On a session where the keychain is unlocked, run:',
|
|
78
|
+
' security unlock-keychain ~/Library/Keychains/login.keychain-db',
|
|
79
|
+
' umask 077 && mkdir -p "$HOME/.agent-infra" && \\',
|
|
80
|
+
' security find-generic-password -s "Claude Code-credentials" -w \\',
|
|
81
|
+
' > "$HOME/.agent-infra/claude-credentials.json"',
|
|
82
|
+
' chmod 600 "$HOME/.agent-infra/claude-credentials.json"',
|
|
83
|
+
' Then on the SSH / CI side:',
|
|
84
|
+
' export AGENT_INFRA_CLAUDE_CREDENTIALS_FILE="$HOME/.agent-infra/claude-credentials.json"',
|
|
85
|
+
' ai sandbox refresh',
|
|
86
|
+
' Subsequent reads/writes use that file instead of the keychain.'
|
|
87
|
+
].join('\n');
|
|
88
|
+
}
|
|
89
|
+
export function claudeCredentialsEnvOverride(env = process.env) {
|
|
90
|
+
const raw = env?.AGENT_INFRA_CLAUDE_CREDENTIALS_FILE;
|
|
91
|
+
if (!raw || typeof raw !== 'string') {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return { path: raw, source: 'AGENT_INFRA_CLAUDE_CREDENTIALS_FILE' };
|
|
95
|
+
}
|
|
96
|
+
export function validateClaudeCredentialsEnvOverride(env = process.env) {
|
|
97
|
+
const raw = env?.AGENT_INFRA_CLAUDE_CREDENTIALS_FILE;
|
|
98
|
+
if (raw === undefined || raw === '') {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (typeof raw !== 'string' || !path.isAbsolute(raw)) {
|
|
102
|
+
throw new Error('Invalid AGENT_INFRA_CLAUDE_CREDENTIALS_FILE value. Expected an absolute file path.');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Reconcile treats the freshest valid endpoint as authoritative so sandbox
|
|
106
|
+
// token rotations can flow back to the host credential store.
|
|
107
|
+
function validateClaudeCredentialsBlob(raw, blob = null) {
|
|
108
|
+
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
|
109
|
+
if (!trimmed) {
|
|
110
|
+
return { status: 'MISSING' };
|
|
111
|
+
}
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(trimmed);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return { status: 'STALE_ACCESS' };
|
|
118
|
+
}
|
|
119
|
+
const payload = parsed?.claudeAiOauth ?? parsed;
|
|
120
|
+
const scopes = Array.isArray(payload?.scopes) ? payload.scopes : [];
|
|
121
|
+
const hasRequiredScopes = scopes.includes('user:profile')
|
|
122
|
+
&& scopes.includes('user:sessions:claude_code');
|
|
123
|
+
if (!payload?.accessToken || !payload?.refreshToken || !hasRequiredScopes) {
|
|
124
|
+
return { status: 'STALE_ACCESS' };
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
status: 'OK',
|
|
128
|
+
blob: blob ?? trimmed,
|
|
129
|
+
expiresAt: typeof payload?.expiresAt === 'number' ? payload.expiresAt : null
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export function readClaudeCredentialsFile(filePath, readFn = (targetPath) => fs.readFileSync(targetPath, 'utf8'), existsFn = fs.existsSync) {
|
|
133
|
+
if (!existsFn(filePath)) {
|
|
134
|
+
return { status: 'MISSING' };
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const raw = readFn(filePath);
|
|
138
|
+
return validateClaudeCredentialsBlob(raw, raw);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return { status: 'STALE_ACCESS' };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function inspectClaudeKeychainStatus(home, execFn = execFileSync, options = {}) {
|
|
145
|
+
const { readFn, existsFn, envFn = () => process.env } = options;
|
|
146
|
+
const override = claudeCredentialsEnvOverride(envFn());
|
|
147
|
+
if (override) {
|
|
148
|
+
return readClaudeCredentialsFile(override.path, readFn, existsFn);
|
|
149
|
+
}
|
|
150
|
+
if (process.platform === 'darwin') {
|
|
151
|
+
const result = runSecurity([
|
|
152
|
+
'find-generic-password',
|
|
153
|
+
'-a',
|
|
154
|
+
path.basename(home),
|
|
155
|
+
'-s',
|
|
156
|
+
'Claude Code-credentials',
|
|
157
|
+
'-w'
|
|
158
|
+
], {
|
|
159
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
160
|
+
}, execFn);
|
|
161
|
+
if (result.ok) {
|
|
162
|
+
return validateClaudeCredentialsBlob(result.stdout);
|
|
163
|
+
}
|
|
164
|
+
if (result.classification === 'NOT_FOUND') {
|
|
165
|
+
return { status: 'MISSING' };
|
|
166
|
+
}
|
|
167
|
+
if (result.classification === 'LOCKED') {
|
|
168
|
+
return { status: 'KEYCHAIN_LOCKED', detail: result.stderr };
|
|
169
|
+
}
|
|
170
|
+
return { status: 'KEYCHAIN_ERROR', detail: result.stderr };
|
|
171
|
+
}
|
|
172
|
+
const credentialsPath = path.join(home, '.claude', '.credentials.json');
|
|
173
|
+
return readClaudeCredentialsFile(credentialsPath, readFn, existsFn);
|
|
174
|
+
}
|
|
175
|
+
export function inspectClaudeMountFile(home, project, options = {}) {
|
|
176
|
+
return readClaudeCredentialsFile(claudeCredentialsPath(home, project), options.readFn, options.existsFn);
|
|
177
|
+
}
|
|
178
|
+
export function extractClaudeCredentialsBlob(home, execFn = execFileSync) {
|
|
179
|
+
const inspection = inspectClaudeKeychainStatus(home, execFn);
|
|
180
|
+
return inspection.status === 'OK' ? inspection.blob : null;
|
|
181
|
+
}
|
|
182
|
+
export function claudeCredentialsDir(home, project) {
|
|
183
|
+
return hostJoin(home, '.agent-infra', 'credentials', project, 'claude-code');
|
|
184
|
+
}
|
|
185
|
+
export function claudeCredentialsPath(home, project) {
|
|
186
|
+
return hostJoin(claudeCredentialsDir(home, project), '.credentials.json');
|
|
187
|
+
}
|
|
188
|
+
export function writeClaudeCredentialsFile(home, project, blob) {
|
|
189
|
+
const dir = claudeCredentialsDir(home, project);
|
|
190
|
+
const filePath = claudeCredentialsPath(home, project);
|
|
191
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
192
|
+
fs.chmodSync(dir, 0o700);
|
|
193
|
+
fs.writeFileSync(filePath, blob, { mode: 0o600 });
|
|
194
|
+
fs.chmodSync(filePath, 0o600);
|
|
195
|
+
}
|
|
196
|
+
export function discoverProjects(home) {
|
|
197
|
+
const credentialsRoot = hostJoin(home, '.agent-infra', 'credentials');
|
|
198
|
+
if (!fs.existsSync(credentialsRoot)) {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
return fs.readdirSync(credentialsRoot, { withFileTypes: true })
|
|
202
|
+
.filter((entry) => entry.isDirectory())
|
|
203
|
+
.map((entry) => entry.name)
|
|
204
|
+
.filter((project) => fs.existsSync(claudeCredentialsPath(home, project)));
|
|
205
|
+
}
|
|
206
|
+
export function writeClaudeCredentialsToHost(home, blob, options = {}) {
|
|
207
|
+
const { execFn = execFileSync, mkdirFn = fs.mkdirSync, chmodFn = fs.chmodSync, writeFileFn = fs.writeFileSync, renameFn = fs.renameSync, rmFn = fs.rmSync, randomFn = () => randomBytes(6).toString('hex'), envFn = () => process.env } = options;
|
|
208
|
+
const override = claudeCredentialsEnvOverride(envFn());
|
|
209
|
+
if (!override && process.platform === 'darwin') {
|
|
210
|
+
const result = runSecurity([
|
|
211
|
+
'add-generic-password',
|
|
212
|
+
'-U',
|
|
213
|
+
'-a',
|
|
214
|
+
path.basename(home),
|
|
215
|
+
'-s',
|
|
216
|
+
'Claude Code-credentials',
|
|
217
|
+
'-w',
|
|
218
|
+
blob
|
|
219
|
+
], {
|
|
220
|
+
stdio: ['ignore', 'ignore', 'pipe']
|
|
221
|
+
}, execFn);
|
|
222
|
+
if (result.ok) {
|
|
223
|
+
return { ok: true };
|
|
224
|
+
}
|
|
225
|
+
if (result.classification === 'LOCKED') {
|
|
226
|
+
return { ok: false, classification: 'LOCKED', error: buildLockedGuidance() };
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
classification: result.classification,
|
|
231
|
+
error: `security command failed: ${result.stderr || 'unknown error'}`
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const targetPath = override?.path ?? path.join(home, '.claude', '.credentials.json');
|
|
235
|
+
const dir = path.dirname(targetPath);
|
|
236
|
+
const tmpPath = `${targetPath}.tmp.${process.pid}.${randomFn()}`;
|
|
237
|
+
try {
|
|
238
|
+
mkdirFn(dir, { recursive: true, mode: 0o700 });
|
|
239
|
+
chmodFn(dir, 0o700);
|
|
240
|
+
writeFileFn(tmpPath, blob, { mode: 0o600 });
|
|
241
|
+
chmodFn(tmpPath, 0o600);
|
|
242
|
+
renameFn(tmpPath, targetPath);
|
|
243
|
+
return { ok: true };
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
try {
|
|
247
|
+
rmFn(tmpPath, { force: true });
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Best-effort cleanup only.
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
classification: 'OTHER',
|
|
255
|
+
error: redactCommandError(errorMessage(error))
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
export function formatCredentialWarnings(warnings = []) {
|
|
260
|
+
return warnings
|
|
261
|
+
.map((warning) => (typeof warning === 'string' ? warning : warning?.message))
|
|
262
|
+
.filter(Boolean)
|
|
263
|
+
.join('; ');
|
|
264
|
+
}
|
|
265
|
+
function endpointNameForFile(project) {
|
|
266
|
+
return `file:${project}`;
|
|
267
|
+
}
|
|
268
|
+
function chooseAuthoritativeEndpoint(endpoints) {
|
|
269
|
+
const okEndpoints = endpoints.filter((endpoint) => endpoint.status === 'OK');
|
|
270
|
+
if (okEndpoints.length === 0) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
const hostEndpoint = okEndpoints.find((endpoint) => endpoint.name === 'host');
|
|
274
|
+
if (hostEndpoint && typeof hostEndpoint.expiresAt !== 'number') {
|
|
275
|
+
return hostEndpoint;
|
|
276
|
+
}
|
|
277
|
+
const withExpiresAt = okEndpoints.filter((endpoint) => typeof endpoint.expiresAt === 'number');
|
|
278
|
+
if (withExpiresAt.length > 0) {
|
|
279
|
+
return withExpiresAt.reduce((best, endpoint) => (endpoint.expiresAt > best.expiresAt ? endpoint : best));
|
|
280
|
+
}
|
|
281
|
+
return okEndpoints.find((endpoint) => endpoint.name === 'host') ?? okEndpoints[0] ?? null;
|
|
282
|
+
}
|
|
283
|
+
function shouldWriteEndpoint(authoritative, target) {
|
|
284
|
+
if (target.status !== 'OK') {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
if (typeof authoritative.expiresAt === 'number' && typeof target.expiresAt === 'number') {
|
|
288
|
+
return authoritative.expiresAt > target.expiresAt;
|
|
289
|
+
}
|
|
290
|
+
// Both endpoints are OK but expiresAt is not comparable (one or both non-numeric,
|
|
291
|
+
// or values are equal at the same millisecond). Stay conservative and refuse to
|
|
292
|
+
// write — a real rotation will produce a strictly larger expiresAt next time.
|
|
293
|
+
// This guards against leaner host blobs (e.g. stored without subscriptionType)
|
|
294
|
+
// overwriting a richer mount blob solely on the basis of byte differences.
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
export function reconcileClaudeCredentials(home, options = {}) {
|
|
298
|
+
const { execFn = execFileSync, writeFn = writeClaudeCredentialsFile, writeHostFn = writeClaudeCredentialsToHost, readFn, existsFn, discoverFn = discoverProjects, projects = null, singleProject = null, inspection = null, envFn = () => process.env } = options;
|
|
299
|
+
const effectiveProjects = singleProject
|
|
300
|
+
? [singleProject]
|
|
301
|
+
: (projects ?? discoverFn(home));
|
|
302
|
+
const hostInspection = inspection ?? inspectClaudeKeychainStatus(home, execFn, { readFn, existsFn, envFn });
|
|
303
|
+
const hostEndpoint = { name: 'host', ...hostInspection };
|
|
304
|
+
const fileEndpoints = effectiveProjects.map((project) => ({
|
|
305
|
+
name: endpointNameForFile(project),
|
|
306
|
+
project,
|
|
307
|
+
...inspectClaudeMountFile(home, project, { readFn, existsFn })
|
|
308
|
+
}));
|
|
309
|
+
const endpoints = [hostEndpoint, ...fileEndpoints];
|
|
310
|
+
const authoritative = chooseAuthoritativeEndpoint(endpoints);
|
|
311
|
+
if (!authoritative) {
|
|
312
|
+
const hasStaleEndpoint = endpoints.some((endpoint) => endpoint.status === 'STALE_ACCESS');
|
|
313
|
+
const unavailableStatus = ['KEYCHAIN_LOCKED', 'KEYCHAIN_ERROR'].includes(hostEndpoint.status)
|
|
314
|
+
? hostEndpoint.status
|
|
315
|
+
: null;
|
|
316
|
+
return {
|
|
317
|
+
status: unavailableStatus ?? (hasStaleEndpoint ? 'STALE_ACCESS' : 'MISSING'),
|
|
318
|
+
authoritative: null,
|
|
319
|
+
expiresAt: null,
|
|
320
|
+
hostWritten: false,
|
|
321
|
+
filesWritten: [],
|
|
322
|
+
fileErrors: [],
|
|
323
|
+
warnings: [],
|
|
324
|
+
detail: 'detail' in hostEndpoint ? hostEndpoint.detail ?? null : null
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const warnings = [];
|
|
328
|
+
const filesWritten = [];
|
|
329
|
+
const fileErrors = [];
|
|
330
|
+
let hostWritten = false;
|
|
331
|
+
let hostWriteFailed = false;
|
|
332
|
+
if (authoritative.name !== 'host' && shouldWriteEndpoint(authoritative, hostEndpoint)) {
|
|
333
|
+
const result = writeHostFn(home, authoritative.blob, { execFn, envFn });
|
|
334
|
+
if (result?.ok) {
|
|
335
|
+
hostWritten = true;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
hostWriteFailed = true;
|
|
339
|
+
warnings.push({
|
|
340
|
+
source: 'host-keychain',
|
|
341
|
+
classification: result?.classification ?? 'OTHER',
|
|
342
|
+
message: result?.error ?? 'unknown error'
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
for (const endpoint of fileEndpoints) {
|
|
347
|
+
if (endpoint.name === authoritative.name || !shouldWriteEndpoint(authoritative, endpoint)) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
writeFn(home, endpoint.project, authoritative.blob);
|
|
352
|
+
filesWritten.push(endpoint.project);
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
fileErrors.push({ project: endpoint.project, error: errorMessage(error) });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
status: hostWriteFailed ? 'KEYCHAIN_WRITE_FAILED' : 'OK',
|
|
360
|
+
authoritative: authoritative.name,
|
|
361
|
+
expiresAt: authoritative.expiresAt ?? null,
|
|
362
|
+
hostWritten,
|
|
363
|
+
filesWritten,
|
|
364
|
+
fileErrors,
|
|
365
|
+
warnings
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
export function syncClaudeCredentialsFromKeychain(home, project, options = {}) {
|
|
369
|
+
const result = reconcileClaudeCredentials(home, {
|
|
370
|
+
...options,
|
|
371
|
+
singleProject: project
|
|
372
|
+
});
|
|
373
|
+
if (result.status !== 'OK' && result.status !== 'KEYCHAIN_WRITE_FAILED') {
|
|
374
|
+
return {
|
|
375
|
+
status: result.status,
|
|
376
|
+
written: false
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
status: result.status === 'KEYCHAIN_WRITE_FAILED' ? 'OK' : result.status,
|
|
381
|
+
written: result.filesWritten.includes(project),
|
|
382
|
+
expiresAt: result.expiresAt
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
export function formatRemaining(expiresAt) {
|
|
386
|
+
if (typeof expiresAt !== 'number') {
|
|
387
|
+
return 'unknown';
|
|
388
|
+
}
|
|
389
|
+
const ms = expiresAt - Date.now();
|
|
390
|
+
if (ms <= 0) {
|
|
391
|
+
return 'EXPIRED';
|
|
392
|
+
}
|
|
393
|
+
const totalMinutes = Math.floor(ms / 60_000);
|
|
394
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
395
|
+
const minutes = totalMinutes % 60;
|
|
396
|
+
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
397
|
+
}
|
|
398
|
+
export function assertClaudeCredentialsAvailable(home, project, resolvedTools, extractFn = extractClaudeCredentialsBlob, writeFn = writeClaudeCredentialsFile, inspectFn = inspectClaudeKeychainStatus) {
|
|
399
|
+
const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
|
|
400
|
+
if (!claudeCodeEntry) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
let blob = null;
|
|
404
|
+
const hasCustomInspectFn = inspectFn !== inspectClaudeKeychainStatus;
|
|
405
|
+
const hasCustomExtractFn = extractFn !== extractClaudeCredentialsBlob;
|
|
406
|
+
if (hasCustomInspectFn || !hasCustomExtractFn) {
|
|
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'));
|
|
414
|
+
}
|
|
415
|
+
blob = inspection.status === 'OK' ? inspection.blob : null;
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
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'));
|
|
434
|
+
}
|
|
435
|
+
writeFn(home, project, blob);
|
|
436
|
+
}
|
|
437
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
const RUNTIMES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'runtimes');
|
|
7
|
+
function listRuntimeFragments() {
|
|
8
|
+
return fs.readdirSync(RUNTIMES_DIR)
|
|
9
|
+
.filter((file) => file.endsWith('.dockerfile'))
|
|
10
|
+
.map((file) => file.replace(/\.dockerfile$/, ''));
|
|
11
|
+
}
|
|
12
|
+
export function availableRuntimes() {
|
|
13
|
+
return listRuntimeFragments()
|
|
14
|
+
.filter((name) => name !== 'base' && name !== 'ai-tools')
|
|
15
|
+
.sort();
|
|
16
|
+
}
|
|
17
|
+
function dockerfileContent(config) {
|
|
18
|
+
if (config.dockerfile) {
|
|
19
|
+
const customPath = path.resolve(config.repoRoot, config.dockerfile);
|
|
20
|
+
if (!fs.existsSync(customPath)) {
|
|
21
|
+
throw new Error(`Custom Dockerfile not found: ${customPath}`);
|
|
22
|
+
}
|
|
23
|
+
return fs.readFileSync(customPath, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
const validRuntimes = new Set(availableRuntimes());
|
|
26
|
+
for (const runtime of config.runtimes) {
|
|
27
|
+
if (!validRuntimes.has(runtime)) {
|
|
28
|
+
throw new Error(`Unknown runtime: ${runtime}. Available runtimes: ${[...validRuntimes].join(', ')}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const fragments = [
|
|
32
|
+
'base.dockerfile',
|
|
33
|
+
...config.runtimes.map((runtime) => `${runtime}.dockerfile`),
|
|
34
|
+
'ai-tools.dockerfile'
|
|
35
|
+
];
|
|
36
|
+
const content = fragments
|
|
37
|
+
.map((fragment) => fs.readFileSync(path.join(RUNTIMES_DIR, fragment), 'utf8').trimEnd())
|
|
38
|
+
.join('\n\n');
|
|
39
|
+
return `${content}\n`;
|
|
40
|
+
}
|
|
41
|
+
export function dockerfileSignature(config) {
|
|
42
|
+
return createHash('sha256')
|
|
43
|
+
.update(dockerfileContent(config))
|
|
44
|
+
.digest('hex')
|
|
45
|
+
.slice(0, 12);
|
|
46
|
+
}
|
|
47
|
+
export function prepareDockerfile(config) {
|
|
48
|
+
if (config.dockerfile) {
|
|
49
|
+
const customPath = path.resolve(config.repoRoot, config.dockerfile);
|
|
50
|
+
if (!fs.existsSync(customPath)) {
|
|
51
|
+
throw new Error(`Custom Dockerfile not found: ${customPath}`);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
path: customPath,
|
|
55
|
+
signature: dockerfileSignature(config),
|
|
56
|
+
cleanup() { }
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `${config.project}-sandbox-`));
|
|
60
|
+
const tempPath = path.join(tempDir, 'Dockerfile');
|
|
61
|
+
fs.writeFileSync(tempPath, dockerfileContent(config), 'utf8');
|
|
62
|
+
return {
|
|
63
|
+
path: tempPath,
|
|
64
|
+
signature: dockerfileSignature(config),
|
|
65
|
+
cleanup() {
|
|
66
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function composeDockerfile(config) {
|
|
71
|
+
const content = dockerfileContent(config);
|
|
72
|
+
const tempPath = path.join(os.tmpdir(), `${config.project}-sandbox.Dockerfile`);
|
|
73
|
+
fs.writeFileSync(tempPath, content, 'utf8');
|
|
74
|
+
return tempPath;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=dockerfile.js.map
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { hostJoin } from "./engines/wsl2-paths.js";
|
|
4
|
+
export function dotfilesCacheDir(home, project) {
|
|
5
|
+
return hostJoin(home, '.agent-infra', '.cache', 'dotfiles-resolved', project);
|
|
6
|
+
}
|
|
7
|
+
function dotfilesWarning(warnings, writeStderr, relPath, reason, detail = '') {
|
|
8
|
+
const warning = { rel: relPath, reason };
|
|
9
|
+
if (detail) {
|
|
10
|
+
warning.detail = detail;
|
|
11
|
+
}
|
|
12
|
+
warnings.push(warning);
|
|
13
|
+
const suffix = detail ? `: ${detail}` : '';
|
|
14
|
+
writeStderr(`sandbox-dotfiles (host): skipping ${relPath} (${reason}${suffix})\n`);
|
|
15
|
+
}
|
|
16
|
+
function errorDetail(error) {
|
|
17
|
+
return error instanceof Error ? error.message : 'unknown error';
|
|
18
|
+
}
|
|
19
|
+
function errorCodeOrDetail(error) {
|
|
20
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
21
|
+
? String(error.code)
|
|
22
|
+
: errorDetail(error);
|
|
23
|
+
}
|
|
24
|
+
function copyDotfile(srcPath, dstPath, context) {
|
|
25
|
+
const { fsModule, relPath, warnings, writeStderr } = context;
|
|
26
|
+
try {
|
|
27
|
+
fsModule.mkdirSync(path.dirname(dstPath), { recursive: true });
|
|
28
|
+
fsModule.copyFileSync(srcPath, dstPath);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
dotfilesWarning(warnings, writeStderr, relPath, 'copy failed', errorCodeOrDetail(error));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function walkAndMaterializeDotfiles(context) {
|
|
35
|
+
const { srcDir, dstDir, relParts, depth, maxDepth, activeDirs, warnings, writeStderr, fsModule } = context;
|
|
36
|
+
const relPath = relParts.length > 0 ? relParts.join('/') : '.';
|
|
37
|
+
if (depth > maxDepth) {
|
|
38
|
+
dotfilesWarning(warnings, writeStderr, relPath, 'depth exceeds limit', String(maxDepth));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = fsModule.readdirSync(srcDir, { withFileTypes: true });
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
dotfilesWarning(warnings, writeStderr, relPath, 'read failed', errorCodeOrDetail(error));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const childSrc = path.join(srcDir, entry.name);
|
|
51
|
+
const childDst = path.join(dstDir, entry.name);
|
|
52
|
+
const childRelParts = [...relParts, entry.name];
|
|
53
|
+
const childRelPath = childRelParts.join('/');
|
|
54
|
+
if (entry.isSymbolicLink()) {
|
|
55
|
+
let resolvedTarget;
|
|
56
|
+
try {
|
|
57
|
+
resolvedTarget = fsModule.realpathSync(childSrc);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
const code = errorCodeOrDetail(error);
|
|
61
|
+
const reason = code === 'ELOOP' ? 'symlink loop' : 'dangling symlink';
|
|
62
|
+
dotfilesWarning(warnings, writeStderr, childRelPath, reason, code || 'unresolved');
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
let targetStat;
|
|
66
|
+
try {
|
|
67
|
+
targetStat = fsModule.statSync(resolvedTarget);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
dotfilesWarning(warnings, writeStderr, childRelPath, 'target stat failed', errorCodeOrDetail(error));
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (targetStat.isDirectory()) {
|
|
74
|
+
if (activeDirs.has(resolvedTarget)) {
|
|
75
|
+
dotfilesWarning(warnings, writeStderr, childRelPath, 'symlink loop');
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
activeDirs.add(resolvedTarget);
|
|
79
|
+
walkAndMaterializeDotfiles({
|
|
80
|
+
srcDir: resolvedTarget,
|
|
81
|
+
dstDir: childDst,
|
|
82
|
+
relParts: childRelParts,
|
|
83
|
+
depth: depth + 1,
|
|
84
|
+
maxDepth,
|
|
85
|
+
activeDirs,
|
|
86
|
+
warnings,
|
|
87
|
+
writeStderr,
|
|
88
|
+
fsModule
|
|
89
|
+
});
|
|
90
|
+
activeDirs.delete(resolvedTarget);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (targetStat.isFile()) {
|
|
94
|
+
copyDotfile(resolvedTarget, childDst, {
|
|
95
|
+
fsModule,
|
|
96
|
+
relPath: childRelPath,
|
|
97
|
+
warnings,
|
|
98
|
+
writeStderr
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (entry.isDirectory()) {
|
|
104
|
+
let childRealPath = null;
|
|
105
|
+
try {
|
|
106
|
+
childRealPath = fsModule.realpathSync(childSrc);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// A real directory may disappear during traversal; readdir will warn below.
|
|
110
|
+
}
|
|
111
|
+
if (childRealPath) {
|
|
112
|
+
activeDirs.add(childRealPath);
|
|
113
|
+
}
|
|
114
|
+
walkAndMaterializeDotfiles({
|
|
115
|
+
srcDir: childSrc,
|
|
116
|
+
dstDir: childDst,
|
|
117
|
+
relParts: childRelParts,
|
|
118
|
+
depth: depth + 1,
|
|
119
|
+
maxDepth,
|
|
120
|
+
activeDirs,
|
|
121
|
+
warnings,
|
|
122
|
+
writeStderr,
|
|
123
|
+
fsModule
|
|
124
|
+
});
|
|
125
|
+
if (childRealPath) {
|
|
126
|
+
activeDirs.delete(childRealPath);
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (entry.isFile()) {
|
|
131
|
+
copyDotfile(childSrc, childDst, {
|
|
132
|
+
fsModule,
|
|
133
|
+
relPath: childRelPath,
|
|
134
|
+
warnings,
|
|
135
|
+
writeStderr
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function materializeDotfiles(srcDir, cacheDir, options = {}) {
|
|
141
|
+
const { writeStderr = (message) => process.stderr.write(message), maxDepth = 32, fsModule = fs } = options;
|
|
142
|
+
if (!srcDir || !fsModule.existsSync(srcDir)) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
fsModule.mkdirSync(cacheDir, { recursive: true });
|
|
146
|
+
for (const entry of fsModule.readdirSync(cacheDir)) {
|
|
147
|
+
fsModule.rmSync(path.join(cacheDir, entry), { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
const warnings = [];
|
|
150
|
+
const activeDirs = new Set();
|
|
151
|
+
try {
|
|
152
|
+
activeDirs.add(fsModule.realpathSync(srcDir));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
activeDirs.add(srcDir);
|
|
156
|
+
}
|
|
157
|
+
walkAndMaterializeDotfiles({
|
|
158
|
+
srcDir,
|
|
159
|
+
dstDir: cacheDir,
|
|
160
|
+
relParts: [],
|
|
161
|
+
depth: 0,
|
|
162
|
+
maxDepth,
|
|
163
|
+
activeDirs,
|
|
164
|
+
warnings,
|
|
165
|
+
writeStderr,
|
|
166
|
+
fsModule
|
|
167
|
+
});
|
|
168
|
+
return { cacheDir, warnings };
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=dotfiles.js.map
|