@hachej/boring-execution 0.2.0 → 0.2.2

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 CHANGED
@@ -90,9 +90,9 @@ Run `node examples/remote-files.mjs` after building to write and stream a fictio
90
90
 
91
91
  - **Commands** run through `startCommandExecution`, then `getTask` every `pollIntervalMs` (500 ms by default) until a terminal status. The cwd, environment (`env`, or `env -i` for `inheritEnv: false`) and `umask` (default `002`) are prefixed to the command, which runs under `bash -c`. `onOutput` receives the new part of stdout, then of stderr, after each poll; spill thresholds are accepted and nothing is spilled. `timeout` (seconds) and an aborted context call `stopTask` and return `timeout`/`aborted`, termination unconfirmed. A `failed` status with an exit code is a normal result.
92
92
  - **Files** run on this host's mount through Pi's `NodeExecutionEnv`: no bytes cross the API. A path outside `mount.path`, or one whose resolution (symbolic links included, dangling ones followed by hand) leaves `mount.root`, returns `FileError('permission_denied')`; operations that do not follow a final link (`fileInfo`, `exists`, `remove`, `renameFile`) check its directory. Paths in results and errors are session paths. `createTempDir`/`createTempFile` return `not_supported` (a temporary file here would be invisible to the session). The check and the use are separate steps: a link changed in between by a concurrent command is not caught.
93
- - **Session**: `{ sessionId }` borrows a running session, which `stop()` never ends; `{ start: { name?, sessionTimeoutSeconds?, filesystemConfigurations? } }` starts one on the first command and `stop()` ends it once. `env.cleanup()` never stops a session. When the service reports the session gone (`ResourceNotFoundException`), the running and every later command return `shell_unavailable` and `lost()` is true; files stay. `renew: 'auto'` (default for started sessions; borrowed ones are always manual) starts a new session on the NEXT command after a loss: the failing command still returns `shell_unavailable` and is never replayed, since it may have had side effects. Before a command, a session older than `sessionTimeoutSeconds - rotateMarginSeconds` (default 60 s, at most half the time to live) is replaced for NEW commands. Rotation never stops the old session: a command stays on the session it started in (an `unknown` outcome after a transport failure stays unknown; nothing kills or replays it) and the old session lingers until its own `sessionTimeoutSeconds`, so hosts that care about idle sessions set a shorter timeout. Starts are single-flight (commands arriving during a rotation share the one new session); a failed Start leaves commands on the current session and backs off. `stop()` stops the current session and every session this env started and has not confirmed stopped, and waits for them; a failed stop stays tracked, rejects, and is retried by the next `stop()`. `renew: 'manual'` keeps the earlier behaviour: only `renew()` (started sessions only) lets the next command start a new session. Loss is `ResourceNotFoundException` plus the names and message patterns in the exported `SESSION_ENDED_ERRORS` (for example a `ValidationException` about a terminated session): those extra shapes are UNVERIFIED against real AgentCore (open question), and `AccessDeniedException` is never loss. Service and file errors map to `ExecutionError`/`FileError` with messages bounded by the same `boundedMessage` (no stack frames, at most `MAX_ERROR_CHARS`).
93
+ - **Session**: `{ sessionId }` borrows a running session, which `stop()` never ends; `{ start: { name?, sessionTimeoutSeconds?, filesystemConfigurations? } }` starts one on the first command and `stop()` ends it once. `env.cleanup()` never stops a session. When the service reports the session gone (`ResourceNotFoundException`), the running and every later command return `shell_unavailable` and `lost()` is true; files stay. `renew: 'auto'` (default for started sessions; borrowed ones are always manual) starts a new session on the NEXT command after a loss: the failing command still returns `shell_unavailable` and is never replayed, since it may have had side effects. Before a command, a session older than `sessionTimeoutSeconds - rotateMarginSeconds` (default 60 s, at most half the time to live) is replaced for NEW commands. Rotation never stops the old session: a command stays on the session it started in (an `unknown` outcome after a transport failure stays unknown; nothing kills or replays it) and the old session lingers until its own `sessionTimeoutSeconds`, so hosts that care about idle sessions set a shorter timeout. Starts are single-flight (commands arriving during a rotation share the one new session); a failed Start leaves commands on the current session and backs off. `stop()` stops the current session and every session this env started and has not confirmed stopped, and waits for them; a failed stop stays tracked, rejects, and is retried by the next `stop()`. `renew: 'manual'` keeps the earlier behaviour: only `renew()` (started sessions only) lets the next command start a new session. Loss is `ResourceNotFoundException` plus the names and message patterns in the exported `SESSION_ENDED_ERRORS` (for example a `ValidationException` about a terminated session): those extra shapes are verified against the real service (owner, 2026-10-12; see [HOST-RECIPE-AWS.md](../../docs/architecture/HOST-RECIPE-AWS.md)), and `AccessDeniedException` is never loss. Service and file errors map to `ExecutionError`/`FileError` with messages bounded by the same `boundedMessage` (no stack frames, at most `MAX_ERROR_CHARS`).
94
94
 
95
95
  `efsUserLayout({ userId, uid, gid?, runtimeMountPath?, runtimeAccessPointRoot?, interpreterMountPath? })` is pure: for a user id (`[a-z0-9][a-z0-9_-]{0,63}`, never a path) and a host-allocated uid it returns the access point to create (root `/boring/users/<id>`, POSIX user `uid:1000`, setgid `2770`, a `boring:user` tag), the session mount (`/mnt/workspace`), this host's folder (`/mnt/efs/users/<id>`), the state directory outside it (`/mnt/efs/state/<id>`, with `journal.sqlite` and `harnessFile(key)`), the namespace id and the `filesystemConfigurations` entry.
96
96
 
97
- `test/packages/execution.test.mjs` drives the real SDK client against `examples/aws/fake-code-interpreter.mjs` (the wire format, without AWS): shared files, output, exit codes, cwd and environment, timeout and abort through `stopTask`, a lost session (auto renewal mid-turn and under concurrent commands, rotation before the time to live without stopping the old session, a stop that fails once and is retried, manual mode, borrowed sessions), borrowed versus owned stop, and two users who never see each other's folders (`..`, absolute paths, links and dangling links made by a command). Nothing has run against the real service; see the open questions in HOST-RECIPE-AWS.md.
97
+ `test/packages/execution.test.mjs` drives the real SDK client against `examples/aws/fake-code-interpreter.mjs` (the wire format, without AWS): shared files, output, exit codes, cwd and environment, timeout and abort through `stopTask`, a lost session (auto renewal mid-turn and under concurrent commands, rotation before the time to live without stopping the old session, a stop that fails once and is retried, manual mode, borrowed sessions), borrowed versus owned stop, and two users who never see each other's folders (`..`, absolute paths, links and dangling links made by a command). The full deployment journey has not run against AWS (the session-ended error shapes are verified against the real service, owner 2026-10-12); see the open questions in HOST-RECIPE-AWS.md.
98
98
 
@@ -38,6 +38,8 @@ export interface CodeInterpreterEnvOptions {
38
38
  readonly umask?: string;
39
39
  /** Delay between `getTask` polls while a command runs (default 500 ms). */
40
40
  readonly pollIntervalMs?: number;
41
+ /** Deadline for the whole answer to one start or stop call (default 30 s); the SDK sets no timeout of its own. */
42
+ readonly serviceDeadlineMs?: number;
41
43
  /**
42
44
  * What happens after a started session is lost or nearly expired. `'auto'` (default for `{ start }`): the failing
43
45
  * command still returns `shell_unavailable` (it is never replayed: it may have had side effects) and the NEXT command
@@ -70,8 +72,7 @@ export interface CodeInterpreterEnv {
70
72
  }
71
73
  /**
72
74
  * Error names and messages, besides `ResourceNotFoundException`, taken to mean "the session ended".
73
- * UNVERIFIED against the real AgentCore service (open question in HOST-RECIPE-AWS.md): they are the likely shapes of an
74
- * expired or terminated session. `AccessDeniedException` is deliberately NOT here: it is a permissions problem and
75
+ * These shapes are verified against the real AgentCore service (owner, 2026-10-12). `AccessDeniedException` is deliberately NOT here: it is a permissions problem and
75
76
  * renewing would not fix it. Matching needs both a listed name and a message pattern.
76
77
  */
77
78
  export declare const SESSION_ENDED_ERRORS: readonly {
@@ -20,8 +20,7 @@ const sleep = (ms, signal) => new Promise(resolve => {
20
20
  });
21
21
  /**
22
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
23
+ * These shapes are verified against the real AgentCore service (owner, 2026-10-12). `AccessDeniedException` is deliberately NOT here: it is a permissions problem and
25
24
  * renewing would not fix it. Matching needs both a listed name and a message pattern.
26
25
  */
27
26
  export const SESSION_ENDED_ERRORS = [
@@ -60,6 +59,9 @@ export function createCodeInterpreterEnv(options) {
60
59
  if (!/^[0-7]{3,4}$/.test(umask))
61
60
  throw new TypeError('umask must be octal');
62
61
  const pollMs = options.pollIntervalMs ?? 500;
62
+ const serviceDeadlineMs = options.serviceDeadlineMs ?? 30_000;
63
+ /** The one deadline for every service call that could otherwise wait forever (the SDK sets no timeout): start, begin, polls and stop. */
64
+ const serviceDeadline = () => AbortSignal.timeout(serviceDeadlineMs);
63
65
  const borrowed = 'sessionId' in options.session ? options.session.sessionId : undefined;
64
66
  const start = 'start' in options.session ? options.session.start : undefined;
65
67
  const auto = start !== undefined && (options.renew ?? 'auto') === 'auto';
@@ -106,12 +108,21 @@ export function createCodeInterpreterEnv(options) {
106
108
  throw new ServiceError('SessionMissing', 'No Code Interpreter session');
107
109
  const { name, sessionTimeoutSeconds, filesystemConfigurations } = start;
108
110
  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
- }));
111
+ const deadline = serviceDeadline();
112
+ let response;
113
+ try {
114
+ response = await client.send(new StartCodeInterpreterSessionCommand({
115
+ codeInterpreterIdentifier,
116
+ ...(name === undefined ? {} : { name }),
117
+ ...(sessionTimeoutSeconds === undefined ? {} : { sessionTimeoutSeconds }),
118
+ ...(filesystemConfigurations === undefined ? {} : { filesystemConfigurations: [...filesystemConfigurations] }),
119
+ }), { abortSignal: deadline });
120
+ }
121
+ catch (error) {
122
+ if (deadline.aborted)
123
+ throw new ServiceError('StartTimeout', `The Code Interpreter did not answer the session start within ${serviceDeadlineMs / 1000} s: whether a session started is unknown`);
124
+ throw error;
125
+ }
115
126
  const id = response.sessionId;
116
127
  if (!id)
117
128
  throw new ServiceError('SessionMissing', 'StartCodeInterpreterSession returned no session id');
@@ -146,10 +157,10 @@ export function createCodeInterpreterEnv(options) {
146
157
  }
147
158
  }
148
159
  /** One tool call; the streamed result, or a ServiceError for an exception (thrown or streamed). */
149
- async function invoke(name, args, context, pinned) {
160
+ async function invoke(name, args, signal, pinned) {
150
161
  const id = pinned ?? await session();
151
162
  try {
152
- const response = await client.send(new InvokeCodeInterpreterCommand({ codeInterpreterIdentifier, sessionId: id, name, arguments: args }), context?.abortSignal ? { abortSignal: context.abortSignal } : {});
163
+ const response = await client.send(new InvokeCodeInterpreterCommand({ codeInterpreterIdentifier, sessionId: id, name, arguments: args }), signal ? { abortSignal: signal } : {});
153
164
  let result;
154
165
  for await (const event of response.stream ?? []) {
155
166
  if (event.result) {
@@ -187,8 +198,9 @@ export function createCodeInterpreterEnv(options) {
187
198
  const text = (result) => (result.content ?? []).map(block => block.text ?? '').join('');
188
199
  const TERMINAL = new Set(['completed', 'failed', 'canceled']);
189
200
  /** Asks the service to stop the task; the outcome is never assumed (the command's result says it is not confirmed). */
201
+ // Bounded like the start call: the SDK has no request timeout, so a stop answer that never comes must not hang the command.
190
202
  const stopTask = async (taskId, id) => { try {
191
- await invoke('stopTask', { taskId }, undefined, id);
203
+ await invoke('stopTask', { taskId }, serviceDeadline(), id);
192
204
  }
193
205
  catch { /* reported as unconfirmed termination */ } };
194
206
  async function exec(command, execOptions, context) {
@@ -211,14 +223,30 @@ export function createCodeInterpreterEnv(options) {
211
223
  await prepare();
212
224
  // Every call of this command goes to the session it started in, even if a later command rotates.
213
225
  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
226
  }
219
227
  catch (error) {
220
228
  return err(failure(error, 'spawn_error'));
221
229
  }
230
+ // A caller that cancelled while the session was being acquired gets no command: nothing is dispatched.
231
+ if (context.abortSignal?.aborted)
232
+ return err(new ExecutionError('aborted', 'Command aborted before dispatch'));
233
+ // The start call is not aborted by the caller: a task it started must always come back with its id, so the polling loop
234
+ // below can stop it. It has its own deadline instead, covering the whole response, because the SDK sets none. Nothing is
235
+ // replayed when it expires: the task may or may not exist, and only a stop with its id could end it.
236
+ const startDeadline = serviceDeadline();
237
+ let started;
238
+ try {
239
+ started = await invoke('startCommandExecution', { command: script }, startDeadline, pinned);
240
+ }
241
+ catch (error) {
242
+ if (startDeadline.aborted) {
243
+ return err(new ExecutionError('unknown', boundedMessage(`The Code Interpreter did not answer the start call within ${serviceDeadlineMs / 1000} s: whether the command started is unknown and it was not retried`)));
244
+ }
245
+ return err(failure(error, 'spawn_error'));
246
+ }
247
+ taskId = started.structuredContent?.taskId;
248
+ if (!taskId)
249
+ return err(new ExecutionError('spawn_error', boundedMessage(`Code Interpreter did not start the command: ${text(started) || 'no task id'}`)));
222
250
  const deadline = execOptions?.timeout && execOptions.timeout > 0 ? Date.now() + execOptions.timeout * 1000 : undefined;
223
251
  const seen = { stdout: '', stderr: '' };
224
252
  // getTask reports the output so far; a value that does not extend what was seen is treated as new output.
@@ -240,8 +268,9 @@ export function createCodeInterpreterEnv(options) {
240
268
  return err(new ExecutionError('timeout', `Command exceeded ${execOptions?.timeout} s; stopTask was requested, termination is not confirmed`));
241
269
  }
242
270
  let status;
271
+ const pollDeadline = serviceDeadline();
243
272
  try {
244
- const polled = await invoke('getTask', { taskId }, context, pinned);
273
+ const polled = await invoke('getTask', { taskId }, context.abortSignal ? AbortSignal.any([context.abortSignal, pollDeadline]) : pollDeadline, pinned);
245
274
  status = polled.structuredContent;
246
275
  try {
247
276
  deliver('stdout', status?.stdout);
@@ -257,6 +286,9 @@ export function createCodeInterpreterEnv(options) {
257
286
  catch (error) {
258
287
  if (context.abortSignal?.aborted)
259
288
  continue;
289
+ // A poll that never answers is a transient poll failure: the state is unknown, and nothing is stopped or replayed.
290
+ if (pollDeadline.aborted)
291
+ return err(new ExecutionError('unknown', boundedMessage(`The Code Interpreter did not answer a status poll within ${serviceDeadlineMs / 1000} s: the command's state is unknown`)));
260
292
  return err(failure(error, 'unknown'));
261
293
  }
262
294
  if (status.taskStatus === 'completed' || (status.taskStatus === 'failed' && typeof status.exitCode === 'number'))
@@ -1,5 +1,5 @@
1
1
  import type { FileSystem } from '@earendil-works/pi-durable/env';
2
- type Source = Pick<FileSystem, 'readBinaryFile' | 'fileInfo' | 'listDir'>;
2
+ type Source = Pick<FileSystem, 'readBinaryFile' | 'fileInfo' | 'listDir' | 'canonicalPath'>;
3
3
  export declare function snapshotReaders(source: Source): Pick<FileSystem, 'openBinaryReader' | 'openDirReader' | 'watch'>;
4
4
  /** A POSIX shell command that runs exactly `argv`: each word single-quoted. These environments run commands through a shell. */
5
5
  export declare function shellCommand(command: string | readonly string[]): string;
@@ -6,15 +6,26 @@ import { FileError, LineScanner, ok, err } from '@earendil-works/pi-durable/env'
6
6
  export function snapshotReaders(source) {
7
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
8
  return {
9
- openBinaryReader: async (path, _options, context) => {
10
- const info = await source.fileInfo(path, context);
9
+ openBinaryReader: async (path, options, context) => {
10
+ // `fileInfo` describes the final path itself. A symbolic link is followed unless the caller refuses it (`noFollow`).
11
+ const link = await source.fileInfo(path, context);
12
+ if (!link.ok)
13
+ return link;
14
+ let resolved = path;
15
+ if (link.value.kind === 'symlink' && !options?.noFollow) {
16
+ const canonical = await source.canonicalPath(path, context);
17
+ if (!canonical.ok)
18
+ return canonical;
19
+ resolved = canonical.value;
20
+ }
21
+ const info = resolved === path ? link : await source.fileInfo(resolved, context);
11
22
  if (!info.ok)
12
23
  return info;
13
24
  if (info.value.kind === 'directory')
14
25
  return err(new FileError('is_directory', 'EISDIR: illegal operation on a directory', path));
15
26
  if (info.value.kind !== 'file')
16
27
  return err(new FileError('invalid', 'Not a regular file', path));
17
- const content = await source.readBinaryFile(path, context);
28
+ const content = await source.readBinaryFile(resolved, context);
18
29
  if (!content.ok)
19
30
  return content;
20
31
  const bytes = content.value, opened = { ...info.value, size: bytes.length };
@@ -44,10 +55,15 @@ export function snapshotReaders(source) {
44
55
  });
45
56
  },
46
57
  openDirReader: async (path, context) => {
47
- const listed = await source.listDir(path, context);
58
+ // Resolve the directory once: a later change of the environment's cwd must not move this reader to another directory.
59
+ const directory = await source.canonicalPath(path, context);
60
+ if (!directory.ok)
61
+ return directory;
62
+ const listed = await source.listDir(directory.value, context);
48
63
  if (!listed.ok)
49
64
  return listed;
50
- const entries = listed.value.filter(entry => entry.kind === 'file' || entry.kind === 'directory' || entry.kind === 'symlink');
65
+ const prefix = directory.value === '/' ? '' : directory.value;
66
+ const pending = listed.value.filter(entry => entry.kind === 'file' || entry.kind === 'directory' || entry.kind === 'symlink').map(entry => `${prefix}/${entry.name}`);
51
67
  let offset = 0, closed = false;
52
68
  return ok({
53
69
  next: async (maxEntries, inner) => {
@@ -56,9 +72,16 @@ export function snapshotReaders(source) {
56
72
  return err(failed);
57
73
  if (!Number.isSafeInteger(maxEntries) || maxEntries < 1)
58
74
  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 });
75
+ // Stat only the entries this page consumes; an entry removed since the open is skipped, as Pi's readers allow.
76
+ const page = [];
77
+ while (page.length < maxEntries && offset < pending.length) {
78
+ const info = await source.fileInfo(pending[offset++], inner);
79
+ if (info.ok)
80
+ page.push(info.value);
81
+ else if (info.error.code !== 'not_found')
82
+ return err(info.error);
83
+ }
84
+ return ok({ entries: page, done: offset >= pending.length });
62
85
  },
63
86
  close: async () => { closed = true; },
64
87
  });
package/dist/virtual.js CHANGED
@@ -135,7 +135,7 @@ function nativeEnvironment(fs, id, cwd, closed, release, createBash) {
135
135
  return path;
136
136
  }
137
137
  // Readers and the watcher over the memory file system's whole-file reads (see fs-readers.ts).
138
- const readers = snapshotReaders({ readBinaryFile: (path, context) => environment.readBinaryFile(path, context), fileInfo: (path, context) => environment.fileInfo(path, context), listDir: (path, context) => environment.listDir(path, context) });
138
+ const readers = snapshotReaders({ readBinaryFile: (path, context) => environment.readBinaryFile(path, context), fileInfo: (path, context) => environment.fileInfo(path, context), listDir: (path, context) => environment.listDir(path, context), canonicalPath: (path, context) => environment.canonicalPath(path, context) });
139
139
  const environment = {
140
140
  id, cwd: posix.normalize(cwd), ...readers,
141
141
  absolutePath: (path, context) => run(path, context, path => path),
@@ -242,6 +242,9 @@ function nativeEnvironment(fs, id, cwd, closed, release, createBash) {
242
242
  catch (cause) {
243
243
  return err(new ExecutionError('spawn_error', `Working directory does not exist: ${cwd}\nCannot execute bash commands.`, cause instanceof Error ? cause : undefined));
244
244
  }
245
+ // An argv with no program is a spawn error; nothing runs.
246
+ if (typeof command !== 'string' && command.length === 0)
247
+ return err(new ExecutionError('spawn_error', 'Empty argv: there is no program to run'));
245
248
  const result = await createBash({ cwd, ...(options?.timeout === undefined ? {} : { executionLimits: { maxExecutionTimeMs: options.timeout * 1000 } }) }).exec(shellCommand(command), {
246
249
  ...(options?.env === undefined ? {} : { env: options.env }), replaceEnv: options?.inheritEnv === false,
247
250
  signal: AbortSignal.any(signals),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-execution",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -52,13 +52,13 @@
52
52
  }
53
53
  },
54
54
  "peerDependencies": {
55
- "@earendil-works/pi-durable": "1.1.0",
56
- "@earendil-works/chord": "1.1.0",
57
- "just-bash": "3.6.0",
58
- "isomorphic-git": "1.42.6",
59
- "@hachej/boring-files": "0.2.0",
60
- "zod": "4.6.5",
61
- "@aws-sdk/client-bedrock-agentcore": "3.1146.0"
55
+ "@earendil-works/pi-durable": "^1.1.0",
56
+ "@earendil-works/chord": "^1.1.0",
57
+ "just-bash": "^3.6.0",
58
+ "isomorphic-git": "^1.42.6",
59
+ "@hachej/boring-files": "^0.2.2",
60
+ "zod": "^4.6.5",
61
+ "@aws-sdk/client-bedrock-agentcore": "^3.1146.0"
62
62
  },
63
63
  "engines": {
64
64
  "node": ">=22.19.0"