@struktoai/mirage-dsh 0.0.1 → 0.0.6

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.
@@ -0,0 +1,698 @@
1
+ import {
2
+ TailBuffer,
3
+ tailCap
4
+ } from "./chunk-2F2PJ7D7.js";
5
+
6
+ // src/shell.ts
7
+ import { DSH_ENV_PREFIX, ShellExecutor } from "@deepseek-ai/dsh-shell";
8
+ import {
9
+ Channel as Channel2,
10
+ JobConsole,
11
+ KILLED_OUTCOME,
12
+ RAMConsoleStore,
13
+ exitOutcome
14
+ } from "@struktoai/mirage-core/shell/console/index";
15
+ import { setCwd } from "@struktoai/mirage-core/workspace/session/shell_dirs";
16
+ import { sessionView } from "@struktoai/mirage-core/workspace/session/state";
17
+
18
+ // src/spill.ts
19
+ import { Channel } from "@struktoai/mirage-core/shell/console/index";
20
+ async function ensureDirPath(dirs, dir) {
21
+ let path = "";
22
+ for (const part of dir.split("/").filter((p) => p !== "")) {
23
+ path += `/${part}`;
24
+ if (await dirs.exists(path)) continue;
25
+ try {
26
+ await dirs.mkdir(path);
27
+ } catch (err) {
28
+ if (!await dirs.exists(path)) throw err;
29
+ }
30
+ }
31
+ }
32
+ function totalLength(parts) {
33
+ return parts.reduce((sum, p) => sum + p.byteLength, 0);
34
+ }
35
+ function concat(parts) {
36
+ const out = new Uint8Array(totalLength(parts));
37
+ let at = 0;
38
+ for (const p of parts) {
39
+ out.set(p, at);
40
+ at += p.byteLength;
41
+ }
42
+ return out;
43
+ }
44
+ var SpillSink = class {
45
+ stdoutPath;
46
+ stderrPath;
47
+ target;
48
+ dir;
49
+ base;
50
+ onFailure;
51
+ started = false;
52
+ failed = false;
53
+ stdoutParts = [];
54
+ stderrParts = [];
55
+ constructor(target, dir, base, onFailure = () => void 0) {
56
+ this.target = target;
57
+ this.dir = dir;
58
+ this.base = base;
59
+ this.onFailure = onFailure;
60
+ }
61
+ /** Buffer a chunk before spill starts, or append it to the file after. */
62
+ async ingest(channel, data) {
63
+ if (this.failed) return;
64
+ if (!this.started) {
65
+ if (channel === Channel.STDERR) this.stderrParts.push(data);
66
+ else this.stdoutParts.push(data);
67
+ return;
68
+ }
69
+ await this.appendFile(channel, data);
70
+ }
71
+ /** Flush the buffered streams to files; called once, on the first overrun. */
72
+ async begin() {
73
+ if (this.started || this.failed) return;
74
+ try {
75
+ await this.target.ensureDir(this.dir);
76
+ if (totalLength(this.stdoutParts) > 0) {
77
+ this.stdoutPath = `${this.dir}/${this.base}.stdout`;
78
+ await this.target.write(this.stdoutPath, concat(this.stdoutParts));
79
+ }
80
+ if (totalLength(this.stderrParts) > 0) {
81
+ this.stderrPath = `${this.dir}/${this.base}.stderr`;
82
+ await this.target.write(this.stderrPath, concat(this.stderrParts));
83
+ }
84
+ this.started = true;
85
+ this.stdoutParts = [];
86
+ this.stderrParts = [];
87
+ } catch (err) {
88
+ this.onFailure(err);
89
+ this.disable();
90
+ }
91
+ }
92
+ async appendFile(channel, data) {
93
+ try {
94
+ if (channel === Channel.STDERR) {
95
+ if (this.stderrPath === void 0) {
96
+ this.stderrPath = `${this.dir}/${this.base}.stderr`;
97
+ await this.target.write(this.stderrPath, data);
98
+ } else {
99
+ await this.target.append(this.stderrPath, data);
100
+ }
101
+ } else {
102
+ if (this.stdoutPath === void 0) {
103
+ this.stdoutPath = `${this.dir}/${this.base}.stdout`;
104
+ await this.target.write(this.stdoutPath, data);
105
+ } else {
106
+ await this.target.append(this.stdoutPath, data);
107
+ }
108
+ }
109
+ } catch (err) {
110
+ this.onFailure(err);
111
+ this.disable();
112
+ }
113
+ }
114
+ /**
115
+ * Give up on spilling and report no path.
116
+ *
117
+ * Called on a write failure, and by a reader that discovers it lost
118
+ * bytes before they ever reached here: a file missing the middle of
119
+ * the stream is worse than no file, because the path presents it as
120
+ * the whole one.
121
+ */
122
+ disable() {
123
+ this.failed = true;
124
+ this.stdoutPath = void 0;
125
+ this.stderrPath = void 0;
126
+ this.stdoutParts = [];
127
+ this.stderrParts = [];
128
+ }
129
+ };
130
+
131
+ // src/shell.ts
132
+ var DEFAULT_TIMEOUT_MS = 12e4;
133
+ var MAX_TIMEOUT_MS = 6e5;
134
+ var DEFAULT_STDOUT_MAX_BYTES = 2e5;
135
+ var DEFAULT_STDERR_MAX_BYTES = 64e3;
136
+ var STDERR_MARKER = new TextEncoder().encode("\n--- stderr ---\n");
137
+ var CONSOLE_RETENTION_DELTAS = 5;
138
+ var spillCounter = 0;
139
+ var SINK_PREFIX = "/dev";
140
+ var DENIAL_SIGNATURES = ["read-only mount at ", ": Permission denied", ": Read-only file system"];
141
+ function collect(text, maxBytes) {
142
+ const capped = tailCap(text, maxBytes);
143
+ return { text: capped.text, truncated: capped.truncated };
144
+ }
145
+ function executeOptions(spec, workdir, signal, sessionId, bound, fallbackWorkdir, sink) {
146
+ const managed = spec.dshEnv ?? {};
147
+ const env = bound ? { ...spec.env ?? {} } : { ...spec.env ?? {}, ...managed };
148
+ const cwd = workdir !== "" ? workdir : bound ? void 0 : fallbackWorkdir;
149
+ return {
150
+ signal,
151
+ ...sessionId !== void 0 ? { sessionId } : {},
152
+ ...cwd !== void 0 ? { cwd } : {},
153
+ ...Object.keys(env).length > 0 ? { env } : {},
154
+ ...spec.stdin !== void 0 ? { stdin: new TextEncoder().encode(spec.stdin) } : {},
155
+ ...sink !== void 0 ? { sink } : {}
156
+ };
157
+ }
158
+ var MirageShellProcess = class {
159
+ status = "running";
160
+ exitCode = null;
161
+ signal = null;
162
+ sandbox;
163
+ done;
164
+ controller;
165
+ console;
166
+ budget;
167
+ spill;
168
+ sandboxInfo;
169
+ consumed;
170
+ pending;
171
+ lossy = false;
172
+ inStderr = false;
173
+ settled = false;
174
+ expectSeq = 0;
175
+ constructor(run, controller, console_, budget, spill, sandboxInfo) {
176
+ this.controller = controller;
177
+ this.console = console_;
178
+ this.budget = budget;
179
+ this.pending = new TailBuffer(budget);
180
+ this.spill = spill;
181
+ this.sandboxInfo = sandboxInfo;
182
+ this.consumed = this.consume();
183
+ this.done = run.then(
184
+ (result) => this.settle(result, null),
185
+ (err) => this.settle(null, err)
186
+ );
187
+ }
188
+ async consume() {
189
+ for await (const chunk of this.console.follow(0)) {
190
+ if (chunk.channel === Channel2.CONTROL) return;
191
+ if (chunk.seq !== this.expectSeq) {
192
+ this.lossy = true;
193
+ this.spill?.disable();
194
+ }
195
+ this.expectSeq = chunk.seq + 1;
196
+ await this.appendChunk(chunk);
197
+ }
198
+ }
199
+ async appendChunk(chunk) {
200
+ if (this.spill !== null) await this.spill.ingest(chunk.channel, chunk.data);
201
+ let dropped = false;
202
+ if (chunk.channel === Channel2.STDERR) {
203
+ if (!this.inStderr) {
204
+ dropped = this.pending.append(STDERR_MARKER);
205
+ this.inStderr = true;
206
+ }
207
+ } else {
208
+ this.inStderr = false;
209
+ }
210
+ dropped = this.pending.append(chunk.data) || dropped;
211
+ if (dropped) {
212
+ this.lossy = true;
213
+ if (this.spill !== null) await this.spill.begin();
214
+ }
215
+ }
216
+ async settle(result, err) {
217
+ this.settled = true;
218
+ if (this.sandboxInfo !== void 0) this.sandbox = this.sandboxInfo;
219
+ let outcome;
220
+ if (result !== null) {
221
+ this.status = "completed";
222
+ this.exitCode = result.exitCode;
223
+ outcome = exitOutcome(result.exitCode);
224
+ } else {
225
+ this.status = "killed";
226
+ this.signal = "SIGTERM";
227
+ const message = err instanceof Error ? err.message : String(err);
228
+ await this.console.emit(Channel2.STDERR, new TextEncoder().encode(message));
229
+ outcome = KILLED_OUTCOME;
230
+ }
231
+ await this.console.finish(outcome);
232
+ await this.consumed;
233
+ }
234
+ readOutput() {
235
+ const delta = this.pending.take();
236
+ const lossy = this.lossy;
237
+ this.lossy = false;
238
+ return {
239
+ delta,
240
+ lossy,
241
+ ...this.spill?.stdoutPath !== void 0 ? { stdoutSpillPath: this.spill.stdoutPath } : {},
242
+ ...this.spill?.stderrPath !== void 0 ? { stderrSpillPath: this.spill.stderrPath } : {}
243
+ };
244
+ }
245
+ kill() {
246
+ if (this.settled) return false;
247
+ this.controller.abort();
248
+ return true;
249
+ }
250
+ };
251
+ var MirageShellExecutor = class extends ShellExecutor {
252
+ static inject = ["mirage"];
253
+ workdir;
254
+ defaultTimeoutMs;
255
+ maxTimeoutMs;
256
+ stdoutMaxBytes;
257
+ stderrMaxBytes;
258
+ sessionId;
259
+ spillDir;
260
+ sessionReady = null;
261
+ readOnlyReady = null;
262
+ constructor(ctx, config = {}) {
263
+ super(ctx);
264
+ this.workdir = config.workdir ?? "/";
265
+ this.defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
266
+ this.maxTimeoutMs = config.maxTimeoutMs ?? MAX_TIMEOUT_MS;
267
+ this.stdoutMaxBytes = config.stdoutMaxBytes ?? DEFAULT_STDOUT_MAX_BYTES;
268
+ this.stderrMaxBytes = config.stderrMaxBytes ?? DEFAULT_STDERR_MAX_BYTES;
269
+ this.sessionId = config.sessionId;
270
+ this.spillDir = config.spillDir;
271
+ }
272
+ // The workspace may still be building (declarative mounts resolve
273
+ // asynchronously), so every execution awaits the service's `ready`.
274
+ workspace() {
275
+ return this.ctx.mirage.ready;
276
+ }
277
+ /**
278
+ * The directory this command actually runs in.
279
+ *
280
+ * dsh fills an unspecified workdir from the calling session's cwd (by
281
+ * way of the sandbox policy's workspace root), and that is a directory
282
+ * on the harness's own machine, which names nothing here. Running
283
+ * there leaves `pwd` reporting a path the agent cannot reach and every
284
+ * relative path failing, and, because a per-call cwd forks a subshell,
285
+ * it also defeats a bound session on every call. So a workdir that is
286
+ * not a directory in this world is treated as unset: the configured
287
+ * default when unbound, the session's own cwd when bound.
288
+ *
289
+ * @param spec the resolved spec whose workdir is being placed.
290
+ * @returns the workdir to execute under, `''` meaning the session's own.
291
+ */
292
+ async worldWorkdir(spec) {
293
+ if (spec.workdir === "") return "";
294
+ const ws = await this.workspace();
295
+ if (await ws.fs.isDir(spec.workdir)) return spec.workdir;
296
+ return this.sessionId === void 0 ? this.workdir : "";
297
+ }
298
+ // A spill sink for one background command, or null when no spill
299
+ // directory is configured. The target reaches the live workspace so
300
+ // the full stream lands on a mount the agent can read back.
301
+ newSpill() {
302
+ const dir = this.spillDir;
303
+ if (dir === void 0) return null;
304
+ const target = {
305
+ ensureDir: async (d) => {
306
+ const ws = await this.workspace();
307
+ await ensureDirPath({ exists: (p) => ws.fs.exists(p), mkdir: (p) => ws.fs.mkdir(p) }, d);
308
+ },
309
+ write: async (p, bytes) => {
310
+ const ws = await this.workspace();
311
+ await ws.fs.writeFile(p, bytes);
312
+ },
313
+ append: async (p, bytes) => {
314
+ const ws = await this.workspace();
315
+ await ws.fs.append(p, bytes);
316
+ }
317
+ };
318
+ spillCounter += 1;
319
+ const log = this.ctx.logger("mirage-dsh");
320
+ return new SpillSink(target, dir, `mirage-shell-${spillCounter.toString()}`, (err) => {
321
+ log.debug("spill to %s failed, output will not be recoverable: %o", dir, err);
322
+ });
323
+ }
324
+ /**
325
+ * With every runtime in the world reaching only the vfs
326
+ * (`ctx.mirage.vfsOnly`), the workspace dispatch is the single gate
327
+ * for anything a command can do, so this executor behaves like a
328
+ * workspace-write sandbox: reads and writes land only where mounts
329
+ * (and their modes) allow. Declaring it lets sandbox-aware plugins
330
+ * (dsh's permission presets) compose over this executor. A world
331
+ * holding a runtime with doors around the gate (the host `local`
332
+ * python, a remote sandbox) voids that claim, so this answers
333
+ * undefined then (the base contract's "does not sandbox") and those
334
+ * plugins refuse to compose instead of trusting a lie.
335
+ */
336
+ get sandboxMode() {
337
+ return this.ctx.mirage.vfsOnly ? "workspace-write" : void 0;
338
+ }
339
+ /**
340
+ * The mode this one call runs under: the policy the caller resolved
341
+ * for it, or this executor's own default when the call carried none.
342
+ * Undefined keeps the "no claim" answer for a world some runtime can
343
+ * act outside of, where no mode would be true.
344
+ *
345
+ * @param spec the resolved spec whose policy is being read.
346
+ * @returns the effective mode, or undefined when nothing is claimed.
347
+ */
348
+ modeFor(spec) {
349
+ const declared = this.sandboxMode;
350
+ if (declared === void 0) return void 0;
351
+ return spec.sandboxPolicy?.mode ?? declared;
352
+ }
353
+ /**
354
+ * The session this call runs in: the read-only twin when the policy
355
+ * confines it to reads, else this executor's own binding.
356
+ *
357
+ * `workspace-write` and `danger-full-access` both run in the ordinary
358
+ * session, because the mounts and their modes already are the
359
+ * workspace boundary and mirage has nothing wider to grant.
360
+ *
361
+ * @param spec the resolved spec whose policy selects the session.
362
+ * @returns the session id to execute under, or undefined for the default.
363
+ */
364
+ async sessionFor(spec) {
365
+ if (this.modeFor(spec) !== "read-only") return this.sessionId;
366
+ return this.readOnlySession();
367
+ }
368
+ /**
369
+ * The sandbox facts to stamp on this run's result and process handle,
370
+ * or undefined when the world is not fully workspace-bound (no claim).
371
+ *
372
+ * `enforcement` is 'full': when every runtime reaches only the vfs, the
373
+ * workspace gate cannot be bypassed, so unlike an OS sandbox on an older
374
+ * kernel there is no promised effect it fails to govern. `runnerFailed`
375
+ * is false because the workspace executor is the runner and a failure to
376
+ * run surfaces as a rejected/aborted execution, not a runner that never
377
+ * started.
378
+ *
379
+ * @param spec the resolved spec this run was built from.
380
+ * @param denied whether the run was refused a write by the narrowing.
381
+ * @returns the facts to stamp, or undefined when nothing is claimed.
382
+ */
383
+ sandboxInfo(spec, denied = false) {
384
+ const mode = this.modeFor(spec);
385
+ if (mode === void 0) return void 0;
386
+ return { mode, denied, enforcement: "full", runnerFailed: false };
387
+ }
388
+ /**
389
+ * The retention budget of a background command's console.
390
+ *
391
+ * Only the background path caps retention: there a follow loop drains
392
+ * the console while the command still runs, so the budget bounds what
393
+ * the loop has not reached yet, and a chunk lost to it is reported as
394
+ * a gap in the sequence. A foreground console is read once, after the
395
+ * fact, with nothing to bound.
396
+ *
397
+ * @param spec the resolved spec carrying the stdout budget.
398
+ * @returns the retention budget in bytes.
399
+ */
400
+ retentionFor(spec) {
401
+ return Math.max(spec.stdoutMaxBytes, this.stderrMaxBytes) * CONSOLE_RETENTION_DELTAS;
402
+ }
403
+ /**
404
+ * Take a finished run's two streams off its console, capped, and spill
405
+ * whichever one lost bytes.
406
+ *
407
+ * A foreground run streams into a console rather than returning its
408
+ * output whole, because mirage throws on abort: bytes a killed command
409
+ * had already printed are recoverable from a sink and nowhere else.
410
+ * Only a truncated stream spills, since an untruncated one is already
411
+ * whole in `text` and writing a file for it would put a copy of every
412
+ * command's output on a mount. The console this reads is untrimmed, so
413
+ * a spill is the whole stream rather than the tail of one.
414
+ *
415
+ * @param console_ the console this run streamed into.
416
+ * @param spec the resolved spec carrying the stdout budget.
417
+ * @returns the capped streams, each with a spill path when it lost bytes.
418
+ */
419
+ async collectFrom(console_, spec) {
420
+ const decoder = new TextDecoder();
421
+ const outBytes = await console_.snapshot(Channel2.STDOUT);
422
+ const errBytes = await console_.snapshot(Channel2.STDERR);
423
+ const stdout = collect(decoder.decode(outBytes), spec.stdoutMaxBytes);
424
+ const stderr = collect(decoder.decode(errBytes), this.stderrMaxBytes);
425
+ if (!stdout.truncated && !stderr.truncated) return { stdout, stderr };
426
+ const spill = this.newSpill();
427
+ if (spill === null) return { stdout, stderr };
428
+ if (stdout.truncated) await spill.ingest(Channel2.STDOUT, outBytes);
429
+ if (stderr.truncated) await spill.ingest(Channel2.STDERR, errBytes);
430
+ await spill.begin();
431
+ return {
432
+ stdout: {
433
+ ...stdout,
434
+ ...spill.stdoutPath !== void 0 ? { spillPath: spill.stdoutPath } : {}
435
+ },
436
+ stderr: {
437
+ ...stderr,
438
+ ...spill.stderrPath !== void 0 ? { spillPath: spill.stderrPath } : {}
439
+ }
440
+ };
441
+ }
442
+ /**
443
+ * Whether this run was refused, by the session's permission document
444
+ * or by the read-only narrowing.
445
+ *
446
+ * The document's refusals ride the result itself: a `Deny`, an
447
+ * unanswered ask and a policy that raised all leave `refusal` on the
448
+ * `ExecuteResult`, whatever the line did with its streams (`2>&1`, a
449
+ * trailing command that owns the status). That record is read for
450
+ * every call, because a role's `commands.deny` and `commands.ask`
451
+ * rules bind under `workspace-write` and `danger-full-access` alike:
452
+ * a mode says what the mounts allow, and says nothing about whether a
453
+ * rule forbids the line. The narrowing has no record, since it is
454
+ * EROFS/EACCES from the mounts, so its signatures are still read off
455
+ * stderr, and only for a call that ran read-only, where this executor
456
+ * is what imposed it.
457
+ *
458
+ * @param spec the resolved spec this run was built from.
459
+ * @param result what the workspace answered.
460
+ * @param stderr the run's captured standard error.
461
+ * @returns true when something refused the run.
462
+ */
463
+ wasDenied(spec, result, stderr) {
464
+ if (result.refusal !== null) return true;
465
+ if (this.modeFor(spec) !== "read-only") return false;
466
+ return DENIAL_SIGNATURES.some((signature) => stderr.includes(signature));
467
+ }
468
+ resolve(request) {
469
+ const workdir = request.workdir ?? (this.sessionId === void 0 ? this.workdir : "");
470
+ return {
471
+ command: request.command,
472
+ workdir,
473
+ timeoutMs: Math.min(request.timeoutMs ?? this.defaultTimeoutMs, this.maxTimeoutMs),
474
+ stdoutMaxBytes: request.stdoutMaxBytes ?? this.stdoutMaxBytes,
475
+ signal: request.signal,
476
+ stdin: request.stdin,
477
+ env: request.env,
478
+ dshEnv: request.dshEnv,
479
+ sandboxPolicy: request.sandboxPolicy
480
+ };
481
+ }
482
+ ensureSession() {
483
+ if (this.sessionId === void 0) return Promise.resolve();
484
+ this.sessionReady ??= this.provisionSession(this.sessionId).catch((err) => {
485
+ this.sessionReady = null;
486
+ throw err;
487
+ });
488
+ return this.sessionReady;
489
+ }
490
+ /**
491
+ * Seed this call's managed `DSH_*` snapshot into the bound session.
492
+ *
493
+ * These are harness facts about the session (its home, its id), not
494
+ * overrides for one command, and on a bound session they have to live
495
+ * in the session: handed over as a per-call `env` they would fork a
496
+ * subshell on every call, since dsh never sends an empty snapshot.
497
+ *
498
+ * The snapshot replaces rather than merges, per the seam's own rule
499
+ * that a fact absent from the current snapshot must not inherit a
500
+ * stale value from an earlier one. Only the managed namespace is
501
+ * touched, so a variable the agent exported itself is left alone.
502
+ *
503
+ * @param ws the live workspace holding the session.
504
+ * @param sessionId the session this call runs in.
505
+ * @param spec the resolved spec carrying the snapshot.
506
+ */
507
+ async applyManagedEnv(ws, sessionId, spec) {
508
+ const managed = spec.dshEnv;
509
+ if (managed === void 0) return;
510
+ const session = ws.getSession(sessionId);
511
+ const view = sessionView(session);
512
+ for (const key of Object.keys(session.env)) {
513
+ if (key.startsWith(DSH_ENV_PREFIX) && !(key in managed)) await view.unset(key);
514
+ }
515
+ for (const [key, value] of Object.entries(managed)) await view.set(key, value);
516
+ }
517
+ readOnlySession() {
518
+ this.readOnlyReady ??= this.provisionReadOnly().catch((err) => {
519
+ this.readOnlyReady = null;
520
+ throw err;
521
+ });
522
+ return this.readOnlyReady;
523
+ }
524
+ /**
525
+ * Create (once) the session a read-only call runs in: a twin of the
526
+ * session this executor would otherwise use, with every grant it holds
527
+ * narrowed to `read`, so mirage's own dispatch is what refuses the
528
+ * write rather than a second permission layer bolted on here.
529
+ *
530
+ * The twin narrows, never widens, and that takes every part of the
531
+ * source's view, which is what `narrow` in core stamps: modes, hidden
532
+ * paths, hidden variables, command rules. Its modes cover every mount
533
+ * at `read` (the one exception is the null sink, per
534
+ * {@link SINK_PREFIX}), which is at least as narrow as whatever the
535
+ * source held, since `read` is the weakest mode there is; naming a
536
+ * mount only narrows it, so a prefix the map omits would keep its own
537
+ * mode rather than disappear. The other three are copied from the
538
+ * source session rather than recompiled, because the profile it was
539
+ * created under is not something a session records.
540
+ *
541
+ * Leaving any of them behind widens. Hides are the obvious one: a
542
+ * binding confined to `/allowed` would read `/secret` in read-only
543
+ * mode although the same command is refused outside it. Command rules
544
+ * are the one modes cannot stand in for, because a mode bounds a
545
+ * mount and an account CLI reaches a service: a profile that denies
546
+ * `slack message send` or `git push` still denies it here, where
547
+ * every mount being `read` says nothing at all about it.
548
+ *
549
+ * The policy's `workspaceRoot` is deliberately not consulted anywhere:
550
+ * it is a directory on the harness's machine, so containment against
551
+ * it says nothing about this world. The mounts are the boundary.
552
+ *
553
+ * @returns the id of the read-only session.
554
+ */
555
+ async provisionReadOnly() {
556
+ const ws = await this.workspace();
557
+ const sessionId = `${this.sessionId ?? "mirage-dsh"}::read-only`;
558
+ await ws.ensureSessionsLoaded();
559
+ if (ws.listSessions().some((s) => s.sessionId === sessionId)) return sessionId;
560
+ const source = ws.getSession(this.sessionId ?? ws.defaultSessionId);
561
+ const grants = {};
562
+ for (const entry of ws.mounts()) {
563
+ grants[entry.prefix] = entry.prefix.replace(/\/+$/, "") === SINK_PREFIX ? "exec" : "read";
564
+ }
565
+ const hide = [...source.hiddenPaths?.paths ?? [], ...source.hiddenPaths?.patterns ?? []];
566
+ const twin = ws.createSession(sessionId, {
567
+ mounts: grants,
568
+ ...hide.length > 0 ? { permissions: { paths: { hide } } } : {}
569
+ });
570
+ twin.commands = source.commands;
571
+ twin.hiddenVars = source.hiddenVars;
572
+ setCwd(twin, this.workdir);
573
+ return sessionId;
574
+ }
575
+ async provisionSession(sessionId) {
576
+ const ws = await this.workspace();
577
+ await ws.ensureSessionsLoaded();
578
+ if (ws.listSessions().some((s) => s.sessionId === sessionId)) return;
579
+ setCwd(ws.createSession(sessionId), this.workdir);
580
+ }
581
+ async run(spec) {
582
+ const sandbox = this.sandboxInfo(spec);
583
+ if (spec.signal?.aborted === true) {
584
+ return {
585
+ exitCode: null,
586
+ signal: "SIGTERM",
587
+ timedOut: false,
588
+ aborted: true,
589
+ timeoutMs: spec.timeoutMs,
590
+ stdout: { text: "", truncated: false },
591
+ stderr: { text: "", truncated: false },
592
+ ...sandbox !== void 0 ? { sandbox } : {}
593
+ };
594
+ }
595
+ const controller = new AbortController();
596
+ const console_ = new JobConsole(new RAMConsoleStore(null));
597
+ let timedOut = false;
598
+ let aborted = false;
599
+ const timer = setTimeout(() => {
600
+ timedOut = true;
601
+ controller.abort();
602
+ }, spec.timeoutMs);
603
+ const onAbort = () => {
604
+ if (!timedOut && !aborted) {
605
+ aborted = true;
606
+ controller.abort();
607
+ }
608
+ };
609
+ spec.signal?.addEventListener("abort", onAbort, { once: true });
610
+ try {
611
+ await this.ensureSession();
612
+ const ws = await this.workspace();
613
+ const sessionId = await this.sessionFor(spec);
614
+ const bound = this.sessionId !== void 0;
615
+ if (bound && sessionId !== void 0) await this.applyManagedEnv(ws, sessionId, spec);
616
+ const workdir = await this.worldWorkdir(spec);
617
+ const result = await ws.execute(
618
+ spec.command,
619
+ executeOptions(spec, workdir, controller.signal, sessionId, bound, this.workdir, console_)
620
+ );
621
+ const captured = await this.collectFrom(console_, spec);
622
+ const settled = this.sandboxInfo(spec, this.wasDenied(spec, result, captured.stderr.text));
623
+ return {
624
+ exitCode: result.exitCode,
625
+ signal: null,
626
+ timedOut: false,
627
+ aborted: false,
628
+ timeoutMs: spec.timeoutMs,
629
+ ...captured,
630
+ ...settled !== void 0 ? { sandbox: settled } : {}
631
+ };
632
+ } catch (err) {
633
+ if (!controller.signal.aborted) throw err;
634
+ return {
635
+ exitCode: null,
636
+ signal: "SIGTERM",
637
+ timedOut,
638
+ aborted,
639
+ timeoutMs: spec.timeoutMs,
640
+ // Whatever the command printed before the kill landed. GNU's own
641
+ // timeout keeps it, and a model that watched a build run for two
642
+ // minutes is owed the log rather than an empty string.
643
+ ...await this.collectFrom(console_, spec),
644
+ ...sandbox !== void 0 ? { sandbox } : {}
645
+ };
646
+ } finally {
647
+ clearTimeout(timer);
648
+ spec.signal?.removeEventListener("abort", onAbort);
649
+ }
650
+ }
651
+ start(spec) {
652
+ const sandbox = this.sandboxInfo(spec);
653
+ const controller = new AbortController();
654
+ const console_ = new JobConsole(new RAMConsoleStore(this.retentionFor(spec)));
655
+ const spill = this.newSpill();
656
+ if (spec.signal?.aborted === true) {
657
+ controller.abort();
658
+ return new MirageShellProcess(
659
+ Promise.reject(new Error("command aborted before start")),
660
+ controller,
661
+ console_,
662
+ spec.stdoutMaxBytes,
663
+ spill,
664
+ sandbox
665
+ );
666
+ }
667
+ const onAbort = () => {
668
+ controller.abort();
669
+ };
670
+ spec.signal?.addEventListener("abort", onAbort, { once: true });
671
+ const run = this.ensureSession().then(async () => {
672
+ const sessionId = await this.sessionFor(spec);
673
+ const ws = await this.workspace();
674
+ const bound = this.sessionId !== void 0;
675
+ if (bound && sessionId !== void 0) await this.applyManagedEnv(ws, sessionId, spec);
676
+ return { ws, sessionId, bound, workdir: await this.worldWorkdir(spec) };
677
+ }).then(
678
+ ({ ws, sessionId, bound, workdir }) => ws.execute(
679
+ spec.command,
680
+ executeOptions(
681
+ spec,
682
+ workdir,
683
+ controller.signal,
684
+ sessionId,
685
+ bound,
686
+ this.workdir,
687
+ console_
688
+ )
689
+ )
690
+ ).finally(() => spec.signal?.removeEventListener("abort", onAbort));
691
+ return new MirageShellProcess(run, controller, console_, spec.stdoutMaxBytes, spill, sandbox);
692
+ }
693
+ };
694
+
695
+ export {
696
+ MirageShellExecutor
697
+ };
698
+ //# sourceMappingURL=chunk-QTBMLMKT.js.map