@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 +2 -2
- package/dist/aws-code-interpreter.d.ts +3 -2
- package/dist/aws-code-interpreter.js +48 -16
- package/dist/fs-readers.d.ts +1 -1
- package/dist/fs-readers.js +31 -8
- package/dist/virtual.js +4 -1
- package/package.json +8 -8
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
|
|
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).
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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,
|
|
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 }),
|
|
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 },
|
|
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'))
|
package/dist/fs-readers.d.ts
CHANGED
|
@@ -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;
|
package/dist/fs-readers.js
CHANGED
|
@@ -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,
|
|
10
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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.
|
|
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"
|