@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.
- package/cordis.patch.yml +49 -0
- package/dist/{chunk-CATVBGGR.js → chunk-2F2PJ7D7.js} +66 -4
- package/dist/{chunk-CATVBGGR.js.map → chunk-2F2PJ7D7.js.map} +1 -1
- package/dist/chunk-4CZV24HW.js +196 -0
- package/dist/chunk-4CZV24HW.js.map +1 -0
- package/dist/{chunk-C67TSSYB.js → chunk-M4JOF35U.js} +108 -15
- package/dist/chunk-M4JOF35U.js.map +1 -0
- package/dist/chunk-QTBMLMKT.js +698 -0
- package/dist/chunk-QTBMLMKT.js.map +1 -0
- package/dist/fs-B3Zi9oo6.d.ts +104 -0
- package/dist/index.d.ts +137 -5
- package/dist/index.js +14 -6
- package/dist/plugin/fs.d.ts +1 -1
- package/dist/plugin/fs.js +2 -2
- package/dist/plugin/service.d.ts +3 -2
- package/dist/plugin/service.js +1 -1
- package/dist/plugin/shell.d.ts +1 -1
- package/dist/plugin/shell.js +2 -2
- package/dist/{service-CxFYAvoV.d.ts → service-BK6NR_RX.d.ts} +51 -1
- package/dist/shell-BTf2wsDy.d.ts +258 -0
- package/package.json +10 -7
- package/dist/chunk-C67TSSYB.js.map +0 -1
- package/dist/chunk-J6F4OX6Z.js +0 -102
- package/dist/chunk-J6F4OX6Z.js.map +0 -1
- package/dist/chunk-ZSZVHBSL.js +0 -455
- package/dist/chunk-ZSZVHBSL.js.map +0 -1
- package/dist/fs-CuiUEA7A.d.ts +0 -54
- package/dist/shell-B0N_jppN.d.ts +0 -108
|
@@ -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
|