@fabric-harness/sdk 0.1.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/LICENSE +202 -0
- package/README.md +73 -0
- package/dist/agent-definition.d.ts +61 -0
- package/dist/agent-definition.d.ts.map +1 -0
- package/dist/agent-definition.js +52 -0
- package/dist/agent-definition.js.map +1 -0
- package/dist/agent.d.ts +13 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +99 -0
- package/dist/agent.js.map +1 -0
- package/dist/context-budget.d.ts +20 -0
- package/dist/context-budget.d.ts.map +1 -0
- package/dist/context-budget.js +27 -0
- package/dist/context-budget.js.map +1 -0
- package/dist/context.d.ts +3 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +8 -0
- package/dist/context.js.map +1 -0
- package/dist/error.d.ts +25 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +48 -0
- package/dist/error.js.map +1 -0
- package/dist/filesystem-source.d.ts +69 -0
- package/dist/filesystem-source.d.ts.map +1 -0
- package/dist/filesystem-source.js +109 -0
- package/dist/filesystem-source.js.map +1 -0
- package/dist/history.d.ts +16 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +118 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/lite.d.ts +46 -0
- package/dist/lite.d.ts.map +1 -0
- package/dist/lite.js +38 -0
- package/dist/lite.js.map +1 -0
- package/dist/loop.d.ts +41 -0
- package/dist/loop.d.ts.map +1 -0
- package/dist/loop.js +94 -0
- package/dist/loop.js.map +1 -0
- package/dist/mcp-stdio.d.ts +28 -0
- package/dist/mcp-stdio.d.ts.map +1 -0
- package/dist/mcp-stdio.js +102 -0
- package/dist/mcp-stdio.js.map +1 -0
- package/dist/mcp.d.ts +40 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +153 -0
- package/dist/mcp.js.map +1 -0
- package/dist/model.d.ts +204 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +661 -0
- package/dist/model.js.map +1 -0
- package/dist/otel.d.ts +17 -0
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +25 -0
- package/dist/otel.js.map +1 -0
- package/dist/policy.d.ts +54 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +148 -0
- package/dist/policy.js.map +1 -0
- package/dist/providers.d.ts +37 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +265 -0
- package/dist/providers.js.map +1 -0
- package/dist/redaction.d.ts +11 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +41 -0
- package/dist/redaction.js.map +1 -0
- package/dist/result.d.ts +7 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +105 -0
- package/dist/result.js.map +1 -0
- package/dist/runtime-mode.d.ts +30 -0
- package/dist/runtime-mode.d.ts.map +1 -0
- package/dist/runtime-mode.js +42 -0
- package/dist/runtime-mode.js.map +1 -0
- package/dist/sandbox.d.ts +247 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +922 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/schema.d.ts +37 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +121 -0
- package/dist/schema.js.map +1 -0
- package/dist/session.d.ts +60 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +762 -0
- package/dist/session.js.map +1 -0
- package/dist/store.d.ts +48 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +348 -0
- package/dist/store.js.map +1 -0
- package/dist/telemetry.d.ts +15 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +51 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/tools.d.ts +131 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +235 -0
- package/dist/tools.js.map +1 -0
- package/dist/types.d.ts +343 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +71 -0
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
/**
|
|
7
|
+
* Wrap a provider-owned remote sandbox client in Fabric's SandboxEnv contract.
|
|
8
|
+
* Provider credentials and SDK objects remain outside model context/history;
|
|
9
|
+
* Fabric only sees the narrow file/shell/snapshot API exposed here.
|
|
10
|
+
*/
|
|
11
|
+
export function createRemoteSandboxEnv(api, options = {}) {
|
|
12
|
+
return new RemoteSandboxEnv(api, options);
|
|
13
|
+
}
|
|
14
|
+
class RemoteSandboxEnv {
|
|
15
|
+
api;
|
|
16
|
+
options;
|
|
17
|
+
cwd;
|
|
18
|
+
capabilities;
|
|
19
|
+
constructor(api, options) {
|
|
20
|
+
this.api = api;
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.cwd = normalizeRemotePath(options.cwd ?? '/workspace');
|
|
23
|
+
this.capabilities = options.capabilities ?? {
|
|
24
|
+
exec: true,
|
|
25
|
+
filesystem: true,
|
|
26
|
+
binaryFiles: true,
|
|
27
|
+
snapshots: typeof api.snapshot === 'function',
|
|
28
|
+
restore: typeof api.restore === 'function',
|
|
29
|
+
network: 'provider-managed',
|
|
30
|
+
persistence: 'session',
|
|
31
|
+
isolation: 'provider',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
exec(command, options = {}) {
|
|
35
|
+
const execOptions = { ...options };
|
|
36
|
+
execOptions.cwd = options.cwd ? this.resolvePath(options.cwd) : this.cwd;
|
|
37
|
+
return this.api.exec(command, execOptions);
|
|
38
|
+
}
|
|
39
|
+
readFile(filePath) { return this.api.readFile(this.resolvePath(filePath)); }
|
|
40
|
+
readFileBuffer(filePath) { return this.api.readFileBuffer(this.resolvePath(filePath)); }
|
|
41
|
+
writeFile(filePath, content) { return this.api.writeFile(this.resolvePath(filePath), content); }
|
|
42
|
+
stat(filePath) { return this.api.stat(this.resolvePath(filePath)); }
|
|
43
|
+
readdir(dirPath) { return this.api.readdir(this.resolvePath(dirPath)); }
|
|
44
|
+
exists(filePath) { return this.api.exists(this.resolvePath(filePath)); }
|
|
45
|
+
mkdir(dirPath, options = {}) { return this.api.mkdir(this.resolvePath(dirPath), options); }
|
|
46
|
+
rm(filePath, options = {}) { return this.api.rm(this.resolvePath(filePath), options); }
|
|
47
|
+
resolvePath(filePath) {
|
|
48
|
+
const resolved = filePath.startsWith('/')
|
|
49
|
+
? normalizeRemotePath(filePath)
|
|
50
|
+
: normalizeRemotePath(path.posix.join(this.cwd, filePath));
|
|
51
|
+
if (resolved !== this.cwd && !resolved.startsWith(`${this.cwd}/`)) {
|
|
52
|
+
throw new Error(`Path escapes remote sandbox workspace: ${filePath}`);
|
|
53
|
+
}
|
|
54
|
+
return resolved;
|
|
55
|
+
}
|
|
56
|
+
async snapshot() {
|
|
57
|
+
if (!this.api.snapshot)
|
|
58
|
+
throw new Error('Remote sandbox does not support snapshots.');
|
|
59
|
+
return this.api.snapshot();
|
|
60
|
+
}
|
|
61
|
+
async restore(snapshot) {
|
|
62
|
+
if (!this.api.restore)
|
|
63
|
+
throw new Error('Remote sandbox does not support restore.');
|
|
64
|
+
await this.api.restore(snapshot);
|
|
65
|
+
}
|
|
66
|
+
async cleanup() {
|
|
67
|
+
const cleanup = this.options.cleanup;
|
|
68
|
+
if (!cleanup)
|
|
69
|
+
return;
|
|
70
|
+
if (typeof cleanup === 'function')
|
|
71
|
+
await cleanup();
|
|
72
|
+
else
|
|
73
|
+
await cleanup.cleanup();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function normalizeRemotePath(value) {
|
|
77
|
+
const normalized = path.posix.resolve('/', value);
|
|
78
|
+
return normalized === '/' ? '/' : normalized.replace(/\/+$/u, '');
|
|
79
|
+
}
|
|
80
|
+
function fileStatFromNode(node) {
|
|
81
|
+
const stat = {
|
|
82
|
+
type: node.type,
|
|
83
|
+
size: node.type === 'file' ? node.content.byteLength : 0,
|
|
84
|
+
};
|
|
85
|
+
if (node.mtime !== undefined) {
|
|
86
|
+
stat.mtime = node.mtime;
|
|
87
|
+
}
|
|
88
|
+
if (node.mode !== undefined) {
|
|
89
|
+
stat.mode = node.mode;
|
|
90
|
+
}
|
|
91
|
+
return stat;
|
|
92
|
+
}
|
|
93
|
+
function fileStatFromFs(stats) {
|
|
94
|
+
const type = stats.isFile()
|
|
95
|
+
? 'file'
|
|
96
|
+
: stats.isDirectory()
|
|
97
|
+
? 'directory'
|
|
98
|
+
: stats.isSymbolicLink()
|
|
99
|
+
? 'symlink'
|
|
100
|
+
: 'other';
|
|
101
|
+
return {
|
|
102
|
+
type,
|
|
103
|
+
size: Number(stats.size),
|
|
104
|
+
mtime: stats.mtime,
|
|
105
|
+
mode: Number(stats.mode),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function cloneEntries(entries) {
|
|
109
|
+
const clone = new Map();
|
|
110
|
+
for (const [entryPath, entry] of entries) {
|
|
111
|
+
clone.set(entryPath, entry.type === 'file'
|
|
112
|
+
? { ...entry, content: new Uint8Array(entry.content) }
|
|
113
|
+
: { ...entry });
|
|
114
|
+
}
|
|
115
|
+
return clone;
|
|
116
|
+
}
|
|
117
|
+
export class EmptySandboxEnv {
|
|
118
|
+
cwd;
|
|
119
|
+
capabilities = { exec: false, filesystem: true, binaryFiles: true, snapshots: true, restore: true, network: 'none', persistence: 'ephemeral', isolation: 'process' };
|
|
120
|
+
entries = new Map();
|
|
121
|
+
snapshots = new Map();
|
|
122
|
+
nextSnapshotId = 0;
|
|
123
|
+
constructor(cwd = '/workspace') {
|
|
124
|
+
this.cwd = path.posix.resolve('/', cwd);
|
|
125
|
+
this.entries.set(this.cwd, { type: 'directory', mtime: new Date() });
|
|
126
|
+
}
|
|
127
|
+
async exec(command) {
|
|
128
|
+
return {
|
|
129
|
+
command,
|
|
130
|
+
exitCode: 127,
|
|
131
|
+
stdout: '',
|
|
132
|
+
stderr: 'Empty sandbox does not support shell execution.',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async readFile(filePath) {
|
|
136
|
+
return new TextDecoder().decode(await this.readFileBuffer(filePath));
|
|
137
|
+
}
|
|
138
|
+
async readFileBuffer(filePath) {
|
|
139
|
+
const entry = this.entries.get(this.resolvePath(filePath));
|
|
140
|
+
if (!entry || entry.type !== 'file') {
|
|
141
|
+
throw new Error(`File not found: ${filePath}`);
|
|
142
|
+
}
|
|
143
|
+
return new Uint8Array(entry.content);
|
|
144
|
+
}
|
|
145
|
+
async writeFile(filePath, content) {
|
|
146
|
+
const resolved = this.resolvePath(filePath);
|
|
147
|
+
const parent = path.posix.dirname(resolved);
|
|
148
|
+
if (!(await this.exists(parent))) {
|
|
149
|
+
await this.mkdir(parent, { recursive: true });
|
|
150
|
+
}
|
|
151
|
+
const bytes = typeof content === 'string' ? new TextEncoder().encode(content) : content;
|
|
152
|
+
this.entries.set(resolved, {
|
|
153
|
+
type: 'file',
|
|
154
|
+
content: new Uint8Array(bytes),
|
|
155
|
+
mtime: new Date(),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async stat(filePath) {
|
|
159
|
+
const entry = this.entries.get(this.resolvePath(filePath));
|
|
160
|
+
if (!entry) {
|
|
161
|
+
throw new Error(`Path not found: ${filePath}`);
|
|
162
|
+
}
|
|
163
|
+
return fileStatFromNode(entry);
|
|
164
|
+
}
|
|
165
|
+
async readdir(dirPath) {
|
|
166
|
+
const resolved = this.resolvePath(dirPath);
|
|
167
|
+
const entry = this.entries.get(resolved);
|
|
168
|
+
if (!entry || entry.type !== 'directory') {
|
|
169
|
+
throw new Error(`Directory not found: ${dirPath}`);
|
|
170
|
+
}
|
|
171
|
+
const names = new Set();
|
|
172
|
+
const prefix = resolved.endsWith('/') ? resolved : `${resolved}/`;
|
|
173
|
+
for (const entryPath of this.entries.keys()) {
|
|
174
|
+
if (!entryPath.startsWith(prefix) || entryPath === resolved) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const remainder = entryPath.slice(prefix.length);
|
|
178
|
+
const [name] = remainder.split('/');
|
|
179
|
+
if (name) {
|
|
180
|
+
names.add(name);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return [...names].sort();
|
|
184
|
+
}
|
|
185
|
+
async exists(filePath) {
|
|
186
|
+
return this.entries.has(this.resolvePath(filePath));
|
|
187
|
+
}
|
|
188
|
+
async mkdir(dirPath, options = {}) {
|
|
189
|
+
const resolved = this.resolvePath(dirPath);
|
|
190
|
+
const segments = resolved.split('/').filter(Boolean);
|
|
191
|
+
let current = '';
|
|
192
|
+
for (const segment of segments) {
|
|
193
|
+
current = `${current}/${segment}`;
|
|
194
|
+
const entry = this.entries.get(current);
|
|
195
|
+
if (entry?.type === 'file') {
|
|
196
|
+
throw new Error(`Cannot create directory over file: ${current}`);
|
|
197
|
+
}
|
|
198
|
+
if (!entry) {
|
|
199
|
+
if (!options.recursive && current !== resolved) {
|
|
200
|
+
throw new Error(`Parent directory does not exist: ${path.posix.dirname(resolved)}`);
|
|
201
|
+
}
|
|
202
|
+
this.entries.set(current, { type: 'directory', mtime: new Date() });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async rm(filePath, options = {}) {
|
|
207
|
+
const resolved = this.resolvePath(filePath);
|
|
208
|
+
const entry = this.entries.get(resolved);
|
|
209
|
+
if (!entry) {
|
|
210
|
+
if (options.force) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
throw new Error(`Path not found: ${filePath}`);
|
|
214
|
+
}
|
|
215
|
+
if (entry.type === 'directory') {
|
|
216
|
+
const prefix = resolved.endsWith('/') ? resolved : `${resolved}/`;
|
|
217
|
+
const children = [...this.entries.keys()].filter((entryPath) => entryPath.startsWith(prefix));
|
|
218
|
+
if (children.length > 0 && !options.recursive) {
|
|
219
|
+
throw new Error(`Directory is not empty: ${filePath}`);
|
|
220
|
+
}
|
|
221
|
+
for (const child of children) {
|
|
222
|
+
this.entries.delete(child);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (resolved !== this.cwd) {
|
|
226
|
+
this.entries.delete(resolved);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
resolvePath(filePath) {
|
|
230
|
+
const resolved = filePath.startsWith('/')
|
|
231
|
+
? path.posix.resolve(filePath)
|
|
232
|
+
: path.posix.resolve(this.cwd, filePath);
|
|
233
|
+
if (resolved !== this.cwd && !resolved.startsWith(`${this.cwd}/`)) {
|
|
234
|
+
throw new Error(`Path escapes sandbox workspace: ${filePath}`);
|
|
235
|
+
}
|
|
236
|
+
return resolved;
|
|
237
|
+
}
|
|
238
|
+
async snapshot() {
|
|
239
|
+
const id = `empty-snapshot-${++this.nextSnapshotId}`;
|
|
240
|
+
this.snapshots.set(id, cloneEntries(this.entries));
|
|
241
|
+
return { id };
|
|
242
|
+
}
|
|
243
|
+
async restore(snapshot) {
|
|
244
|
+
const entries = this.snapshots.get(snapshot.id);
|
|
245
|
+
if (!entries) {
|
|
246
|
+
throw new Error(`Snapshot not found: ${snapshot.id}`);
|
|
247
|
+
}
|
|
248
|
+
this.entries = cloneEntries(entries);
|
|
249
|
+
}
|
|
250
|
+
async cleanup() {
|
|
251
|
+
this.snapshots.clear();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
export class LocalSandboxEnv {
|
|
255
|
+
cwd;
|
|
256
|
+
capabilities = { exec: true, filesystem: true, binaryFiles: true, snapshots: true, restore: true, network: 'egress', persistence: 'durable', isolation: 'process' };
|
|
257
|
+
env;
|
|
258
|
+
snapshotRoot;
|
|
259
|
+
outputLimitBytes;
|
|
260
|
+
defaultTimeoutMs;
|
|
261
|
+
constructor(options = {}) {
|
|
262
|
+
this.cwd = path.resolve(options.workspacePath ?? process.cwd());
|
|
263
|
+
this.env = {
|
|
264
|
+
...(options.inheritSafeEnv === false ? {} : safeHostEnv()),
|
|
265
|
+
...envFromAllowlist(options.envAllowlist),
|
|
266
|
+
...(options.env ?? {}),
|
|
267
|
+
};
|
|
268
|
+
this.outputLimitBytes = options.outputLimitBytes ?? 1024 * 1024;
|
|
269
|
+
if (options.defaultTimeoutMs !== undefined)
|
|
270
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs;
|
|
271
|
+
this.snapshotRoot = path.resolve(options.snapshotRoot ?? path.join(this.cwd, '.fabricharness', 'checkpoints', sanitizeId(options.sessionId ?? 'local')));
|
|
272
|
+
}
|
|
273
|
+
async exec(command, options = {}) {
|
|
274
|
+
const startedAt = Date.now();
|
|
275
|
+
const cwd = options.cwd ? await this.resolveExistingPath(options.cwd) : this.cwd;
|
|
276
|
+
const childEnv = { ...this.env, ...options.env };
|
|
277
|
+
return new Promise((resolve) => {
|
|
278
|
+
const child = spawn(command, {
|
|
279
|
+
cwd,
|
|
280
|
+
env: childEnv,
|
|
281
|
+
shell: true,
|
|
282
|
+
windowsHide: true,
|
|
283
|
+
});
|
|
284
|
+
let stdout = '';
|
|
285
|
+
let stderr = '';
|
|
286
|
+
let timedOut = false;
|
|
287
|
+
let aborted = false;
|
|
288
|
+
const abort = () => {
|
|
289
|
+
aborted = true;
|
|
290
|
+
child.kill('SIGTERM');
|
|
291
|
+
};
|
|
292
|
+
if (options.signal?.aborted)
|
|
293
|
+
abort();
|
|
294
|
+
else
|
|
295
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
296
|
+
const effectiveTimeout = options.timeout ?? this.defaultTimeoutMs;
|
|
297
|
+
const timeout = effectiveTimeout
|
|
298
|
+
? setTimeout(() => {
|
|
299
|
+
timedOut = true;
|
|
300
|
+
child.kill('SIGTERM');
|
|
301
|
+
}, effectiveTimeout)
|
|
302
|
+
: undefined;
|
|
303
|
+
child.stdout?.setEncoding('utf8');
|
|
304
|
+
child.stderr?.setEncoding('utf8');
|
|
305
|
+
child.stdout?.on('data', (chunk) => {
|
|
306
|
+
stdout = appendLimited(stdout, chunk, this.outputLimitBytes);
|
|
307
|
+
});
|
|
308
|
+
child.stderr?.on('data', (chunk) => {
|
|
309
|
+
stderr = appendLimited(stderr, chunk, this.outputLimitBytes);
|
|
310
|
+
});
|
|
311
|
+
child.on('error', (error) => {
|
|
312
|
+
if (timeout) {
|
|
313
|
+
clearTimeout(timeout);
|
|
314
|
+
}
|
|
315
|
+
options.signal?.removeEventListener('abort', abort);
|
|
316
|
+
resolve({
|
|
317
|
+
command,
|
|
318
|
+
exitCode: aborted ? 130 : 1,
|
|
319
|
+
stdout,
|
|
320
|
+
stderr: aborted ? `${stderr}Command aborted.\n` : `${stderr}${error.message}`,
|
|
321
|
+
durationMs: Date.now() - startedAt,
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
child.on('close', (code, signal) => {
|
|
325
|
+
if (timeout) {
|
|
326
|
+
clearTimeout(timeout);
|
|
327
|
+
}
|
|
328
|
+
options.signal?.removeEventListener('abort', abort);
|
|
329
|
+
resolve({
|
|
330
|
+
command,
|
|
331
|
+
exitCode: aborted ? 130 : timedOut ? 124 : (code ?? 1),
|
|
332
|
+
stdout,
|
|
333
|
+
stderr: aborted ? `${stderr}Command aborted.\n` : timedOut ? `${stderr}Command timed out and was terminated.\n` : stderr,
|
|
334
|
+
durationMs: Date.now() - startedAt,
|
|
335
|
+
...(signal ? { signal } : {}),
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
async readFile(filePath) {
|
|
341
|
+
return fs.readFile(await this.resolveExistingPath(filePath), 'utf8');
|
|
342
|
+
}
|
|
343
|
+
async readFileBuffer(filePath) {
|
|
344
|
+
return fs.readFile(await this.resolveExistingPath(filePath));
|
|
345
|
+
}
|
|
346
|
+
async writeFile(filePath, content) {
|
|
347
|
+
const resolved = await this.resolveWritablePath(filePath);
|
|
348
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
349
|
+
await fs.writeFile(resolved, content);
|
|
350
|
+
}
|
|
351
|
+
async stat(filePath) {
|
|
352
|
+
return fileStatFromFs(await fs.stat(await this.resolveExistingPath(filePath)));
|
|
353
|
+
}
|
|
354
|
+
async readdir(dirPath) {
|
|
355
|
+
return fs.readdir(await this.resolveExistingPath(dirPath));
|
|
356
|
+
}
|
|
357
|
+
async exists(filePath) {
|
|
358
|
+
try {
|
|
359
|
+
await fs.access(await this.resolveExistingPath(filePath));
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async mkdir(dirPath, options = {}) {
|
|
367
|
+
await fs.mkdir(await this.resolveWritablePath(dirPath), { recursive: options.recursive });
|
|
368
|
+
}
|
|
369
|
+
async rm(filePath, options = {}) {
|
|
370
|
+
await fs.rm(await this.resolveExistingPath(filePath), options);
|
|
371
|
+
}
|
|
372
|
+
resolvePath(filePath) {
|
|
373
|
+
const resolved = path.resolve(this.cwd, filePath);
|
|
374
|
+
this.assertInsideWorkspace(resolved, filePath);
|
|
375
|
+
return resolved;
|
|
376
|
+
}
|
|
377
|
+
async resolveExistingPath(filePath) {
|
|
378
|
+
const resolved = this.resolvePath(filePath);
|
|
379
|
+
const real = await fs.realpath(resolved);
|
|
380
|
+
this.assertInsideWorkspace(real, filePath);
|
|
381
|
+
return real;
|
|
382
|
+
}
|
|
383
|
+
async resolveWritablePath(filePath) {
|
|
384
|
+
const resolved = this.resolvePath(filePath);
|
|
385
|
+
try {
|
|
386
|
+
const real = await fs.realpath(resolved);
|
|
387
|
+
this.assertInsideWorkspace(real, filePath);
|
|
388
|
+
return real;
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
const parentReal = await fs.realpath(await this.nearestExistingParent(path.dirname(resolved)));
|
|
392
|
+
this.assertInsideWorkspace(parentReal, filePath);
|
|
393
|
+
return resolved;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async nearestExistingParent(dirPath) {
|
|
397
|
+
let current = dirPath;
|
|
398
|
+
while (current !== path.dirname(current)) {
|
|
399
|
+
try {
|
|
400
|
+
const stats = await fs.stat(current);
|
|
401
|
+
if (stats.isDirectory())
|
|
402
|
+
return current;
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// keep walking toward the workspace root
|
|
406
|
+
}
|
|
407
|
+
current = path.dirname(current);
|
|
408
|
+
}
|
|
409
|
+
return this.cwd;
|
|
410
|
+
}
|
|
411
|
+
assertInsideWorkspace(resolved, original) {
|
|
412
|
+
if (resolved !== this.cwd && !resolved.startsWith(`${this.cwd}${path.sep}`)) {
|
|
413
|
+
throw new Error(`Path escapes sandbox workspace: ${original}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async snapshot() {
|
|
417
|
+
return snapshotWorkspace(this.cwd, this.snapshotRoot, 'local');
|
|
418
|
+
}
|
|
419
|
+
async restore(snapshot) {
|
|
420
|
+
await restoreWorkspace(this.cwd, this.snapshotRoot, snapshot);
|
|
421
|
+
}
|
|
422
|
+
async cleanup() {
|
|
423
|
+
// Local sandbox does not own the workspace directory or checkpoint store.
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
export class DockerSandboxEnv {
|
|
427
|
+
cwd = '/workspace';
|
|
428
|
+
capabilities = { exec: true, filesystem: true, binaryFiles: true, snapshots: true, restore: true, network: 'restricted', persistence: 'session', isolation: 'container' };
|
|
429
|
+
hostWorkspacePath;
|
|
430
|
+
image;
|
|
431
|
+
env;
|
|
432
|
+
network;
|
|
433
|
+
user;
|
|
434
|
+
cpus;
|
|
435
|
+
memory;
|
|
436
|
+
pidsLimit;
|
|
437
|
+
readOnlyRootFilesystem;
|
|
438
|
+
mountReadOnly;
|
|
439
|
+
outputLimitBytes;
|
|
440
|
+
ownsWorkspace;
|
|
441
|
+
snapshotRoot;
|
|
442
|
+
constructor(options) {
|
|
443
|
+
this.hostWorkspacePath = path.resolve(options.workspacePath);
|
|
444
|
+
this.image = options.image ?? 'node:22-alpine';
|
|
445
|
+
this.env = { ...envFromAllowlist(options.envAllowlist), ...(options.env ?? {}) };
|
|
446
|
+
this.network = normalizeDockerNetwork(options.network);
|
|
447
|
+
if (options.user !== undefined)
|
|
448
|
+
this.user = options.user;
|
|
449
|
+
if (options.cpus !== undefined)
|
|
450
|
+
this.cpus = options.cpus;
|
|
451
|
+
if (options.memory !== undefined)
|
|
452
|
+
this.memory = options.memory;
|
|
453
|
+
if (options.pidsLimit !== undefined)
|
|
454
|
+
this.pidsLimit = options.pidsLimit;
|
|
455
|
+
this.readOnlyRootFilesystem = options.readOnlyRootFilesystem ?? true;
|
|
456
|
+
this.mountReadOnly = options.mountReadOnly ?? false;
|
|
457
|
+
this.outputLimitBytes = options.outputLimitBytes ?? 1024 * 1024;
|
|
458
|
+
this.ownsWorkspace = options.ownsWorkspace ?? false;
|
|
459
|
+
this.snapshotRoot = path.resolve(options.snapshotRoot ?? path.join(this.hostWorkspacePath, '.fabricharness', 'checkpoints', sanitizeId(options.sessionId ?? 'docker')));
|
|
460
|
+
}
|
|
461
|
+
async exec(command, options = {}) {
|
|
462
|
+
await fs.mkdir(this.hostWorkspacePath, { recursive: true });
|
|
463
|
+
const startedAt = Date.now();
|
|
464
|
+
const containerName = `fabric-harness-${randomUUID()}`;
|
|
465
|
+
const cwd = options.cwd ? this.toContainerPath(options.cwd) : this.cwd;
|
|
466
|
+
const args = [
|
|
467
|
+
'run',
|
|
468
|
+
'--rm',
|
|
469
|
+
'--name',
|
|
470
|
+
containerName,
|
|
471
|
+
'--network',
|
|
472
|
+
this.network,
|
|
473
|
+
'-v',
|
|
474
|
+
`${this.hostWorkspacePath}:${this.cwd}${this.mountReadOnly ? ':ro' : ''}`,
|
|
475
|
+
'-w',
|
|
476
|
+
cwd,
|
|
477
|
+
];
|
|
478
|
+
if (this.user)
|
|
479
|
+
args.push('-u', this.user);
|
|
480
|
+
if (this.cpus !== undefined)
|
|
481
|
+
args.push('--cpus', String(this.cpus));
|
|
482
|
+
if (this.memory !== undefined)
|
|
483
|
+
args.push('--memory', this.memory);
|
|
484
|
+
if (this.pidsLimit !== undefined)
|
|
485
|
+
args.push('--pids-limit', String(this.pidsLimit));
|
|
486
|
+
if (this.readOnlyRootFilesystem)
|
|
487
|
+
args.push('--read-only', '--tmpfs', '/tmp:rw,noexec,nosuid,size=64m');
|
|
488
|
+
for (const [key, value] of Object.entries({ ...this.env, ...options.env }))
|
|
489
|
+
args.push('-e', `${key}=${value}`);
|
|
490
|
+
args.push(this.image, 'sh', '-lc', command);
|
|
491
|
+
return new Promise((resolve) => {
|
|
492
|
+
const child = spawn('docker', args, { windowsHide: true });
|
|
493
|
+
let stdout = '';
|
|
494
|
+
let stderr = '';
|
|
495
|
+
let timedOut = false;
|
|
496
|
+
let aborted = false;
|
|
497
|
+
const abort = () => {
|
|
498
|
+
aborted = true;
|
|
499
|
+
child.kill('SIGTERM');
|
|
500
|
+
void runDocker(['rm', '-f', containerName]);
|
|
501
|
+
};
|
|
502
|
+
if (options.signal?.aborted)
|
|
503
|
+
abort();
|
|
504
|
+
else
|
|
505
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
506
|
+
const timeout = options.timeout
|
|
507
|
+
? setTimeout(() => {
|
|
508
|
+
timedOut = true;
|
|
509
|
+
child.kill('SIGTERM');
|
|
510
|
+
void runDocker(['rm', '-f', containerName]);
|
|
511
|
+
}, options.timeout)
|
|
512
|
+
: undefined;
|
|
513
|
+
child.stdout?.setEncoding('utf8');
|
|
514
|
+
child.stderr?.setEncoding('utf8');
|
|
515
|
+
child.stdout?.on('data', (chunk) => { stdout = appendLimited(stdout, chunk, this.outputLimitBytes); });
|
|
516
|
+
child.stderr?.on('data', (chunk) => { stderr = appendLimited(stderr, chunk, this.outputLimitBytes); });
|
|
517
|
+
child.on('error', (error) => {
|
|
518
|
+
if (timeout)
|
|
519
|
+
clearTimeout(timeout);
|
|
520
|
+
options.signal?.removeEventListener('abort', abort);
|
|
521
|
+
resolve({ command, exitCode: aborted ? 130 : 1, stdout, stderr: aborted ? `${stderr}Command aborted.\n` : `${stderr}${error.message}`, durationMs: Date.now() - startedAt });
|
|
522
|
+
});
|
|
523
|
+
child.on('close', (code, signal) => {
|
|
524
|
+
if (timeout)
|
|
525
|
+
clearTimeout(timeout);
|
|
526
|
+
options.signal?.removeEventListener('abort', abort);
|
|
527
|
+
resolve({
|
|
528
|
+
command,
|
|
529
|
+
exitCode: aborted ? 130 : timedOut ? 124 : (code ?? 1),
|
|
530
|
+
stdout,
|
|
531
|
+
stderr: aborted ? `${stderr}Command aborted.\n` : timedOut ? `${stderr}Command timed out and container was terminated.\n` : stderr,
|
|
532
|
+
durationMs: Date.now() - startedAt,
|
|
533
|
+
...(signal ? { signal } : {}),
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
async readFile(filePath) {
|
|
539
|
+
return fs.readFile(this.resolvePath(filePath), 'utf8');
|
|
540
|
+
}
|
|
541
|
+
async readFileBuffer(filePath) {
|
|
542
|
+
return fs.readFile(this.resolvePath(filePath));
|
|
543
|
+
}
|
|
544
|
+
async writeFile(filePath, content) {
|
|
545
|
+
const resolved = this.resolvePath(filePath);
|
|
546
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
547
|
+
await fs.writeFile(resolved, content);
|
|
548
|
+
}
|
|
549
|
+
async stat(filePath) {
|
|
550
|
+
return fileStatFromFs(await fs.stat(this.resolvePath(filePath)));
|
|
551
|
+
}
|
|
552
|
+
async readdir(dirPath) {
|
|
553
|
+
return fs.readdir(this.resolvePath(dirPath));
|
|
554
|
+
}
|
|
555
|
+
async exists(filePath) {
|
|
556
|
+
try {
|
|
557
|
+
await fs.access(this.resolvePath(filePath));
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
async mkdir(dirPath, options = {}) {
|
|
565
|
+
await fs.mkdir(this.resolvePath(dirPath), { recursive: options.recursive });
|
|
566
|
+
}
|
|
567
|
+
async rm(filePath, options = {}) {
|
|
568
|
+
await fs.rm(this.resolvePath(filePath), options);
|
|
569
|
+
}
|
|
570
|
+
resolvePath(filePath) {
|
|
571
|
+
const relative = this.toWorkspaceRelativePath(filePath);
|
|
572
|
+
const resolved = path.resolve(this.hostWorkspacePath, relative);
|
|
573
|
+
if (resolved !== this.hostWorkspacePath && !resolved.startsWith(`${this.hostWorkspacePath}${path.sep}`)) {
|
|
574
|
+
throw new Error(`Path escapes sandbox workspace: ${filePath}`);
|
|
575
|
+
}
|
|
576
|
+
return resolved;
|
|
577
|
+
}
|
|
578
|
+
async snapshot() {
|
|
579
|
+
return snapshotWorkspace(this.hostWorkspacePath, this.snapshotRoot, 'docker');
|
|
580
|
+
}
|
|
581
|
+
async restore(snapshot) {
|
|
582
|
+
await restoreWorkspace(this.hostWorkspacePath, this.snapshotRoot, snapshot);
|
|
583
|
+
}
|
|
584
|
+
async cleanup() {
|
|
585
|
+
if (this.ownsWorkspace)
|
|
586
|
+
await fs.rm(this.hostWorkspacePath, { recursive: true, force: true });
|
|
587
|
+
}
|
|
588
|
+
toContainerPath(filePath) {
|
|
589
|
+
const relative = this.toWorkspaceRelativePath(filePath);
|
|
590
|
+
return relative ? path.posix.join(this.cwd, relative.split(path.sep).join('/')) : this.cwd;
|
|
591
|
+
}
|
|
592
|
+
toWorkspaceRelativePath(filePath) {
|
|
593
|
+
if (filePath.startsWith('/')) {
|
|
594
|
+
const resolved = path.posix.resolve(filePath);
|
|
595
|
+
if (resolved !== this.cwd && !resolved.startsWith(`${this.cwd}/`)) {
|
|
596
|
+
throw new Error(`Path escapes sandbox workspace: ${filePath}`);
|
|
597
|
+
}
|
|
598
|
+
return resolved.slice(this.cwd.length).replace(/^\//, '');
|
|
599
|
+
}
|
|
600
|
+
const resolved = path.posix.resolve(this.cwd, filePath);
|
|
601
|
+
if (resolved !== this.cwd && !resolved.startsWith(`${this.cwd}/`)) {
|
|
602
|
+
throw new Error(`Path escapes sandbox workspace: ${filePath}`);
|
|
603
|
+
}
|
|
604
|
+
return resolved.slice(this.cwd.length).replace(/^\//, '');
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
export async function pruneSnapshots(snapshotRoot, options = {}) {
|
|
608
|
+
if (!await fileExists(snapshotRoot))
|
|
609
|
+
return { removed: [], kept: [] };
|
|
610
|
+
const entries = await fs.readdir(snapshotRoot, { withFileTypes: true });
|
|
611
|
+
const snapshots = [];
|
|
612
|
+
for (const entry of entries) {
|
|
613
|
+
if (!entry.isDirectory())
|
|
614
|
+
continue;
|
|
615
|
+
const snapshotPath = path.join(snapshotRoot, entry.name);
|
|
616
|
+
const manifestPath = path.join(snapshotPath, 'manifest.json');
|
|
617
|
+
let createdAt = (await fs.stat(snapshotPath)).mtimeMs;
|
|
618
|
+
if (await fileExists(manifestPath)) {
|
|
619
|
+
try {
|
|
620
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
|
621
|
+
if (manifest.createdAt)
|
|
622
|
+
createdAt = new Date(manifest.createdAt).getTime();
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
// Ignore malformed manifests during retention scanning; restore still validates integrity.
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
snapshots.push({ id: entry.name, path: snapshotPath, createdAt });
|
|
629
|
+
}
|
|
630
|
+
snapshots.sort((a, b) => b.createdAt - a.createdAt);
|
|
631
|
+
const keep = Math.max(0, options.keep ?? snapshots.length);
|
|
632
|
+
const cutoff = options.olderThanMs !== undefined ? Date.now() - options.olderThanMs : undefined;
|
|
633
|
+
const removed = [];
|
|
634
|
+
const kept = [];
|
|
635
|
+
for (const [index, snapshot] of snapshots.entries()) {
|
|
636
|
+
const removeByCount = index >= keep;
|
|
637
|
+
const removeByAge = cutoff !== undefined && snapshot.createdAt < cutoff;
|
|
638
|
+
if (removeByCount || removeByAge) {
|
|
639
|
+
await fs.rm(snapshot.path, { recursive: true, force: true });
|
|
640
|
+
removed.push(snapshot.id);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
kept.push(snapshot.id);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return { removed, kept };
|
|
647
|
+
}
|
|
648
|
+
async function snapshotWorkspace(workspaceRoot, snapshotRoot, backend) {
|
|
649
|
+
const id = `${backend}-snapshot-${Date.now()}-${randomUUID()}`;
|
|
650
|
+
const snapshotDir = path.join(snapshotRoot, id);
|
|
651
|
+
const filesDir = path.join(snapshotDir, 'files');
|
|
652
|
+
await fs.mkdir(filesDir, { recursive: true });
|
|
653
|
+
await copyDirectory(workspaceRoot, filesDir, { excludeRoots: [snapshotRoot] });
|
|
654
|
+
const files = await snapshotFileManifest(filesDir);
|
|
655
|
+
const manifest = {
|
|
656
|
+
schemaVersion: 1,
|
|
657
|
+
id,
|
|
658
|
+
backend,
|
|
659
|
+
workspaceRoot,
|
|
660
|
+
createdAt: new Date().toISOString(),
|
|
661
|
+
parent: null,
|
|
662
|
+
files,
|
|
663
|
+
totalBytes: files.reduce((sum, file) => sum + file.bytes, 0),
|
|
664
|
+
};
|
|
665
|
+
await fs.writeFile(path.join(snapshotDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
666
|
+
return { id, metadata: { path: snapshotDir, backend } };
|
|
667
|
+
}
|
|
668
|
+
async function restoreWorkspace(workspaceRoot, snapshotRoot, snapshot) {
|
|
669
|
+
const snapshotDir = resolveSnapshotDir(snapshotRoot, snapshot);
|
|
670
|
+
const filesDir = path.join(snapshotDir, 'files');
|
|
671
|
+
const stat = await fs.stat(filesDir);
|
|
672
|
+
if (!stat.isDirectory())
|
|
673
|
+
throw new Error(`Snapshot files directory not found: ${snapshot.id}`);
|
|
674
|
+
await verifySnapshotIntegrity(snapshotDir, filesDir);
|
|
675
|
+
await fs.mkdir(workspaceRoot, { recursive: true });
|
|
676
|
+
await removeDirectoryContents(workspaceRoot, [snapshotRoot]);
|
|
677
|
+
await copyDirectory(filesDir, workspaceRoot);
|
|
678
|
+
}
|
|
679
|
+
async function verifySnapshotIntegrity(snapshotDir, filesDir) {
|
|
680
|
+
const manifestPath = path.join(snapshotDir, 'manifest.json');
|
|
681
|
+
if (!await fileExists(manifestPath))
|
|
682
|
+
return;
|
|
683
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
|
684
|
+
if (!Array.isArray(manifest.files))
|
|
685
|
+
return;
|
|
686
|
+
for (const file of manifest.files) {
|
|
687
|
+
const filePath = path.resolve(filesDir, file.path);
|
|
688
|
+
if (filePath !== filesDir && !filePath.startsWith(`${filesDir}${path.sep}`))
|
|
689
|
+
throw new Error(`Snapshot manifest path escapes files directory: ${file.path}`);
|
|
690
|
+
const stat = await fs.stat(filePath);
|
|
691
|
+
if (stat.size !== file.bytes)
|
|
692
|
+
throw new Error(`Snapshot file size mismatch: ${file.path}`);
|
|
693
|
+
const digest = await sha256File(filePath);
|
|
694
|
+
if (digest !== file.sha256)
|
|
695
|
+
throw new Error(`Snapshot file checksum mismatch: ${file.path}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async function snapshotFileManifest(root) {
|
|
699
|
+
const files = await listRegularFiles(root);
|
|
700
|
+
const manifest = [];
|
|
701
|
+
for (const file of files) {
|
|
702
|
+
const stat = await fs.stat(file);
|
|
703
|
+
manifest.push({ path: toPosix(path.relative(root, file)), bytes: stat.size, sha256: await sha256File(file) });
|
|
704
|
+
}
|
|
705
|
+
return manifest.sort((a, b) => a.path.localeCompare(b.path));
|
|
706
|
+
}
|
|
707
|
+
async function listRegularFiles(root) {
|
|
708
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
709
|
+
const files = [];
|
|
710
|
+
for (const entry of entries) {
|
|
711
|
+
const entryPath = path.join(root, entry.name);
|
|
712
|
+
if (entry.isDirectory())
|
|
713
|
+
files.push(...await listRegularFiles(entryPath));
|
|
714
|
+
else if (entry.isFile())
|
|
715
|
+
files.push(entryPath);
|
|
716
|
+
}
|
|
717
|
+
return files;
|
|
718
|
+
}
|
|
719
|
+
async function sha256File(filePath) {
|
|
720
|
+
return createHash('sha256').update(await fs.readFile(filePath)).digest('hex');
|
|
721
|
+
}
|
|
722
|
+
async function fileExists(filePath) {
|
|
723
|
+
try {
|
|
724
|
+
await fs.access(filePath);
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function toPosix(value) {
|
|
732
|
+
return value.split(path.sep).join('/');
|
|
733
|
+
}
|
|
734
|
+
function resolveSnapshotDir(snapshotRoot, snapshot) {
|
|
735
|
+
const metadataPath = typeof snapshot.metadata?.path === 'string' ? snapshot.metadata.path : undefined;
|
|
736
|
+
const candidate = path.resolve(metadataPath ?? path.join(snapshotRoot, snapshot.id));
|
|
737
|
+
const root = path.resolve(snapshotRoot);
|
|
738
|
+
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) {
|
|
739
|
+
throw new Error(`Snapshot path escapes snapshot root: ${snapshot.id}`);
|
|
740
|
+
}
|
|
741
|
+
return candidate;
|
|
742
|
+
}
|
|
743
|
+
async function copyDirectory(source, destination, options = {}) {
|
|
744
|
+
await fs.mkdir(destination, { recursive: true });
|
|
745
|
+
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
746
|
+
for (const entry of entries) {
|
|
747
|
+
const sourcePath = path.join(source, entry.name);
|
|
748
|
+
if (isExcludedPath(sourcePath, options.excludeRoots ?? []))
|
|
749
|
+
continue;
|
|
750
|
+
const destinationPath = path.join(destination, entry.name);
|
|
751
|
+
if (entry.isDirectory()) {
|
|
752
|
+
await copyDirectory(sourcePath, destinationPath, options);
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (entry.isSymbolicLink()) {
|
|
756
|
+
const target = await fs.readlink(sourcePath);
|
|
757
|
+
await fs.symlink(target, destinationPath);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
if (entry.isFile()) {
|
|
761
|
+
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
|
|
762
|
+
await fs.copyFile(sourcePath, destinationPath);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async function removeDirectoryContents(directory, excludeRoots = []) {
|
|
767
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
768
|
+
for (const entry of entries) {
|
|
769
|
+
const entryPath = path.join(directory, entry.name);
|
|
770
|
+
if (isExcludedPath(entryPath, excludeRoots) || containsExcludedRoot(entryPath, excludeRoots))
|
|
771
|
+
continue;
|
|
772
|
+
await fs.rm(entryPath, { recursive: true, force: true });
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
function isExcludedPath(candidate, excludeRoots) {
|
|
776
|
+
const resolved = path.resolve(candidate);
|
|
777
|
+
return excludeRoots.some((root) => {
|
|
778
|
+
const excluded = path.resolve(root);
|
|
779
|
+
return resolved === excluded || resolved.startsWith(`${excluded}${path.sep}`);
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
function containsExcludedRoot(candidate, excludeRoots) {
|
|
783
|
+
const resolved = path.resolve(candidate);
|
|
784
|
+
return excludeRoots.some((root) => {
|
|
785
|
+
const excluded = path.resolve(root);
|
|
786
|
+
return excluded.startsWith(`${resolved}${path.sep}`);
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
function sanitizeId(value) {
|
|
790
|
+
return value.replace(/[^a-zA-Z0-9._:-]/g, '_');
|
|
791
|
+
}
|
|
792
|
+
function normalizeDockerNetwork(network) {
|
|
793
|
+
if (network === true)
|
|
794
|
+
return 'bridge';
|
|
795
|
+
if (network === false || network === undefined)
|
|
796
|
+
return 'none';
|
|
797
|
+
return network;
|
|
798
|
+
}
|
|
799
|
+
function envFromAllowlist(allowlist) {
|
|
800
|
+
if (!allowlist || allowlist.length === 0)
|
|
801
|
+
return {};
|
|
802
|
+
const env = {};
|
|
803
|
+
for (const key of allowlist) {
|
|
804
|
+
const value = process.env[key];
|
|
805
|
+
if (value !== undefined)
|
|
806
|
+
env[key] = value;
|
|
807
|
+
}
|
|
808
|
+
return env;
|
|
809
|
+
}
|
|
810
|
+
const SAFE_HOST_ENV_KEYS = [
|
|
811
|
+
'PATH',
|
|
812
|
+
'HOME',
|
|
813
|
+
'USER',
|
|
814
|
+
'LOGNAME',
|
|
815
|
+
'HOSTNAME',
|
|
816
|
+
'SHELL',
|
|
817
|
+
'LANG',
|
|
818
|
+
'LC_ALL',
|
|
819
|
+
'LC_CTYPE',
|
|
820
|
+
'TZ',
|
|
821
|
+
'TERM',
|
|
822
|
+
'TMPDIR',
|
|
823
|
+
'TMP',
|
|
824
|
+
'TEMP',
|
|
825
|
+
];
|
|
826
|
+
function safeHostEnv() {
|
|
827
|
+
return envFromAllowlist(SAFE_HOST_ENV_KEYS);
|
|
828
|
+
}
|
|
829
|
+
function appendLimited(current, chunk, limit) {
|
|
830
|
+
if (limit <= 0)
|
|
831
|
+
return '';
|
|
832
|
+
const next = `${current}${chunk}`;
|
|
833
|
+
if (Buffer.byteLength(next, 'utf8') <= limit)
|
|
834
|
+
return next;
|
|
835
|
+
const marker = '\n[output truncated by Fabric Harness]\n';
|
|
836
|
+
return `${next.slice(Math.max(0, next.length - limit + marker.length))}${marker}`;
|
|
837
|
+
}
|
|
838
|
+
function runDocker(args) {
|
|
839
|
+
return new Promise((resolve) => {
|
|
840
|
+
const child = spawn('docker', args, { stdio: 'ignore', windowsHide: true });
|
|
841
|
+
child.on('close', () => resolve());
|
|
842
|
+
child.on('error', () => resolve());
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
export class UnimplementedSandboxEnv extends EmptySandboxEnv {
|
|
846
|
+
async exec(command) {
|
|
847
|
+
return {
|
|
848
|
+
command,
|
|
849
|
+
exitCode: 127,
|
|
850
|
+
stdout: '',
|
|
851
|
+
stderr: 'Sandbox runtime not implemented yet.',
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
export async function createSandboxEnv(options = {}) {
|
|
856
|
+
const backend = options.backend ?? 'empty';
|
|
857
|
+
if (backend === 'empty') {
|
|
858
|
+
return new EmptySandboxEnv();
|
|
859
|
+
}
|
|
860
|
+
if (backend === 'local') {
|
|
861
|
+
const localOptions = {};
|
|
862
|
+
if (options.workspacePath !== undefined) {
|
|
863
|
+
localOptions.workspacePath = options.workspacePath;
|
|
864
|
+
}
|
|
865
|
+
if (options.env !== undefined) {
|
|
866
|
+
localOptions.env = options.env;
|
|
867
|
+
}
|
|
868
|
+
if (options.sessionId !== undefined) {
|
|
869
|
+
localOptions.sessionId = options.sessionId;
|
|
870
|
+
}
|
|
871
|
+
if (typeof options.metadata?.snapshotRoot === 'string') {
|
|
872
|
+
localOptions.snapshotRoot = options.metadata.snapshotRoot;
|
|
873
|
+
}
|
|
874
|
+
if (typeof options.metadata?.outputLimitBytes === 'number')
|
|
875
|
+
localOptions.outputLimitBytes = options.metadata.outputLimitBytes;
|
|
876
|
+
if (typeof options.metadata?.defaultTimeoutMs === 'number')
|
|
877
|
+
localOptions.defaultTimeoutMs = options.metadata.defaultTimeoutMs;
|
|
878
|
+
if (typeof options.metadata?.inheritSafeEnv === 'boolean')
|
|
879
|
+
localOptions.inheritSafeEnv = options.metadata.inheritSafeEnv;
|
|
880
|
+
if (Array.isArray(options.metadata?.envAllowlist))
|
|
881
|
+
localOptions.envAllowlist = options.metadata.envAllowlist.filter((value) => typeof value === 'string');
|
|
882
|
+
return new LocalSandboxEnv(localOptions);
|
|
883
|
+
}
|
|
884
|
+
if (backend === 'docker') {
|
|
885
|
+
const workspacePath = options.workspacePath ?? await fs.mkdtemp(path.join(tmpdir(), 'fabric-harness-docker-'));
|
|
886
|
+
const dockerOptions = {
|
|
887
|
+
workspacePath,
|
|
888
|
+
ownsWorkspace: options.workspacePath === undefined,
|
|
889
|
+
};
|
|
890
|
+
if (options.env !== undefined)
|
|
891
|
+
dockerOptions.env = options.env;
|
|
892
|
+
if (options.sessionId !== undefined)
|
|
893
|
+
dockerOptions.sessionId = options.sessionId;
|
|
894
|
+
if (typeof options.metadata?.snapshotRoot === 'string')
|
|
895
|
+
dockerOptions.snapshotRoot = options.metadata.snapshotRoot;
|
|
896
|
+
if (typeof options.metadata?.image === 'string')
|
|
897
|
+
dockerOptions.image = options.metadata.image;
|
|
898
|
+
if (typeof options.metadata?.dockerImage === 'string')
|
|
899
|
+
dockerOptions.image = options.metadata.dockerImage;
|
|
900
|
+
if (typeof options.metadata?.network === 'string' || typeof options.metadata?.network === 'boolean')
|
|
901
|
+
dockerOptions.network = options.metadata.network;
|
|
902
|
+
if (typeof options.metadata?.user === 'string')
|
|
903
|
+
dockerOptions.user = options.metadata.user;
|
|
904
|
+
if (typeof options.metadata?.cpus === 'string' || typeof options.metadata?.cpus === 'number')
|
|
905
|
+
dockerOptions.cpus = options.metadata.cpus;
|
|
906
|
+
if (typeof options.metadata?.memory === 'string')
|
|
907
|
+
dockerOptions.memory = options.metadata.memory;
|
|
908
|
+
if (typeof options.metadata?.pidsLimit === 'number')
|
|
909
|
+
dockerOptions.pidsLimit = options.metadata.pidsLimit;
|
|
910
|
+
if (typeof options.metadata?.readOnlyRootFilesystem === 'boolean')
|
|
911
|
+
dockerOptions.readOnlyRootFilesystem = options.metadata.readOnlyRootFilesystem;
|
|
912
|
+
if (typeof options.metadata?.mountReadOnly === 'boolean')
|
|
913
|
+
dockerOptions.mountReadOnly = options.metadata.mountReadOnly;
|
|
914
|
+
if (typeof options.metadata?.outputLimitBytes === 'number')
|
|
915
|
+
dockerOptions.outputLimitBytes = options.metadata.outputLimitBytes;
|
|
916
|
+
if (Array.isArray(options.metadata?.envAllowlist))
|
|
917
|
+
dockerOptions.envAllowlist = options.metadata.envAllowlist.filter((value) => typeof value === 'string');
|
|
918
|
+
return new DockerSandboxEnv(dockerOptions);
|
|
919
|
+
}
|
|
920
|
+
throw new Error(`Sandbox backend is not implemented yet: ${backend}`);
|
|
921
|
+
}
|
|
922
|
+
//# sourceMappingURL=sandbox.js.map
|