@demicodes/shell 0.14.0 → 0.14.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.
@@ -15,14 +15,27 @@ type VirtualFileSystemNode = {
15
15
  interface VirtualFileSystemProvider {
16
16
  lookup(path: string): Promise<VirtualFileSystemNode | null> | VirtualFileSystemNode | null;
17
17
  }
18
+ interface HostBackedFileSystemOptions {
19
+ lookup?: VirtualFileSystemProvider['lookup'];
20
+ /**
21
+ * Ceiling for a single file read through this filesystem. Portable commands
22
+ * (cat/grep/rg/head/...) run in-process and load files whole, so one read of
23
+ * an oversized file becomes resident memory in the embedding host — a broad
24
+ * scan over a large tree can balloon the process by gigabytes. Oversized
25
+ * files fail loudly instead; real-process routes (`bash -c`) stay available
26
+ * for genuinely large files.
27
+ */
28
+ maxFileReadBytes?: number;
29
+ }
18
30
  /** Builds a virtual directory node; entry names containing a dot are treated as files. */
19
31
  declare function virtualDirectory(names: string[]): VirtualFileSystemNode;
20
32
  /** Builds a virtual file node from raw bytes. */
21
33
  declare function virtualFile(content: Uint8Array): VirtualFileSystemNode;
22
34
  declare class HostBackedFileSystem implements IFileSystem {
23
35
  private readonly host;
24
- private readonly virtualProvider?;
25
- constructor(host: Host, virtualProvider?: VirtualFileSystemProvider | undefined);
36
+ private readonly options;
37
+ private readonly maxFileReadBytes;
38
+ constructor(host: Host, options?: HostBackedFileSystemOptions);
26
39
  readFile(path: string, options?: ReadFileOptions | BufferEncoding): Promise<string>;
27
40
  readFileBuffer(path: string): Promise<Uint8Array>;
28
41
  writeFile(path: string, content: FileContent, options?: WriteFileOptions | BufferEncoding): Promise<void>;
@@ -48,4 +61,4 @@ declare class HostBackedFileSystem implements IFileSystem {
48
61
  private assertWritablePath;
49
62
  }
50
63
  //#endregion
51
- export { HostBackedFileSystem, VirtualFileSystemNode, VirtualFileSystemProvider, virtualDirectory, virtualFile };
64
+ export { HostBackedFileSystem, HostBackedFileSystemOptions, VirtualFileSystemNode, VirtualFileSystemProvider, virtualDirectory, virtualFile };
package/dist/host-fs.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { decodeUtf8, encodeUtf8, isAbsolutePath, normalizePath } from "@demicodes/utils";
2
2
  //#region src/host-fs.ts
3
+ const DEFAULT_FILE_READ_LIMIT_BYTES = 67108864;
3
4
  /** Builds a virtual directory node; entry names containing a dot are treated as files. */
4
5
  function virtualDirectory(names) {
5
6
  return {
@@ -21,10 +22,12 @@ function virtualFile(content) {
21
22
  }
22
23
  var HostBackedFileSystem = class {
23
24
  host;
24
- virtualProvider;
25
- constructor(host, virtualProvider) {
25
+ options;
26
+ maxFileReadBytes;
27
+ constructor(host, options = {}) {
26
28
  this.host = host;
27
- this.virtualProvider = virtualProvider;
29
+ this.options = options;
30
+ this.maxFileReadBytes = options.maxFileReadBytes ?? DEFAULT_FILE_READ_LIMIT_BYTES;
28
31
  }
29
32
  async readFile(path, options) {
30
33
  return decodeBytes(await this.readFileBuffer(path), encodingFrom(options));
@@ -35,6 +38,8 @@ var HostBackedFileSystem = class {
35
38
  if (virtual.kind !== "file") throw new Error(`EISDIR: illegal operation on a directory, read '${path}'`);
36
39
  return virtual.content;
37
40
  }
41
+ const stat = await this.host.fs.stat(path, { cwd: this.host.defaultCwd });
42
+ if (stat.isFile && stat.size > this.maxFileReadBytes) throw new Error(`EFBIG: file exceeds the ${this.maxFileReadBytes}-byte in-shell read limit (${stat.size} bytes), read '${path}'; read a slice instead, or process it with a real process via 'bash -c'`);
38
43
  return this.host.fs.readFile(path, { cwd: this.host.defaultCwd });
39
44
  }
40
45
  async writeFile(path, content, options) {
@@ -151,8 +156,8 @@ var HostBackedFileSystem = class {
151
156
  await this.host.fs.utimes(path, atime, mtime, { cwd: this.host.defaultCwd });
152
157
  }
153
158
  async lookupVirtual(path) {
154
- if (!this.virtualProvider || !isVirtualPath(path)) return null;
155
- return await this.virtualProvider.lookup(normalizePath(path)) ?? null;
159
+ if (!this.options.lookup || !isVirtualPath(path)) return null;
160
+ return await this.options.lookup(normalizePath(path)) ?? null;
156
161
  }
157
162
  assertWritablePath(path) {
158
163
  if (isVirtualPath(path)) throw new Error(`EROFS: read-only virtual filesystem, '${path}'`);
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as HostProcess, c as HostSpawnHandle, i as HostFileSystem, l as HostSpawnParams, n as HostDirent, o as HostProcessOutputChunk, r as HostFileStat, s as HostSpawnExit, t as Host, u as HostStore } from "./host-DIg4RxcL.mjs";
2
- import { HostBackedFileSystem, VirtualFileSystemNode, VirtualFileSystemProvider, virtualDirectory, virtualFile } from "./host-fs.mjs";
2
+ import { HostBackedFileSystem, HostBackedFileSystemOptions, VirtualFileSystemNode, VirtualFileSystemProvider, virtualDirectory, virtualFile } from "./host-fs.mjs";
3
3
  import { _ as runRegisteredCommand, a as CommandIO, c as CommandRegistry, d as CommandStdin, f as CommandStorage, g as renderCommandHelp, h as parseCommandInput, i as CommandExecutionContext, l as CommandRunContext, m as emptyStdin, n as COMMAND_HELP_DEFAULTS, o as CommandInputSpec, p as ParsedCommandInput, r as Command, s as CommandOutputSpec, t as AgentSessionCommandStorage, u as CommandRunResult } from "./storage-CNMBL9_j.mjs";
4
4
  import { CommandName } from "@demicodes/just-bash/commands";
5
5
  import { Interpreter, InterpreterState } from "@demicodes/just-bash/interpreter";
@@ -38,6 +38,9 @@ interface BackgroundJob {
38
38
  handle: HostSpawnHandle;
39
39
  stdoutBuffer: string;
40
40
  stderrBuffer: string;
41
+ /** Chars discarded from the head of each buffer once it outgrew the capture limit. */
42
+ droppedStdoutChars: number;
43
+ droppedStderrChars: number;
41
44
  stdoutPump: Promise<void>;
42
45
  stderrPump: Promise<void>;
43
46
  exitPromise: Promise<{
@@ -66,6 +69,10 @@ interface ForegroundProcess {
66
69
  /** Interleaved visible chunks with running byte offsets, for merged replay. */
67
70
  outputChunks: ShellOutputRecordChunk[];
68
71
  outputBytes: number;
72
+ /** Total bytes ingested across both streams; the capture limit judges this. */
73
+ capturedBytes: number;
74
+ /** Set when the capture limit was breached: the process is killed and further chunks dropped. */
75
+ captureOverflowed: boolean;
69
76
  audit: BashAuditEvent[];
70
77
  stdoutPump: Promise<void>;
71
78
  stderrPump: Promise<void>;
@@ -106,6 +113,19 @@ interface BashEnvironmentOptions {
106
113
  * above, where models are known.
107
114
  */
108
115
  maxBinaryBytes?: number;
116
+ /**
117
+ * Ceiling for the bytes a single command's output capture may ingest.
118
+ *
119
+ * Distinct from `maxOutputBytes`, which sizes the rendered view: capture is
120
+ * what the environment holds in memory while a command runs, and it holds
121
+ * several copies (raw bytes for the pipe, text renders, replay chunks). A
122
+ * foreground process that produces more than this is SIGKILLed and the
123
+ * command fails with an explicit error; a background job keeps only the most
124
+ * recent bytes within the ceiling. Without this ceiling one stray
125
+ * `rg`/`cat` over a large tree balloons the embedding process by tens of
126
+ * gigabytes until the kernel OOM-kills it.
127
+ */
128
+ maxCaptureBytes?: number;
109
129
  }
110
130
  interface ShellExecInput {
111
131
  script: string;
@@ -246,6 +266,7 @@ declare class BashEnvironment {
246
266
  private readonly initialEnv;
247
267
  private readonly defaultOutputLimitBytes;
248
268
  private readonly defaultBinaryLimitBytes;
269
+ private readonly captureLimitBytes;
249
270
  private readonly shells;
250
271
  private readonly defaultShellByAgentSessionId;
251
272
  private readonly commandsById;
@@ -321,4 +342,4 @@ declare function shellQuote(value: string): string;
321
342
  /** Picks a heredoc delimiter that does not collide with any line already in `body`. */
322
343
  declare function heredocDelimiter(body: string): string;
323
344
  //#endregion
324
- export { AgentSessionCommandStorage, BashAuditEvent, BashEnvironment, BashEnvironmentOptions, BinaryStdout, COMMAND_HELP_DEFAULTS, Command, CommandExecutionContext, CommandIO, CommandInputSpec, CommandMetadataRecord, CommandOutputSpec, CommandRegistry, CommandRunContext, CommandRunResult, CommandStdin, CommandStorage, DEMI_PORTABLE_COMMANDS, Host, HostBackedFileSystem, HostDirent, HostFileStat, HostFileSystem, HostProcess, HostProcessOutputChunk, HostSpawnExit, HostSpawnHandle, HostSpawnParams, HostStore, MAX_TIMEOUT_MS, ParsedCommandInput, RESERVED_COMMAND_NAMES, ShellAbortInput, ShellCommandStatus, ShellExecInput, ShellOutputChunk, ShellOutputRecordChunk, ShellOutputView, ShellStatusInput, ShellStreamView, ShellWriteInput, VirtualFileSystemNode, VirtualFileSystemProvider, emptyStdin, heredocDelimiter, parseCommandInput, renderCommandHelp, runRegisteredCommand, shellQuote, virtualDirectory, virtualFile };
345
+ export { AgentSessionCommandStorage, BashAuditEvent, BashEnvironment, BashEnvironmentOptions, BinaryStdout, COMMAND_HELP_DEFAULTS, Command, CommandExecutionContext, CommandIO, CommandInputSpec, CommandMetadataRecord, CommandOutputSpec, CommandRegistry, CommandRunContext, CommandRunResult, CommandStdin, CommandStorage, DEMI_PORTABLE_COMMANDS, Host, HostBackedFileSystem, HostBackedFileSystemOptions, HostDirent, HostFileStat, HostFileSystem, HostProcess, HostProcessOutputChunk, HostSpawnExit, HostSpawnHandle, HostSpawnParams, HostStore, MAX_TIMEOUT_MS, ParsedCommandInput, RESERVED_COMMAND_NAMES, ShellAbortInput, ShellCommandStatus, ShellExecInput, ShellOutputChunk, ShellOutputRecordChunk, ShellOutputView, ShellStatusInput, ShellStreamView, ShellWriteInput, VirtualFileSystemNode, VirtualFileSystemProvider, emptyStdin, heredocDelimiter, parseCommandInput, renderCommandHelp, runRegisteredCommand, shellQuote, virtualDirectory, virtualFile };
package/dist/index.mjs CHANGED
@@ -501,7 +501,16 @@ function createOutputSinks(fs, cwd, redirections) {
501
501
  }
502
502
  return routes;
503
503
  }
504
- function recordForegroundChunk(foreground, sourceFd, chunk) {
504
+ function recordForegroundChunk(foreground, sourceFd, chunk, captureLimitBytes) {
505
+ if (foreground.captureOverflowed) return;
506
+ if (foreground.capturedBytes + chunk.byteLength > captureLimitBytes) {
507
+ foreground.captureOverflowed = true;
508
+ foreground.rawStdoutBuffer = "";
509
+ foreground.rawStdoutBytes = [];
510
+ foreground.handle.kill("SIGKILL").catch(() => {});
511
+ return;
512
+ }
513
+ foreground.capturedBytes += chunk.byteLength;
505
514
  const text = decodeUtf8(chunk);
506
515
  foreground.lastOutputAt = Date.now();
507
516
  if (sourceFd === 1) {
@@ -778,6 +787,13 @@ const DEFAULT_OUTPUT_LIMIT_BYTES = 1048576;
778
787
  * while still bounding a runaway producer.
779
788
  */
780
789
  const DEFAULT_BINARY_LIMIT_BYTES = 16777216;
790
+ /**
791
+ * Ceiling for a single command's in-memory output capture. Sized to the same
792
+ * order as the in-shell file read limit: the execution model buffers whole
793
+ * command outputs (in several copies), so this is a hard memory-safety bound
794
+ * on the embedding process, not a view budget.
795
+ */
796
+ const DEFAULT_CAPTURE_LIMIT_BYTES = 67108864;
781
797
  /** Upper bound for a single exec observation window (also the command-bridge wait ceiling). */
782
798
  const MAX_TIMEOUT_MS = 6e5;
783
799
  var BashEnvironment = class {
@@ -788,6 +804,7 @@ var BashEnvironment = class {
788
804
  initialEnv;
789
805
  defaultOutputLimitBytes;
790
806
  defaultBinaryLimitBytes;
807
+ captureLimitBytes;
791
808
  shells = /* @__PURE__ */ new Map();
792
809
  defaultShellByAgentSessionId = /* @__PURE__ */ new Map();
793
810
  commandsById = /* @__PURE__ */ new Map();
@@ -801,6 +818,7 @@ var BashEnvironment = class {
801
818
  this.initialEnv = options.initialEnv ?? {};
802
819
  this.defaultOutputLimitBytes = options.maxOutputBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES;
803
820
  this.defaultBinaryLimitBytes = options.maxBinaryBytes ?? DEFAULT_BINARY_LIMIT_BYTES;
821
+ this.captureLimitBytes = options.maxCaptureBytes ?? DEFAULT_CAPTURE_LIMIT_BYTES;
804
822
  }
805
823
  getShell(shellId) {
806
824
  return this.shells.get(shellId) ?? null;
@@ -1025,7 +1043,7 @@ var BashEnvironment = class {
1025
1043
  for (const command of this.commands.list()) forkCommands.set(command.name, commandToForkCommand(session, command, storage, this.host));
1026
1044
  session.abortController = new AbortController();
1027
1045
  const limits = resolveLimits({
1028
- maxOutputSize: 1073741824,
1046
+ maxOutputSize: this.captureLimitBytes,
1029
1047
  maxCommandCount: 1e6,
1030
1048
  maxLoopIterations: 1e6,
1031
1049
  maxCallDepth: 1e3,
@@ -1135,15 +1153,26 @@ var BashEnvironment = class {
1135
1153
  handle,
1136
1154
  stdoutBuffer: "",
1137
1155
  stderrBuffer: "",
1156
+ droppedStdoutChars: 0,
1157
+ droppedStderrChars: 0,
1138
1158
  stdoutPump: Promise.resolve(),
1139
1159
  stderrPump: Promise.resolve(),
1140
1160
  exitPromise: handle.wait()
1141
1161
  };
1162
+ const retainLimit = this.defaultOutputLimitBytes;
1142
1163
  job.stdoutPump = pumpStream(handle.stdout, (chunk) => {
1143
1164
  job.stdoutBuffer += decodeUtf8(chunk);
1165
+ if (job.stdoutBuffer.length > retainLimit) {
1166
+ job.droppedStdoutChars += job.stdoutBuffer.length - retainLimit;
1167
+ job.stdoutBuffer = job.stdoutBuffer.slice(-retainLimit);
1168
+ }
1144
1169
  });
1145
1170
  job.stderrPump = pumpStream(handle.stderr, (chunk) => {
1146
1171
  job.stderrBuffer += decodeUtf8(chunk);
1172
+ if (job.stderrBuffer.length > retainLimit) {
1173
+ job.droppedStderrChars += job.stderrBuffer.length - retainLimit;
1174
+ job.stderrBuffer = job.stderrBuffer.slice(-retainLimit);
1175
+ }
1147
1176
  });
1148
1177
  session.backgroundJobs.set(id, job);
1149
1178
  session.state.lastBackgroundPid = id;
@@ -1191,7 +1220,8 @@ var BashEnvironment = class {
1191
1220
  await Promise.allSettled([job.stdoutPump, job.stderrPump]);
1192
1221
  session.backgroundJobs.delete(id);
1193
1222
  const exitCode = exit.exitCode ?? 127;
1194
- const stderr = exit.exitCode === null && job.stderrBuffer.length === 0 ? `${job.command}: ${exit.signal ?? "command not found"}\n` : job.stderrBuffer;
1223
+ const stdout = job.droppedStdoutChars > 0 ? `[... dropped ${job.droppedStdoutChars} chars of earlier stdout over the capture limit ...]\n${job.stdoutBuffer}` : job.stdoutBuffer;
1224
+ const stderr = exit.exitCode === null && job.stderrBuffer.length === 0 ? `${job.command}: ${exit.signal ?? "command not found"}\n` : job.droppedStderrChars > 0 ? `[... dropped ${job.droppedStderrChars} chars of earlier stderr over the capture limit ...]\n${job.stderrBuffer}` : job.stderrBuffer;
1195
1225
  session.accumulator.audit.push({
1196
1226
  kind: "system-command",
1197
1227
  name: job.command,
@@ -1200,7 +1230,7 @@ var BashEnvironment = class {
1200
1230
  exitCode
1201
1231
  });
1202
1232
  return {
1203
- stdout: job.stdoutBuffer,
1233
+ stdout,
1204
1234
  stderr,
1205
1235
  exitCode
1206
1236
  };
@@ -1321,6 +1351,8 @@ var BashEnvironment = class {
1321
1351
  stderrBuffer: "",
1322
1352
  outputChunks: [],
1323
1353
  outputBytes: 0,
1354
+ capturedBytes: 0,
1355
+ captureOverflowed: false,
1324
1356
  audit: [{
1325
1357
  kind: "system-command",
1326
1358
  name: command,
@@ -1340,18 +1372,18 @@ var BashEnvironment = class {
1340
1372
  if (opts.stdinProvided) await handle.closeStdin();
1341
1373
  if (handle.output) {
1342
1374
  foreground.stdoutPump = pumpOutputStream(handle.output, (chunk) => {
1343
- recordForegroundChunk(foreground, chunk.stream === "stdout" ? 1 : 2, chunk.chunk);
1375
+ recordForegroundChunk(foreground, chunk.stream === "stdout" ? 1 : 2, chunk.chunk, this.captureLimitBytes);
1344
1376
  });
1345
1377
  foreground.stderrPump = Promise.resolve();
1346
1378
  } else {
1347
- foreground.stdoutPump = pumpStream(handle.stdout, (chunk) => recordForegroundChunk(foreground, 1, chunk));
1348
- foreground.stderrPump = pumpStream(handle.stderr, (chunk) => recordForegroundChunk(foreground, 2, chunk));
1379
+ foreground.stdoutPump = pumpStream(handle.stdout, (chunk) => recordForegroundChunk(foreground, 1, chunk, this.captureLimitBytes));
1380
+ foreground.stderrPump = pumpStream(handle.stderr, (chunk) => recordForegroundChunk(foreground, 2, chunk, this.captureLimitBytes));
1349
1381
  }
1350
1382
  const exit = await foreground.exitPromise;
1351
1383
  await Promise.allSettled([foreground.stdoutPump, foreground.stderrPump]);
1352
- const stdout = decodeLatin1(concatBytes(foreground.rawStdoutBytes));
1353
- const exitCode = exit.exitCode ?? 127;
1354
- const stderr = exit.exitCode === null && foreground.rawStderrBuffer.length === 0 ? `${command}: ${exit.signal ?? "command not found"}\n` : foreground.rawStderrBuffer;
1384
+ const stdout = foreground.captureOverflowed ? "" : decodeLatin1(concatBytes(foreground.rawStdoutBytes));
1385
+ const exitCode = foreground.captureOverflowed ? 137 : exit.exitCode ?? 127;
1386
+ const stderr = foreground.captureOverflowed ? `${command}: output exceeded the ${this.captureLimitBytes}-byte capture limit and the process was killed; the shell buffers whole command outputs in memory — narrow the output at the source (filters, head, tighter paths)\n` : exit.exitCode === null && foreground.rawStderrBuffer.length === 0 ? `${command}: ${exit.signal ?? "command not found"}\n` : foreground.rawStderrBuffer;
1355
1387
  foreground.audit[0] = {
1356
1388
  kind: "system-command",
1357
1389
  name: command,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@demicodes/shell",
3
3
  "description": "Sandboxable bash engine and Host contract for Demi.",
4
- "version": "0.14.0",
4
+ "version": "0.14.2",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "exports": {
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@demicodes/just-bash": "^3.0.1-demi.5",
23
- "@demicodes/utils": "^0.14.0",
23
+ "@demicodes/utils": "^0.14.2",
24
24
  "zod": "^4.0.0"
25
25
  },
26
26
  "license": "Apache-2.0",