@stixxert/pi-docker-sandbox 1.1.1 → 1.1.3
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/package.json +1 -1
- package/sandbox/failure.ts +155 -0
- package/sandbox/index.ts +68 -2
- package/sandbox/operations.ts +48 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stixxert/pi-docker-sandbox",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "pi extension: a private docker sandbox (sbx microVM with its own daemon) as the agent's deploy target — the host's docker is never exposed.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"publishConfig": {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification of a *sandbox-runtime* failure.
|
|
3
|
+
*
|
|
4
|
+
* `sbx exec` normally exits non-zero when the command inside the sandbox exits
|
|
5
|
+
* non-zero — but it ALSO exits non-zero when it never managed to reach the
|
|
6
|
+
* command at all, because the sandbox VM's runtime failed to come up. Those two
|
|
7
|
+
* cases look identical from the transport's point of view (a non-zero exit and
|
|
8
|
+
* some text on stderr), and confusing them is actively harmful:
|
|
9
|
+
*
|
|
10
|
+
* - reporting "not readable: <path>" / "Path not found" for a dead VM sends
|
|
11
|
+
* the agent chasing a file that is perfectly fine, and
|
|
12
|
+
* - treating it as a normal command result means nothing ever invalidates the
|
|
13
|
+
* memoised transport, so the backend never recovers.
|
|
14
|
+
*
|
|
15
|
+
* This module is deliberately tiny and has ZERO imports (not even node
|
|
16
|
+
* builtins) so the classifier can be unit-tested without the pi packages —
|
|
17
|
+
* see the consumer in `operations.ts`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The `ExecOutcome` shape, structurally: an exit code plus its output. */
|
|
21
|
+
export interface ExecLike {
|
|
22
|
+
/** null when the process was killed by a signal. */
|
|
23
|
+
exitCode: number | null;
|
|
24
|
+
stdout?: Uint8Array | string;
|
|
25
|
+
stderr?: Uint8Array | string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function asText(value: Uint8Array | string | undefined): string {
|
|
29
|
+
if (value === undefined) return "";
|
|
30
|
+
return typeof value === "string" ? value : new TextDecoder().decode(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The characteristic signatures of "the sandbox runtime could not start".
|
|
35
|
+
*
|
|
36
|
+
* Chosen to be NARROW — specifically, long enough that a *user command's* own
|
|
37
|
+
* stderr cannot plausibly collide with them. That specificity is load-bearing:
|
|
38
|
+
* a false positive REPLACES the command's real exit status with a
|
|
39
|
+
* `SandboxUnavailableError`, and the caller then reports that the command's
|
|
40
|
+
* effects are unknown — which is exactly the ambiguity that tempts an agent to
|
|
41
|
+
* re-run a side-effecting command. So every pattern anchors on the runtime's
|
|
42
|
+
* actual wrapper phrasing, never on a short phrase a project script, CI helper
|
|
43
|
+
* or test wrapper might print about its OWN docker/CI runtime:
|
|
44
|
+
*
|
|
45
|
+
* 1. `failed to start sandbox: start runtime:` — the CLI's own wrapper chained
|
|
46
|
+
* to the runtime start, e.g. the reproduced
|
|
47
|
+
* `failed to start sandbox: start runtime: request failed: 500 ...`. BOTH
|
|
48
|
+
* halves must appear together; the bare `failed to start sandbox` alone is
|
|
49
|
+
* the kind of line a user's own launcher could emit, so it is not used.
|
|
50
|
+
* 2. `start runtime: request failed: 5xx` — sandboxd's runtime API rejecting
|
|
51
|
+
* the start with an HTTP 5xx. Anchored on `start runtime: request failed:`
|
|
52
|
+
* rather than a bare `request failed: 500`, because the bare form could
|
|
53
|
+
* come from any command's stderr (a curl wrapper, an API client, ...) and
|
|
54
|
+
* would then misclassify an ordinary failing command.
|
|
55
|
+
* 3. `docker daemon failed to start inside the sandbox` — the in-VM cause, as
|
|
56
|
+
* the FULL phrase the runtime reports. The short `docker daemon failed to
|
|
57
|
+
* start` alone is NOT enough: a user command or CI wrapper that manages its
|
|
58
|
+
* own docker could print exactly that about a local daemon.
|
|
59
|
+
*
|
|
60
|
+
* Matching deliberately requires BOTH a non-zero exit AND a signature on
|
|
61
|
+
* *stderr* (diagnostics live there; stdout is the command's own output, where a
|
|
62
|
+
* phrase like "request failed: 500" is far more likely to be a false match).
|
|
63
|
+
* A signal kill (exitCode null — our own abort/timeout) is never classified.
|
|
64
|
+
*/
|
|
65
|
+
const RUNTIME_FAILURE_SIGNATURES: readonly RegExp[] = [
|
|
66
|
+
/failed to start sandbox:\s*start runtime:/i,
|
|
67
|
+
/start runtime: request failed:\s*5\d\d\b/i,
|
|
68
|
+
/docker daemon failed to start inside the sandbox\b/i,
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Is this outcome "the sandbox runtime failed", rather than "the command
|
|
73
|
+
* exited non-zero"? Conservative on purpose: a plain `exitCode 1` with ordinary
|
|
74
|
+
* stderr (e.g. `grep: no match`) must NOT match.
|
|
75
|
+
*/
|
|
76
|
+
export function isSandboxUnavailableFailure(outcome: ExecLike): boolean {
|
|
77
|
+
if (outcome.exitCode === 0 || outcome.exitCode === null) return false;
|
|
78
|
+
const stderr = asText(outcome.stderr);
|
|
79
|
+
if (!stderr) return false;
|
|
80
|
+
return RUNTIME_FAILURE_SIGNATURES.some((signature) => signature.test(stderr));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The typed error for a dead sandbox runtime.
|
|
85
|
+
*
|
|
86
|
+
* Carries the sandbox target and the raw stderr, and its message names the
|
|
87
|
+
* sandbox explicitly so it is actionable wherever it surfaces — the caller
|
|
88
|
+
* (see `sandbox/index.ts`) turns it into a one-per-episode user notification
|
|
89
|
+
* and invalidates the memoised transport, so the NEXT tool call can start the
|
|
90
|
+
* VM again. It is never a licence to retry the command or to fall back to the
|
|
91
|
+
* host: re-running arbitrary work is unsafe, and the host is not the execution
|
|
92
|
+
* environment.
|
|
93
|
+
*/
|
|
94
|
+
export class SandboxUnavailableError extends Error {
|
|
95
|
+
readonly target: string;
|
|
96
|
+
readonly stderr: string;
|
|
97
|
+
|
|
98
|
+
constructor(target: string, stderr: string) {
|
|
99
|
+
const detail = stderr.trim();
|
|
100
|
+
super(
|
|
101
|
+
`sbx sandbox "${target}" is unavailable: the sandbox runtime failed to start, so this is not the ` +
|
|
102
|
+
`command's own exit status and the command's effects cannot be assumed to have happened.\n` +
|
|
103
|
+
`The command was not run on the host and was not retried. The VM may need recreating (check ` +
|
|
104
|
+
`\`sbx ls\`, \`sbx stop ${target}\`, or recreate it) — the next tool call will try to start the ` +
|
|
105
|
+
`sandbox again.` +
|
|
106
|
+
(detail ? `\n\n${detail}` : ""),
|
|
107
|
+
);
|
|
108
|
+
this.name = "SandboxUnavailableError";
|
|
109
|
+
this.target = target;
|
|
110
|
+
this.stderr = detail;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Per-episode state for the runtime-failure notification.
|
|
116
|
+
*
|
|
117
|
+
* `withSandboxFailureHandling` (see `sandbox/index.ts`) wraps every routed tool
|
|
118
|
+
* call. A runtime failure should tell the user ONCE — not once per tool call in
|
|
119
|
+
* an otherwise broken session — and then tell them AGAIN if a later call
|
|
120
|
+
* succeeds and a new, distinct outage begins.
|
|
121
|
+
*
|
|
122
|
+
* The subtlety the caller cannot express on its own: a tool call may arrive
|
|
123
|
+
* without a UI context, so a failure cannot always be surfaced. A failure that
|
|
124
|
+
* cannot be surfaced must NOT consume the episode's single notification, or an
|
|
125
|
+
* early headless failure would suppress the one notification the user needs for
|
|
126
|
+
* the whole episode. Hence `claimNotification(canNotify)`: the claim is only
|
|
127
|
+
* used up when the notification is actually deliverable.
|
|
128
|
+
*
|
|
129
|
+
* Pure and dependency-free (like the rest of this module) so the episode's
|
|
130
|
+
* behaviour can be unit-tested without the pi packages.
|
|
131
|
+
*/
|
|
132
|
+
export class SandboxFailureEpisode {
|
|
133
|
+
/** Has this episode's one notification already been delivered? */
|
|
134
|
+
private notified = false;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Claim this episode's single notification.
|
|
138
|
+
*
|
|
139
|
+
* @param canNotify Whether the caller can actually deliver a notification
|
|
140
|
+
* right now (i.e. it has a UI context). When false the claim is left
|
|
141
|
+
* untouched, so a later, deliverable failure in the same episode still
|
|
142
|
+
* notifies.
|
|
143
|
+
* @returns true only for the first *deliverable* failure of the episode.
|
|
144
|
+
*/
|
|
145
|
+
claimNotification(canNotify: boolean): boolean {
|
|
146
|
+
if (this.notified || !canNotify) return false;
|
|
147
|
+
this.notified = true;
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A successful sandbox round-trip ends the episode. */
|
|
152
|
+
succeeded(): void {
|
|
153
|
+
this.notified = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/sandbox/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
createWriteToolDefinition,
|
|
42
42
|
} from "@earendil-works/pi-coding-agent";
|
|
43
43
|
import { armSessionLifecycle, debugEnabled, envAllowlist, teardownSandbox } from "../index.ts";
|
|
44
|
+
import { SandboxFailureEpisode, SandboxUnavailableError } from "./failure.ts";
|
|
44
45
|
import {
|
|
45
46
|
createBashOps,
|
|
46
47
|
createEditOps,
|
|
@@ -78,6 +79,69 @@ export default function (pi: ExtensionAPI) {
|
|
|
78
79
|
let transport: ExecTransport | undefined;
|
|
79
80
|
let starting: Promise<ExecTransport | undefined> | undefined;
|
|
80
81
|
let lastError: string | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Notification state for the CURRENT run of runtime failures. One message per
|
|
84
|
+
* episode, reset by a successful round-trip — see `SandboxFailureEpisode`.
|
|
85
|
+
*/
|
|
86
|
+
const sandboxEpisode = new SandboxFailureEpisode();
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Forget the memoised transport so the next tool call re-resolves it.
|
|
90
|
+
*
|
|
91
|
+
* This is the whole recovery mechanism: `resolveTransport()` re-derives the
|
|
92
|
+
* sandbox and (re)starts the VM, so a runtime failure is an episode rather
|
|
93
|
+
* than a permanent bricking of every remaining tool call in the session.
|
|
94
|
+
*
|
|
95
|
+
* `starting` is deliberately NOT reset here. It is non-undefined only while a
|
|
96
|
+
* `resolveTransport()` is genuinely in flight, and that in-flight resolution
|
|
97
|
+
* will itself publish a fresh transport when it settles — so the next
|
|
98
|
+
* `ensureTransport()` awaits it, which is exactly the recovery we want.
|
|
99
|
+
* Clearing it here would instead let a concurrent caller launch a SECOND
|
|
100
|
+
* resolution, and each resolution can boot/create a VM. The resulting race is
|
|
101
|
+
* benign today only because every resolution targets the same per-project
|
|
102
|
+
* sandbox name and therefore reuses one VM; there is no reason to open it up.
|
|
103
|
+
*/
|
|
104
|
+
function invalidateTransport(): void {
|
|
105
|
+
transport = undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run one sandbox round-trip, turning a dead sandbox into: invalidate +
|
|
110
|
+
* notify (ONCE per episode) + rethrow.
|
|
111
|
+
*
|
|
112
|
+
* Deliberately NOT done here: retrying the command, or running it locally.
|
|
113
|
+
* Re-running arbitrary work is unsafe (side effects), and the host is not the
|
|
114
|
+
* execution environment — that would be a sandbox escape. The resolution-time
|
|
115
|
+
* local fallback (see `ensureTransport`) is unchanged and only applies when no
|
|
116
|
+
* transport can be resolved at all; it never triggers from here.
|
|
117
|
+
*/
|
|
118
|
+
async function withSandboxFailureHandling<T>(
|
|
119
|
+
ctx: ExtensionContext | undefined,
|
|
120
|
+
run: () => Promise<T>,
|
|
121
|
+
): Promise<T> {
|
|
122
|
+
try {
|
|
123
|
+
const result = await run();
|
|
124
|
+
sandboxEpisode.succeeded(); // a success ends the episode
|
|
125
|
+
return result;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (!(err instanceof SandboxUnavailableError)) throw err;
|
|
128
|
+
invalidateTransport();
|
|
129
|
+
// Tell the user ONCE per episode. The claim is only taken when a
|
|
130
|
+
// notification is actually deliverable (`ctx` present) — a failure we
|
|
131
|
+
// cannot surface must not swallow the episode's one message.
|
|
132
|
+
if (sandboxEpisode.claimNotification(ctx !== undefined)) {
|
|
133
|
+
ctx?.ui.notify(
|
|
134
|
+
`sbx sandbox "${err.target}" is unavailable — the sandbox runtime failed to start, so this ` +
|
|
135
|
+
`result is not the command's own exit status and the command's effects cannot be assumed ` +
|
|
136
|
+
`to have happened. The command was NOT run on the host and was not retried. The next tool ` +
|
|
137
|
+
`call will try to start the sandbox again; if it keeps failing, the VM may need recreating ` +
|
|
138
|
+
`(\`sbx ls\`, then \`sbx stop ${err.target}\`).`,
|
|
139
|
+
"error",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
81
145
|
|
|
82
146
|
/**
|
|
83
147
|
* Resolve (and memoize) the transport. Never throws: if sbx is missing or
|
|
@@ -129,7 +193,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
129
193
|
async execute(id: unknown, params: unknown, signal: unknown, onUpdate: unknown, ctx?: ExtensionContext) {
|
|
130
194
|
const t = await ensureTransport(ctx);
|
|
131
195
|
if (!t) return (local.execute as Function)(id, params, signal, onUpdate, ctx);
|
|
132
|
-
return (
|
|
196
|
+
return withSandboxFailureHandling(ctx, () =>
|
|
197
|
+
(build(t).execute as Function)(id, params, signal, onUpdate, ctx),
|
|
198
|
+
);
|
|
133
199
|
},
|
|
134
200
|
} as T;
|
|
135
201
|
}
|
|
@@ -205,7 +271,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
205
271
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
206
272
|
const t = await ensureTransport(ctx);
|
|
207
273
|
if (!t) return localGrep.execute(id, params, signal, onUpdate, ctx);
|
|
208
|
-
return executeSandboxGrep(t, localCwd, params as GrepToolInput);
|
|
274
|
+
return withSandboxFailureHandling(ctx, () => executeSandboxGrep(t, localCwd, params as GrepToolInput));
|
|
209
275
|
},
|
|
210
276
|
});
|
|
211
277
|
|
package/sandbox/operations.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
ReadOperations,
|
|
41
41
|
WriteOperations,
|
|
42
42
|
} from "@earendil-works/pi-coding-agent";
|
|
43
|
+
import { SandboxUnavailableError, isSandboxUnavailableFailure } from "./failure.ts";
|
|
43
44
|
import { type ExecOptions, type ExecOutcome, type ExecTransport, shArgs, shQuote } from "./transport.ts";
|
|
44
45
|
|
|
45
46
|
/** Files larger than this would exceed a comfortable argv budget when base64'd. */
|
|
@@ -77,19 +78,42 @@ function opOpts(extra?: ExecOptions): ExecOptions {
|
|
|
77
78
|
return { timeout: DEFAULT_OP_TIMEOUT, ...extra };
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Throw if this outcome means the sandbox runtime itself failed to start.
|
|
83
|
+
*
|
|
84
|
+
* A non-zero exit from `sbx exec` is ambiguous: it is the inner command's
|
|
85
|
+
* status when the VM was reachable, and the CLI's own failure when it was not.
|
|
86
|
+
* This is the one place that ambiguity is resolved, so no call site below can
|
|
87
|
+
* mistake "the whole sandbox is down" for "this file does not exist".
|
|
88
|
+
*/
|
|
89
|
+
function throwIfSandboxUnavailable(t: ExecTransport, r: ExecOutcome): void {
|
|
90
|
+
if (isSandboxUnavailableFailure(r)) throw new SandboxUnavailableError(t.target, r.stderr.toString("utf8"));
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
async function must(t: ExecTransport, argv: string[], fallback: string, opts?: ExecOptions): Promise<ExecOutcome> {
|
|
81
94
|
const r = await t.exec(argv, opOpts(opts));
|
|
95
|
+
throwIfSandboxUnavailable(t, r);
|
|
82
96
|
if (r.exitCode !== 0) throw new Error(errText(r, fallback));
|
|
83
97
|
return r;
|
|
84
98
|
}
|
|
85
99
|
|
|
100
|
+
/**
|
|
101
|
+
* "Did this probe exit 0?" for the `test -e` / `test -r` style primitives.
|
|
102
|
+
*
|
|
103
|
+
* A transport-level rejection (spawn failure, abort, timeout) is still reported
|
|
104
|
+
* as `false` — that is what this helper is for. A sandbox-runtime failure is
|
|
105
|
+
* NOT: it propagates, so callers cannot silently turn a dead VM into "this path
|
|
106
|
+
* does not exist".
|
|
107
|
+
*/
|
|
86
108
|
async function ok(t: ExecTransport, argv: string[]): Promise<boolean> {
|
|
109
|
+
let r: ExecOutcome;
|
|
87
110
|
try {
|
|
88
|
-
|
|
89
|
-
return r.exitCode === 0;
|
|
111
|
+
r = await t.exec(argv, opOpts());
|
|
90
112
|
} catch {
|
|
91
113
|
return false;
|
|
92
114
|
}
|
|
115
|
+
throwIfSandboxUnavailable(t, r);
|
|
116
|
+
return r.exitCode === 0;
|
|
93
117
|
}
|
|
94
118
|
|
|
95
119
|
/* ------------------------------------------------------------------ */
|
|
@@ -215,7 +239,13 @@ export function createLsOps(t: ExecTransport): LsOperations {
|
|
|
215
239
|
if (knownHit !== undefined) return { isDirectory: () => knownHit };
|
|
216
240
|
const cached = listings.get(path.dirname(p));
|
|
217
241
|
if (cached) {
|
|
218
|
-
|
|
242
|
+
// A failed parent listing is normally "not there / unreadable", which
|
|
243
|
+
// just means "fall through to a direct probe". A sandbox-runtime
|
|
244
|
+
// failure is not that, and must not be swallowed.
|
|
245
|
+
const entries = await cached.catch((err) => {
|
|
246
|
+
if (err instanceof SandboxUnavailableError) throw err;
|
|
247
|
+
return undefined;
|
|
248
|
+
});
|
|
219
249
|
const hit = entries?.get(path.basename(p));
|
|
220
250
|
if (hit !== undefined) {
|
|
221
251
|
known.set(p, hit);
|
|
@@ -253,13 +283,16 @@ async function gitSearchableFiles(t: ExecTransport, root: string): Promise<strin
|
|
|
253
283
|
shArgs('git -c safe.directory=\'*\' -C "$1" ls-files -z --cached --others --exclude-standard', root),
|
|
254
284
|
opOpts(),
|
|
255
285
|
);
|
|
286
|
+
// Not a repo / git missing: fall back to the walk. A dead sandbox: throw.
|
|
287
|
+
throwIfSandboxUnavailable(t, r);
|
|
256
288
|
if (r.exitCode !== 0) return null;
|
|
257
289
|
const files: string[] = [];
|
|
258
290
|
for (const relative of r.stdout.toString("utf8").split("\0")) {
|
|
259
291
|
if (relative) files.push(path.join(root, relative));
|
|
260
292
|
}
|
|
261
293
|
return files;
|
|
262
|
-
} catch {
|
|
294
|
+
} catch (err) {
|
|
295
|
+
if (err instanceof SandboxUnavailableError) throw err;
|
|
263
296
|
return null;
|
|
264
297
|
}
|
|
265
298
|
}
|
|
@@ -326,7 +359,8 @@ async function walkFiles(
|
|
|
326
359
|
let entries: Map<string, boolean>;
|
|
327
360
|
try {
|
|
328
361
|
entries = await listDirEntries(t, dir);
|
|
329
|
-
} catch {
|
|
362
|
+
} catch (err) {
|
|
363
|
+
if (err instanceof SandboxUnavailableError) throw err;
|
|
330
364
|
return true; // unreadable subtree: skip, like the built-in does
|
|
331
365
|
}
|
|
332
366
|
for (const [name, isDir] of entries) {
|
|
@@ -367,7 +401,8 @@ export async function executeSandboxGrep(
|
|
|
367
401
|
let content: string;
|
|
368
402
|
try {
|
|
369
403
|
content = (await readBytes(t, absolute)).toString("utf8");
|
|
370
|
-
} catch {
|
|
404
|
+
} catch (err) {
|
|
405
|
+
if (err instanceof SandboxUnavailableError) throw err;
|
|
371
406
|
return true; // binary/unreadable file
|
|
372
407
|
}
|
|
373
408
|
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
@@ -490,6 +525,13 @@ export function createBashOps(t: ExecTransport, options: BashOpsOptions = {}): B
|
|
|
490
525
|
.filter(Boolean)
|
|
491
526
|
.join("\n");
|
|
492
527
|
const r = await t.exec(["sh", "-lc", script], { onData, signal, timeout });
|
|
528
|
+
// A non-zero exit here is usually the command's OWN status (grep found
|
|
529
|
+
// nothing, a test failed, `false`) and must stay an exit code. But when
|
|
530
|
+
// the signature says the VM never started, the command did not run at
|
|
531
|
+
// all — surface that instead of a bogus status, so the caller can
|
|
532
|
+
// invalidate the transport. bash is the most common way to hit a dead
|
|
533
|
+
// sandbox, so it must not be the one path that never recovers.
|
|
534
|
+
throwIfSandboxUnavailable(t, r);
|
|
493
535
|
return { exitCode: r.exitCode };
|
|
494
536
|
},
|
|
495
537
|
};
|