@cruxy/cli 0.11.0 → 0.13.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 -15
- package/dist/cli/commands/checkpoint.js +6 -4
- package/dist/cli/commands/config.js +10 -7
- package/dist/cli/commands/index.js +16 -15
- package/dist/cli/commands/init.js +5 -3
- package/dist/cli/commands/login.js +5 -3
- package/dist/cli/commands/pr.js +8 -7
- package/dist/cli/commands/rollback.js +7 -6
- package/dist/cli/commands/run.js +26 -7
- package/dist/cli/commands/skills.js +12 -10
- package/dist/cli/program.js +7 -6
- package/dist/cli/repl.js +11 -9
- package/dist/cli/session-factory.d.ts +2 -1
- package/dist/cli/session-factory.js +10 -3
- package/dist/components/frame.js +3 -1
- package/dist/components/fuzzy.d.ts +4 -4
- package/dist/components/fuzzy.js +14 -13
- package/dist/components/select.js +8 -7
- 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/format.js +8 -8
- package/dist/errors/types.d.ts +5 -0
- package/dist/errors/types.js +11 -0
- package/dist/onboarding/flow.js +6 -6
- package/dist/onboarding/steps.js +11 -11
- package/dist/plan/approve.js +6 -6
- package/dist/plan/render.js +26 -18
- package/dist/render/capabilities.js +4 -0
- package/dist/render/diff.d.ts +6 -7
- package/dist/render/diff.js +33 -22
- package/dist/render/highlight.d.ts +3 -3
- package/dist/render/highlight.js +15 -15
- package/dist/render/index.d.ts +1 -1
- package/dist/render/plain-renderer.d.ts +2 -1
- package/dist/render/plain-renderer.js +7 -6
- package/dist/render/state.d.ts +7 -2
- package/dist/render/state.js +16 -10
- package/dist/render/tty-renderer.d.ts +2 -1
- package/dist/render/tty-renderer.js +20 -17
- package/dist/render/types.d.ts +7 -0
- 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 +22 -6
- 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/index.d.ts +2 -0
- package/dist/theme/index.js +2 -0
- package/dist/theme/resolve.d.ts +32 -0
- package/dist/theme/resolve.js +73 -0
- package/dist/theme/tokens.d.ts +104 -0
- package/dist/theme/tokens.js +52 -0
- package/dist/tools/shell/run-command.js +35 -1
- package/dist/tools/types.d.ts +10 -0
- package/dist/utils/logger.d.ts +2 -0
- package/dist/utils/logger.js +7 -4
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ActionPreview } from "../tools/types.js";
|
|
2
|
+
import { type Theme } from "../theme/index.js";
|
|
2
3
|
import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
|
|
3
4
|
/**
|
|
4
5
|
* The interactive renderer: committed content is append-only; the one transient
|
|
@@ -28,7 +29,7 @@ import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, Stre
|
|
|
28
29
|
export declare class TtyRenderer implements StreamRenderer {
|
|
29
30
|
readonly caps: RenderCapabilities;
|
|
30
31
|
private readonly out;
|
|
31
|
-
|
|
32
|
+
readonly theme: Theme;
|
|
32
33
|
private print;
|
|
33
34
|
private highlighter;
|
|
34
35
|
private wroteInSegment;
|
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { resolveTheme } from "../theme/index.js";
|
|
2
2
|
import { createStreamPrinter } from "../cli/stream-print.js";
|
|
3
3
|
import { renderActionPreview } from "./diff.js";
|
|
4
4
|
import { createStreamHighlighter, } from "./highlight.js";
|
|
5
5
|
import { composeStatusLine, ELAPSED_AFTER_MS, formatElapsed, phaseIdentity, } from "./state.js";
|
|
6
6
|
/** Erase the current line and return the cursor to column 0. */
|
|
7
7
|
const CLEAR_LINE = "\r\x1b[2K";
|
|
8
|
-
/** Spinner frames (braille); a static glyph when animation is disabled. */
|
|
9
|
-
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
10
|
-
const STATIC_FRAME = "◐";
|
|
11
8
|
const SPINNER_INTERVAL_MS = 100;
|
|
12
9
|
/**
|
|
13
10
|
* The interactive renderer: committed content is append-only; the one transient
|
|
@@ -37,7 +34,7 @@ const SPINNER_INTERVAL_MS = 100;
|
|
|
37
34
|
export class TtyRenderer {
|
|
38
35
|
caps;
|
|
39
36
|
out;
|
|
40
|
-
|
|
37
|
+
theme;
|
|
41
38
|
print;
|
|
42
39
|
highlighter;
|
|
43
40
|
wroteInSegment = false;
|
|
@@ -59,8 +56,8 @@ export class TtyRenderer {
|
|
|
59
56
|
constructor(caps, out) {
|
|
60
57
|
this.caps = caps;
|
|
61
58
|
this.out = out;
|
|
62
|
-
this.
|
|
63
|
-
this.highlighter = createStreamHighlighter(this.
|
|
59
|
+
this.theme = resolveTheme(caps);
|
|
60
|
+
this.highlighter = createStreamHighlighter(this.theme);
|
|
64
61
|
this.print = this.newPrinter();
|
|
65
62
|
}
|
|
66
63
|
newPrinter() {
|
|
@@ -115,7 +112,7 @@ export class TtyRenderer {
|
|
|
115
112
|
const elapsed = this.phase !== null && this.caps.spinner
|
|
116
113
|
? Date.now() - this.phaseStartedAt
|
|
117
114
|
: undefined;
|
|
118
|
-
return composeStatusLine(this.progressState, this.phase, elapsed);
|
|
115
|
+
return composeStatusLine(this.progressState, this.phase, elapsed, this.theme.glyph);
|
|
119
116
|
}
|
|
120
117
|
/** Redraw the live line from current state, or hide it when there is none. */
|
|
121
118
|
refresh() {
|
|
@@ -138,16 +135,19 @@ export class TtyRenderer {
|
|
|
138
135
|
}
|
|
139
136
|
drawLine(text) {
|
|
140
137
|
this.lineVisible = true;
|
|
138
|
+
const frames = this.theme.glyph.spinnerFrames;
|
|
141
139
|
const glyph = this.caps.spinner
|
|
142
|
-
?
|
|
143
|
-
:
|
|
140
|
+
? frames[this.frame % frames.length]
|
|
141
|
+
: this.theme.glyph.spinnerStatic;
|
|
144
142
|
// Reserve glyph + space; truncate so the live line can never soft-wrap.
|
|
145
143
|
const room = Math.max(1, this.caps.width - 2);
|
|
146
|
-
const line = text.length > room
|
|
147
|
-
|
|
144
|
+
const line = text.length > room
|
|
145
|
+
? text.slice(0, Math.max(0, room - 1)) + this.theme.glyph.ellipsis
|
|
146
|
+
: text;
|
|
147
|
+
this.out.write(`${CLEAR_LINE}${this.theme.accent(glyph)} ${this.theme.muted(line)}`);
|
|
148
148
|
}
|
|
149
149
|
beginTurn() {
|
|
150
|
-
this.highlighter = createStreamHighlighter(this.
|
|
150
|
+
this.highlighter = createStreamHighlighter(this.theme);
|
|
151
151
|
this.print = this.newPrinter();
|
|
152
152
|
this.wroteInSegment = false;
|
|
153
153
|
}
|
|
@@ -169,13 +169,15 @@ export class TtyRenderer {
|
|
|
169
169
|
if (this.closed)
|
|
170
170
|
return;
|
|
171
171
|
const room = Math.max(1, this.caps.width);
|
|
172
|
-
const line = text.length > room
|
|
173
|
-
|
|
172
|
+
const line = text.length > room
|
|
173
|
+
? text.slice(0, room - 1) + this.theme.glyph.ellipsis
|
|
174
|
+
: text;
|
|
175
|
+
this.commit(this.theme.muted(line) + "\n");
|
|
174
176
|
}
|
|
175
177
|
preview(preview) {
|
|
176
178
|
if (this.closed)
|
|
177
179
|
return;
|
|
178
|
-
const block = renderActionPreview(preview, this.
|
|
180
|
+
const block = renderActionPreview(preview, this.theme);
|
|
179
181
|
if (block)
|
|
180
182
|
this.commit(block + "\n");
|
|
181
183
|
}
|
|
@@ -237,7 +239,8 @@ export class TtyRenderer {
|
|
|
237
239
|
// honest even with CRUXY_NO_SPINNER; shown only once it means something.
|
|
238
240
|
const elapsed = started === null ? 0 : Date.now() - started.at;
|
|
239
241
|
const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
|
|
240
|
-
|
|
242
|
+
const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
|
|
243
|
+
this.note(`${mark} ${event.label}${suffix}`);
|
|
241
244
|
}
|
|
242
245
|
promptResolved() {
|
|
243
246
|
if (this.closed)
|
package/dist/render/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ActionPreview } from "../tools/types.js";
|
|
2
|
+
import type { Theme } from "../theme/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* The streaming render seam (U.2): the agent loop talks to a
|
|
4
5
|
* {@link StreamRenderer}, never to raw stdout. Two implementations exist —
|
|
@@ -21,6 +22,9 @@ export interface RenderCapabilities {
|
|
|
21
22
|
cursor: boolean;
|
|
22
23
|
/** Animation is welcome (`cursor` and CRUXY_NO_SPINNER unset). */
|
|
23
24
|
spinner: boolean;
|
|
25
|
+
/** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
|
|
26
|
+
* independent of `color`. Drives the theme's glyph table, not its stylers. */
|
|
27
|
+
unicode: boolean;
|
|
24
28
|
/** Terminal columns; 80 when unknown (non-TTY). */
|
|
25
29
|
width: number;
|
|
26
30
|
}
|
|
@@ -97,6 +101,9 @@ export type ToolLifecycleEvent = {
|
|
|
97
101
|
*/
|
|
98
102
|
export interface StreamRenderer {
|
|
99
103
|
readonly caps: RenderCapabilities;
|
|
104
|
+
/** The one resolved design system (U.1) — glyphs/roles for chrome a
|
|
105
|
+
* surface emits through this renderer (e.g. the subagent trail notes). */
|
|
106
|
+
readonly theme: Theme;
|
|
100
107
|
/** Start a user turn: reset leading-newline trim and code-fence state. */
|
|
101
108
|
beginTurn(): void;
|
|
102
109
|
/**
|
|
@@ -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;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, resolve as resolvePath } from "node:path";
|
|
3
|
+
import { configInvalid } from "../errors/index.js";
|
|
4
|
+
import { globalDir } from "../config/paths.js";
|
|
5
|
+
/**
|
|
6
|
+
* Turn a validated {@link SandboxConfig} + the run's cwd into a fully-resolved
|
|
7
|
+
* {@link IsolationPolicy}. This is where the security posture is decided, and
|
|
8
|
+
* every default here is deny/minimal:
|
|
9
|
+
*
|
|
10
|
+
* - the ONLY read-write mount is the project workdir (at its identical absolute
|
|
11
|
+
* path, so paths stay coherent with the host and the C.32 checkpoint);
|
|
12
|
+
* - extra mounts come solely from `sandbox.mounts` (explicit by construction),
|
|
13
|
+
* and a mount of the docker socket, the cruxy home, or the user's home root
|
|
14
|
+
* is rejected — those are the escape hatches we refuse to open;
|
|
15
|
+
* - the container runs as the host's non-root uid:gid so mounted edits are
|
|
16
|
+
* writable and never left root-owned;
|
|
17
|
+
* - network defaults to `none`; any widening can only come from explicit config.
|
|
18
|
+
*/
|
|
19
|
+
export function buildPolicy(cfg, cwd) {
|
|
20
|
+
const workdir = {
|
|
21
|
+
source: cwd,
|
|
22
|
+
target: cwd,
|
|
23
|
+
readonly: false,
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
image: cfg.image,
|
|
27
|
+
network: cfg.network,
|
|
28
|
+
user: resolveUser(),
|
|
29
|
+
memory: cfg.memory,
|
|
30
|
+
pids: cfg.pids,
|
|
31
|
+
cpus: cfg.cpus,
|
|
32
|
+
workdir,
|
|
33
|
+
mounts: cfg.mounts.map((spec) => parseMount(spec, cwd)),
|
|
34
|
+
tmpfs: "/tmp",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Host `uid:gid` — non-root (the human isn't uid 0) and keeps mounted files
|
|
38
|
+
* writable without leaving them root-owned. Falls back to a conventional
|
|
39
|
+
* non-root id where `getuid` is unavailable (non-POSIX). */
|
|
40
|
+
function resolveUser() {
|
|
41
|
+
const getuid = process.getuid?.bind(process);
|
|
42
|
+
const getgid = process.getgid?.bind(process);
|
|
43
|
+
if (getuid && getgid)
|
|
44
|
+
return `${getuid()}:${getgid()}`;
|
|
45
|
+
return "1000:1000";
|
|
46
|
+
}
|
|
47
|
+
/** Sources we refuse to bind-mount into the box — the whole point is that the
|
|
48
|
+
* container cannot reach the docker socket, the cruxy credential store, or the
|
|
49
|
+
* user's home. Matched by resolved absolute path. */
|
|
50
|
+
function forbiddenMountSource(source) {
|
|
51
|
+
const resolved = resolvePath(source);
|
|
52
|
+
const home = homedir();
|
|
53
|
+
if (resolved === "/var/run/docker.sock" || resolved.endsWith("docker.sock")) {
|
|
54
|
+
return "the docker socket (would grant full host control)";
|
|
55
|
+
}
|
|
56
|
+
if (resolved === globalDir() || resolved.startsWith(globalDir() + "/")) {
|
|
57
|
+
return "the cruxy home (holds credentials)";
|
|
58
|
+
}
|
|
59
|
+
if (resolved === home)
|
|
60
|
+
return "the home directory root";
|
|
61
|
+
if (resolved === "/")
|
|
62
|
+
return "the filesystem root";
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
/** Parse one `src:dst[:ro|:rw]` mount spec into a validated {@link BindMount}.
|
|
66
|
+
* Relative sources resolve against the run's cwd; a forbidden source throws a
|
|
67
|
+
* config error (never silently dropped). */
|
|
68
|
+
function parseMount(spec, cwd) {
|
|
69
|
+
// Split on ":" but keep it simple — sources/targets are absolute POSIX-ish
|
|
70
|
+
// paths; a Windows drive letter is out of scope for this build.
|
|
71
|
+
const parts = spec.split(":");
|
|
72
|
+
if (parts.length < 2 || parts.length > 3) {
|
|
73
|
+
throw configInvalid(`sandbox.mounts entry "${spec}" must be "src:dst" or "src:dst:ro|rw"`);
|
|
74
|
+
}
|
|
75
|
+
const [rawSource, target, mode] = parts;
|
|
76
|
+
if (!target || !isAbsolute(target)) {
|
|
77
|
+
throw configInvalid(`sandbox.mounts entry "${spec}" needs an absolute container path (dst)`);
|
|
78
|
+
}
|
|
79
|
+
if (mode !== undefined && mode !== "ro" && mode !== "rw") {
|
|
80
|
+
throw configInvalid(`sandbox.mounts entry "${spec}" mode must be "ro" or "rw"`);
|
|
81
|
+
}
|
|
82
|
+
const source = isAbsolute(rawSource)
|
|
83
|
+
? rawSource
|
|
84
|
+
: resolvePath(cwd, rawSource);
|
|
85
|
+
const forbidden = forbiddenMountSource(source);
|
|
86
|
+
if (forbidden) {
|
|
87
|
+
throw configInvalid(`sandbox.mounts refuses to mount ${forbidden}: "${spec}"`);
|
|
88
|
+
}
|
|
89
|
+
return { source, target, readonly: mode === "ro" };
|
|
90
|
+
}
|