@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
|
@@ -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 —
|
|
@@ -65,9 +65,12 @@ export class SubagentOrchestrator {
|
|
|
65
65
|
recordArtifacts(action, artifacts, deps.cwd);
|
|
66
66
|
return decision;
|
|
67
67
|
},
|
|
68
|
+
sandbox: deps.sandbox,
|
|
68
69
|
};
|
|
69
70
|
const label = taskLabel(spec.task);
|
|
70
|
-
deps.renderer
|
|
71
|
+
if (deps.renderer) {
|
|
72
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.play} subagent: ${label}`);
|
|
73
|
+
}
|
|
71
74
|
deps.renderer?.setPhase({ kind: "subagent", label });
|
|
72
75
|
// The isolation seam: a brand-new history seeded with ONLY the task. The
|
|
73
76
|
// parent's messages are never in scope here, and this array dies with the
|
|
@@ -96,7 +99,9 @@ export class SubagentOrchestrator {
|
|
|
96
99
|
deps.renderer?.setPhase(null);
|
|
97
100
|
throw err;
|
|
98
101
|
}
|
|
99
|
-
deps.renderer
|
|
102
|
+
if (deps.renderer) {
|
|
103
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.failure} subagent failed: ${label}`);
|
|
104
|
+
}
|
|
100
105
|
deps.renderer?.setPhase(null);
|
|
101
106
|
return {
|
|
102
107
|
status: "failed",
|
|
@@ -123,7 +128,9 @@ export class SubagentOrchestrator {
|
|
|
123
128
|
usage: run.usage,
|
|
124
129
|
};
|
|
125
130
|
if (run.stop === "completed") {
|
|
126
|
-
this.deps.renderer
|
|
131
|
+
const r = this.deps.renderer;
|
|
132
|
+
if (r)
|
|
133
|
+
r.note(`${r.theme.glyph.success} subagent done: ${label}`);
|
|
127
134
|
return { status: "done", ...base };
|
|
128
135
|
}
|
|
129
136
|
// Both cap paths are the same outcome for the parent: a truncated, partial
|
|
@@ -132,7 +139,9 @@ export class SubagentOrchestrator {
|
|
|
132
139
|
const reason = run.stop === "budget"
|
|
133
140
|
? (run.stopReason ?? "budget cap reached")
|
|
134
141
|
: `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
|
|
135
|
-
this.deps.renderer
|
|
142
|
+
const r = this.deps.renderer;
|
|
143
|
+
if (r)
|
|
144
|
+
r.note(`${r.theme.glyph.failure} subagent stopped (budget): ${label}`);
|
|
136
145
|
return {
|
|
137
146
|
status: "budget-exceeded",
|
|
138
147
|
...base,
|
|
@@ -195,12 +204,16 @@ function lastAssistantText(messages) {
|
|
|
195
204
|
*/
|
|
196
205
|
class SubagentRenderer {
|
|
197
206
|
caps;
|
|
207
|
+
theme;
|
|
198
208
|
inner;
|
|
199
209
|
label;
|
|
210
|
+
prefix;
|
|
200
211
|
constructor(inner, label) {
|
|
201
212
|
this.inner = inner;
|
|
202
213
|
this.label = label;
|
|
203
214
|
this.caps = inner.caps;
|
|
215
|
+
this.theme = inner.theme;
|
|
216
|
+
this.prefix = `subagent ${inner.theme.glyph.sep} `;
|
|
204
217
|
}
|
|
205
218
|
/** Turn framing belongs to the parent's turn — the child's is dropped. */
|
|
206
219
|
beginTurn() { }
|
|
@@ -209,7 +222,7 @@ class SubagentRenderer {
|
|
|
209
222
|
write() { }
|
|
210
223
|
endSegment() { }
|
|
211
224
|
note(text) {
|
|
212
|
-
this.inner.note(
|
|
225
|
+
this.inner.note(`${this.prefix}${text}`);
|
|
213
226
|
}
|
|
214
227
|
preview(preview) {
|
|
215
228
|
this.inner.preview(preview);
|
|
@@ -231,7 +244,10 @@ class SubagentRenderer {
|
|
|
231
244
|
/** The plan executor owns the progress register (C.31) — never the child. */
|
|
232
245
|
progress() { }
|
|
233
246
|
toolLifecycle(event) {
|
|
234
|
-
this.inner.toolLifecycle({
|
|
247
|
+
this.inner.toolLifecycle({
|
|
248
|
+
...event,
|
|
249
|
+
label: `${this.prefix}${event.label}`,
|
|
250
|
+
});
|
|
235
251
|
}
|
|
236
252
|
promptResolved() {
|
|
237
253
|
this.inner.promptResolved();
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Theme, type ThemeCapabilities } from "./tokens.js";
|
|
2
|
+
/**
|
|
3
|
+
* Theme resolution (U.1) — the ONE place picocolors is used. The two axes are
|
|
4
|
+
* fully independent by construction:
|
|
5
|
+
*
|
|
6
|
+
* - `color` drives the stylers only. `pc.createColors(false)` is the identity,
|
|
7
|
+
* so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
|
|
8
|
+
* tests.
|
|
9
|
+
* - `unicode` drives the glyph table only. It never touches color: a NO_COLOR
|
|
10
|
+
* unicode terminal still prints ✓/✗ (glyphs aren't ANSI), and a colored
|
|
11
|
+
* CRUXY_ASCII terminal prints a colored `[ok]`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveTheme(caps: ThemeCapabilities): Theme;
|
|
14
|
+
/**
|
|
15
|
+
* Whether the terminal can render unicode glyphs. `TERM=dumb` and an explicit
|
|
16
|
+
* `CRUXY_ASCII` opt-in fall back to the ASCII table; everything else (real
|
|
17
|
+
* terminals AND pipes) keeps unicode — piping ✓/✗ to a file is fine, and this
|
|
18
|
+
* preserves pre-U.1 behavior for non-TTY output.
|
|
19
|
+
*
|
|
20
|
+
* The single source of the rule: `render/capabilities.ts` calls this to fill
|
|
21
|
+
* `RenderCapabilities.unicode`, and the boolean-seam surfaces (errors,
|
|
22
|
+
* approval, plan, onboarding, CLI commands) call it to build a theme without a
|
|
23
|
+
* signature change.
|
|
24
|
+
*/
|
|
25
|
+
export declare function detectUnicode(env?: NodeJS.ProcessEnv): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Build a theme for a surface that only knows a `color` boolean (the U.3
|
|
28
|
+
* PromptIO, error formatting, plan/onboarding/CLI copy). Unicode is detected
|
|
29
|
+
* from the environment so these surfaces still degrade on dumb terminals,
|
|
30
|
+
* with no change to their public boolean signatures.
|
|
31
|
+
*/
|
|
32
|
+
export declare function themeForColor(color: boolean, env?: NodeJS.ProcessEnv): Theme;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { ASCII_GLYPHS, UNICODE_GLYPHS, } from "./tokens.js";
|
|
3
|
+
/**
|
|
4
|
+
* Theme resolution (U.1) — the ONE place picocolors is used. The two axes are
|
|
5
|
+
* fully independent by construction:
|
|
6
|
+
*
|
|
7
|
+
* - `color` drives the stylers only. `pc.createColors(false)` is the identity,
|
|
8
|
+
* so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
|
|
9
|
+
* tests.
|
|
10
|
+
* - `unicode` drives the glyph table only. It never touches color: a NO_COLOR
|
|
11
|
+
* unicode terminal still prints ✓/✗ (glyphs aren't ANSI), and a colored
|
|
12
|
+
* CRUXY_ASCII terminal prints a colored `[ok]`.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveTheme(caps) {
|
|
15
|
+
const c = pc.createColors(caps.color);
|
|
16
|
+
const glyph = caps.unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
|
|
17
|
+
const strong = c.bold;
|
|
18
|
+
const indent = (text, level = 1) => {
|
|
19
|
+
const pad = " ".repeat(Math.max(0, level));
|
|
20
|
+
return text
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((line) => (line === "" ? line : pad + line))
|
|
23
|
+
.join("\n");
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
danger: c.red,
|
|
27
|
+
warning: c.yellow,
|
|
28
|
+
success: c.green,
|
|
29
|
+
accent: c.cyan,
|
|
30
|
+
muted: c.dim,
|
|
31
|
+
strong,
|
|
32
|
+
syntax: {
|
|
33
|
+
keyword: c.magenta,
|
|
34
|
+
string: c.green,
|
|
35
|
+
number: c.yellow,
|
|
36
|
+
comment: c.dim,
|
|
37
|
+
},
|
|
38
|
+
glyph,
|
|
39
|
+
heading: strong,
|
|
40
|
+
indent,
|
|
41
|
+
kv: (key, value, keyWidth) => `${strong(keyWidth ? key.padEnd(keyWidth) : key)} ${value}`,
|
|
42
|
+
sep: ` ${glyph.sep} `,
|
|
43
|
+
color: caps.color,
|
|
44
|
+
unicode: caps.unicode,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether the terminal can render unicode glyphs. `TERM=dumb` and an explicit
|
|
49
|
+
* `CRUXY_ASCII` opt-in fall back to the ASCII table; everything else (real
|
|
50
|
+
* terminals AND pipes) keeps unicode — piping ✓/✗ to a file is fine, and this
|
|
51
|
+
* preserves pre-U.1 behavior for non-TTY output.
|
|
52
|
+
*
|
|
53
|
+
* The single source of the rule: `render/capabilities.ts` calls this to fill
|
|
54
|
+
* `RenderCapabilities.unicode`, and the boolean-seam surfaces (errors,
|
|
55
|
+
* approval, plan, onboarding, CLI commands) call it to build a theme without a
|
|
56
|
+
* signature change.
|
|
57
|
+
*/
|
|
58
|
+
export function detectUnicode(env = process.env) {
|
|
59
|
+
if (env.CRUXY_ASCII !== undefined && env.CRUXY_ASCII !== "")
|
|
60
|
+
return false;
|
|
61
|
+
if (env.TERM === "dumb")
|
|
62
|
+
return false;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build a theme for a surface that only knows a `color` boolean (the U.3
|
|
67
|
+
* PromptIO, error formatting, plan/onboarding/CLI copy). Unicode is detected
|
|
68
|
+
* from the environment so these surfaces still degrade on dumb terminals,
|
|
69
|
+
* with no change to their public boolean signatures.
|
|
70
|
+
*/
|
|
71
|
+
export function themeForColor(color, env = process.env) {
|
|
72
|
+
return resolveTheme({ color, unicode: detectUnicode(env) });
|
|
73
|
+
}
|