@purista/harness 1.2.5 → 1.5.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 +6 -0
- package/dist/agents/index.d.ts +7 -1
- package/dist/agents/index.js +59 -39
- package/dist/errors/catalog.d.ts +18 -2
- package/dist/errors/catalog.js +10 -0
- package/dist/eval/index.d.ts +3 -3
- package/dist/eval/index.js +15 -1
- package/dist/harness/defineHarness.d.ts +91 -1
- package/dist/harness/defineHarness.js +110 -1
- package/dist/index.d.ts +37 -17
- package/dist/index.js +30 -16
- package/dist/local/index.d.ts +36 -0
- package/dist/local/index.js +24 -0
- package/dist/local/local-sandbox.d.ts +25 -0
- package/dist/local/local-sandbox.js +368 -0
- package/dist/local/local-workspace.d.ts +56 -0
- package/dist/local/local-workspace.js +496 -0
- package/dist/local/ref-hash.d.ts +6 -0
- package/dist/local/ref-hash.js +9 -0
- package/dist/local/sqlite-storage.d.ts +106 -0
- package/dist/local/sqlite-storage.js +680 -0
- package/dist/models/adapter-utils.d.ts +52 -0
- package/dist/models/adapter-utils.js +81 -0
- package/dist/models/registry.d.ts +2 -1
- package/dist/models/registry.js +28 -37
- package/dist/models/stream-pump.d.ts +16 -0
- package/dist/models/stream-pump.js +77 -0
- package/dist/ports/base-model-provider.d.ts +7 -1
- package/dist/ports/base-model-provider.js +384 -87
- package/dist/ports/capabilities.d.ts +16 -2
- package/dist/ports/context-checkpoints.d.ts +63 -0
- package/dist/ports/context-checkpoints.js +33 -0
- package/dist/ports/index.d.ts +1 -0
- package/dist/ports/index.js +1 -0
- package/dist/ports/model-provider.d.ts +110 -0
- package/dist/runtime/durable.d.ts +11 -0
- package/dist/runtime/durable.js +15 -2
- package/dist/runtime/sessionDurable.js +47 -21
- package/dist/sessions/index.d.ts +17 -6
- package/dist/sessions/index.js +337 -81
- package/dist/skills/index.d.ts +0 -2
- package/dist/skills/index.js +0 -8
- package/dist/state/in-memory.js +6 -6
- package/dist/telemetry/shim.js +2 -6
- package/dist/telemetry/span-attrs.d.ts +9 -0
- package/dist/telemetry/span-attrs.js +27 -0
- package/dist/testing/durableWorkspaceStoreContract.js +69 -0
- package/dist/testing/fakeLogger.d.ts +29 -0
- package/dist/testing/fakeLogger.js +47 -0
- package/dist/testing/fakeSandbox.d.ts +27 -0
- package/dist/testing/fakeSandbox.js +153 -0
- package/dist/testing/fakeStateStore.d.ts +36 -0
- package/dist/testing/fakeStateStore.js +66 -0
- package/dist/testing/index.d.ts +10 -4
- package/dist/testing/index.js +14 -4
- package/dist/testing/loggerContract.d.ts +9 -0
- package/dist/testing/loggerContract.js +62 -0
- package/dist/testing/modelProviderContract.d.ts +12 -0
- package/dist/testing/modelProviderContract.js +222 -0
- package/dist/testing/recordEvents.d.ts +3 -0
- package/dist/testing/recordEvents.js +8 -0
- package/dist/testing/stateStoreContract.js +27 -0
- package/dist/tools/index.js +26 -1
- package/dist/tools/mcp/http.d.ts +2 -0
- package/dist/tools/mcp/http.js +34 -21
- package/dist/tools/mcp/runner.d.ts +4 -0
- package/dist/tools/mcp/runner.js +75 -21
- package/dist/tools/mcp/stdio.d.ts +7 -1
- package/dist/tools/mcp/stdio.js +102 -23
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace/in-memory.d.ts +1 -0
- package/dist/workspace/in-memory.js +47 -12
- package/package.json +2 -1
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, writeFile, rm, readdir, stat, lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import { resolve, dirname, posix, join } from 'node:path';
|
|
4
|
+
import { SandboxError, SandboxNoExecutorError, OperationTimeoutError } from '../errors/index.js';
|
|
5
|
+
import { abortError } from '../runtime/abort.js';
|
|
6
|
+
import { sha256Hex } from './ref-hash.js';
|
|
7
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 120_000;
|
|
8
|
+
/** Maximum captured stdout/stderr bytes per exec call (spec 22 §5). */
|
|
9
|
+
const MAX_EXEC_CAPTURE_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const EXEC_OUTPUT_TRUNCATION_MARKER = '\n[truncated: local sandbox capture limit reached]';
|
|
11
|
+
/** Shell metacharacters rejected outside quotes when an allow-list is active (spec 22 §5). */
|
|
12
|
+
const SHELL_METACHARACTERS = new Set([';', '|', '&', '<', '>', '`', '$', '(', ')', '\n', '\r']);
|
|
13
|
+
/** Path-segment-safe id for sandbox session roots (no separators, no dot segments). */
|
|
14
|
+
const SANDBOX_ID_SEGMENT_PATTERN = /^[A-Za-z0-9_.:-]{1,200}$/;
|
|
15
|
+
function assertSafeIdSegment(value, field) {
|
|
16
|
+
if (!SANDBOX_ID_SEGMENT_PATTERN.test(value) || value === '.' || value === '..' || value.includes('/') || value.includes('\\')) {
|
|
17
|
+
throw new SandboxError(`Sandbox ${field} contains unsupported path characters.`, { reason: 'invalid_path' });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Tokenizes a command line without invoking a shell. Supports single/double
|
|
22
|
+
* quotes for grouping; performs no expansion, substitution, or redirection.
|
|
23
|
+
* When `rejectMetacharacters` is set (active allow-list), unquoted shell
|
|
24
|
+
* metacharacters are rejected so the allow-list cannot be bypassed.
|
|
25
|
+
*/
|
|
26
|
+
function tokenizeCommand(command, opts) {
|
|
27
|
+
const tokens = [];
|
|
28
|
+
let current = '';
|
|
29
|
+
let quote;
|
|
30
|
+
let hasToken = false;
|
|
31
|
+
for (const char of command) {
|
|
32
|
+
if (quote) {
|
|
33
|
+
if (char === quote)
|
|
34
|
+
quote = undefined;
|
|
35
|
+
else
|
|
36
|
+
current += char;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (char === '"' || char === "'") {
|
|
40
|
+
quote = char;
|
|
41
|
+
hasToken = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (opts.rejectMetacharacters && SHELL_METACHARACTERS.has(char)) {
|
|
45
|
+
throw new SandboxError('Command contains shell metacharacters that are not allowed by local sandbox policy.', { reason: 'exec_failed' });
|
|
46
|
+
}
|
|
47
|
+
if (char === ' ' || char === '\t') {
|
|
48
|
+
if (hasToken) {
|
|
49
|
+
tokens.push(current);
|
|
50
|
+
current = '';
|
|
51
|
+
hasToken = false;
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
current += char;
|
|
56
|
+
hasToken = true;
|
|
57
|
+
}
|
|
58
|
+
if (quote)
|
|
59
|
+
throw new SandboxError('Command has an unterminated quote.', { reason: 'exec_failed' });
|
|
60
|
+
if (hasToken)
|
|
61
|
+
tokens.push(current);
|
|
62
|
+
return tokens;
|
|
63
|
+
}
|
|
64
|
+
function appendCapped(target, chunk) {
|
|
65
|
+
if (target.truncated)
|
|
66
|
+
return;
|
|
67
|
+
const chunkBytes = Buffer.byteLength(chunk);
|
|
68
|
+
if (target.bytes + chunkBytes <= MAX_EXEC_CAPTURE_BYTES) {
|
|
69
|
+
target.text += chunk;
|
|
70
|
+
target.bytes += chunkBytes;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const remaining = MAX_EXEC_CAPTURE_BYTES - target.bytes;
|
|
74
|
+
target.text += chunk.slice(0, Math.max(0, remaining)) + EXEC_OUTPUT_TRUNCATION_MARKER;
|
|
75
|
+
target.bytes = MAX_EXEC_CAPTURE_BYTES;
|
|
76
|
+
target.truncated = true;
|
|
77
|
+
}
|
|
78
|
+
class LocalDirectorySandboxSession {
|
|
79
|
+
executor;
|
|
80
|
+
root;
|
|
81
|
+
execPolicy;
|
|
82
|
+
telemetry;
|
|
83
|
+
fallbackExecTimeoutMs;
|
|
84
|
+
constructor(root, execPolicy, telemetry, fallbackExecTimeoutMs) {
|
|
85
|
+
this.root = resolve(root);
|
|
86
|
+
this.execPolicy = execPolicy;
|
|
87
|
+
this.telemetry = telemetry;
|
|
88
|
+
this.fallbackExecTimeoutMs = fallbackExecTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
|
|
89
|
+
this.executor = execPolicy === false ? 'unavailable' : 'available';
|
|
90
|
+
}
|
|
91
|
+
async read(path) {
|
|
92
|
+
return this.sandboxSpan('read', {}, async () => readFile(await this.toPhysical(path)));
|
|
93
|
+
}
|
|
94
|
+
async readText(path) {
|
|
95
|
+
return this.sandboxSpan('read_text', {}, async () => readFile(await this.toPhysical(path), 'utf8'));
|
|
96
|
+
}
|
|
97
|
+
async write(path, data) {
|
|
98
|
+
return this.sandboxSpan('write', {
|
|
99
|
+
'harness.sandbox.write_bytes': typeof data === 'string' ? Buffer.byteLength(data) : data.byteLength
|
|
100
|
+
}, async () => {
|
|
101
|
+
const physical = await this.toPhysical(path, { forWrite: true });
|
|
102
|
+
await mkdir(dirname(physical), { recursive: true });
|
|
103
|
+
await writeFile(physical, data);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async remove(path, opts = {}) {
|
|
107
|
+
return this.sandboxSpan('remove', {
|
|
108
|
+
'harness.sandbox.recursive': opts.recursive ?? false
|
|
109
|
+
}, async () => { await rm(await this.toPhysical(path), { recursive: opts.recursive ?? false, force: true }); });
|
|
110
|
+
}
|
|
111
|
+
async list(path, opts = {}) {
|
|
112
|
+
return this.sandboxSpan('list', {
|
|
113
|
+
'harness.sandbox.recursive': opts.recursive ?? false,
|
|
114
|
+
'harness.sandbox.has_glob': Boolean(opts.glob)
|
|
115
|
+
}, async () => {
|
|
116
|
+
const root = await this.toPhysical(path);
|
|
117
|
+
const entries = [];
|
|
118
|
+
await this.collect(root, path, opts.recursive ?? false, entries);
|
|
119
|
+
if (!opts.glob)
|
|
120
|
+
return entries;
|
|
121
|
+
const globPattern = globToRegExp(opts.glob);
|
|
122
|
+
return entries.filter((entry) => globPattern.test(entry.path));
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async stat(path) {
|
|
126
|
+
return this.sandboxSpan('stat', {}, async () => {
|
|
127
|
+
const info = await stat(await this.toPhysical(path));
|
|
128
|
+
return { kind: info.isDirectory() ? 'directory' : 'file', size: info.isDirectory() ? 0 : info.size, modifiedAt: info.mtime.toISOString() };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async exists(path) {
|
|
132
|
+
return this.sandboxSpan('exists', {}, async () => {
|
|
133
|
+
try {
|
|
134
|
+
await this.toPhysical(path);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
async mount(files, atPath) {
|
|
143
|
+
return this.sandboxSpan('mount', {
|
|
144
|
+
'harness.sandbox.file_count': files.size
|
|
145
|
+
}, async () => {
|
|
146
|
+
for (const [name, data] of files) {
|
|
147
|
+
const target = posix.join(atPath, name);
|
|
148
|
+
await this.write(target, data);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async exec(command, opts = {}) {
|
|
153
|
+
return this.sandboxSpan('exec', {
|
|
154
|
+
'harness.sandbox.has_cwd': Boolean(opts.cwd),
|
|
155
|
+
'harness.sandbox.has_stdin': Boolean(opts.stdin)
|
|
156
|
+
}, async () => {
|
|
157
|
+
if (this.execPolicy === false) {
|
|
158
|
+
throw new SandboxNoExecutorError('Sandbox session has no executor.', { session_id: 'local' });
|
|
159
|
+
}
|
|
160
|
+
const policy = this.execPolicy;
|
|
161
|
+
// No shell is involved: the command is tokenized and spawned as argv, so
|
|
162
|
+
// metacharacters carry no semantics. With an allow-list they are rejected
|
|
163
|
+
// outright to keep the policy boundary obvious (spec 22 §5).
|
|
164
|
+
const argv = tokenizeCommand(command, { rejectMetacharacters: policy.allowCommands !== undefined });
|
|
165
|
+
const commandName = argv[0];
|
|
166
|
+
if (!commandName) {
|
|
167
|
+
throw new SandboxError('Sandbox command is empty.', { reason: 'exec_failed' });
|
|
168
|
+
}
|
|
169
|
+
if (policy.allowCommands && !policy.allowCommands.includes(commandName)) {
|
|
170
|
+
throw new SandboxError('Command is not allowed by local sandbox policy.', { reason: 'exec_failed' });
|
|
171
|
+
}
|
|
172
|
+
const cwd = await this.toPhysical(opts.cwd ?? '/workspace');
|
|
173
|
+
const timeoutMs = opts.timeoutMs ?? policy.timeoutMs ?? this.fallbackExecTimeoutMs;
|
|
174
|
+
const signal = opts.signal;
|
|
175
|
+
if (signal?.aborted)
|
|
176
|
+
throw abortError(signal, 'sandbox', 'Sandbox exec was cancelled.');
|
|
177
|
+
const started = Date.now();
|
|
178
|
+
return new Promise((resolveExec, rejectExec) => {
|
|
179
|
+
const child = spawn(commandName, argv.slice(1), {
|
|
180
|
+
cwd,
|
|
181
|
+
env: { PATH: process.env['PATH'] ?? '', HOME: this.root, ...policy.env, ...opts.env },
|
|
182
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
183
|
+
});
|
|
184
|
+
const stdout = { text: '', bytes: 0, truncated: false };
|
|
185
|
+
const stderr = { text: '', bytes: 0, truncated: false };
|
|
186
|
+
let settled = false;
|
|
187
|
+
const onAbort = () => {
|
|
188
|
+
child.kill('SIGTERM');
|
|
189
|
+
finish(() => rejectExec(abortError(signal, 'sandbox', 'Sandbox exec was cancelled.')));
|
|
190
|
+
};
|
|
191
|
+
const timer = setTimeout(() => {
|
|
192
|
+
child.kill('SIGKILL');
|
|
193
|
+
finish(() => rejectExec(new OperationTimeoutError('Sandbox exec timed out.', { scope: 'sandbox_run', timeout_ms: timeoutMs })));
|
|
194
|
+
}, timeoutMs);
|
|
195
|
+
const finish = (settle) => {
|
|
196
|
+
if (settled)
|
|
197
|
+
return;
|
|
198
|
+
settled = true;
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
signal?.removeEventListener('abort', onAbort);
|
|
201
|
+
settle();
|
|
202
|
+
};
|
|
203
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
204
|
+
child.stdout.on('data', (chunk) => { appendCapped(stdout, String(chunk)); });
|
|
205
|
+
child.stderr.on('data', (chunk) => { appendCapped(stderr, String(chunk)); });
|
|
206
|
+
child.on('error', (error) => {
|
|
207
|
+
finish(() => rejectExec(new SandboxError('Local sandbox exec failed.', { reason: 'exec_failed', stdout: stdout.text, stderr: stderr.text }, error)));
|
|
208
|
+
});
|
|
209
|
+
child.on('close', (exitCode, exitSignal) => {
|
|
210
|
+
finish(() => {
|
|
211
|
+
if (exitCode === null) {
|
|
212
|
+
rejectExec(new SandboxError(`Local sandbox exec was terminated by signal ${exitSignal ?? 'unknown'}.`, { reason: 'exec_failed', stdout: stdout.text, stderr: stderr.text }));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
resolveExec({ stdout: stdout.text, stderr: stderr.text, exitCode, durationSeconds: (Date.now() - started) / 1000 });
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
if (opts.stdin)
|
|
219
|
+
child.stdin.end(opts.stdin);
|
|
220
|
+
else
|
|
221
|
+
child.stdin.end();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
async close() { }
|
|
226
|
+
async collect(root, virtualRoot, recursive, out) {
|
|
227
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
228
|
+
const physical = join(root, entry.name);
|
|
229
|
+
const virtual = posix.join(virtualRoot, entry.name);
|
|
230
|
+
const info = await stat(physical);
|
|
231
|
+
out.push({ name: entry.name, path: virtual, kind: entry.isDirectory() ? 'directory' : 'file', ...(entry.isFile() ? { size: info.size } : {}) });
|
|
232
|
+
if (recursive && entry.isDirectory())
|
|
233
|
+
await this.collect(physical, virtual, recursive, out);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async toPhysical(input, opts = {}) {
|
|
237
|
+
if (!input.startsWith('/'))
|
|
238
|
+
throw new SandboxError('Invalid path.', { reason: 'invalid_path' });
|
|
239
|
+
const normalized = posix.normalize(input);
|
|
240
|
+
if (!normalized.startsWith('/'))
|
|
241
|
+
throw new SandboxError('Invalid path.', { reason: 'invalid_path' });
|
|
242
|
+
const target = resolve(this.root, `.${normalized}`);
|
|
243
|
+
const guardPath = opts.forWrite ? dirname(target) : target;
|
|
244
|
+
const existing = await realpath(guardPath).catch(() => opts.forWrite ? realpathExistingParent(guardPath) : undefined);
|
|
245
|
+
if (!existing)
|
|
246
|
+
throw new SandboxError('Path not found.', { reason: 'fs_failed' });
|
|
247
|
+
const rootReal = await realpath(this.root);
|
|
248
|
+
if (existing !== rootReal && !existing.startsWith(`${rootReal}/`)) {
|
|
249
|
+
throw new SandboxError('Path escapes local sandbox root.', { reason: 'invalid_path' });
|
|
250
|
+
}
|
|
251
|
+
if (opts.forWrite) {
|
|
252
|
+
// The final component must not be a symlink (including dangling ones):
|
|
253
|
+
// writing through it would follow the link target outside the jail.
|
|
254
|
+
const targetInfo = await lstat(target).catch(() => undefined);
|
|
255
|
+
if (targetInfo?.isSymbolicLink()) {
|
|
256
|
+
throw new SandboxError('Path escapes local sandbox root.', { reason: 'invalid_path' });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return target;
|
|
260
|
+
}
|
|
261
|
+
async sandboxSpan(operation, attrs, fn) {
|
|
262
|
+
const spanAttrs = {
|
|
263
|
+
'harness.sandbox.adapter': 'local_directory_sandbox',
|
|
264
|
+
'harness.sandbox.operation': operation,
|
|
265
|
+
'harness.sandbox.exec_enabled': this.execPolicy !== false,
|
|
266
|
+
...attrs
|
|
267
|
+
};
|
|
268
|
+
const started = Date.now();
|
|
269
|
+
const run = async () => {
|
|
270
|
+
try {
|
|
271
|
+
const result = await fn();
|
|
272
|
+
this.telemetry?.recordCounter('harness.local_sandbox.operations', 1, spanAttrs);
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
this.telemetry?.recordHistogram('harness.local_sandbox.operation.duration', (Date.now() - started) / 1000, spanAttrs);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
return this.telemetry ? this.telemetry.span(`harness.local_sandbox.${operation}`, spanAttrs, run) : run();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
class FilesOnlyLocalSandboxSession extends LocalDirectorySandboxSession {
|
|
283
|
+
constructor(root, telemetry) {
|
|
284
|
+
super(root, false, telemetry, undefined);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
class ExecLocalSandboxSession extends LocalDirectorySandboxSession {
|
|
288
|
+
constructor(root, execPolicy, telemetry, fallbackExecTimeoutMs) {
|
|
289
|
+
super(root, execPolicy, telemetry, fallbackExecTimeoutMs);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
class BaseLocalDirectorySandbox {
|
|
293
|
+
options;
|
|
294
|
+
execEnabled;
|
|
295
|
+
telemetry;
|
|
296
|
+
toolTimeoutMs;
|
|
297
|
+
constructor(options, execEnabled) {
|
|
298
|
+
this.options = options;
|
|
299
|
+
this.execEnabled = execEnabled;
|
|
300
|
+
}
|
|
301
|
+
configureHarnessContext(context) {
|
|
302
|
+
this.telemetry = context.telemetry;
|
|
303
|
+
// Spec 22 §2: exec timeout falls back to the configured harness toolTimeoutMs.
|
|
304
|
+
this.toolTimeoutMs = context.defaults.toolTimeoutMs;
|
|
305
|
+
}
|
|
306
|
+
async openRoot(opts, make) {
|
|
307
|
+
assertSafeIdSegment(opts.sessionId, 'sessionId');
|
|
308
|
+
assertSafeIdSegment(opts.runId, 'runId');
|
|
309
|
+
const active = this.options.coordinator?.get(opts.runId, opts.sessionId);
|
|
310
|
+
const spanAttrs = {
|
|
311
|
+
'harness.sandbox.adapter': 'local_directory_sandbox',
|
|
312
|
+
'harness.sandbox.operation': 'open',
|
|
313
|
+
'harness.sandbox.exec_enabled': this.execEnabled,
|
|
314
|
+
...(active ? { 'harness.workspace.ref_hash': sha256Hex(active.workspaceRef) } : {}),
|
|
315
|
+
'harness.run.id': opts.runId,
|
|
316
|
+
'harness.session.id': opts.sessionId
|
|
317
|
+
};
|
|
318
|
+
const started = Date.now();
|
|
319
|
+
const run = async () => {
|
|
320
|
+
const root = active?.activePath ?? resolve(this.options.root, 'sessions', opts.sessionId, opts.runId);
|
|
321
|
+
await mkdir(join(root, 'workspace'), { recursive: true });
|
|
322
|
+
this.telemetry?.recordCounter('harness.local_sandbox.operations', 1, spanAttrs);
|
|
323
|
+
this.telemetry?.recordHistogram('harness.local_sandbox.operation.duration', (Date.now() - started) / 1000, spanAttrs);
|
|
324
|
+
return make(root);
|
|
325
|
+
};
|
|
326
|
+
return this.telemetry ? this.telemetry.span('harness.local_sandbox.open', spanAttrs, async () => run()) : run();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
class FilesOnlyLocalDirectorySandbox extends BaseLocalDirectorySandbox {
|
|
330
|
+
capabilities = ['sandbox.fs', 'sandbox.persistent_fs'];
|
|
331
|
+
constructor(options) {
|
|
332
|
+
super(options, false);
|
|
333
|
+
}
|
|
334
|
+
async open(opts) {
|
|
335
|
+
return this.openRoot(opts, (root) => new FilesOnlyLocalSandboxSession(root, this.telemetry));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
class ExecLocalDirectorySandbox extends BaseLocalDirectorySandbox {
|
|
339
|
+
execPolicy;
|
|
340
|
+
capabilities = ['sandbox.fs', 'sandbox.exec', 'sandbox.persistent_fs'];
|
|
341
|
+
constructor(options, execPolicy) {
|
|
342
|
+
super(options, true);
|
|
343
|
+
this.execPolicy = execPolicy;
|
|
344
|
+
}
|
|
345
|
+
async open(opts) {
|
|
346
|
+
return this.openRoot(opts, (root) => new ExecLocalSandboxSession(root, this.execPolicy, this.telemetry, this.toolTimeoutMs));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async function realpathExistingParent(path) {
|
|
350
|
+
let current = path;
|
|
351
|
+
while (current !== dirname(current)) {
|
|
352
|
+
try {
|
|
353
|
+
return await realpath(current);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
current = dirname(current);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
function globToRegExp(glob) {
|
|
362
|
+
const source = glob.split('*').map((part) => part.replace(/[\\^$+?.()|[\]{}]/g, '\\$&')).join('.*');
|
|
363
|
+
return new RegExp(`^${source}$`);
|
|
364
|
+
}
|
|
365
|
+
export function localDirectorySandbox(options) {
|
|
366
|
+
const exec = options.exec ?? false;
|
|
367
|
+
return exec === false ? new FilesOnlyLocalDirectorySandbox(options) : new ExecLocalDirectorySandbox(options, exec);
|
|
368
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AdapterCapability } from '../ports/capabilities.js';
|
|
2
|
+
import type { HarnessAdapterContext } from '../ports/harness-context.js';
|
|
3
|
+
import type { DurableWorkspacePolicy, DurableWorkspaceStore, DurableWorkspaceStoreInfo, WorkspaceAbortOptions, WorkspaceAbortResult, WorkspaceCheckpoint, WorkspaceCleanupOptions, WorkspaceCleanupResult, WorkspaceHandle, WorkspaceInspection, WorkspaceInspectionOptions, WorkspacePauseOptions, WorkspaceResumeOptions, WorkspaceStartOptions } from '../ports/workspace.js';
|
|
4
|
+
export interface LocalWorkspaceCoordinator {
|
|
5
|
+
bind(runId: string, sessionId: string, workspaceRef: string, activePath: string): void;
|
|
6
|
+
get(runId: string, sessionId: string): {
|
|
7
|
+
workspaceRef: string;
|
|
8
|
+
activePath: string;
|
|
9
|
+
} | undefined;
|
|
10
|
+
unbind(runId: string, sessionId: string): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function createLocalWorkspaceCoordinator(): LocalWorkspaceCoordinator;
|
|
13
|
+
export interface LocalDirectoryWorkspaceStoreOptions {
|
|
14
|
+
/** Host root for durable workspaces. */
|
|
15
|
+
root: string;
|
|
16
|
+
/** Optional policy metadata reported by the adapter. */
|
|
17
|
+
policy?: Partial<DurableWorkspacePolicy>;
|
|
18
|
+
/** Internal coordinator shared with localDirectorySandbox. */
|
|
19
|
+
coordinator?: LocalWorkspaceCoordinator;
|
|
20
|
+
}
|
|
21
|
+
/** Host-directory durable workspace store used by localDurableExecution. */
|
|
22
|
+
export declare class LocalDirectoryWorkspaceStore implements DurableWorkspaceStore {
|
|
23
|
+
readonly info: DurableWorkspaceStoreInfo;
|
|
24
|
+
readonly capabilities: readonly AdapterCapability[];
|
|
25
|
+
private readonly root;
|
|
26
|
+
private readonly coordinator;
|
|
27
|
+
/** In-process lookup caches; the persisted `meta.json` files stay authoritative. */
|
|
28
|
+
private readonly runIdIndex;
|
|
29
|
+
private readonly opKeyIndex;
|
|
30
|
+
private telemetry;
|
|
31
|
+
constructor(options: LocalDirectoryWorkspaceStoreOptions);
|
|
32
|
+
configureHarnessContext(context: HarnessAdapterContext): void;
|
|
33
|
+
/** Drops the run→sandbox coordinator binding once a durable run is finished/disposed. */
|
|
34
|
+
releaseRunBinding(runId: string, sessionId: string): void;
|
|
35
|
+
startWorkspace(opts: WorkspaceStartOptions): Promise<WorkspaceHandle>;
|
|
36
|
+
pauseWorkspace(opts: WorkspacePauseOptions): Promise<WorkspaceCheckpoint>;
|
|
37
|
+
resumeWorkspace(opts: WorkspaceResumeOptions): Promise<WorkspaceHandle>;
|
|
38
|
+
abortWorkspace(opts: WorkspaceAbortOptions): Promise<WorkspaceAbortResult>;
|
|
39
|
+
cleanupWorkspace(opts: WorkspaceCleanupOptions): Promise<WorkspaceCleanupResult>;
|
|
40
|
+
inspectWorkspace(opts: WorkspaceInspectionOptions): Promise<WorkspaceInspection>;
|
|
41
|
+
private workspacePath;
|
|
42
|
+
private activePath;
|
|
43
|
+
private checkpointPath;
|
|
44
|
+
private metaPath;
|
|
45
|
+
private readMeta;
|
|
46
|
+
/** Crash-atomic meta write: temp file plus rename (spec 21 §9 pause-failure semantics). */
|
|
47
|
+
private writeMeta;
|
|
48
|
+
private persistOp;
|
|
49
|
+
private evictFromIndexes;
|
|
50
|
+
private findPersistedOp;
|
|
51
|
+
private findByRun;
|
|
52
|
+
private findRefByCheckpoint;
|
|
53
|
+
private scanMetas;
|
|
54
|
+
private workspaceSpan;
|
|
55
|
+
}
|
|
56
|
+
export declare function localDirectoryWorkspaceStore(options: LocalDirectoryWorkspaceStoreOptions): DurableWorkspaceStore;
|