@cruxy/cli 0.12.0 → 0.14.0
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/dist/approval/prompt.js +17 -3
- package/dist/cli/commands/run.js +19 -1
- package/dist/cli/session-factory.d.ts +2 -1
- package/dist/cli/session-factory.js +10 -3
- package/dist/components/fuzzy.js +7 -1
- package/dist/config/schema.d.ts +123 -0
- package/dist/config/schema.js +40 -0
- package/dist/errors/constructors.d.ts +21 -0
- package/dist/errors/constructors.js +58 -0
- package/dist/errors/types.d.ts +5 -0
- package/dist/errors/types.js +11 -0
- package/dist/onboarding/steps.js +4 -1
- package/dist/render/capabilities.d.ts +10 -2
- package/dist/render/capabilities.js +26 -6
- package/dist/render/index.d.ts +9 -5
- package/dist/render/index.js +12 -5
- package/dist/render/plain-renderer.d.ts +3 -3
- package/dist/render/plain-renderer.js +10 -2
- package/dist/render/screen-reader-renderer.d.ts +45 -0
- package/dist/render/screen-reader-renderer.js +75 -0
- package/dist/render/types.d.ts +15 -1
- package/dist/sandbox/detect.d.ts +22 -0
- package/dist/sandbox/detect.js +67 -0
- package/dist/sandbox/docker-runtime.d.ts +32 -0
- package/dist/sandbox/docker-runtime.js +263 -0
- package/dist/sandbox/index.d.ts +7 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/policy.d.ts +17 -0
- package/dist/sandbox/policy.js +90 -0
- package/dist/sandbox/service.d.ts +57 -0
- package/dist/sandbox/service.js +64 -0
- package/dist/sandbox/types.d.ts +114 -0
- package/dist/sandbox/types.js +17 -0
- package/dist/subagent/orchestrator.d.ts +7 -0
- package/dist/subagent/orchestrator.js +1 -0
- package/dist/testing/run-tests-tool.d.ts +5 -1
- package/dist/testing/run-tests-tool.js +8 -1
- package/dist/testing/sandbox-runner.d.ts +16 -0
- package/dist/testing/sandbox-runner.js +47 -0
- package/dist/theme/resolve.d.ts +18 -7
- package/dist/theme/resolve.js +32 -10
- package/dist/theme/tokens.d.ts +16 -1
- package/dist/theme/tokens.js +27 -0
- package/dist/tools/shell/run-command.js +35 -1
- package/dist/tools/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ActionPreview } from "../tools/types.js";
|
|
2
2
|
import { type Theme } from "../theme/index.js";
|
|
3
|
-
import type { RenderCapabilities, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
|
|
3
|
+
import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
|
|
4
4
|
/**
|
|
5
5
|
* The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
|
|
6
6
|
* cursor-control sequences ever, and no color unless the capabilities say so
|
|
@@ -30,8 +30,8 @@ export declare class PlainRenderer implements StreamRenderer {
|
|
|
30
30
|
note(text: string): void;
|
|
31
31
|
preview(preview: ActionPreview): void;
|
|
32
32
|
status(): void;
|
|
33
|
-
setPhase(): void;
|
|
34
|
-
progress(): void;
|
|
33
|
+
setPhase(phase: RenderPhase | null): void;
|
|
34
|
+
progress(state: ProgressState | null): void;
|
|
35
35
|
toolLifecycle(event: ToolLifecycleEvent): void;
|
|
36
36
|
promptResolved(): void;
|
|
37
37
|
endTurn(): void;
|
|
@@ -59,12 +59,20 @@ export class PlainRenderer {
|
|
|
59
59
|
status() {
|
|
60
60
|
// Append-only medium: transient state is dropped by design.
|
|
61
61
|
}
|
|
62
|
-
setPhase
|
|
62
|
+
// setPhase / progress declare their StreamRenderer params because the
|
|
63
|
+
// ScreenReaderRenderer subclass overrides them to announce (the override must
|
|
64
|
+
// be signature-compatible). In the plain medium they are no-ops: there is no
|
|
65
|
+
// live region to update, so the guard simply returns.
|
|
66
|
+
setPhase(phase) {
|
|
63
67
|
// Phases are live-region state; there is no live region here (U.4).
|
|
68
|
+
if (phase !== null)
|
|
69
|
+
return;
|
|
64
70
|
}
|
|
65
|
-
progress() {
|
|
71
|
+
progress(state) {
|
|
66
72
|
// The committed plan trail (C.31, via PromptIO) is the record in this
|
|
67
73
|
// medium; a live [i/n] prefix would just duplicate it line by line.
|
|
74
|
+
if (state !== null)
|
|
75
|
+
return;
|
|
68
76
|
}
|
|
69
77
|
toolLifecycle(event) {
|
|
70
78
|
if (event.event === "start") {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { PlainRenderer } from "./plain-renderer.js";
|
|
2
|
+
import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, ToolLifecycleEvent } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The screen-reader renderer (U.11): {@link PlainRenderer}'s linear,
|
|
5
|
+
* append-only, zero-cursor-control output — plus two things a screen reader
|
|
6
|
+
* needs that the plain path drops.
|
|
7
|
+
*
|
|
8
|
+
* 1. **Worded status.** Its theme resolves the {@link SCREEN_READER_GLYPHS}
|
|
9
|
+
* table (via `caps.screenReader`), so every inherited code path that writes
|
|
10
|
+
* `theme.glyph.success` prints `done` instead of `✓`, `failed` for `✗`, etc.
|
|
11
|
+
* Nothing here special-cases it — the glyph table does the work.
|
|
12
|
+
*
|
|
13
|
+
* 2. **Announced state.** A screen reader cannot see an in-place live region, so
|
|
14
|
+
* each state change is emitted as its own committed line (never a redraw):
|
|
15
|
+
* a phase becomes `working: read_file src/x.ts`, a tool run brackets as
|
|
16
|
+
* `working: …` → `done …`, and plan progress announces `step 2 of 5: title`.
|
|
17
|
+
* Announcements are deduped by phase *identity* so token-count updates within
|
|
18
|
+
* the same activity don't repeat a line.
|
|
19
|
+
*
|
|
20
|
+
* Everything else — text streaming, `endSegment`, `note`, `preview` — is
|
|
21
|
+
* inherited unchanged. This is the whole of screen-reader mode: no parallel
|
|
22
|
+
* path, no behavior change to the plain or TTY renderers.
|
|
23
|
+
*/
|
|
24
|
+
export declare class ScreenReaderRenderer extends PlainRenderer {
|
|
25
|
+
/** Identity of the last phase announced, so we speak each activity once. */
|
|
26
|
+
private lastPhaseId;
|
|
27
|
+
/** Text of the last progress line announced, to avoid repeats. */
|
|
28
|
+
private lastProgress;
|
|
29
|
+
constructor(caps: RenderCapabilities, out: RenderStream, err: RenderStream);
|
|
30
|
+
/**
|
|
31
|
+
* Announce a phase transition as a committed line. `null`, `awaiting-approval`
|
|
32
|
+
* (the prompt block is its own announcement), and `calling-tool` (announced by
|
|
33
|
+
* {@link toolLifecycle}) are silent; repeats of the same activity are deduped.
|
|
34
|
+
*/
|
|
35
|
+
setPhase(phase: RenderPhase | null): void;
|
|
36
|
+
/** Announce plan-step progress as `step i of n: title`, deduped. */
|
|
37
|
+
progress(state: ProgressState | null): void;
|
|
38
|
+
/**
|
|
39
|
+
* Bracket a tool call with two committed lines — `working: label` on start,
|
|
40
|
+
* `done/failed label` on end — so a screen-reader user hears both that a call
|
|
41
|
+
* began (crucial for long ones) and how it resolved. The end note + honest
|
|
42
|
+
* duration is the inherited plain behavior; only the start line is added.
|
|
43
|
+
*/
|
|
44
|
+
toolLifecycle(event: ToolLifecycleEvent): void;
|
|
45
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { PlainRenderer } from "./plain-renderer.js";
|
|
2
|
+
import { describePhase, phaseIdentity } from "./state.js";
|
|
3
|
+
/**
|
|
4
|
+
* The screen-reader renderer (U.11): {@link PlainRenderer}'s linear,
|
|
5
|
+
* append-only, zero-cursor-control output — plus two things a screen reader
|
|
6
|
+
* needs that the plain path drops.
|
|
7
|
+
*
|
|
8
|
+
* 1. **Worded status.** Its theme resolves the {@link SCREEN_READER_GLYPHS}
|
|
9
|
+
* table (via `caps.screenReader`), so every inherited code path that writes
|
|
10
|
+
* `theme.glyph.success` prints `done` instead of `✓`, `failed` for `✗`, etc.
|
|
11
|
+
* Nothing here special-cases it — the glyph table does the work.
|
|
12
|
+
*
|
|
13
|
+
* 2. **Announced state.** A screen reader cannot see an in-place live region, so
|
|
14
|
+
* each state change is emitted as its own committed line (never a redraw):
|
|
15
|
+
* a phase becomes `working: read_file src/x.ts`, a tool run brackets as
|
|
16
|
+
* `working: …` → `done …`, and plan progress announces `step 2 of 5: title`.
|
|
17
|
+
* Announcements are deduped by phase *identity* so token-count updates within
|
|
18
|
+
* the same activity don't repeat a line.
|
|
19
|
+
*
|
|
20
|
+
* Everything else — text streaming, `endSegment`, `note`, `preview` — is
|
|
21
|
+
* inherited unchanged. This is the whole of screen-reader mode: no parallel
|
|
22
|
+
* path, no behavior change to the plain or TTY renderers.
|
|
23
|
+
*/
|
|
24
|
+
export class ScreenReaderRenderer extends PlainRenderer {
|
|
25
|
+
/** Identity of the last phase announced, so we speak each activity once. */
|
|
26
|
+
lastPhaseId = "";
|
|
27
|
+
/** Text of the last progress line announced, to avoid repeats. */
|
|
28
|
+
lastProgress = "";
|
|
29
|
+
constructor(caps, out, err) {
|
|
30
|
+
super(caps, out, err);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Announce a phase transition as a committed line. `null`, `awaiting-approval`
|
|
34
|
+
* (the prompt block is its own announcement), and `calling-tool` (announced by
|
|
35
|
+
* {@link toolLifecycle}) are silent; repeats of the same activity are deduped.
|
|
36
|
+
*/
|
|
37
|
+
setPhase(phase) {
|
|
38
|
+
const id = phaseIdentity(phase);
|
|
39
|
+
if (id === this.lastPhaseId)
|
|
40
|
+
return;
|
|
41
|
+
this.lastPhaseId = id;
|
|
42
|
+
if (phase === null ||
|
|
43
|
+
phase.kind === "awaiting-approval" ||
|
|
44
|
+
phase.kind === "calling-tool") {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.note(describePhase(phase, this.theme.glyph));
|
|
48
|
+
}
|
|
49
|
+
/** Announce plan-step progress as `step i of n: title`, deduped. */
|
|
50
|
+
progress(state) {
|
|
51
|
+
if (state === null) {
|
|
52
|
+
this.lastProgress = "";
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const line = `step ${state.step} of ${state.of}: ${state.title}`;
|
|
56
|
+
if (line === this.lastProgress)
|
|
57
|
+
return;
|
|
58
|
+
this.lastProgress = line;
|
|
59
|
+
this.note(line);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Bracket a tool call with two committed lines — `working: label` on start,
|
|
63
|
+
* `done/failed label` on end — so a screen-reader user hears both that a call
|
|
64
|
+
* began (crucial for long ones) and how it resolved. The end note + honest
|
|
65
|
+
* duration is the inherited plain behavior; only the start line is added.
|
|
66
|
+
*/
|
|
67
|
+
toolLifecycle(event) {
|
|
68
|
+
if (event.event === "start") {
|
|
69
|
+
super.toolLifecycle(event); // records the start time (silent in plain)
|
|
70
|
+
this.note(`${this.theme.glyph.running}: ${event.label}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
super.toolLifecycle(event); // commits `done/failed label (duration)`
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/render/types.d.ts
CHANGED
|
@@ -20,8 +20,22 @@ export interface RenderCapabilities {
|
|
|
20
20
|
color: boolean;
|
|
21
21
|
/** Cursor-control sequences are safe (`tty` and not `TERM=dumb`). */
|
|
22
22
|
cursor: boolean;
|
|
23
|
-
/** Animation is welcome (`cursor` and
|
|
23
|
+
/** Animation is welcome (`cursor` and motion is not reduced). */
|
|
24
24
|
spinner: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Motion is reduced (U.11): no spinner animation/timer, no in-place
|
|
27
|
+
* re-animation — state survives as static text, only movement stops. True
|
|
28
|
+
* under `CRUXY_NO_SPINNER` (alias) / `NO_MOTION` / `CRUXY_REDUCED_MOTION`, and
|
|
29
|
+
* implied by `screenReader`. The one axis the spinner gate keys off.
|
|
30
|
+
*/
|
|
31
|
+
reducedMotion: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Screen-reader mode (U.11): plain, linear, announce-friendly output — no
|
|
34
|
+
* live-region redraws (each state change is a committed line), no spinners,
|
|
35
|
+
* status glyphs rendered as words. Opt-in via `CRUXY_SCREEN_READER` /
|
|
36
|
+
* `ACCESSIBLE`; routes rendering to the linear path regardless of `cursor`.
|
|
37
|
+
*/
|
|
38
|
+
screenReader: boolean;
|
|
25
39
|
/** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
|
|
26
40
|
* independent of `color`. Drives the theme's glyph table, not its stylers. */
|
|
27
41
|
unicode: boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SandboxCapability } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Runtime detection (C.16): a container runtime is a *capability*, not an
|
|
4
|
+
* assumption. Presence means both that the binary exists AND its daemon
|
|
5
|
+
* answers — `docker` installed with a dead daemon is NOT available, and the
|
|
6
|
+
* caller must fail loud rather than pretend a box exists.
|
|
7
|
+
*/
|
|
8
|
+
/** Injectable probe seam — spawns a short command and reports how it exited. */
|
|
9
|
+
export type RuntimeProbe = (bin: string, args: string[]) => Promise<{
|
|
10
|
+
code: number | null;
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
}>;
|
|
14
|
+
/**
|
|
15
|
+
* Detect the Docker runtime. `docker version --format {{.Server.Version}}`
|
|
16
|
+
* exits non-zero when the daemon is unreachable (even though the client is
|
|
17
|
+
* installed), so a zero exit with a server version is the honest "available"
|
|
18
|
+
* signal. Memoized for the process; pass a probe (tests) to bypass the cache.
|
|
19
|
+
*/
|
|
20
|
+
export declare function detectDocker(probe?: RuntimeProbe): Promise<SandboxCapability>;
|
|
21
|
+
/** Clear the memoized capability (tests). */
|
|
22
|
+
export declare function resetDetectionCache(): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/** Default probe: spawn the binary, capture output, treat a spawn error (e.g.
|
|
3
|
+
* ENOENT — binary missing) as a non-zero exit rather than a throw. */
|
|
4
|
+
const spawnProbe = (bin, args) => new Promise((resolve) => {
|
|
5
|
+
let child;
|
|
6
|
+
try {
|
|
7
|
+
child = spawn(bin, args);
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
resolve({ code: null, stdout: "", stderr: err.message });
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
let out = "";
|
|
14
|
+
let errText = "";
|
|
15
|
+
let settled = false;
|
|
16
|
+
const done = (code, stderr = errText) => {
|
|
17
|
+
if (settled)
|
|
18
|
+
return;
|
|
19
|
+
settled = true;
|
|
20
|
+
resolve({ code, stdout: out, stderr });
|
|
21
|
+
};
|
|
22
|
+
// The daemon can hang; a probe must never wedge startup.
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
child.kill("SIGKILL");
|
|
25
|
+
done(null, "timed out probing the runtime");
|
|
26
|
+
}, PROBE_TIMEOUT_MS);
|
|
27
|
+
timer.unref?.();
|
|
28
|
+
child.stdout?.on("data", (b) => (out += b.toString("utf8")));
|
|
29
|
+
child.stderr?.on("data", (b) => (errText += b.toString("utf8")));
|
|
30
|
+
child.on("error", (err) => done(null, err.message));
|
|
31
|
+
child.on("close", (code) => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
done(code);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
37
|
+
let cached;
|
|
38
|
+
/**
|
|
39
|
+
* Detect the Docker runtime. `docker version --format {{.Server.Version}}`
|
|
40
|
+
* exits non-zero when the daemon is unreachable (even though the client is
|
|
41
|
+
* installed), so a zero exit with a server version is the honest "available"
|
|
42
|
+
* signal. Memoized for the process; pass a probe (tests) to bypass the cache.
|
|
43
|
+
*/
|
|
44
|
+
export function detectDocker(probe) {
|
|
45
|
+
if (probe)
|
|
46
|
+
return probeDocker(probe);
|
|
47
|
+
cached ??= probeDocker(spawnProbe);
|
|
48
|
+
return cached;
|
|
49
|
+
}
|
|
50
|
+
/** Clear the memoized capability (tests). */
|
|
51
|
+
export function resetDetectionCache() {
|
|
52
|
+
cached = undefined;
|
|
53
|
+
}
|
|
54
|
+
async function probeDocker(probe) {
|
|
55
|
+
const { code, stdout, stderr } = await probe("docker", [
|
|
56
|
+
"version",
|
|
57
|
+
"--format",
|
|
58
|
+
"{{.Server.Version}}",
|
|
59
|
+
]);
|
|
60
|
+
if (code === 0 && stdout.trim().length > 0) {
|
|
61
|
+
return { available: true, runtime: "docker" };
|
|
62
|
+
}
|
|
63
|
+
const detail = code === null
|
|
64
|
+
? "the docker binary is not installed or not on PATH"
|
|
65
|
+
: (stderr.trim().split("\n")[0] ?? "docker daemon is not reachable");
|
|
66
|
+
return { available: false, runtime: "docker", detail };
|
|
67
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ExecOptions, ExecResult, IsolationPolicy, SandboxRuntime } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The shipped {@link SandboxRuntime}: shells out to the `docker` CLI (no SDK —
|
|
4
|
+
* matches the no-vendor-client ethos). {@link buildRunArgs} is a pure function
|
|
5
|
+
* so the entire isolation posture can be asserted from the argv without a live
|
|
6
|
+
* daemon; `exec` spawns docker, captures bias-capped output, enforces the
|
|
7
|
+
* wall-clock timeout by force-killing the container, and maps the result.
|
|
8
|
+
*
|
|
9
|
+
* The exit code from `docker run` is the command's own — EXCEPT `125`, which
|
|
10
|
+
* docker reserves for "the run itself failed" (bad flags, daemon error): that,
|
|
11
|
+
* and a spawn failure, are the only container-start failures, surfaced as a
|
|
12
|
+
* coded {@link sandboxExec} error. An ordinary non-zero command exit is a
|
|
13
|
+
* normal result (exit code is truth), never a thrown error and never a host run.
|
|
14
|
+
*/
|
|
15
|
+
export declare class DockerRuntime implements SandboxRuntime {
|
|
16
|
+
private readonly bin;
|
|
17
|
+
readonly name = "docker";
|
|
18
|
+
constructor(bin?: string);
|
|
19
|
+
ensureImage(image: string, onPull?: () => void): Promise<void>;
|
|
20
|
+
/** Run a non-container docker subcommand to completion, capturing output. */
|
|
21
|
+
private simpleRun;
|
|
22
|
+
exec(command: string, policy: IsolationPolicy, opts: ExecOptions): Promise<ExecResult>;
|
|
23
|
+
private run;
|
|
24
|
+
/** Best-effort container teardown after a timeout kill. */
|
|
25
|
+
private forceRemove;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build the `docker run` argv from a resolved policy. Pure and total — the
|
|
29
|
+
* single source of truth for the isolation boundary, asserted directly in
|
|
30
|
+
* tests. Order is stable for readability; docker is order-insensitive for flags.
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildRunArgs(policy: IsolationPolicy, container: string, command: string): string[];
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { sandboxExec, sandboxImage } from "../errors/index.js";
|
|
4
|
+
/**
|
|
5
|
+
* The shipped {@link SandboxRuntime}: shells out to the `docker` CLI (no SDK —
|
|
6
|
+
* matches the no-vendor-client ethos). {@link buildRunArgs} is a pure function
|
|
7
|
+
* so the entire isolation posture can be asserted from the argv without a live
|
|
8
|
+
* daemon; `exec` spawns docker, captures bias-capped output, enforces the
|
|
9
|
+
* wall-clock timeout by force-killing the container, and maps the result.
|
|
10
|
+
*
|
|
11
|
+
* The exit code from `docker run` is the command's own — EXCEPT `125`, which
|
|
12
|
+
* docker reserves for "the run itself failed" (bad flags, daemon error): that,
|
|
13
|
+
* and a spawn failure, are the only container-start failures, surfaced as a
|
|
14
|
+
* coded {@link sandboxExec} error. An ordinary non-zero command exit is a
|
|
15
|
+
* normal result (exit code is truth), never a thrown error and never a host run.
|
|
16
|
+
*/
|
|
17
|
+
export class DockerRuntime {
|
|
18
|
+
bin;
|
|
19
|
+
name = "docker";
|
|
20
|
+
constructor(bin = "docker") {
|
|
21
|
+
this.bin = bin;
|
|
22
|
+
}
|
|
23
|
+
async ensureImage(image, onPull) {
|
|
24
|
+
// Present locally already? `docker image inspect` exits 0 when it is.
|
|
25
|
+
const inspect = await this.simpleRun(["image", "inspect", image]);
|
|
26
|
+
if (inspect.code === 0)
|
|
27
|
+
return;
|
|
28
|
+
// Not present — pull it (surface once), and fail loud if the pull fails.
|
|
29
|
+
onPull?.();
|
|
30
|
+
const pull = await this.simpleRun(["pull", image]);
|
|
31
|
+
if (pull.code !== 0) {
|
|
32
|
+
throw sandboxImage(image, pull.stderr.trim() || pull.stdout.trim());
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Run a non-container docker subcommand to completion, capturing output. */
|
|
36
|
+
simpleRun(args) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let child;
|
|
39
|
+
try {
|
|
40
|
+
child = spawn(this.bin, args);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
resolve({ code: null, stdout: "", stderr: err.message });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let out = "";
|
|
47
|
+
let errText = "";
|
|
48
|
+
child.stdout?.on("data", (b) => (out += b.toString("utf8")));
|
|
49
|
+
child.stderr?.on("data", (b) => (errText += b.toString("utf8")));
|
|
50
|
+
child.on("error", (err) => resolve({ code: null, stdout: out, stderr: err.message }));
|
|
51
|
+
child.on("close", (code) => resolve({ code, stdout: out, stderr: errText }));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
exec(command, policy, opts) {
|
|
55
|
+
const container = `cruxy-sbx-${randomUUID()}`;
|
|
56
|
+
const argv = buildRunArgs(policy, container, command);
|
|
57
|
+
return this.run(argv, container, opts);
|
|
58
|
+
}
|
|
59
|
+
run(argv, container, opts) {
|
|
60
|
+
const startedAt = Date.now();
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const capture = new OutputCapture(opts.maxOutputBytes, opts.capture);
|
|
63
|
+
let child;
|
|
64
|
+
try {
|
|
65
|
+
// `detached` groups the docker client so a timeout kills the whole tree.
|
|
66
|
+
child = spawn(this.bin, argv, { detached: true });
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
reject(sandboxExec(err));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
child.stdout?.on("data", (b) => capture.push(b));
|
|
73
|
+
child.stderr?.on("data", (b) => capture.push(b));
|
|
74
|
+
let settled = false;
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
if (settled)
|
|
77
|
+
return;
|
|
78
|
+
settled = true;
|
|
79
|
+
killTree(child.pid);
|
|
80
|
+
// Killing the client may orphan the container — force-remove it too.
|
|
81
|
+
this.forceRemove(container);
|
|
82
|
+
const { output, truncated } = capture.result();
|
|
83
|
+
resolve({
|
|
84
|
+
exitCode: null,
|
|
85
|
+
output,
|
|
86
|
+
outputTruncated: truncated,
|
|
87
|
+
durationMs: Date.now() - startedAt,
|
|
88
|
+
timedOut: true,
|
|
89
|
+
});
|
|
90
|
+
}, opts.timeoutMs);
|
|
91
|
+
child.on("error", (err) => {
|
|
92
|
+
if (settled)
|
|
93
|
+
return;
|
|
94
|
+
settled = true;
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
reject(sandboxExec(err));
|
|
97
|
+
});
|
|
98
|
+
child.on("close", (code) => {
|
|
99
|
+
if (settled)
|
|
100
|
+
return;
|
|
101
|
+
settled = true;
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
// 125 = `docker run` itself failed (not the inner command) → the
|
|
104
|
+
// container never really started. Fail loud, never fabricate a result.
|
|
105
|
+
if (code === 125) {
|
|
106
|
+
const { output } = capture.result();
|
|
107
|
+
reject(sandboxExec(output.trim() || "docker run exited 125"));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const { output, truncated } = capture.result();
|
|
111
|
+
resolve({
|
|
112
|
+
exitCode: code,
|
|
113
|
+
output,
|
|
114
|
+
outputTruncated: truncated,
|
|
115
|
+
durationMs: Date.now() - startedAt,
|
|
116
|
+
timedOut: false,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** Best-effort container teardown after a timeout kill. */
|
|
122
|
+
forceRemove(container) {
|
|
123
|
+
try {
|
|
124
|
+
const rm = spawn(this.bin, ["rm", "-f", container], {
|
|
125
|
+
stdio: "ignore",
|
|
126
|
+
detached: true,
|
|
127
|
+
});
|
|
128
|
+
rm.on("error", () => { });
|
|
129
|
+
rm.unref();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Nothing more we can do; the container may already be gone.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Build the `docker run` argv from a resolved policy. Pure and total — the
|
|
138
|
+
* single source of truth for the isolation boundary, asserted directly in
|
|
139
|
+
* tests. Order is stable for readability; docker is order-insensitive for flags.
|
|
140
|
+
*/
|
|
141
|
+
export function buildRunArgs(policy, container, command) {
|
|
142
|
+
return [
|
|
143
|
+
"run",
|
|
144
|
+
"--rm", // auto-remove the container when it exits
|
|
145
|
+
"--name",
|
|
146
|
+
container, // so a timeout can force-remove it
|
|
147
|
+
...networkArgs(policy.network),
|
|
148
|
+
"--user",
|
|
149
|
+
policy.user, // non-root
|
|
150
|
+
"--read-only", // root filesystem is read-only …
|
|
151
|
+
"--tmpfs",
|
|
152
|
+
`${policy.tmpfs}:rw,nosuid,nodev,size=64m`, // … except an in-memory tmp
|
|
153
|
+
"-v",
|
|
154
|
+
mountSpec(policy.workdir), // ONLY the workdir, read-write
|
|
155
|
+
...policy.mounts.flatMap((m) => ["-v", mountSpec(m)]),
|
|
156
|
+
"-w",
|
|
157
|
+
policy.workdir.target,
|
|
158
|
+
"--memory",
|
|
159
|
+
policy.memory,
|
|
160
|
+
"--memory-swap",
|
|
161
|
+
policy.memory, // == memory disables swap (no swap-escape of the cap)
|
|
162
|
+
"--pids-limit",
|
|
163
|
+
String(policy.pids),
|
|
164
|
+
"--cpus",
|
|
165
|
+
String(policy.cpus),
|
|
166
|
+
"--security-opt",
|
|
167
|
+
"no-new-privileges", // no setuid privilege escalation
|
|
168
|
+
"--cap-drop",
|
|
169
|
+
"ALL", // drop every Linux capability
|
|
170
|
+
policy.image,
|
|
171
|
+
"sh",
|
|
172
|
+
"-c",
|
|
173
|
+
command,
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Egress flags. `none` denies all network (the default). Widening is a
|
|
178
|
+
* deliberate act: `full` uses the default bridge; `host-loopback` adds a
|
|
179
|
+
* host-gateway alias (best-effort — strict loopback-only firewalling is left
|
|
180
|
+
* for a later build). Anything but `none` can only come from explicit config.
|
|
181
|
+
*/
|
|
182
|
+
function networkArgs(network) {
|
|
183
|
+
switch (network) {
|
|
184
|
+
case "none":
|
|
185
|
+
return ["--network", "none"];
|
|
186
|
+
case "host-loopback":
|
|
187
|
+
return [
|
|
188
|
+
"--network",
|
|
189
|
+
"bridge",
|
|
190
|
+
"--add-host",
|
|
191
|
+
"host.docker.internal:host-gateway",
|
|
192
|
+
];
|
|
193
|
+
case "full":
|
|
194
|
+
return ["--network", "bridge"];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function mountSpec(m) {
|
|
198
|
+
return `${m.source}:${m.target}:${m.readonly ? "ro" : "rw"}`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Bias-capped output capture: `head` keeps the START and stops once the cap is
|
|
202
|
+
* hit (matches `run_command`); `tail` keeps the END, where failures live
|
|
203
|
+
* (matches `run_tests`). Bounded memory either way.
|
|
204
|
+
*/
|
|
205
|
+
class OutputCapture {
|
|
206
|
+
cap;
|
|
207
|
+
bias;
|
|
208
|
+
chunks = [];
|
|
209
|
+
bytes = 0;
|
|
210
|
+
truncated = false;
|
|
211
|
+
constructor(cap, bias) {
|
|
212
|
+
this.cap = cap;
|
|
213
|
+
this.bias = bias;
|
|
214
|
+
}
|
|
215
|
+
push(buf) {
|
|
216
|
+
if (this.bias === "head") {
|
|
217
|
+
if (this.truncated)
|
|
218
|
+
return;
|
|
219
|
+
const room = this.cap - this.bytes;
|
|
220
|
+
if (buf.length <= room) {
|
|
221
|
+
this.chunks.push(buf);
|
|
222
|
+
this.bytes += buf.length;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
if (room > 0) {
|
|
226
|
+
this.chunks.push(buf.subarray(0, room));
|
|
227
|
+
this.bytes += room;
|
|
228
|
+
}
|
|
229
|
+
this.truncated = true;
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// tail: append, dropping whole head chunks while the remainder still meets
|
|
234
|
+
// the cap; a final exact trim happens in result().
|
|
235
|
+
this.chunks.push(buf);
|
|
236
|
+
this.bytes += buf.length;
|
|
237
|
+
while (this.chunks.length > 1 &&
|
|
238
|
+
this.bytes - this.chunks[0].length >= this.cap) {
|
|
239
|
+
this.bytes -= this.chunks[0].length;
|
|
240
|
+
this.chunks.shift();
|
|
241
|
+
this.truncated = true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
result() {
|
|
245
|
+
let all = Buffer.concat(this.chunks);
|
|
246
|
+
if (this.bias === "tail" && all.length > this.cap) {
|
|
247
|
+
all = all.subarray(all.length - this.cap);
|
|
248
|
+
this.truncated = true;
|
|
249
|
+
}
|
|
250
|
+
return { output: all.toString("utf8"), truncated: this.truncated };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Kill the docker client's process group (POSIX; matches run_command). */
|
|
254
|
+
function killTree(pid) {
|
|
255
|
+
if (pid === undefined)
|
|
256
|
+
return;
|
|
257
|
+
try {
|
|
258
|
+
process.kill(-pid, "SIGKILL");
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// Already exited, or no group — nothing to kill.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export { detectDocker, resetDetectionCache } from "./detect.js";
|
|
3
|
+
export type { RuntimeProbe } from "./detect.js";
|
|
4
|
+
export { DockerRuntime, buildRunArgs } from "./docker-runtime.js";
|
|
5
|
+
export { buildPolicy } from "./policy.js";
|
|
6
|
+
export { SandboxService } from "./service.js";
|
|
7
|
+
export type { SandboxReporter, SandboxServiceDeps } from "./service.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SandboxConfig } from "../config/index.js";
|
|
2
|
+
import type { IsolationPolicy } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Turn a validated {@link SandboxConfig} + the run's cwd into a fully-resolved
|
|
5
|
+
* {@link IsolationPolicy}. This is where the security posture is decided, and
|
|
6
|
+
* every default here is deny/minimal:
|
|
7
|
+
*
|
|
8
|
+
* - the ONLY read-write mount is the project workdir (at its identical absolute
|
|
9
|
+
* path, so paths stay coherent with the host and the C.32 checkpoint);
|
|
10
|
+
* - extra mounts come solely from `sandbox.mounts` (explicit by construction),
|
|
11
|
+
* and a mount of the docker socket, the cruxy home, or the user's home root
|
|
12
|
+
* is rejected — those are the escape hatches we refuse to open;
|
|
13
|
+
* - the container runs as the host's non-root uid:gid so mounted edits are
|
|
14
|
+
* writable and never left root-owned;
|
|
15
|
+
* - network defaults to `none`; any widening can only come from explicit config.
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildPolicy(cfg: SandboxConfig, cwd: string): IsolationPolicy;
|