@stixxert/pi-docker-sandbox 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/index.ts +23 -6
- package/package.json +1 -1
- package/sandbox/e2e.mjs +2 -0
- package/sandbox/failure.ts +102 -0
- package/sandbox/index.ts +59 -4
- package/sandbox/operations.ts +48 -6
package/README.md
CHANGED
|
@@ -184,6 +184,11 @@ persistent name (e.g. a shared sandbox reused across restarts), pin
|
|
|
184
184
|
- `DOCKER_SANDBOX_TEMPLATE=<name>` — use a pre-baked `sbx template` for
|
|
185
185
|
auto-created sandboxes (avoids re-pulling common images every session;
|
|
186
186
|
create with `sbx template save <name>` from a prepared sandbox).
|
|
187
|
+
- `DOCKER_SANDBOX_DEBUG=1` — emit the extension's lifecycle/GC diagnostics
|
|
188
|
+
(watchdog armed, teardown, startup sweep) to stderr. **Off by default**: the
|
|
189
|
+
extension runs inside the pi process, so stray console output would land on
|
|
190
|
+
the same terminal the TUI is drawing and corrupt the chat. Turn it on when
|
|
191
|
+
running `pi -p`, in a plain shell, or when diagnosing lifecycle issues.
|
|
187
192
|
|
|
188
193
|
## Ports (verified rules)
|
|
189
194
|
|
package/index.ts
CHANGED
|
@@ -124,9 +124,9 @@ async function teardownSandbox(reason: string): Promise<void> {
|
|
|
124
124
|
].join("\n");
|
|
125
125
|
const child = spawn("/bin/sh", ["-c", script], { detached: true, stdio: "ignore", env: scrubbedEnv() });
|
|
126
126
|
child.unref();
|
|
127
|
-
|
|
127
|
+
note(`session ${reason}: ${action} sandbox "${name}" (detached, retrying)`);
|
|
128
128
|
} catch (e) {
|
|
129
|
-
|
|
129
|
+
note(`session ${reason}: teardown of "${name}" failed: ${(e as Error).message}`);
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
@@ -141,6 +141,22 @@ function findSbxCli(): string {
|
|
|
141
141
|
return "sbx";
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Diagnostics are OFF by default on purpose: the extension runs inside the pi
|
|
146
|
+
* process, so a raw console write lands on the terminal the TUI is drawing and
|
|
147
|
+
* corrupts the chat transcript. Set DOCKER_SANDBOX_DEBUG=1 (or SBX_PI_DEBUG=1 /
|
|
148
|
+
* SBX_DEBUG=1) to get the lifecycle/GC lines on stderr — useful in `pi -p`, a
|
|
149
|
+
* plain shell or when diagnosing, harmless in the TUI because it is opt-in.
|
|
150
|
+
*/
|
|
151
|
+
function debugEnabled(): boolean {
|
|
152
|
+
return /^(1|true|yes|on)$/i.test((env.DOCKER_SANDBOX_DEBUG ?? env.SBX_PI_DEBUG ?? env.SBX_DEBUG ?? "").trim());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Diagnostic line — silent unless debug logging is enabled (see debugEnabled). */
|
|
156
|
+
function note(message: string): void {
|
|
157
|
+
if (debugEnabled()) console.error(`[docker-sandbox] ${message}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
144
160
|
/**
|
|
145
161
|
* Non-secret vars always forwarded to children (the sbx CLI and shells need
|
|
146
162
|
* them to function; they are not credentials).
|
|
@@ -1486,9 +1502,9 @@ function spawnWatchdog(): void {
|
|
|
1486
1502
|
try {
|
|
1487
1503
|
const child = spawn("/bin/sh", ["-c", script], { detached: true, stdio: "ignore", env: scrubbedEnv() });
|
|
1488
1504
|
child.unref();
|
|
1489
|
-
|
|
1505
|
+
note(`watchdog armed for sandbox "${name}" (pid ${pid}, teardown=${mode}, keepalive=${keep})`);
|
|
1490
1506
|
} catch (e) {
|
|
1491
|
-
|
|
1507
|
+
note(`failed to arm watchdog: ${(e as Error).message}`);
|
|
1492
1508
|
}
|
|
1493
1509
|
}
|
|
1494
1510
|
|
|
@@ -1670,8 +1686,8 @@ async function armSessionLifecycle(): Promise<void> {
|
|
|
1670
1686
|
// sandboxes from crashed sessions, and session start must not wait on
|
|
1671
1687
|
// `sbx ls` (and any sandboxd round trip) to get there.
|
|
1672
1688
|
void gcSweep(raw)
|
|
1673
|
-
.then((summary) =>
|
|
1674
|
-
.catch((e) =>
|
|
1689
|
+
.then((summary) => note(summary))
|
|
1690
|
+
.catch((e) => note(`gc at startup failed: ${(e as Error).message}`));
|
|
1675
1691
|
}
|
|
1676
1692
|
|
|
1677
1693
|
export default function (pi: ExtensionAPI) {
|
|
@@ -1973,4 +1989,5 @@ export {
|
|
|
1973
1989
|
ensureSandbox,
|
|
1974
1990
|
teardownSandbox,
|
|
1975
1991
|
armSessionLifecycle,
|
|
1992
|
+
debugEnabled,
|
|
1976
1993
|
};
|
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.2",
|
|
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": {
|
package/sandbox/e2e.mjs
CHANGED
|
@@ -375,6 +375,7 @@ try {
|
|
|
375
375
|
process.env.DOCKER_SANDBOX_KEEPALIVE = "1";
|
|
376
376
|
process.env.DOCKER_SANDBOX_TEARDOWN = "none"; // no stray teardown attempt
|
|
377
377
|
process.env.DOCKER_SANDBOX_GC_HOURS = "0"; // skip the startup sweep
|
|
378
|
+
process.env.DOCKER_SANDBOX_DEBUG = "1"; // lifecycle notes are debug-gated (silent in the TUI by default)
|
|
378
379
|
const { armSessionLifecycle } = await loadTs("index.ts");
|
|
379
380
|
const lifecycleLog = [];
|
|
380
381
|
const realError = console.error;
|
|
@@ -389,6 +390,7 @@ try {
|
|
|
389
390
|
);
|
|
390
391
|
delete process.env.DOCKER_SANDBOX_TEARDOWN;
|
|
391
392
|
delete process.env.DOCKER_SANDBOX_GC_HOURS;
|
|
393
|
+
delete process.env.DOCKER_SANDBOX_DEBUG;
|
|
392
394
|
|
|
393
395
|
/* --- lightweight template handshake -------------------------------- */
|
|
394
396
|
console.log("\nlightweight template handshake");
|
|
@@ -0,0 +1,102 @@
|
|
|
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. Each one is a phrase the `sbx` CLI or its in-VM runtime
|
|
37
|
+
* emits about the sandbox itself, not something a user command would plausibly
|
|
38
|
+
* print as its own output:
|
|
39
|
+
*
|
|
40
|
+
* 1. `failed to start sandbox` — the CLI's own wrapper line for "I could not
|
|
41
|
+
* bring this sandbox up", e.g. the reproduced
|
|
42
|
+
* `failed to start sandbox: start runtime: request failed: 500 ...`.
|
|
43
|
+
* 2. `start runtime: request failed: 5xx` — sandboxd's runtime API rejecting
|
|
44
|
+
* the start with an HTTP 5xx. Anchored on `start runtime: request failed:`
|
|
45
|
+
* rather than a bare `request failed: 500`, because the bare form could
|
|
46
|
+
* come from any command's stderr (a curl wrapper, an API client, ...) and
|
|
47
|
+
* would then misclassify an ordinary failing command.
|
|
48
|
+
* 3. `docker daemon failed to start` — the in-VM cause the runtime reports
|
|
49
|
+
* (`docker daemon failed to start inside the sandbox`).
|
|
50
|
+
*
|
|
51
|
+
* Matching deliberately requires BOTH a non-zero exit AND a signature on
|
|
52
|
+
* *stderr* (diagnostics live there; stdout is the command's own output, where a
|
|
53
|
+
* phrase like "request failed: 500" is far more likely to be a false match).
|
|
54
|
+
* A signal kill (exitCode null — our own abort/timeout) is never classified.
|
|
55
|
+
*/
|
|
56
|
+
const RUNTIME_FAILURE_SIGNATURES: readonly RegExp[] = [
|
|
57
|
+
/failed to start sandbox\b/i,
|
|
58
|
+
/start runtime: request failed:\s*5\d\d\b/i,
|
|
59
|
+
/docker daemon failed to start\b/i,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Is this outcome "the sandbox runtime failed", rather than "the command
|
|
64
|
+
* exited non-zero"? Conservative on purpose: a plain `exitCode 1` with ordinary
|
|
65
|
+
* stderr (e.g. `grep: no match`) must NOT match.
|
|
66
|
+
*/
|
|
67
|
+
export function isSandboxUnavailableFailure(outcome: ExecLike): boolean {
|
|
68
|
+
if (outcome.exitCode === 0 || outcome.exitCode === null) return false;
|
|
69
|
+
const stderr = asText(outcome.stderr);
|
|
70
|
+
if (!stderr) return false;
|
|
71
|
+
return RUNTIME_FAILURE_SIGNATURES.some((signature) => signature.test(stderr));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The typed error for a dead sandbox runtime.
|
|
76
|
+
*
|
|
77
|
+
* Carries the sandbox target and the raw stderr, and its message names the
|
|
78
|
+
* sandbox explicitly so it is actionable wherever it surfaces — the caller
|
|
79
|
+
* (see `sandbox/index.ts`) turns it into a one-per-episode user notification
|
|
80
|
+
* and invalidates the memoised transport, so the NEXT tool call can start the
|
|
81
|
+
* VM again. It is never a licence to retry the command or to fall back to the
|
|
82
|
+
* host: re-running arbitrary work is unsafe, and the host is not the execution
|
|
83
|
+
* environment.
|
|
84
|
+
*/
|
|
85
|
+
export class SandboxUnavailableError extends Error {
|
|
86
|
+
readonly target: string;
|
|
87
|
+
readonly stderr: string;
|
|
88
|
+
|
|
89
|
+
constructor(target: string, stderr: string) {
|
|
90
|
+
const detail = stderr.trim();
|
|
91
|
+
super(
|
|
92
|
+
`sbx sandbox "${target}" is unavailable: the sandbox runtime failed to start, so the command ` +
|
|
93
|
+
`never ran inside it (this is not the command's own exit status).\n` +
|
|
94
|
+
`The VM may need recreating (check \`sbx ls\`, \`sbx stop ${target}\`, or recreate it) — ` +
|
|
95
|
+
`the next tool call will try to start the sandbox again.` +
|
|
96
|
+
(detail ? `\n\n${detail}` : ""),
|
|
97
|
+
);
|
|
98
|
+
this.name = "SandboxUnavailableError";
|
|
99
|
+
this.target = target;
|
|
100
|
+
this.stderr = detail;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/sandbox/index.ts
CHANGED
|
@@ -40,7 +40,8 @@ import {
|
|
|
40
40
|
createReadToolDefinition,
|
|
41
41
|
createWriteToolDefinition,
|
|
42
42
|
} from "@earendil-works/pi-coding-agent";
|
|
43
|
-
import { armSessionLifecycle, envAllowlist, teardownSandbox } from "../index.ts";
|
|
43
|
+
import { armSessionLifecycle, debugEnabled, envAllowlist, teardownSandbox } from "../index.ts";
|
|
44
|
+
import { SandboxUnavailableError } from "./failure.ts";
|
|
44
45
|
import {
|
|
45
46
|
createBashOps,
|
|
46
47
|
createEditOps,
|
|
@@ -78,6 +79,55 @@ 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
|
+
/** Have we already told the user about the CURRENT run of runtime failures? */
|
|
83
|
+
let sandboxFailureReported = false;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Forget the memoised transport so the next tool call re-resolves it.
|
|
87
|
+
*
|
|
88
|
+
* This is the whole recovery mechanism: `resolveTransport()` re-derives the
|
|
89
|
+
* sandbox and (re)starts the VM, so a runtime failure is an episode rather
|
|
90
|
+
* than a permanent bricking of every remaining tool call in the session.
|
|
91
|
+
*/
|
|
92
|
+
function invalidateTransport(): void {
|
|
93
|
+
transport = undefined;
|
|
94
|
+
starting = undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run one sandbox round-trip, turning a dead sandbox into: invalidate +
|
|
99
|
+
* notify (ONCE per episode) + rethrow.
|
|
100
|
+
*
|
|
101
|
+
* Deliberately NOT done here: retrying the command, or running it locally.
|
|
102
|
+
* Re-running arbitrary work is unsafe (side effects), and the host is not the
|
|
103
|
+
* execution environment — that would be a sandbox escape. The resolution-time
|
|
104
|
+
* local fallback (see `ensureTransport`) is unchanged and only applies when no
|
|
105
|
+
* transport can be resolved at all; it never triggers from here.
|
|
106
|
+
*/
|
|
107
|
+
async function withSandboxFailureHandling<T>(
|
|
108
|
+
ctx: ExtensionContext | undefined,
|
|
109
|
+
run: () => Promise<T>,
|
|
110
|
+
): Promise<T> {
|
|
111
|
+
try {
|
|
112
|
+
const result = await run();
|
|
113
|
+
sandboxFailureReported = false; // a success ends the episode
|
|
114
|
+
return result;
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (!(err instanceof SandboxUnavailableError)) throw err;
|
|
117
|
+
invalidateTransport();
|
|
118
|
+
if (!sandboxFailureReported) {
|
|
119
|
+
sandboxFailureReported = true;
|
|
120
|
+
ctx?.ui.notify(
|
|
121
|
+
`sbx sandbox "${err.target}" is unavailable — the sandbox runtime failed to start, so this tool ` +
|
|
122
|
+
`call did not run (it was NOT run on the host and was not retried). The next tool call will try ` +
|
|
123
|
+
`to start the sandbox again; if it keeps failing, the VM may need recreating (\`sbx ls\`, ` +
|
|
124
|
+
`then \`sbx stop ${err.target}\`).`,
|
|
125
|
+
"error",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
81
131
|
|
|
82
132
|
/**
|
|
83
133
|
* Resolve (and memoize) the transport. Never throws: if sbx is missing or
|
|
@@ -129,7 +179,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
129
179
|
async execute(id: unknown, params: unknown, signal: unknown, onUpdate: unknown, ctx?: ExtensionContext) {
|
|
130
180
|
const t = await ensureTransport(ctx);
|
|
131
181
|
if (!t) return (local.execute as Function)(id, params, signal, onUpdate, ctx);
|
|
132
|
-
return (
|
|
182
|
+
return withSandboxFailureHandling(ctx, () =>
|
|
183
|
+
(build(t).execute as Function)(id, params, signal, onUpdate, ctx),
|
|
184
|
+
);
|
|
133
185
|
},
|
|
134
186
|
} as T;
|
|
135
187
|
}
|
|
@@ -157,7 +209,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
157
209
|
void ensureTransport(ctx)
|
|
158
210
|
.then((active) => (active?.kind === "sbx" ? armSessionLifecycle() : undefined))
|
|
159
211
|
.catch((err) => {
|
|
160
|
-
console
|
|
212
|
+
// Raw console writes land on the terminal the TUI is drawing, so cap
|
|
213
|
+
// the failure note behind the debug flag — the backend degrades to
|
|
214
|
+
// local tools either way (the `sbx` command reports live status).
|
|
215
|
+
if (debugEnabled()) console.error(`[sbx] session start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
161
216
|
});
|
|
162
217
|
});
|
|
163
218
|
|
|
@@ -202,7 +257,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
202
257
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
203
258
|
const t = await ensureTransport(ctx);
|
|
204
259
|
if (!t) return localGrep.execute(id, params, signal, onUpdate, ctx);
|
|
205
|
-
return executeSandboxGrep(t, localCwd, params as GrepToolInput);
|
|
260
|
+
return withSandboxFailureHandling(ctx, () => executeSandboxGrep(t, localCwd, params as GrepToolInput));
|
|
206
261
|
},
|
|
207
262
|
});
|
|
208
263
|
|
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
|
};
|