@hachej/boring-execution 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -2
  3. package/dist/aws-code-interpreter.d.ts +149 -0
  4. package/dist/aws-code-interpreter.js +453 -0
  5. package/dist/bounded-error.d.ts +8 -0
  6. package/dist/bounded-error.js +12 -0
  7. package/dist/contracts.d.ts +46 -0
  8. package/dist/contracts.js +1 -0
  9. package/dist/fs-readers.d.ts +7 -0
  10. package/dist/fs-readers.js +72 -0
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.js +1 -0
  13. package/dist/native.d.ts +8 -0
  14. package/dist/native.js +1 -0
  15. package/dist/remote-files-handler.d.ts +21 -0
  16. package/dist/remote-files-handler.js +215 -0
  17. package/dist/remote-files-io.d.ts +8 -0
  18. package/dist/remote-files-io.js +46 -0
  19. package/dist/remote-files-positional.d.ts +12 -0
  20. package/dist/remote-files-positional.js +208 -0
  21. package/dist/remote-files-protocol.d.ts +342 -0
  22. package/dist/remote-files-protocol.js +113 -0
  23. package/dist/remote-files-watch.d.ts +29 -0
  24. package/dist/remote-files-watch.js +154 -0
  25. package/dist/remote-files.d.ts +17 -0
  26. package/dist/remote-files.js +238 -0
  27. package/dist/remote-shell-handler.d.ts +25 -0
  28. package/dist/remote-shell-handler.js +131 -0
  29. package/dist/remote-shell-protocol.d.ts +50 -0
  30. package/dist/remote-shell-protocol.js +163 -0
  31. package/dist/remote-shell-window.d.ts +14 -0
  32. package/dist/remote-shell-window.js +82 -0
  33. package/dist/remote-shell.d.ts +15 -0
  34. package/dist/remote-shell.js +127 -0
  35. package/dist/virtual-filesystem.d.ts +67 -0
  36. package/dist/virtual-filesystem.js +218 -0
  37. package/dist/virtual-git.d.ts +60 -0
  38. package/dist/virtual-git.js +116 -0
  39. package/dist/virtual-sqlite.d.ts +42 -0
  40. package/dist/virtual-sqlite.js +50 -0
  41. package/dist/virtual.d.ts +34 -0
  42. package/dist/virtual.js +277 -0
  43. package/package.json +94 -4
@@ -0,0 +1,453 @@
1
+ import { posix } from 'node:path';
2
+ import { lstat, readlink, realpath } from 'node:fs/promises';
3
+ import { InvokeCodeInterpreterCommand, StartCodeInterpreterSessionCommand, StopCodeInterpreterSessionCommand } from '@aws-sdk/client-bedrock-agentcore';
4
+ import { ExecutionError, FileError, err, ok } from '@earendil-works/pi-durable/env';
5
+ import { NodeExecutionEnv } from '@earendil-works/pi-durable/env/node';
6
+ import { boundedMessage } from './bounded-error.js';
7
+ import { shellCommand } from './fs-readers.js';
8
+ const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
9
+ const MOUNT_PATH = /^\/mnt\/[A-Za-z0-9._-]+$/;
10
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
11
+ const within = (path, root) => path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`);
12
+ const errorName = (error) => typeof error === 'object' && error !== null && typeof error.name === 'string' ? error.name : 'Error';
13
+ const errorText = (error) => boundedMessage(error);
14
+ const sleep = (ms, signal) => new Promise(resolve => {
15
+ if (signal?.aborted)
16
+ return resolve();
17
+ const timer = setTimeout(done, ms);
18
+ function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); }
19
+ signal?.addEventListener('abort', done, { once: true });
20
+ });
21
+ /**
22
+ * Error names and messages, besides `ResourceNotFoundException`, taken to mean "the session ended".
23
+ * UNVERIFIED against the real AgentCore service (open question in HOST-RECIPE-AWS.md): they are the likely shapes of an
24
+ * expired or terminated session. `AccessDeniedException` is deliberately NOT here: it is a permissions problem and
25
+ * renewing would not fix it. Matching needs both a listed name and a message pattern.
26
+ */
27
+ export const SESSION_ENDED_ERRORS = [
28
+ { name: 'ValidationException', message: /session.*(terminat|expir|not (found|active|running)|no longer|ended|stopped)|(terminat|expir|stopped).*session/i },
29
+ { name: 'ConflictException', message: /session.*(terminat|expir|stopped|ended)/i },
30
+ ];
31
+ const sessionEnded = (error) => {
32
+ const name = errorName(error);
33
+ if (name === 'ResourceNotFoundException')
34
+ return true;
35
+ const message = typeof error === 'object' && error !== null && typeof error.message === 'string' ? error.message : '';
36
+ return SESSION_ENDED_ERRORS.some(entry => entry.name === name && entry.message.test(message));
37
+ };
38
+ /** A stream error member of `InvokeCodeInterpreter` (the service reports some failures inside the stream). */
39
+ const STREAM_ERRORS = ['accessDeniedException', 'conflictException', 'internalServerException', 'resourceNotFoundException', 'serviceQuotaExceededException', 'throttlingException', 'validationException'];
40
+ class ServiceError extends Error {
41
+ constructor(name, message) { super(message); this.name = name; }
42
+ }
43
+ /**
44
+ * A native Pi `ExecutionEnv` over an AgentCore Code Interpreter session whose sandbox mounts the same folder this host
45
+ * mounts. Commands run in the session through the task API (`startCommandExecution`, `getTask`, `stopTask`); file
46
+ * operations run on this host's mount of the folder, so the agent's file tools, the workspace provider and the commands
47
+ * see the same bytes without copying them through the API. Nothing here creates AWS resources or credentials.
48
+ */
49
+ export function createCodeInterpreterEnv(options) {
50
+ const { client, codeInterpreterIdentifier } = options;
51
+ const mountPath = posix.normalize(options.mount.path), root = posix.resolve(options.mount.root);
52
+ if (!MOUNT_PATH.test(mountPath) || mountPath === '/mnt/.' || mountPath === '/mnt/..')
53
+ throw new TypeError('mount.path must be /mnt/<name>, as AgentCore requires');
54
+ if (!options.id)
55
+ throw new TypeError('A file namespace id is required');
56
+ const cwd = posix.resolve(mountPath, options.cwd ?? mountPath);
57
+ if (!within(cwd, mountPath))
58
+ throw new TypeError('cwd must be inside mount.path');
59
+ const umask = options.umask ?? '002';
60
+ if (!/^[0-7]{3,4}$/.test(umask))
61
+ throw new TypeError('umask must be octal');
62
+ const pollMs = options.pollIntervalMs ?? 500;
63
+ const borrowed = 'sessionId' in options.session ? options.session.sessionId : undefined;
64
+ const start = 'start' in options.session ? options.session.start : undefined;
65
+ const auto = start !== undefined && (options.renew ?? 'auto') === 'auto';
66
+ const ttlMs = (start?.sessionTimeoutSeconds ?? 900) * 1000;
67
+ const marginMs = Math.min((options.rotateMarginSeconds ?? 60) * 1000, ttlMs / 2);
68
+ let sessionId = borrowed, starting, lost = false, stopped = false, startedAt = 0, rotateRetryAt = 0;
69
+ const forgetLost = () => { lost = false; sessionId = undefined; }; // the next command starts a new session
70
+ // Every session this env started and has not confirmed stopped (rotation never stops one), and the stops in flight.
71
+ const owned = new Set(), stopping = new Map();
72
+ const stopSession = (id, signal) => {
73
+ let stop = stopping.get(id);
74
+ if (!stop) {
75
+ stop = (async () => {
76
+ try {
77
+ await client.send(new StopCodeInterpreterSessionCommand({ codeInterpreterIdentifier, sessionId: id }), signal ? { abortSignal: signal } : {});
78
+ }
79
+ catch (error) {
80
+ if (errorName(error) !== 'ResourceNotFoundException')
81
+ throw error;
82
+ }
83
+ owned.delete(id);
84
+ })().finally(() => { stopping.delete(id); });
85
+ stopping.set(id, stop);
86
+ }
87
+ return stop;
88
+ };
89
+ const local = new NodeExecutionEnv({ cwd: root });
90
+ // ---- Session: borrowed or owned, started lazily, reported lost instead of silently replaced.
91
+ // The start is never cancelled half way: an abandoned start could leave a session nobody stops.
92
+ async function session() {
93
+ if (lost)
94
+ throw new ServiceError('SessionLost', 'The Code Interpreter session is gone (expired or stopped); files on the mount remain');
95
+ if (stopped)
96
+ throw new ServiceError('SessionStopped', 'The Code Interpreter session was stopped by its owner');
97
+ if (sessionId)
98
+ return sessionId;
99
+ if (!start)
100
+ throw new ServiceError('SessionMissing', 'No Code Interpreter session');
101
+ return begin();
102
+ }
103
+ /** Starts a session: the first one, a replacement after a loss, or a rotation. One start at a time; callers share it. */
104
+ function begin() {
105
+ if (!start)
106
+ throw new ServiceError('SessionMissing', 'No Code Interpreter session');
107
+ const { name, sessionTimeoutSeconds, filesystemConfigurations } = start;
108
+ return starting ??= (async () => {
109
+ const response = await client.send(new StartCodeInterpreterSessionCommand({
110
+ codeInterpreterIdentifier,
111
+ ...(name === undefined ? {} : { name }),
112
+ ...(sessionTimeoutSeconds === undefined ? {} : { sessionTimeoutSeconds }),
113
+ ...(filesystemConfigurations === undefined ? {} : { filesystemConfigurations: [...filesystemConfigurations] }),
114
+ }));
115
+ const id = response.sessionId;
116
+ if (!id)
117
+ throw new ServiceError('SessionMissing', 'StartCodeInterpreterSession returned no session id');
118
+ owned.add(id);
119
+ if (!stopped) {
120
+ sessionId = id;
121
+ startedAt = Date.now();
122
+ lost = false;
123
+ }
124
+ return id;
125
+ })().finally(() => { starting = undefined; });
126
+ }
127
+ /**
128
+ * Before a command (`'auto'` only): a lost session is replaced, and one near its time to live is rotated. The old
129
+ * session stays current until the new one is up, so a failed Start leaves everything working on the old one and backs
130
+ * off. Rotation never stops the old session: commands running there finish, and it expires on its own.
131
+ */
132
+ async function prepare() {
133
+ if (!auto || stopped)
134
+ return;
135
+ if (lost) {
136
+ forgetLost();
137
+ return;
138
+ }
139
+ if (!sessionId || (!starting && (Date.now() < rotateRetryAt || Date.now() - startedAt <= ttlMs - marginMs)))
140
+ return;
141
+ try {
142
+ await begin();
143
+ }
144
+ catch {
145
+ rotateRetryAt = Date.now() + Math.max(1000, marginMs / 4);
146
+ }
147
+ }
148
+ /** One tool call; the streamed result, or a ServiceError for an exception (thrown or streamed). */
149
+ async function invoke(name, args, context, pinned) {
150
+ const id = pinned ?? await session();
151
+ try {
152
+ const response = await client.send(new InvokeCodeInterpreterCommand({ codeInterpreterIdentifier, sessionId: id, name, arguments: args }), context?.abortSignal ? { abortSignal: context.abortSignal } : {});
153
+ let result;
154
+ for await (const event of response.stream ?? []) {
155
+ if (event.result) {
156
+ result = event.result;
157
+ continue;
158
+ }
159
+ for (const key of STREAM_ERRORS) {
160
+ const failure = event[key];
161
+ if (failure)
162
+ throw new ServiceError(`${key[0].toUpperCase()}${key.slice(1)}`, failure.message ?? key);
163
+ }
164
+ }
165
+ if (!result)
166
+ throw new ServiceError('EmptyResult', `${name} returned no result`);
167
+ return result;
168
+ }
169
+ catch (error) {
170
+ if (sessionEnded(error)) {
171
+ owned.delete(id);
172
+ if (id === sessionId)
173
+ lost = true;
174
+ }
175
+ throw error;
176
+ }
177
+ }
178
+ const failure = (error, fallback) => {
179
+ const name = errorName(error);
180
+ if (name === 'SessionLost' || name === 'SessionStopped' || name === 'SessionMissing' || name === 'AccessDeniedException' || sessionEnded(error)) {
181
+ return new ExecutionError('shell_unavailable', boundedMessage(`${name}: ${errorText(error)}`));
182
+ }
183
+ if (name === 'AbortError')
184
+ return new ExecutionError('aborted', 'Command aborted');
185
+ return new ExecutionError(fallback, boundedMessage(`${name}: ${errorText(error)}`));
186
+ };
187
+ const text = (result) => (result.content ?? []).map(block => block.text ?? '').join('');
188
+ const TERMINAL = new Set(['completed', 'failed', 'canceled']);
189
+ /** Asks the service to stop the task; the outcome is never assumed (the command's result says it is not confirmed). */
190
+ const stopTask = async (taskId, id) => { try {
191
+ await invoke('stopTask', { taskId }, undefined, id);
192
+ }
193
+ catch { /* reported as unconfirmed termination */ } };
194
+ async function exec(command, execOptions, context) {
195
+ if (context.abortSignal?.aborted)
196
+ return err(new ExecutionError('aborted', 'Command aborted before dispatch'));
197
+ const directory = posix.resolve(env.cwd, execOptions?.cwd ?? '.');
198
+ const variables = Object.entries(execOptions?.env ?? {});
199
+ const invalid = variables.find(([name]) => !ENV_NAME.test(name));
200
+ if (invalid)
201
+ return err(new ExecutionError('spawn_error', boundedMessage(`Invalid environment variable name: ${invalid[0]}`)));
202
+ const assignments = variables.map(([name, value]) => `${name}=${quote(value)}`).join(' ');
203
+ // The task API takes one command string: cwd, environment and umask become part of it.
204
+ const runner = execOptions?.inheritEnv === false ? `env -i ${assignments} bash -c` : `${assignments ? `env ${assignments} ` : ''}bash -c`;
205
+ const script = `umask ${umask} && cd ${quote(directory)} && ${runner} ${quote(shellCommand(command))}`;
206
+ return dispatch(script, execOptions, context);
207
+ }
208
+ async function dispatch(script, execOptions, context) {
209
+ let taskId, pinned;
210
+ try {
211
+ await prepare();
212
+ // Every call of this command goes to the session it started in, even if a later command rotates.
213
+ pinned = await session();
214
+ const started = await invoke('startCommandExecution', { command: script }, context, pinned);
215
+ taskId = started.structuredContent?.taskId;
216
+ if (!taskId)
217
+ return err(new ExecutionError('spawn_error', boundedMessage(`Code Interpreter did not start the command: ${text(started) || 'no task id'}`)));
218
+ }
219
+ catch (error) {
220
+ return err(failure(error, 'spawn_error'));
221
+ }
222
+ const deadline = execOptions?.timeout && execOptions.timeout > 0 ? Date.now() + execOptions.timeout * 1000 : undefined;
223
+ const seen = { stdout: '', stderr: '' };
224
+ // getTask reports the output so far; a value that does not extend what was seen is treated as new output.
225
+ const deliver = (stream, value) => {
226
+ if (!value)
227
+ return;
228
+ const delta = value.startsWith(seen[stream]) ? value.slice(seen[stream].length) : value;
229
+ seen[stream] = value.startsWith(seen[stream]) ? value : seen[stream] + value;
230
+ if (delta)
231
+ execOptions?.onOutput?.(delta, context, { stream });
232
+ };
233
+ for (;;) {
234
+ if (context.abortSignal?.aborted) {
235
+ await stopTask(taskId, pinned);
236
+ return err(new ExecutionError('aborted', 'Command aborted; stopTask was requested, termination is not confirmed'));
237
+ }
238
+ if (deadline !== undefined && Date.now() >= deadline) {
239
+ await stopTask(taskId, pinned);
240
+ return err(new ExecutionError('timeout', `Command exceeded ${execOptions?.timeout} s; stopTask was requested, termination is not confirmed`));
241
+ }
242
+ let status;
243
+ try {
244
+ const polled = await invoke('getTask', { taskId }, context, pinned);
245
+ status = polled.structuredContent;
246
+ try {
247
+ deliver('stdout', status?.stdout);
248
+ deliver('stderr', status?.stderr);
249
+ }
250
+ catch (error) {
251
+ await stopTask(taskId, pinned);
252
+ return err(new ExecutionError('callback_error', boundedMessage(`Output callback failed: ${errorText(error)}`)));
253
+ }
254
+ if (!status?.taskStatus)
255
+ return err(new ExecutionError('unknown', boundedMessage(`getTask returned no task status: ${text(polled)}`)));
256
+ }
257
+ catch (error) {
258
+ if (context.abortSignal?.aborted)
259
+ continue;
260
+ return err(failure(error, 'unknown'));
261
+ }
262
+ if (status.taskStatus === 'completed' || (status.taskStatus === 'failed' && typeof status.exitCode === 'number'))
263
+ return ok({ exitCode: status.exitCode ?? 0 });
264
+ if (status.taskStatus === 'failed')
265
+ return err(new ExecutionError('unknown', 'The command failed without an exit code'));
266
+ if (status.taskStatus === 'canceled')
267
+ return err(new ExecutionError('aborted', 'The command was canceled in the Code Interpreter'));
268
+ await sleep(pollMs, context.abortSignal);
269
+ }
270
+ }
271
+ // ---- Files: interpreter paths mapped onto this host's mount of the same folder, confined to it.
272
+ let realRoot;
273
+ const namespace = (path) => posix.resolve(env.cwd, path);
274
+ const toLocal = (path) => {
275
+ const absolute = namespace(path);
276
+ return within(absolute, mountPath) ? posix.join(root, absolute.slice(mountPath.length)) : undefined;
277
+ };
278
+ const fromLocal = (path, base = root) => within(path, base) ? posix.join(mountPath, path.slice(base.length)) : mountPath;
279
+ const outside = (path) => new FileError('permission_denied', boundedMessage(`Outside the workspace: ${namespace(path)}`), namespace(path));
280
+ /** Whether `local` stays in the folder; `follow` also resolves a final symbolic link (dangling ones included). */
281
+ async function confined(local, follow) {
282
+ const base = await (realRoot ??= realpath(root));
283
+ let target = follow ? local : posix.dirname(local);
284
+ for (let hops = 0; hops < 40; hops++) {
285
+ let probe = target;
286
+ for (;;) {
287
+ try {
288
+ await lstat(probe);
289
+ break;
290
+ }
291
+ catch (error) {
292
+ const code = error.code;
293
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
294
+ return false;
295
+ const up = posix.dirname(probe);
296
+ if (up === probe)
297
+ return false;
298
+ probe = up;
299
+ }
300
+ }
301
+ try {
302
+ return within(await realpath(probe), base);
303
+ }
304
+ catch (error) {
305
+ if (error.code !== 'ENOENT')
306
+ return false;
307
+ // The probe exists but does not resolve: a dangling symbolic link. Follow it by hand.
308
+ try {
309
+ target = posix.join(posix.resolve(posix.dirname(probe), await readlink(probe)), target.slice(probe.length));
310
+ }
311
+ catch {
312
+ return false;
313
+ }
314
+ }
315
+ }
316
+ return false;
317
+ }
318
+ const mapError = (error) => new FileError(error.code, boundedMessage(error.message.split(root).join(mountPath)), error.path !== undefined && within(error.path, root) ? fromLocal(error.path) : error.path, error);
319
+ const mapInfo = (info) => ({ ...info, path: fromLocal(info.path) });
320
+ async function guarded(paths, run) {
321
+ const locals = [];
322
+ for (const [path, follow] of paths) {
323
+ const mapped = toLocal(path);
324
+ if (mapped === undefined)
325
+ return err(outside(path));
326
+ try {
327
+ if (!await confined(mapped, follow))
328
+ return err(outside(path));
329
+ }
330
+ catch (error) {
331
+ return err(new FileError('unknown', errorText(error), namespace(path)));
332
+ }
333
+ locals.push(mapped);
334
+ }
335
+ const result = await run(...locals);
336
+ return result.ok ? result : err(mapError(result.error));
337
+ }
338
+ const notSupported = (what) => err(new FileError('not_supported', `${what} is not available on the shared workspace mount`));
339
+ const env = {
340
+ id: options.id,
341
+ cwd,
342
+ absolutePath: async (path) => ok(namespace(path)),
343
+ joinPath: async (parts) => ok(posix.join(...parts)),
344
+ readTextFile: (path, context) => guarded([[path, true]], file => local.readTextFile(file, context)),
345
+ readBinaryFile: (path, context) => guarded([[path, true]], file => local.readBinaryFile(file, context)),
346
+ readTextLines: (path, lines, context) => guarded([[path, true]], file => local.readTextLines(file, lines, context)),
347
+ openTextLineReader: (path, context) => guarded([[path, true]], file => local.openTextLineReader(file, context)),
348
+ openBinaryReader: (path, readerOptions, context) => guarded([[path, !readerOptions?.noFollow]], file => local.openBinaryReader(file, readerOptions, context)),
349
+ openDirReader: (path, context) => guarded([[path, true]], directory => local.openDirReader(directory, context)),
350
+ // The mount's own watcher, with its paths reported in the workspace's namespace.
351
+ watch: async (targets, onChange, context) => {
352
+ const mapped = [];
353
+ for (const target of targets) {
354
+ const path = toLocal(target.path);
355
+ if (path === undefined)
356
+ return err(outside(target.path));
357
+ mapped.push({ ...target, path });
358
+ }
359
+ const watcher = await local.watch(mapped, change => onChange('paths' in change ? { paths: change.paths.map(path => fromLocal(path)) } : 'error' in change ? { error: mapError(change.error) } : change), context);
360
+ return watcher.ok ? watcher : err(mapError(watcher.error));
361
+ },
362
+ writeFile: (path, content, context) => guarded([[path, true]], file => local.writeFile(file, content, context)),
363
+ appendFile: (path, content, context) => guarded([[path, true]], file => local.appendFile(file, content, context)),
364
+ truncateFile: (path, size, context) => guarded([[path, true]], file => local.truncateFile(file, size, context)),
365
+ flushFile: (path, context) => guarded([[path, true]], file => local.flushFile(file, context)),
366
+ renameFile: (source, destination, context) => guarded([[source, false], [destination, false]], (from, to) => local.renameFile(from, to, context)),
367
+ fileInfo: async (path, context) => { const info = await guarded([[path, false]], file => local.fileInfo(file, context)); return info.ok ? ok(mapInfo(info.value)) : info; },
368
+ listDir: async (path, context) => { const list = await guarded([[path, true]], directory => local.listDir(directory, context)); return list.ok ? ok(list.value.map(mapInfo)) : list; },
369
+ canonicalPath: async (path, context) => {
370
+ const resolved = await guarded([[path, true]], file => local.canonicalPath(file, context));
371
+ if (!resolved.ok)
372
+ return resolved;
373
+ const base = await (realRoot ??= realpath(root));
374
+ return within(resolved.value, base) ? ok(fromLocal(resolved.value, base)) : err(outside(path));
375
+ },
376
+ exists: (path, context) => guarded([[path, false]], file => local.exists(file, context)),
377
+ createDir: (path, directory, context) => guarded([[path, true]], file => local.createDir(file, directory, context)),
378
+ remove: (path, removal, context) => guarded([[path, false]], file => local.remove(file, removal, context)),
379
+ // A temporary file on this host would not be visible to the interpreter, and one in the folder would show in the workspace.
380
+ createTempDir: async () => notSupported('createTempDir'),
381
+ createTempFile: async () => notSupported('createTempFile'),
382
+ // Releasing a consumer's view never ends the session: only the owner's stop() does.
383
+ cleanup: async () => { },
384
+ exec,
385
+ };
386
+ return {
387
+ env, owned: start !== undefined,
388
+ sessionId: () => sessionId,
389
+ lost: () => lost,
390
+ renew: () => {
391
+ if (!start || stopped)
392
+ return false;
393
+ if (lost)
394
+ forgetLost();
395
+ return true;
396
+ },
397
+ stop: async (context) => {
398
+ if (!start)
399
+ return;
400
+ stopped = true;
401
+ await Promise.allSettled([starting]); // a start in flight still registers its session
402
+ const results = await Promise.allSettled([...owned].map(id => stopSession(id, context.abortSignal)));
403
+ const failed = results.find((result) => result.status === 'rejected');
404
+ if (failed)
405
+ throw failed.reason;
406
+ },
407
+ };
408
+ }
409
+ const USER_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
410
+ /**
411
+ * One EFS file system, one folder per user below the runtime's access point (root `/boring`, mounted at `/mnt/efs`):
412
+ * `users/<id>` (the interpreter's whole view, through the user's own access point rooted there) and `state/<id>` (the
413
+ * runtime's SQLite files, which no interpreter can reach). The runtime confines itself to the user's folders; the
414
+ * interpreter is confined by the access point itself.
415
+ */
416
+ export function efsUserLayout(options) {
417
+ const { userId, uid } = options, gid = options.gid ?? 1000;
418
+ if (typeof userId !== 'string' || !USER_ID.test(userId))
419
+ throw new TypeError('userId must match [a-z0-9][a-z0-9_-]{0,63}');
420
+ if (!Number.isInteger(uid) || uid < 1001 || uid > 2_147_483_646)
421
+ throw new TypeError('uid must be an integer in 1001..2147483646');
422
+ if (!Number.isInteger(gid) || gid < 1 || gid > 2_147_483_646)
423
+ throw new TypeError('gid must be a positive integer');
424
+ const runtimeMount = posix.normalize(options.runtimeMountPath ?? '/mnt/efs'), interpreterMount = posix.normalize(options.interpreterMountPath ?? '/mnt/workspace');
425
+ if (!MOUNT_PATH.test(interpreterMount) || interpreterMount === '/mnt/.' || interpreterMount === '/mnt/..')
426
+ throw new TypeError(`The interpreter mount path must be /mnt/<name>: ${interpreterMount}`);
427
+ // AgentCore Runtime also requires /mnt/<name>; an ECS task or a test may mount the file system elsewhere.
428
+ if (!posix.isAbsolute(runtimeMount) || runtimeMount === '/')
429
+ throw new TypeError(`The runtime mount path must be an absolute directory: ${runtimeMount}`);
430
+ const base = posix.normalize(options.runtimeAccessPointRoot ?? '/boring');
431
+ if (!posix.isAbsolute(base) || base.split('/').includes('..'))
432
+ throw new TypeError(`The runtime access point root must be an absolute path: ${base}`);
433
+ const state = posix.join(runtimeMount, 'state', userId);
434
+ return {
435
+ userId,
436
+ namespaceId: `efs:${posix.join(base, 'users', userId)}`,
437
+ accessPoint: {
438
+ rootDirectory: posix.join(base, 'users', userId),
439
+ posixUser: { uid, gid },
440
+ // setgid directory: new entries keep the shared group.
441
+ creationInfo: { ownerUid: uid, ownerGid: gid, permissions: '2770' },
442
+ tags: [{ key: 'boring:user', value: userId }],
443
+ },
444
+ interpreter: { mountPath: interpreterMount, cwd: interpreterMount },
445
+ runtime: { root: posix.join(runtimeMount, 'users', userId), state, journal: posix.join(state, 'journal.sqlite') },
446
+ harnessFile: key => {
447
+ if (!/^[a-z0-9_-]{1,64}$/.test(key))
448
+ throw new TypeError('A conversation key matches [a-z0-9_-]{1,64}');
449
+ return posix.join(state, `${key}.pi.sqlite`);
450
+ },
451
+ filesystemConfiguration: ({ accessPointArn, fileSystemArn }) => ({ efsConfiguration: { accessPointArn, fileSystemArn, mountPath: interpreterMount } }),
452
+ };
453
+ }
@@ -0,0 +1,8 @@
1
+ /** Largest error text, in characters, that the shell, Git and the native environment of a workspace pass on (into a tool result). */
2
+ export declare const MAX_ERROR_CHARS = 2000;
3
+ /**
4
+ * The one bound on error text leaving a workspace environment (virtual or AWS Code Interpreter): the message only, never a stack (stack frames are dropped even when a backing
5
+ * put them in its message), at most `MAX_ERROR_CHARS`. The whole error stays on `cause` for the host's own logs.
6
+ */
7
+ export declare function boundedMessage(error: unknown): string;
8
+ //# sourceMappingURL=bounded-error.d.ts.map
@@ -0,0 +1,12 @@
1
+ // No imports: shared by the virtual workspace and the AWS Code Interpreter environment without loading either one's dependencies.
2
+ /** Largest error text, in characters, that the shell, Git and the native environment of a workspace pass on (into a tool result). */
3
+ export const MAX_ERROR_CHARS = 2000;
4
+ /**
5
+ * The one bound on error text leaving a workspace environment (virtual or AWS Code Interpreter): the message only, never a stack (stack frames are dropped even when a backing
6
+ * put them in its message), at most `MAX_ERROR_CHARS`. The whole error stays on `cause` for the host's own logs.
7
+ */
8
+ export function boundedMessage(error) {
9
+ const text = (error instanceof Error ? error.message : String(error)).replace(/\n[ \t]*at [^\n]*/g, '').trim() || 'Unknown error';
10
+ const note = ` [error truncated at ${MAX_ERROR_CHARS} characters]`;
11
+ return text.length > MAX_ERROR_CHARS ? `${text.slice(0, MAX_ERROR_CHARS - note.length)}${note}` : text;
12
+ }
@@ -0,0 +1,46 @@
1
+ import type { Context } from '@earendil-works/chord';
2
+ import type { FileSystem, Shell, ExecutionEnv } from '@earendil-works/pi-durable/env';
3
+ export interface WorkspaceIdentity {
4
+ readonly providerId: string;
5
+ readonly instanceId: string;
6
+ readonly incarnation: string;
7
+ readonly viewId: string;
8
+ }
9
+ /** A provider can expose files, shell, or both. Native Shell-only capability
10
+ * must not force a dummy filesystem. Pi's env factory still requires its
11
+ * complete ExecutionEnv when used; a shell-only tool closes over Shell.
12
+ *
13
+ * Reuse a native environment for compatible, stable cwd/view/scope bindings;
14
+ * fresh allocation per invocation is not required. Do not mutate one shared
15
+ * cwd across concurrent incompatible calls. Identity is not permission.
16
+ */
17
+ export interface WorkspaceLease<Environment extends FileSystem | Shell = FileSystem> {
18
+ readonly identity: WorkspaceIdentity;
19
+ readonly environment: Environment;
20
+ readonly ownership: 'owned' | 'borrowed';
21
+ /** Release this lease, not other owners. Host supplies a usable cleanup
22
+ * Context. Idempotent release cannot count twice or destroy another lease.
23
+ * Native cleanup on a borrowed per-call facade is not provider disposal.
24
+ */
25
+ readonly release: (context: Context) => Promise<void>;
26
+ }
27
+ export type CodingWorkspaceLease = WorkspaceLease<ExecutionEnv>;
28
+ export interface WorkspaceRequest<Input> {
29
+ readonly operationId: string;
30
+ readonly input: Input;
31
+ }
32
+ export interface WorkspaceProvider<Input, Environment extends FileSystem | Shell = FileSystem> {
33
+ readonly providerId: string;
34
+ readonly acquire: (request: WorkspaceRequest<Input>, context: Context) => Promise<WorkspaceLease<Environment>>;
35
+ }
36
+ export type Reattachment<Environment extends FileSystem | Shell> = {
37
+ readonly kind: 'available';
38
+ readonly lease: WorkspaceLease<Environment>;
39
+ } | {
40
+ readonly kind: 'missing' | 'expired' | 'unavailable';
41
+ readonly reason: string;
42
+ };
43
+ export interface RecoverableWorkspaceProvider<Input, Environment extends FileSystem | Shell = FileSystem> extends WorkspaceProvider<Input, Environment> {
44
+ readonly reattach: (identity: WorkspaceIdentity, context: Context) => Promise<Reattachment<Environment>>;
45
+ }
46
+ //# sourceMappingURL=contracts.d.ts.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { FileSystem } from '@earendil-works/pi-durable/env';
2
+ type Source = Pick<FileSystem, 'readBinaryFile' | 'fileInfo' | 'listDir'>;
3
+ export declare function snapshotReaders(source: Source): Pick<FileSystem, 'openBinaryReader' | 'openDirReader' | 'watch'>;
4
+ /** A POSIX shell command that runs exactly `argv`: each word single-quoted. These environments run commands through a shell. */
5
+ export declare function shellCommand(command: string | readonly string[]): string;
6
+ export {};
7
+ //# sourceMappingURL=fs-readers.d.ts.map
@@ -0,0 +1,72 @@
1
+ // Pi 1.0.3 asks every `FileSystem` for positional readers, paged directory readers and watchers. Environments here that hold no open
2
+ // file handles (the in-memory virtual file system) build the readers on whole-file and whole-directory reads they
3
+ // already have: a reader keeps the bytes or entries as they were when opened. They report no changes: `watch` is `not_supported`. The remote file system does not use this: its worker has native positional
4
+ // readers, which `remote-files-positional.ts` forwards without moving the whole file.
5
+ import { FileError, LineScanner, ok, err } from '@earendil-works/pi-durable/env';
6
+ export function snapshotReaders(source) {
7
+ const guard = (closed, path, context) => context.abortSignal?.aborted ? new FileError('aborted', 'Operation aborted', path) : closed ? new FileError('invalid', 'Reader is closed', path) : undefined;
8
+ return {
9
+ openBinaryReader: async (path, _options, context) => {
10
+ const info = await source.fileInfo(path, context);
11
+ if (!info.ok)
12
+ return info;
13
+ if (info.value.kind === 'directory')
14
+ return err(new FileError('is_directory', 'EISDIR: illegal operation on a directory', path));
15
+ if (info.value.kind !== 'file')
16
+ return err(new FileError('invalid', 'Not a regular file', path));
17
+ const content = await source.readBinaryFile(path, context);
18
+ if (!content.ok)
19
+ return content;
20
+ const bytes = content.value, opened = { ...info.value, size: bytes.length };
21
+ let closed = false;
22
+ return ok({
23
+ info: async (inner) => { const failed = guard(closed, path, inner); return failed ? err(failed) : ok({ ...opened }); },
24
+ read: async (offset, length, inner) => {
25
+ const failed = guard(closed, path, inner);
26
+ if (failed)
27
+ return err(failed);
28
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length < 0)
29
+ return err(new FileError('invalid', 'Offset and length must be non-negative integers', path));
30
+ return ok(bytes.slice(offset, offset + length));
31
+ },
32
+ scanLines: async ({ startLine, endLine }, inner) => {
33
+ const failed = guard(closed, path, inner);
34
+ if (failed)
35
+ return err(failed);
36
+ if (!Number.isSafeInteger(startLine) || startLine < 0 || (endLine !== undefined && (!Number.isSafeInteger(endLine) || endLine <= startLine))) {
37
+ return err(new FileError('invalid', 'Line range must be non-negative integers with endLine > startLine', path));
38
+ }
39
+ const scanner = new LineScanner(startLine, endLine);
40
+ scanner.push(bytes);
41
+ return ok(scanner.finish());
42
+ },
43
+ close: async () => { closed = true; },
44
+ });
45
+ },
46
+ openDirReader: async (path, context) => {
47
+ const listed = await source.listDir(path, context);
48
+ if (!listed.ok)
49
+ return listed;
50
+ const entries = listed.value.filter(entry => entry.kind === 'file' || entry.kind === 'directory' || entry.kind === 'symlink');
51
+ let offset = 0, closed = false;
52
+ return ok({
53
+ next: async (maxEntries, inner) => {
54
+ const failed = guard(closed, path, inner);
55
+ if (failed)
56
+ return err(failed);
57
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1)
58
+ return err(new FileError('invalid', 'maxEntries must be a positive integer', path));
59
+ const page = entries.slice(offset, offset + maxEntries);
60
+ offset += page.length;
61
+ return ok({ entries: page, done: offset >= entries.length });
62
+ },
63
+ close: async () => { closed = true; },
64
+ });
65
+ },
66
+ watch: async () => err(new FileError('not_supported', 'This environment does not report file changes')),
67
+ };
68
+ }
69
+ /** A POSIX shell command that runs exactly `argv`: each word single-quoted. These environments run commands through a shell. */
70
+ export function shellCommand(command) {
71
+ return typeof command === 'string' ? command : command.map(word => `'${word.replaceAll("'", `'\\''`)}'`).join(' ');
72
+ }
@@ -0,0 +1,3 @@
1
+ export type * from './native.js';
2
+ export type * from './contracts.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ /** Native types are aliases, never a duplicated/narrowed Boring environment. */
2
+ import type { HarnessOptions } from '@earendil-works/pi-durable';
3
+ export type { FileSystem, Shell, ExecutionEnv, FileInfo, FileError, ExecutionError, Result, TextLineReader, ShellExecOptions, ShellExecResult, ShellSpillOptions, } from '@earendil-works/pi-durable/env';
4
+ export type { Context } from '@earendil-works/chord';
5
+ export type { EnvTarget } from '@earendil-works/pi-durable';
6
+ /** Preserve native target/committed reads/Context, async and undefined results. */
7
+ export type EnvironmentFactory = NonNullable<HarnessOptions['env']>;
8
+ //# sourceMappingURL=native.d.ts.map
package/dist/native.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import type { Context } from '@earendil-works/chord';
2
+ import type { FileSystem } from '@earendil-works/pi-durable/env';
3
+ import type { WorkspaceIdentity, WorkspaceLease } from './contracts.js';
4
+ import type { RemoteFileSystemCall } from './remote-files-protocol.js';
5
+ export interface RemoteFileSystemAccess {
6
+ readonly identity: WorkspaceIdentity;
7
+ readonly filesystemId: string;
8
+ readonly context: Context;
9
+ readonly revoked: AbortSignal;
10
+ readonly bindFileSystem: (cwd: string, context: Context) => Promise<WorkspaceLease<FileSystem>>;
11
+ readonly authorize: (call: RemoteFileSystemCall, cwd: string) => boolean | Promise<boolean>;
12
+ }
13
+ export interface RemoteFileSystemHandlerOptions {
14
+ readonly authenticate: (request: Request) => Promise<RemoteFileSystemAccess | null>;
15
+ readonly maxRequestBytes?: number;
16
+ readonly maxResponseBytes?: number;
17
+ readonly maxFrameBytes?: number;
18
+ readonly onCleanupError?: (error: unknown) => void;
19
+ }
20
+ export declare function createRemoteFileSystemHandler(options: RemoteFileSystemHandlerOptions): (request: Request) => Promise<Response>;
21
+ //# sourceMappingURL=remote-files-handler.d.ts.map