@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { ExecOptions, ExecResult, IsolationPolicy, SandboxCapability, SandboxRuntime } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The minimal surface the sandbox needs to report a first-run image pull
|
|
5
|
+
* through the U.4 state layer. `StreamRenderer` satisfies it structurally, so
|
|
6
|
+
* callers pass the renderer directly and the sandbox stays render-decoupled.
|
|
7
|
+
*/
|
|
8
|
+
export interface SandboxReporter {
|
|
9
|
+
status(text: string | null): void;
|
|
10
|
+
}
|
|
11
|
+
export interface SandboxServiceDeps {
|
|
12
|
+
config: CruxyConfig;
|
|
13
|
+
/** The run's working directory — mounted read-write as the workdir. */
|
|
14
|
+
cwd: string;
|
|
15
|
+
/** Execution runtime seam (defaults to Docker). */
|
|
16
|
+
runtime?: SandboxRuntime;
|
|
17
|
+
/** Capability probe seam (defaults to real docker detection). */
|
|
18
|
+
detect?: () => Promise<SandboxCapability>;
|
|
19
|
+
/** U.4 sink for the "pulling sandbox image…" line (optional). */
|
|
20
|
+
reporter?: SandboxReporter;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The sandbox execution service (C.16) — what `ToolContext.sandbox` points at.
|
|
24
|
+
* It is only ever constructed when the sandbox is enabled, and constructing it
|
|
25
|
+
* is where the fail-loud guarantee lives: {@link SandboxService.create} probes
|
|
26
|
+
* the runtime and throws {@link sandboxUnavailable} if it isn't available, so a
|
|
27
|
+
* user who asked for the box either gets the box or a loud, coded error — never
|
|
28
|
+
* a silent drop back to host execution. `exec` then contains an approved
|
|
29
|
+
* command inside the isolation policy, ensuring the image on first use.
|
|
30
|
+
*/
|
|
31
|
+
export declare class SandboxService {
|
|
32
|
+
private readonly runtime;
|
|
33
|
+
private readonly policy;
|
|
34
|
+
private readonly reporter?;
|
|
35
|
+
private imageReady?;
|
|
36
|
+
private constructor();
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the runtime and build the policy. THROWS {@link sandboxUnavailable}
|
|
39
|
+
* when the runtime is missing/unreachable — the caller (session wiring) lets
|
|
40
|
+
* it propagate so the run stops before any command executes. There is no code
|
|
41
|
+
* path from here to host execution.
|
|
42
|
+
*/
|
|
43
|
+
static create(deps: SandboxServiceDeps): Promise<SandboxService>;
|
|
44
|
+
/** The runtime backing this service (e.g. "docker") — for logging. */
|
|
45
|
+
get runtimeName(): string;
|
|
46
|
+
/** The resolved isolation policy — exposed for logging/inspection. */
|
|
47
|
+
get isolationPolicy(): IsolationPolicy;
|
|
48
|
+
/**
|
|
49
|
+
* Execute an already-approved command inside the box. Ensures the image once
|
|
50
|
+
* (surfacing the pull via U.4), then delegates to the runtime. A container
|
|
51
|
+
* that fails to start throws a coded error; an ordinary non-zero command exit
|
|
52
|
+
* comes back as a normal {@link ExecResult} — exit code is truth.
|
|
53
|
+
*/
|
|
54
|
+
exec(command: string, opts: ExecOptions): Promise<ExecResult>;
|
|
55
|
+
/** Ensure the image is present, at most once per service (memoized). */
|
|
56
|
+
private ensureImage;
|
|
57
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { sandboxUnavailable } from "../errors/index.js";
|
|
2
|
+
import { detectDocker } from "./detect.js";
|
|
3
|
+
import { DockerRuntime } from "./docker-runtime.js";
|
|
4
|
+
import { buildPolicy } from "./policy.js";
|
|
5
|
+
/**
|
|
6
|
+
* The sandbox execution service (C.16) — what `ToolContext.sandbox` points at.
|
|
7
|
+
* It is only ever constructed when the sandbox is enabled, and constructing it
|
|
8
|
+
* is where the fail-loud guarantee lives: {@link SandboxService.create} probes
|
|
9
|
+
* the runtime and throws {@link sandboxUnavailable} if it isn't available, so a
|
|
10
|
+
* user who asked for the box either gets the box or a loud, coded error — never
|
|
11
|
+
* a silent drop back to host execution. `exec` then contains an approved
|
|
12
|
+
* command inside the isolation policy, ensuring the image on first use.
|
|
13
|
+
*/
|
|
14
|
+
export class SandboxService {
|
|
15
|
+
runtime;
|
|
16
|
+
policy;
|
|
17
|
+
reporter;
|
|
18
|
+
imageReady;
|
|
19
|
+
constructor(runtime, policy, reporter) {
|
|
20
|
+
this.runtime = runtime;
|
|
21
|
+
this.policy = policy;
|
|
22
|
+
this.reporter = reporter;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the runtime and build the policy. THROWS {@link sandboxUnavailable}
|
|
26
|
+
* when the runtime is missing/unreachable — the caller (session wiring) lets
|
|
27
|
+
* it propagate so the run stops before any command executes. There is no code
|
|
28
|
+
* path from here to host execution.
|
|
29
|
+
*/
|
|
30
|
+
static async create(deps) {
|
|
31
|
+
const runtime = deps.runtime ?? new DockerRuntime();
|
|
32
|
+
const detect = deps.detect ?? (() => detectDocker());
|
|
33
|
+
const capability = await detect();
|
|
34
|
+
if (!capability.available) {
|
|
35
|
+
throw sandboxUnavailable(capability.runtime, capability.detail);
|
|
36
|
+
}
|
|
37
|
+
const policy = buildPolicy(deps.config.sandbox, deps.cwd);
|
|
38
|
+
return new SandboxService(runtime, policy, deps.reporter);
|
|
39
|
+
}
|
|
40
|
+
/** The runtime backing this service (e.g. "docker") — for logging. */
|
|
41
|
+
get runtimeName() {
|
|
42
|
+
return this.runtime.name;
|
|
43
|
+
}
|
|
44
|
+
/** The resolved isolation policy — exposed for logging/inspection. */
|
|
45
|
+
get isolationPolicy() {
|
|
46
|
+
return this.policy;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Execute an already-approved command inside the box. Ensures the image once
|
|
50
|
+
* (surfacing the pull via U.4), then delegates to the runtime. A container
|
|
51
|
+
* that fails to start throws a coded error; an ordinary non-zero command exit
|
|
52
|
+
* comes back as a normal {@link ExecResult} — exit code is truth.
|
|
53
|
+
*/
|
|
54
|
+
async exec(command, opts) {
|
|
55
|
+
await this.ensureImage();
|
|
56
|
+
return this.runtime.exec(command, this.policy, opts);
|
|
57
|
+
}
|
|
58
|
+
/** Ensure the image is present, at most once per service (memoized). */
|
|
59
|
+
ensureImage() {
|
|
60
|
+
return (this.imageReady ??= this.runtime
|
|
61
|
+
.ensureImage(this.policy.image, () => this.reporter?.status(`pulling sandbox image ${this.policy.image}…`))
|
|
62
|
+
.then(() => this.reporter?.status(null)));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox execution (C.16) — the isolation boundary beneath the U.3 gate.
|
|
3
|
+
*
|
|
4
|
+
* When the sandbox is enabled, the highest-risk tools (`run_command`,
|
|
5
|
+
* `run_tests`) execute inside a container instead of directly on the host: the
|
|
6
|
+
* project workdir is mounted so the agent edits real files, but the *process*
|
|
7
|
+
* cannot reach the network, cannot touch the host beyond that mount, and cannot
|
|
8
|
+
* exhaust the machine. Approval still gates every command (see U.3) — the
|
|
9
|
+
* sandbox contains what an approved command is able to do; it does not replace
|
|
10
|
+
* the decision to run it.
|
|
11
|
+
*
|
|
12
|
+
* Execution is abstracted behind {@link SandboxRuntime} (Docker ships; podman /
|
|
13
|
+
* none slot in without touching call sites), and the neutral {@link ExecResult}
|
|
14
|
+
* matches what host execution conceptually returns so `run_command`/`run_tests`
|
|
15
|
+
* stay substrate-agnostic — exit code is the source of truth on either path.
|
|
16
|
+
*/
|
|
17
|
+
/** Egress policy for the container. `none` is the default (deny all). */
|
|
18
|
+
export type NetworkPolicy = "none" | "host-loopback" | "full";
|
|
19
|
+
/** One resolved bind mount: an absolute host path exposed in the container. */
|
|
20
|
+
export interface BindMount {
|
|
21
|
+
/** Absolute host path. */
|
|
22
|
+
readonly source: string;
|
|
23
|
+
/** Absolute container path (the workdir mount uses `source === target`). */
|
|
24
|
+
readonly target: string;
|
|
25
|
+
/** Read-only when true; the workdir mount is read-write. */
|
|
26
|
+
readonly readonly: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A fully-resolved isolation policy — every knob the runtime needs, already
|
|
30
|
+
* merged from config and validated. `docker-runtime` turns this (plus the
|
|
31
|
+
* command) into an argv; nothing here is optional or defaulted downstream.
|
|
32
|
+
*/
|
|
33
|
+
export interface IsolationPolicy {
|
|
34
|
+
/** Pinned base image the container runs (never chosen dynamically). */
|
|
35
|
+
readonly image: string;
|
|
36
|
+
/** Egress policy — `none` unless the user deliberately widened it. */
|
|
37
|
+
readonly network: NetworkPolicy;
|
|
38
|
+
/** Non-root `uid:gid` the container runs as (host uid, so mounts stay writable). */
|
|
39
|
+
readonly user: string;
|
|
40
|
+
/** Memory cap (`--memory`, also mirrored to `--memory-swap` to disable swap). */
|
|
41
|
+
readonly memory: string;
|
|
42
|
+
/** Process/thread cap (`--pids-limit`). */
|
|
43
|
+
readonly pids: number;
|
|
44
|
+
/** CPU cap (`--cpus`, fractional allowed). */
|
|
45
|
+
readonly cpus: number;
|
|
46
|
+
/** The project workdir, mounted read-write at the identical absolute path. */
|
|
47
|
+
readonly workdir: BindMount;
|
|
48
|
+
/** Extra explicit mounts beyond the workdir (from `sandbox.mounts`). */
|
|
49
|
+
readonly mounts: readonly BindMount[];
|
|
50
|
+
/** Writable in-memory tmp mount point; the rest of the root fs is read-only. */
|
|
51
|
+
readonly tmpfs: string;
|
|
52
|
+
}
|
|
53
|
+
/** Which end of the output to keep when the byte cap is exceeded. */
|
|
54
|
+
export type CaptureBias = "head" | "tail";
|
|
55
|
+
/** Per-exec bounds handed to a runtime. */
|
|
56
|
+
export interface ExecOptions {
|
|
57
|
+
/** Host project directory → bind-mounted as the workdir (same absolute path). */
|
|
58
|
+
readonly cwd: string;
|
|
59
|
+
/** Wall-clock timeout in ms; overrun kills the container and returns a failure. */
|
|
60
|
+
readonly timeoutMs: number;
|
|
61
|
+
/** Cap on combined stdout+stderr bytes captured. */
|
|
62
|
+
readonly maxOutputBytes: number;
|
|
63
|
+
/**
|
|
64
|
+
* Head-bias keeps the start of the output (`run_command`); tail-bias keeps
|
|
65
|
+
* the end, where test runners print their failure summaries (`run_tests`).
|
|
66
|
+
*/
|
|
67
|
+
readonly capture: CaptureBias;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The neutral outcome of one sandboxed execution — the SAME shape host
|
|
71
|
+
* execution conceptually produces, so both `run_command` and `run_tests` map it
|
|
72
|
+
* to their own result type without caring which substrate ran the command.
|
|
73
|
+
*/
|
|
74
|
+
export interface ExecResult {
|
|
75
|
+
/** Process exit code; `null` on a signal kill / timeout. Exit code is truth. */
|
|
76
|
+
readonly exitCode: number | null;
|
|
77
|
+
/** Combined stdout+stderr, already bias-capped to `maxOutputBytes`. */
|
|
78
|
+
readonly output: string;
|
|
79
|
+
/** True when output was dropped to honor the cap. */
|
|
80
|
+
readonly outputTruncated: boolean;
|
|
81
|
+
/** Measured wall-clock duration of the execution. */
|
|
82
|
+
readonly durationMs: number;
|
|
83
|
+
/** True when the wall-clock timeout fired and the container was killed. */
|
|
84
|
+
readonly timedOut: boolean;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The swappable execution seam. Docker ships as {@link DockerRuntime}; a future
|
|
88
|
+
* podman/other runtime implements this without touching the tools or service.
|
|
89
|
+
* `exec` never falls back to the host and never throws for an ordinary non-zero
|
|
90
|
+
* command exit — it throws only when the container itself cannot run (a coded
|
|
91
|
+
* {@link CruxyError}), so the fail-loud guarantee is structural.
|
|
92
|
+
*/
|
|
93
|
+
export interface SandboxRuntime {
|
|
94
|
+
/** Identifier surfaced in errors/logs (e.g. "docker"). */
|
|
95
|
+
readonly name: string;
|
|
96
|
+
/**
|
|
97
|
+
* Ensure `image` is present locally, pulling it if needed. `onPull` fires
|
|
98
|
+
* once, only if a pull actually starts (so callers can surface it via U.4).
|
|
99
|
+
* Throws a coded {@link CruxyError} on pull/build failure — never silently
|
|
100
|
+
* substitutes another image.
|
|
101
|
+
*/
|
|
102
|
+
ensureImage(image: string, onPull?: () => void): Promise<void>;
|
|
103
|
+
/** Run `command` inside the box under `policy`, returning a neutral result. */
|
|
104
|
+
exec(command: string, policy: IsolationPolicy, opts: ExecOptions): Promise<ExecResult>;
|
|
105
|
+
}
|
|
106
|
+
/** Result of probing for a container runtime; presence is a capability. */
|
|
107
|
+
export interface SandboxCapability {
|
|
108
|
+
/** True when the runtime binary exists AND its daemon is reachable. */
|
|
109
|
+
readonly available: boolean;
|
|
110
|
+
/** The runtime that was probed. */
|
|
111
|
+
readonly runtime: string;
|
|
112
|
+
/** Why it's unavailable (for the fail-loud error), when not available. */
|
|
113
|
+
readonly detail?: string;
|
|
114
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox execution (C.16) — the isolation boundary beneath the U.3 gate.
|
|
3
|
+
*
|
|
4
|
+
* When the sandbox is enabled, the highest-risk tools (`run_command`,
|
|
5
|
+
* `run_tests`) execute inside a container instead of directly on the host: the
|
|
6
|
+
* project workdir is mounted so the agent edits real files, but the *process*
|
|
7
|
+
* cannot reach the network, cannot touch the host beyond that mount, and cannot
|
|
8
|
+
* exhaust the machine. Approval still gates every command (see U.3) — the
|
|
9
|
+
* sandbox contains what an approved command is able to do; it does not replace
|
|
10
|
+
* the decision to run it.
|
|
11
|
+
*
|
|
12
|
+
* Execution is abstracted behind {@link SandboxRuntime} (Docker ships; podman /
|
|
13
|
+
* none slot in without touching call sites), and the neutral {@link ExecResult}
|
|
14
|
+
* matches what host execution conceptually returns so `run_command`/`run_tests`
|
|
15
|
+
* stay substrate-agnostic — exit code is the source of truth on either path.
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
|
@@ -3,6 +3,7 @@ import type { ApprovalDecision } from "../approval/types.js";
|
|
|
3
3
|
import type { CruxyConfig } from "../config/index.js";
|
|
4
4
|
import type { StreamRenderer } from "../render/index.js";
|
|
5
5
|
import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
|
|
6
|
+
import type { SandboxService } from "../sandbox/index.js";
|
|
6
7
|
import type { SubagentResult, SubagentSpec } from "./types.js";
|
|
7
8
|
/**
|
|
8
9
|
* Everything a spawn needs from the surrounding session, injected by the
|
|
@@ -24,6 +25,12 @@ export interface SubagentOrchestratorDeps {
|
|
|
24
25
|
} | null;
|
|
25
26
|
projectInstructions?: string | null;
|
|
26
27
|
renderer?: StreamRenderer;
|
|
28
|
+
/**
|
|
29
|
+
* The run's sandbox (C.16), when enabled. Threaded into the child ctx so a
|
|
30
|
+
* subagent's shell/test commands run in the SAME box as the parent's —
|
|
31
|
+
* sandboxing is never silently dropped for a child.
|
|
32
|
+
*/
|
|
33
|
+
sandbox?: SandboxService;
|
|
27
34
|
/**
|
|
28
35
|
* Build a fresh, fully-wrapped approval gate for one child run: a NEW
|
|
29
36
|
* `ApprovalService` (so the child gets its own empty session allowlist —
|
|
@@ -32,7 +32,11 @@ declare const parameters: z.ZodObject<{
|
|
|
32
32
|
command?: string | undefined;
|
|
33
33
|
}>;
|
|
34
34
|
export interface RunTestsToolDeps {
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Execution seam (tests inject a fake). When unset, the runner is chosen per
|
|
37
|
+
* run from `ctx.sandbox`: present → {@link SandboxTestRunner} (run in the box,
|
|
38
|
+
* C.16), absent → host {@link CommandTestRunner} — unchanged.
|
|
39
|
+
*/
|
|
36
40
|
runner?: TestRunner;
|
|
37
41
|
/** Detection seam (defaults to config + package.json detection). */
|
|
38
42
|
detect?: (ctx: ToolContext) => TestCommand | null;
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { ErrorCode } from "../errors/index.js";
|
|
3
3
|
import { detectTestCommand } from "./detect.js";
|
|
4
4
|
import { CommandTestRunner } from "./runner.js";
|
|
5
|
+
import { SandboxTestRunner } from "./sandbox-runner.js";
|
|
5
6
|
/**
|
|
6
7
|
* The `run_tests` tool (C.13): execute the project's test suite and return a
|
|
7
8
|
* structured result the model can iterate on (edit → re-run → repeat). The
|
|
@@ -58,7 +59,6 @@ function renderResult(result, command, iteration) {
|
|
|
58
59
|
}
|
|
59
60
|
/** Build the `run_tests` tool. One instance = one session's iteration budget. */
|
|
60
61
|
export function makeRunTestsTool(deps = {}) {
|
|
61
|
-
const runner = deps.runner ?? new CommandTestRunner();
|
|
62
62
|
const detect = deps.detect ??
|
|
63
63
|
((ctx) => detectTestCommand(ctx.cwd, ctx.config));
|
|
64
64
|
const budget = new TestIterationBudget();
|
|
@@ -104,6 +104,13 @@ export function makeRunTestsTool(deps = {}) {
|
|
|
104
104
|
error: decision.feedback ?? "test run denied by the user",
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
+
// Substrate chosen by ctx.sandbox (C.16): in the box when enabled, else
|
|
108
|
+
// host — identical result shape either way. An explicit deps.runner
|
|
109
|
+
// (tests) always wins. A sandbox that can't run throws (fail loud).
|
|
110
|
+
const runner = deps.runner ??
|
|
111
|
+
(ctx.sandbox
|
|
112
|
+
? new SandboxTestRunner(ctx.sandbox)
|
|
113
|
+
: new CommandTestRunner());
|
|
107
114
|
const result = await runner.run(resolved.command, {
|
|
108
115
|
cwd: ctx.cwd,
|
|
109
116
|
timeoutMs: ctx.config.shell.timeoutMs,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SandboxService } from "../sandbox/index.js";
|
|
2
|
+
import type { TestRunner, TestRunOptions, TestRunResult } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* A {@link TestRunner} that runs the suite inside the C.16 sandbox instead of
|
|
5
|
+
* on the host. It maps the neutral sandbox {@link ExecResult} onto the exact
|
|
6
|
+
* same {@link TestRunResult} the host {@link CommandTestRunner} produces — so
|
|
7
|
+
* honest-green (`passed = exitCode === 0`), tail-biased capture, and the
|
|
8
|
+
* timeout note all hold identically inside the box. A container-start failure
|
|
9
|
+
* is NOT swallowed into a result: `sandbox.exec` throws a coded error that
|
|
10
|
+
* propagates (fail loud), never a fabricated pass.
|
|
11
|
+
*/
|
|
12
|
+
export declare class SandboxTestRunner implements TestRunner {
|
|
13
|
+
private readonly sandbox;
|
|
14
|
+
constructor(sandbox: SandboxService);
|
|
15
|
+
run(command: string, opts: TestRunOptions): Promise<TestRunResult>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { parseFailures } from "./parse.js";
|
|
2
|
+
/**
|
|
3
|
+
* A {@link TestRunner} that runs the suite inside the C.16 sandbox instead of
|
|
4
|
+
* on the host. It maps the neutral sandbox {@link ExecResult} onto the exact
|
|
5
|
+
* same {@link TestRunResult} the host {@link CommandTestRunner} produces — so
|
|
6
|
+
* honest-green (`passed = exitCode === 0`), tail-biased capture, and the
|
|
7
|
+
* timeout note all hold identically inside the box. A container-start failure
|
|
8
|
+
* is NOT swallowed into a result: `sandbox.exec` throws a coded error that
|
|
9
|
+
* propagates (fail loud), never a fabricated pass.
|
|
10
|
+
*/
|
|
11
|
+
export class SandboxTestRunner {
|
|
12
|
+
sandbox;
|
|
13
|
+
constructor(sandbox) {
|
|
14
|
+
this.sandbox = sandbox;
|
|
15
|
+
}
|
|
16
|
+
async run(command, opts) {
|
|
17
|
+
const result = await this.sandbox.exec(command, {
|
|
18
|
+
cwd: opts.cwd,
|
|
19
|
+
timeoutMs: opts.timeoutMs,
|
|
20
|
+
maxOutputBytes: opts.captureBytes,
|
|
21
|
+
capture: "tail", // failures live at the end of test output
|
|
22
|
+
});
|
|
23
|
+
if (result.timedOut) {
|
|
24
|
+
return {
|
|
25
|
+
passed: false,
|
|
26
|
+
exitCode: null,
|
|
27
|
+
durationMs: result.durationMs,
|
|
28
|
+
failures: [],
|
|
29
|
+
output: result.output +
|
|
30
|
+
`\n… [test run timed out after ${opts.timeoutMs}ms and was killed]`,
|
|
31
|
+
outputTruncated: result.outputTruncated,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// THE honest-green rule, unchanged by the substrate: exit 0 means passed.
|
|
35
|
+
const passed = result.exitCode === 0;
|
|
36
|
+
const parsed = passed ? { failures: [] } : parseFailures(result.output);
|
|
37
|
+
return {
|
|
38
|
+
passed,
|
|
39
|
+
exitCode: result.exitCode,
|
|
40
|
+
durationMs: result.durationMs,
|
|
41
|
+
...(parsed.total !== undefined ? { total: parsed.total } : {}),
|
|
42
|
+
failures: parsed.failures,
|
|
43
|
+
output: result.output,
|
|
44
|
+
outputTruncated: result.outputTruncated,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/theme/resolve.d.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { type Theme, type ThemeCapabilities } from "./tokens.js";
|
|
2
2
|
/**
|
|
3
|
-
* Theme resolution (U.1) — the ONE place picocolors is used. The
|
|
3
|
+
* Theme resolution (U.1/U.11) — the ONE place picocolors is used. The axes are
|
|
4
4
|
* fully independent by construction:
|
|
5
5
|
*
|
|
6
6
|
* - `color` drives the stylers only. `pc.createColors(false)` is the identity,
|
|
7
7
|
* so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
|
|
8
8
|
* tests.
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* - the glyph table is chosen by `screenReader` (words) → else `unicode`
|
|
10
|
+
* (✓/✗) → else ASCII (`[ok]`/`[x]`). Never touches color: a NO_COLOR unicode
|
|
11
|
+
* terminal still prints ✓/✗ (glyphs aren't ANSI), a colored CRUXY_ASCII
|
|
12
|
+
* terminal prints a colored `[ok]`, and a screen reader gets colorless words.
|
|
12
13
|
*/
|
|
13
14
|
export declare function resolveTheme(caps: ThemeCapabilities): Theme;
|
|
14
15
|
/**
|
|
@@ -23,10 +24,20 @@ export declare function resolveTheme(caps: ThemeCapabilities): Theme;
|
|
|
23
24
|
* signature change.
|
|
24
25
|
*/
|
|
25
26
|
export declare function detectUnicode(env?: NodeJS.ProcessEnv): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether the user asked for screen-reader mode (U.11) — opt-in via
|
|
29
|
+
* `CRUXY_SCREEN_READER` or the ecosystem `ACCESSIBLE` flag (set-and-non-empty).
|
|
30
|
+
* Deliberately NOT inferred from a pipe: auto-wording every non-TTY stream would
|
|
31
|
+
* change existing CI/plain output. The single source of the rule, called by
|
|
32
|
+
* `render/capabilities.ts` (to fill `RenderCapabilities.screenReader`) and by
|
|
33
|
+
* the boolean-seam {@link themeForColor} surfaces (approval, plan, onboarding).
|
|
34
|
+
*/
|
|
35
|
+
export declare function detectScreenReader(env?: NodeJS.ProcessEnv): boolean;
|
|
26
36
|
/**
|
|
27
37
|
* Build a theme for a surface that only knows a `color` boolean (the U.3
|
|
28
|
-
* PromptIO, error formatting, plan/onboarding/CLI copy). Unicode
|
|
29
|
-
* from the environment so these surfaces still
|
|
30
|
-
*
|
|
38
|
+
* PromptIO, error formatting, plan/onboarding/CLI copy). Unicode and
|
|
39
|
+
* screen-reader mode are detected from the environment so these surfaces still
|
|
40
|
+
* degrade on dumb terminals and speak words to a screen reader, with no change
|
|
41
|
+
* to their public boolean signatures.
|
|
31
42
|
*/
|
|
32
43
|
export declare function themeForColor(color: boolean, env?: NodeJS.ProcessEnv): Theme;
|
package/dist/theme/resolve.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
|
-
import { ASCII_GLYPHS, UNICODE_GLYPHS, } from "./tokens.js";
|
|
2
|
+
import { ASCII_GLYPHS, SCREEN_READER_GLYPHS, UNICODE_GLYPHS, } from "./tokens.js";
|
|
3
3
|
/**
|
|
4
|
-
* Theme resolution (U.1) — the ONE place picocolors is used. The
|
|
4
|
+
* Theme resolution (U.1/U.11) — the ONE place picocolors is used. The axes are
|
|
5
5
|
* fully independent by construction:
|
|
6
6
|
*
|
|
7
7
|
* - `color` drives the stylers only. `pc.createColors(false)` is the identity,
|
|
8
8
|
* so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
|
|
9
9
|
* tests.
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - the glyph table is chosen by `screenReader` (words) → else `unicode`
|
|
11
|
+
* (✓/✗) → else ASCII (`[ok]`/`[x]`). Never touches color: a NO_COLOR unicode
|
|
12
|
+
* terminal still prints ✓/✗ (glyphs aren't ANSI), a colored CRUXY_ASCII
|
|
13
|
+
* terminal prints a colored `[ok]`, and a screen reader gets colorless words.
|
|
13
14
|
*/
|
|
14
15
|
export function resolveTheme(caps) {
|
|
15
16
|
const c = pc.createColors(caps.color);
|
|
16
|
-
const glyph = caps.
|
|
17
|
+
const glyph = caps.screenReader
|
|
18
|
+
? SCREEN_READER_GLYPHS
|
|
19
|
+
: caps.unicode
|
|
20
|
+
? UNICODE_GLYPHS
|
|
21
|
+
: ASCII_GLYPHS;
|
|
17
22
|
const strong = c.bold;
|
|
18
23
|
const indent = (text, level = 1) => {
|
|
19
24
|
const pad = " ".repeat(Math.max(0, level));
|
|
@@ -62,12 +67,29 @@ export function detectUnicode(env = process.env) {
|
|
|
62
67
|
return false;
|
|
63
68
|
return true;
|
|
64
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Whether the user asked for screen-reader mode (U.11) — opt-in via
|
|
72
|
+
* `CRUXY_SCREEN_READER` or the ecosystem `ACCESSIBLE` flag (set-and-non-empty).
|
|
73
|
+
* Deliberately NOT inferred from a pipe: auto-wording every non-TTY stream would
|
|
74
|
+
* change existing CI/plain output. The single source of the rule, called by
|
|
75
|
+
* `render/capabilities.ts` (to fill `RenderCapabilities.screenReader`) and by
|
|
76
|
+
* the boolean-seam {@link themeForColor} surfaces (approval, plan, onboarding).
|
|
77
|
+
*/
|
|
78
|
+
export function detectScreenReader(env = process.env) {
|
|
79
|
+
const set = (v) => v !== undefined && v !== "";
|
|
80
|
+
return set(env.CRUXY_SCREEN_READER) || set(env.ACCESSIBLE);
|
|
81
|
+
}
|
|
65
82
|
/**
|
|
66
83
|
* Build a theme for a surface that only knows a `color` boolean (the U.3
|
|
67
|
-
* PromptIO, error formatting, plan/onboarding/CLI copy). Unicode
|
|
68
|
-
* from the environment so these surfaces still
|
|
69
|
-
*
|
|
84
|
+
* PromptIO, error formatting, plan/onboarding/CLI copy). Unicode and
|
|
85
|
+
* screen-reader mode are detected from the environment so these surfaces still
|
|
86
|
+
* degrade on dumb terminals and speak words to a screen reader, with no change
|
|
87
|
+
* to their public boolean signatures.
|
|
70
88
|
*/
|
|
71
89
|
export function themeForColor(color, env = process.env) {
|
|
72
|
-
return resolveTheme({
|
|
90
|
+
return resolveTheme({
|
|
91
|
+
color,
|
|
92
|
+
unicode: detectUnicode(env),
|
|
93
|
+
screenReader: detectScreenReader(env),
|
|
94
|
+
});
|
|
73
95
|
}
|
package/dist/theme/tokens.d.ts
CHANGED
|
@@ -90,10 +90,16 @@ export interface Theme {
|
|
|
90
90
|
readonly color: boolean;
|
|
91
91
|
readonly unicode: boolean;
|
|
92
92
|
}
|
|
93
|
-
/** The
|
|
93
|
+
/** The axes a theme is resolved from — a structural subset of RenderCapabilities. */
|
|
94
94
|
export interface ThemeCapabilities {
|
|
95
95
|
color: boolean;
|
|
96
96
|
unicode: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Screen-reader mode (U.11): status glyphs render as words. Optional so every
|
|
99
|
+
* existing `{color, unicode}` caller is unchanged (defaults to off). When set
|
|
100
|
+
* it takes precedence over `unicode` for the glyph table.
|
|
101
|
+
*/
|
|
102
|
+
screenReader?: boolean;
|
|
97
103
|
}
|
|
98
104
|
/** The unicode glyph table (real terminals). */
|
|
99
105
|
export declare const UNICODE_GLYPHS: ThemeGlyphs;
|
|
@@ -102,3 +108,12 @@ export declare const UNICODE_GLYPHS: ThemeGlyphs;
|
|
|
102
108
|
* mojibake — the intentional U.1 degradation for unicode-unsafe terminals.
|
|
103
109
|
*/
|
|
104
110
|
export declare const ASCII_GLYPHS: ThemeGlyphs;
|
|
111
|
+
/**
|
|
112
|
+
* The screen-reader glyph table (U.11): the status glyphs a screen reader would
|
|
113
|
+
* otherwise announce as bare punctuation are spelled as words — `done`,
|
|
114
|
+
* `failed`, `working`, `pending` — so `✓ read_file` becomes `done read_file`.
|
|
115
|
+
* Structural glyphs stay ASCII-legible (a pointer/caret has no useful word).
|
|
116
|
+
* This is the entire "worded status" of screen-reader mode: swap the table,
|
|
117
|
+
* reuse every existing renderer/theme code path unchanged.
|
|
118
|
+
*/
|
|
119
|
+
export declare const SCREEN_READER_GLYPHS: ThemeGlyphs;
|
package/dist/theme/tokens.js
CHANGED
|
@@ -50,3 +50,30 @@ export const ASCII_GLYPHS = {
|
|
|
50
50
|
spinnerFrames: ["-", "\\", "|", "/"],
|
|
51
51
|
spinnerStatic: "~",
|
|
52
52
|
};
|
|
53
|
+
/**
|
|
54
|
+
* The screen-reader glyph table (U.11): the status glyphs a screen reader would
|
|
55
|
+
* otherwise announce as bare punctuation are spelled as words — `done`,
|
|
56
|
+
* `failed`, `working`, `pending` — so `✓ read_file` becomes `done read_file`.
|
|
57
|
+
* Structural glyphs stay ASCII-legible (a pointer/caret has no useful word).
|
|
58
|
+
* This is the entire "worded status" of screen-reader mode: swap the table,
|
|
59
|
+
* reuse every existing renderer/theme code path unchanged.
|
|
60
|
+
*/
|
|
61
|
+
export const SCREEN_READER_GLYPHS = {
|
|
62
|
+
success: "done",
|
|
63
|
+
failure: "failed",
|
|
64
|
+
pending: "pending",
|
|
65
|
+
running: "working",
|
|
66
|
+
pointer: ">",
|
|
67
|
+
caret: ">",
|
|
68
|
+
arrow: "->",
|
|
69
|
+
caretUp: "up",
|
|
70
|
+
caretDown: "down",
|
|
71
|
+
cursorBar: "",
|
|
72
|
+
bullet: "-",
|
|
73
|
+
sep: "-",
|
|
74
|
+
ellipsis: "...",
|
|
75
|
+
play: ">",
|
|
76
|
+
// Unused in the linear screen-reader path (no live region), kept legible.
|
|
77
|
+
spinnerFrames: ["working"],
|
|
78
|
+
spinnerStatic: "working",
|
|
79
|
+
};
|