@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
package/dist/approval/prompt.js
CHANGED
|
@@ -37,16 +37,30 @@ export async function promptForApproval(request, io) {
|
|
|
37
37
|
export function render(request, color) {
|
|
38
38
|
const t = themeForColor(color);
|
|
39
39
|
const destructive = request.tier === "destructive";
|
|
40
|
-
//
|
|
41
|
-
//
|
|
40
|
+
// Risk survives all three degradations (U.11): the mark carries it by *shape*
|
|
41
|
+
// (`!` vs `?`) for NO_COLOR, and the label carries it by *word*
|
|
42
|
+
// (`(destructive)` / `(mutate)` / `(read)`) — never by hue alone, and legible
|
|
43
|
+
// to a screen reader that would otherwise read `!` as "exclamation mark".
|
|
42
44
|
const mark = destructive ? t.danger(t.strong("!")) : t.warning("?");
|
|
43
|
-
const label =
|
|
45
|
+
const label = tierLabel(request.tier, t);
|
|
44
46
|
const lines = [];
|
|
45
47
|
lines.push(`${mark} cruxy wants to ${t.strong(request.summary)}${label}`);
|
|
46
48
|
lines.push(detail(request, t));
|
|
47
49
|
lines.push(choices(request.scope, t));
|
|
48
50
|
return lines.filter((l) => l !== "").join("\n") + " ";
|
|
49
51
|
}
|
|
52
|
+
/** The worded risk tag, colored by tier — always present, so meaning never
|
|
53
|
+
* rides on the `!`/`?` shape or its color alone. */
|
|
54
|
+
function tierLabel(tier, t) {
|
|
55
|
+
switch (tier) {
|
|
56
|
+
case "destructive":
|
|
57
|
+
return t.danger(t.strong(" (destructive)"));
|
|
58
|
+
case "mutate":
|
|
59
|
+
return t.warning(" (mutate)");
|
|
60
|
+
case "read":
|
|
61
|
+
return t.muted(" (read)");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
50
64
|
/** The action detail: a diff for file actions, the command + cwd for shell/test. */
|
|
51
65
|
function detail(request, t) {
|
|
52
66
|
if (request.action.kind === "shell" || request.action.kind === "test") {
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -5,6 +5,7 @@ import { authMissingKey, shouldUseColor, usageError, } from "../../errors/index.
|
|
|
5
5
|
import { createRenderer } from "../../render/index.js";
|
|
6
6
|
import { themeForColor } from "../../theme/index.js";
|
|
7
7
|
import { CheckpointService } from "../../checkpoint/index.js";
|
|
8
|
+
import { SandboxService } from "../../sandbox/index.js";
|
|
8
9
|
import { runInteractive } from "../repl.js";
|
|
9
10
|
import { buildAgentSession } from "../session-factory.js";
|
|
10
11
|
import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
|
|
@@ -13,6 +14,7 @@ export function runCommand() {
|
|
|
13
14
|
.description("run cruxy on a prompt (one-shot), or with no prompt for an interactive session")
|
|
14
15
|
.argument("[prompt...]", "the task for cruxy to perform (omit for interactive)")
|
|
15
16
|
.option("--plan", "plan mode: propose a step-by-step plan for approval before executing")
|
|
17
|
+
.option("--sandbox", "run shell + test commands inside an isolated container (fails loud if no runtime)")
|
|
16
18
|
.action(async (promptParts, opts) => {
|
|
17
19
|
const prompt = promptParts.join(" ").trim();
|
|
18
20
|
const t = themeForColor(shouldUseColor(process.stdout));
|
|
@@ -61,7 +63,23 @@ export function runCommand() {
|
|
|
61
63
|
const checkpoints = config.checkpoint.enabled
|
|
62
64
|
? new CheckpointService({ root: process.cwd(), config })
|
|
63
65
|
: undefined;
|
|
64
|
-
|
|
66
|
+
// Sandbox (C.16): opt-in via --sandbox or sandbox.enabled. Resolving the
|
|
67
|
+
// service probes the runtime and THROWS CRUXY_E_SANDBOX_UNAVAILABLE if it
|
|
68
|
+
// is missing — fail loud here, before the agent starts, rather than
|
|
69
|
+
// silently running un-sandboxed. When off, ctx.sandbox stays undefined and
|
|
70
|
+
// execution runs on the host, unchanged.
|
|
71
|
+
const sandboxEnabled = opts.sandbox ?? config.sandbox.enabled;
|
|
72
|
+
const sandbox = sandboxEnabled
|
|
73
|
+
? await SandboxService.create({
|
|
74
|
+
config,
|
|
75
|
+
cwd: process.cwd(),
|
|
76
|
+
reporter: renderer,
|
|
77
|
+
})
|
|
78
|
+
: undefined;
|
|
79
|
+
if (sandbox) {
|
|
80
|
+
logger.info(t.muted(`sandbox: ${sandbox.runtimeName} (network ${config.sandbox.network})`));
|
|
81
|
+
}
|
|
82
|
+
const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox);
|
|
65
83
|
if (interactive) {
|
|
66
84
|
await runInteractive(session, undefined, renderer, checkpoints);
|
|
67
85
|
return;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CruxyConfig } from "../config/index.js";
|
|
2
2
|
import type { ApprovalDecision } from "../approval/index.js";
|
|
3
3
|
import type { CheckpointService } from "../checkpoint/index.js";
|
|
4
|
+
import type { SandboxService } from "../sandbox/index.js";
|
|
4
5
|
import type { StreamRenderer } from "../render/index.js";
|
|
5
6
|
import { type ApproveAction } from "../tools/index.js";
|
|
6
7
|
import { Session } from "../agent/index.js";
|
|
@@ -23,4 +24,4 @@ export declare function withCheckpointGate(requestApproval: (action: ApproveActi
|
|
|
23
24
|
* over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
|
|
24
25
|
* approves → executes. Plan mode is fully opt-in; the default path is unchanged.
|
|
25
26
|
*/
|
|
26
|
-
export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService): Session;
|
|
27
|
+
export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService, sandbox?: SandboxService): Session;
|
|
@@ -87,7 +87,7 @@ export function withCheckpointGate(requestApproval, checkpoints, cwd) {
|
|
|
87
87
|
* over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
|
|
88
88
|
* approves → executes. Plan mode is fully opt-in; the default path is unchanged.
|
|
89
89
|
*/
|
|
90
|
-
export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints) {
|
|
90
|
+
export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints, sandbox) {
|
|
91
91
|
const provider = createProvider({
|
|
92
92
|
provider: config.model.provider,
|
|
93
93
|
apiKey,
|
|
@@ -120,6 +120,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
120
120
|
git,
|
|
121
121
|
projectInstructions,
|
|
122
122
|
renderer,
|
|
123
|
+
sandbox,
|
|
123
124
|
makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
|
|
124
125
|
});
|
|
125
126
|
if (config.subagent.maxDepth > 0) {
|
|
@@ -136,7 +137,13 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
136
137
|
policy: planPolicy,
|
|
137
138
|
io,
|
|
138
139
|
});
|
|
139
|
-
const ctx = {
|
|
140
|
+
const ctx = {
|
|
141
|
+
cwd,
|
|
142
|
+
config,
|
|
143
|
+
logger,
|
|
144
|
+
requestApproval: gate(approval),
|
|
145
|
+
sandbox,
|
|
146
|
+
};
|
|
140
147
|
const planRunner = ({ messages, projectInstructions, renderer: turnRenderer, }) => runPlanSession({
|
|
141
148
|
provider,
|
|
142
149
|
config,
|
|
@@ -166,7 +173,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
166
173
|
interactive: ttyInteractive,
|
|
167
174
|
io,
|
|
168
175
|
});
|
|
169
|
-
const ctx = { cwd, config, logger, requestApproval: gate(approval) };
|
|
176
|
+
const ctx = { cwd, config, logger, requestApproval: gate(approval), sandbox };
|
|
170
177
|
return new Session({
|
|
171
178
|
provider,
|
|
172
179
|
registry: execRegistry,
|
package/dist/components/fuzzy.js
CHANGED
|
@@ -106,7 +106,9 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
|
|
|
106
106
|
lines.push(t.heading(opts.title));
|
|
107
107
|
lines.push(`${t.accent(g.caret)} ${query}${t.muted(g.cursorBar)}`);
|
|
108
108
|
if (ranked.length === 0) {
|
|
109
|
-
|
|
109
|
+
// No dead-end: the only escapes (backspace, esc) stay advertised even
|
|
110
|
+
// when nothing matches — the user is never stranded with no visible exit.
|
|
111
|
+
lines.push(t.muted(" no results — backspace to widen · esc cancel"));
|
|
110
112
|
}
|
|
111
113
|
else {
|
|
112
114
|
// Keep the highlighted row inside the viewport.
|
|
@@ -122,6 +124,10 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
|
|
|
122
124
|
if (hidden > 0)
|
|
123
125
|
lines.push(t.muted(` ${g.ellipsis} ${hidden} more`));
|
|
124
126
|
}
|
|
127
|
+
// Persistent key hint (U.11 keyboard-completeness): every operable key is
|
|
128
|
+
// advertised, mirroring selectList — type filters, arrows move, enter
|
|
129
|
+
// selects, esc cancels. Present in both the results and no-results states.
|
|
130
|
+
lines.push(t.muted(` type to filter ${g.sep} ${g.caretUp}/${g.caretDown} move ${g.sep} enter select ${g.sep} esc cancel`));
|
|
125
131
|
frame.render(lines);
|
|
126
132
|
};
|
|
127
133
|
io.keys.begin();
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -283,6 +283,62 @@ export declare const SubagentConfigSchema: z.ZodObject<{
|
|
|
283
283
|
timeoutMs?: number | undefined;
|
|
284
284
|
} | undefined;
|
|
285
285
|
}>;
|
|
286
|
+
/**
|
|
287
|
+
* Sandbox / container execution (C.16): defense-in-depth beneath the U.3 gate.
|
|
288
|
+
* When enabled, `run_command` and `run_tests` execute inside an isolated,
|
|
289
|
+
* network-denied, resource-capped, non-root container with only the project
|
|
290
|
+
* workdir mounted — never on the host directly. OFF by default (a container
|
|
291
|
+
* runtime isn't universal); turning it on is a hard promise, so if the runtime
|
|
292
|
+
* is missing execution FAILS LOUD rather than silently falling back to the host.
|
|
293
|
+
*/
|
|
294
|
+
export declare const SandboxConfigSchema: z.ZodObject<{
|
|
295
|
+
/** Master switch. When true, shell + test commands run in the sandbox. */
|
|
296
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
297
|
+
/**
|
|
298
|
+
* Base image the container runs. Must ship a POSIX `sh`. Pin by digest
|
|
299
|
+
* (`name@sha256:…`) where possible; cruxy never builds or substitutes one.
|
|
300
|
+
*/
|
|
301
|
+
image: z.ZodDefault<z.ZodString>;
|
|
302
|
+
/**
|
|
303
|
+
* Egress policy. `none` denies all network (default deny); `host-loopback`
|
|
304
|
+
* permits reaching services on the host; `full` allows outbound. Anything
|
|
305
|
+
* other than `none` is a deliberate widening the user must opt into.
|
|
306
|
+
*/
|
|
307
|
+
network: z.ZodDefault<z.ZodEnum<["none", "host-loopback", "full"]>>;
|
|
308
|
+
/** Memory cap (docker `--memory` syntax, e.g. `512m`, `2g`). */
|
|
309
|
+
memory: z.ZodDefault<z.ZodString>;
|
|
310
|
+
/** Process/thread cap (`--pids-limit`) — a fork-bomb guard. */
|
|
311
|
+
pids: z.ZodDefault<z.ZodNumber>;
|
|
312
|
+
/** CPU cap (docker `--cpus`, fractional allowed). */
|
|
313
|
+
cpus: z.ZodDefault<z.ZodNumber>;
|
|
314
|
+
/** Wall-clock timeout in ms; falls back to `shell.timeoutMs` when unset. */
|
|
315
|
+
timeout: z.ZodOptional<z.ZodNumber>;
|
|
316
|
+
/**
|
|
317
|
+
* Extra bind mounts beyond the workdir + tmp, each `src:dst[:ro|:rw]`.
|
|
318
|
+
* Explicit by construction — the default exposes ONLY the workdir. The
|
|
319
|
+
* docker socket and the cruxy home are rejected here (see policy.ts).
|
|
320
|
+
*/
|
|
321
|
+
mounts: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
322
|
+
}, "strict", z.ZodTypeAny, {
|
|
323
|
+
image: string;
|
|
324
|
+
enabled: boolean;
|
|
325
|
+
memory: string;
|
|
326
|
+
network: "none" | "host-loopback" | "full";
|
|
327
|
+
pids: number;
|
|
328
|
+
cpus: number;
|
|
329
|
+
mounts: string[];
|
|
330
|
+
timeout?: number | undefined;
|
|
331
|
+
}, {
|
|
332
|
+
image?: string | undefined;
|
|
333
|
+
enabled?: boolean | undefined;
|
|
334
|
+
memory?: string | undefined;
|
|
335
|
+
network?: "none" | "host-loopback" | "full" | undefined;
|
|
336
|
+
pids?: number | undefined;
|
|
337
|
+
cpus?: number | undefined;
|
|
338
|
+
timeout?: number | undefined;
|
|
339
|
+
mounts?: string[] | undefined;
|
|
340
|
+
}>;
|
|
341
|
+
export type SandboxConfig = z.infer<typeof SandboxConfigSchema>;
|
|
286
342
|
/** MCP server entry — stdio or URL transport (wired up in a later phase). */
|
|
287
343
|
export declare const McpServerSchema: z.ZodObject<{
|
|
288
344
|
command: z.ZodOptional<z.ZodString>;
|
|
@@ -551,6 +607,53 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
551
607
|
command?: string | undefined;
|
|
552
608
|
captureBytes?: number | undefined;
|
|
553
609
|
}>>;
|
|
610
|
+
sandbox: z.ZodDefault<z.ZodObject<{
|
|
611
|
+
/** Master switch. When true, shell + test commands run in the sandbox. */
|
|
612
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
613
|
+
/**
|
|
614
|
+
* Base image the container runs. Must ship a POSIX `sh`. Pin by digest
|
|
615
|
+
* (`name@sha256:…`) where possible; cruxy never builds or substitutes one.
|
|
616
|
+
*/
|
|
617
|
+
image: z.ZodDefault<z.ZodString>;
|
|
618
|
+
/**
|
|
619
|
+
* Egress policy. `none` denies all network (default deny); `host-loopback`
|
|
620
|
+
* permits reaching services on the host; `full` allows outbound. Anything
|
|
621
|
+
* other than `none` is a deliberate widening the user must opt into.
|
|
622
|
+
*/
|
|
623
|
+
network: z.ZodDefault<z.ZodEnum<["none", "host-loopback", "full"]>>;
|
|
624
|
+
/** Memory cap (docker `--memory` syntax, e.g. `512m`, `2g`). */
|
|
625
|
+
memory: z.ZodDefault<z.ZodString>;
|
|
626
|
+
/** Process/thread cap (`--pids-limit`) — a fork-bomb guard. */
|
|
627
|
+
pids: z.ZodDefault<z.ZodNumber>;
|
|
628
|
+
/** CPU cap (docker `--cpus`, fractional allowed). */
|
|
629
|
+
cpus: z.ZodDefault<z.ZodNumber>;
|
|
630
|
+
/** Wall-clock timeout in ms; falls back to `shell.timeoutMs` when unset. */
|
|
631
|
+
timeout: z.ZodOptional<z.ZodNumber>;
|
|
632
|
+
/**
|
|
633
|
+
* Extra bind mounts beyond the workdir + tmp, each `src:dst[:ro|:rw]`.
|
|
634
|
+
* Explicit by construction — the default exposes ONLY the workdir. The
|
|
635
|
+
* docker socket and the cruxy home are rejected here (see policy.ts).
|
|
636
|
+
*/
|
|
637
|
+
mounts: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
638
|
+
}, "strict", z.ZodTypeAny, {
|
|
639
|
+
image: string;
|
|
640
|
+
enabled: boolean;
|
|
641
|
+
memory: string;
|
|
642
|
+
network: "none" | "host-loopback" | "full";
|
|
643
|
+
pids: number;
|
|
644
|
+
cpus: number;
|
|
645
|
+
mounts: string[];
|
|
646
|
+
timeout?: number | undefined;
|
|
647
|
+
}, {
|
|
648
|
+
image?: string | undefined;
|
|
649
|
+
enabled?: boolean | undefined;
|
|
650
|
+
memory?: string | undefined;
|
|
651
|
+
network?: "none" | "host-loopback" | "full" | undefined;
|
|
652
|
+
pids?: number | undefined;
|
|
653
|
+
cpus?: number | undefined;
|
|
654
|
+
timeout?: number | undefined;
|
|
655
|
+
mounts?: string[] | undefined;
|
|
656
|
+
}>>;
|
|
554
657
|
mcpServers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
555
658
|
command: z.ZodOptional<z.ZodString>;
|
|
556
659
|
args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
@@ -634,6 +737,16 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
634
737
|
captureBytes: number;
|
|
635
738
|
command?: string | undefined;
|
|
636
739
|
};
|
|
740
|
+
sandbox: {
|
|
741
|
+
image: string;
|
|
742
|
+
enabled: boolean;
|
|
743
|
+
memory: string;
|
|
744
|
+
network: "none" | "host-loopback" | "full";
|
|
745
|
+
pids: number;
|
|
746
|
+
cpus: number;
|
|
747
|
+
mounts: string[];
|
|
748
|
+
timeout?: number | undefined;
|
|
749
|
+
};
|
|
637
750
|
mcpServers: Record<string, {
|
|
638
751
|
command?: string | undefined;
|
|
639
752
|
args?: string[] | undefined;
|
|
@@ -709,6 +822,16 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
709
822
|
command?: string | undefined;
|
|
710
823
|
captureBytes?: number | undefined;
|
|
711
824
|
} | undefined;
|
|
825
|
+
sandbox?: {
|
|
826
|
+
image?: string | undefined;
|
|
827
|
+
enabled?: boolean | undefined;
|
|
828
|
+
memory?: string | undefined;
|
|
829
|
+
network?: "none" | "host-loopback" | "full" | undefined;
|
|
830
|
+
pids?: number | undefined;
|
|
831
|
+
cpus?: number | undefined;
|
|
832
|
+
timeout?: number | undefined;
|
|
833
|
+
mounts?: string[] | undefined;
|
|
834
|
+
} | undefined;
|
|
712
835
|
mcpServers?: Record<string, {
|
|
713
836
|
command?: string | undefined;
|
|
714
837
|
args?: string[] | undefined;
|
package/dist/config/schema.js
CHANGED
|
@@ -194,6 +194,45 @@ export const SubagentConfigSchema = z
|
|
|
194
194
|
.default({}),
|
|
195
195
|
})
|
|
196
196
|
.strict();
|
|
197
|
+
/**
|
|
198
|
+
* Sandbox / container execution (C.16): defense-in-depth beneath the U.3 gate.
|
|
199
|
+
* When enabled, `run_command` and `run_tests` execute inside an isolated,
|
|
200
|
+
* network-denied, resource-capped, non-root container with only the project
|
|
201
|
+
* workdir mounted — never on the host directly. OFF by default (a container
|
|
202
|
+
* runtime isn't universal); turning it on is a hard promise, so if the runtime
|
|
203
|
+
* is missing execution FAILS LOUD rather than silently falling back to the host.
|
|
204
|
+
*/
|
|
205
|
+
export const SandboxConfigSchema = z
|
|
206
|
+
.object({
|
|
207
|
+
/** Master switch. When true, shell + test commands run in the sandbox. */
|
|
208
|
+
enabled: z.boolean().default(false),
|
|
209
|
+
/**
|
|
210
|
+
* Base image the container runs. Must ship a POSIX `sh`. Pin by digest
|
|
211
|
+
* (`name@sha256:…`) where possible; cruxy never builds or substitutes one.
|
|
212
|
+
*/
|
|
213
|
+
image: z.string().min(1).default("node:20-bookworm-slim"),
|
|
214
|
+
/**
|
|
215
|
+
* Egress policy. `none` denies all network (default deny); `host-loopback`
|
|
216
|
+
* permits reaching services on the host; `full` allows outbound. Anything
|
|
217
|
+
* other than `none` is a deliberate widening the user must opt into.
|
|
218
|
+
*/
|
|
219
|
+
network: z.enum(["none", "host-loopback", "full"]).default("none"),
|
|
220
|
+
/** Memory cap (docker `--memory` syntax, e.g. `512m`, `2g`). */
|
|
221
|
+
memory: z.string().min(1).default("512m"),
|
|
222
|
+
/** Process/thread cap (`--pids-limit`) — a fork-bomb guard. */
|
|
223
|
+
pids: z.number().int().positive().default(512),
|
|
224
|
+
/** CPU cap (docker `--cpus`, fractional allowed). */
|
|
225
|
+
cpus: z.number().positive().default(1),
|
|
226
|
+
/** Wall-clock timeout in ms; falls back to `shell.timeoutMs` when unset. */
|
|
227
|
+
timeout: z.number().int().positive().optional(),
|
|
228
|
+
/**
|
|
229
|
+
* Extra bind mounts beyond the workdir + tmp, each `src:dst[:ro|:rw]`.
|
|
230
|
+
* Explicit by construction — the default exposes ONLY the workdir. The
|
|
231
|
+
* docker socket and the cruxy home are rejected here (see policy.ts).
|
|
232
|
+
*/
|
|
233
|
+
mounts: z.array(z.string().min(1)).default([]),
|
|
234
|
+
})
|
|
235
|
+
.strict();
|
|
197
236
|
/** MCP server entry — stdio or URL transport (wired up in a later phase). */
|
|
198
237
|
export const McpServerSchema = z
|
|
199
238
|
.object({
|
|
@@ -216,6 +255,7 @@ export const CruxyConfigSchema = z
|
|
|
216
255
|
checkpoint: CheckpointConfigSchema.default({}),
|
|
217
256
|
subagent: SubagentConfigSchema.default({}),
|
|
218
257
|
test: TestConfigSchema.default({}),
|
|
258
|
+
sandbox: SandboxConfigSchema.default({}),
|
|
219
259
|
mcpServers: z.record(z.string(), McpServerSchema).default({}),
|
|
220
260
|
logLevel: z.enum(LOG_LEVELS).default("info"),
|
|
221
261
|
})
|
|
@@ -25,6 +25,27 @@ export declare function permissionDenied(path: string, underlying?: unknown): Cr
|
|
|
25
25
|
export declare function indexEmbedderUnavailable(underlying?: unknown): CruxyError;
|
|
26
26
|
export declare function indexStoreUnavailable(underlying?: unknown): CruxyError;
|
|
27
27
|
export declare function indexFailed(underlying?: unknown): CruxyError;
|
|
28
|
+
/**
|
|
29
|
+
* Sandboxing was requested (`--sandbox` / `sandbox.enabled`) but no container
|
|
30
|
+
* runtime is available — docker isn't installed, or its daemon isn't running.
|
|
31
|
+
* THE CRITICAL RULE: this is fatal. A user who turned on the sandbox must never
|
|
32
|
+
* be silently dropped back onto un-sandboxed host execution, so we fail loud
|
|
33
|
+
* here rather than run the command anyway.
|
|
34
|
+
*/
|
|
35
|
+
export declare function sandboxUnavailable(runtime?: string, underlying?: unknown): CruxyError;
|
|
36
|
+
/**
|
|
37
|
+
* The pinned sandbox base image could not be made available (the first-run
|
|
38
|
+
* pull failed, or the image does not exist). Fatal — without the image there
|
|
39
|
+
* is nothing to execute inside, and we never substitute another image.
|
|
40
|
+
*/
|
|
41
|
+
export declare function sandboxImage(image: string, underlying?: unknown): CruxyError;
|
|
42
|
+
/**
|
|
43
|
+
* The container itself failed to start or run (a docker-level failure — bad
|
|
44
|
+
* flags, daemon error, `docker run` exit 125), as opposed to the command
|
|
45
|
+
* inside exiting non-zero (which is an ordinary result). Fatal: a sandbox that
|
|
46
|
+
* can't launch is never papered over with a host run.
|
|
47
|
+
*/
|
|
48
|
+
export declare function sandboxExec(underlying?: unknown): CruxyError;
|
|
28
49
|
/**
|
|
29
50
|
* A side-effecting action needs approval but cruxy can't ask (non-interactive,
|
|
30
51
|
* no policy). Default-deny — never auto-approve. A distinct exit code (10) so CI
|
|
@@ -224,6 +224,64 @@ export function indexFailed(underlying) {
|
|
|
224
224
|
underlying,
|
|
225
225
|
});
|
|
226
226
|
}
|
|
227
|
+
// ── sandbox (exit 12) ─────────────────────────────────────────────────────────
|
|
228
|
+
/**
|
|
229
|
+
* Sandboxing was requested (`--sandbox` / `sandbox.enabled`) but no container
|
|
230
|
+
* runtime is available — docker isn't installed, or its daemon isn't running.
|
|
231
|
+
* THE CRITICAL RULE: this is fatal. A user who turned on the sandbox must never
|
|
232
|
+
* be silently dropped back onto un-sandboxed host execution, so we fail loud
|
|
233
|
+
* here rather than run the command anyway.
|
|
234
|
+
*/
|
|
235
|
+
export function sandboxUnavailable(runtime = "docker", underlying) {
|
|
236
|
+
return new CruxyError({
|
|
237
|
+
code: ErrorCode.SandboxUnavailable,
|
|
238
|
+
title: `sandbox is enabled but the ${runtime} runtime is unavailable`,
|
|
239
|
+
cause: messageOf(underlying) ??
|
|
240
|
+
`${runtime} is not installed or its daemon is not reachable`,
|
|
241
|
+
nextSteps: [
|
|
242
|
+
`install ${runtime} and make sure its daemon is running`,
|
|
243
|
+
"or disable the sandbox: `cruxy config set sandbox.enabled false` (or drop --sandbox)",
|
|
244
|
+
],
|
|
245
|
+
underlying,
|
|
246
|
+
meta: { runtime },
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The pinned sandbox base image could not be made available (the first-run
|
|
251
|
+
* pull failed, or the image does not exist). Fatal — without the image there
|
|
252
|
+
* is nothing to execute inside, and we never substitute another image.
|
|
253
|
+
*/
|
|
254
|
+
export function sandboxImage(image, underlying) {
|
|
255
|
+
return new CruxyError({
|
|
256
|
+
code: ErrorCode.SandboxImage,
|
|
257
|
+
title: `could not pull the sandbox image "${image}"`,
|
|
258
|
+
cause: messageOf(underlying),
|
|
259
|
+
nextSteps: [
|
|
260
|
+
"check the image name/tag and your network access to the registry",
|
|
261
|
+
'set a reachable image, e.g. `cruxy config set sandbox.image "node:20-bookworm-slim"`',
|
|
262
|
+
],
|
|
263
|
+
underlying,
|
|
264
|
+
meta: { image },
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The container itself failed to start or run (a docker-level failure — bad
|
|
269
|
+
* flags, daemon error, `docker run` exit 125), as opposed to the command
|
|
270
|
+
* inside exiting non-zero (which is an ordinary result). Fatal: a sandbox that
|
|
271
|
+
* can't launch is never papered over with a host run.
|
|
272
|
+
*/
|
|
273
|
+
export function sandboxExec(underlying) {
|
|
274
|
+
return new CruxyError({
|
|
275
|
+
code: ErrorCode.SandboxExec,
|
|
276
|
+
title: "the sandbox container failed to start",
|
|
277
|
+
cause: messageOf(underlying),
|
|
278
|
+
nextSteps: [
|
|
279
|
+
"re-run with --verbose for the underlying docker error",
|
|
280
|
+
"verify the docker daemon is healthy (`docker info`)",
|
|
281
|
+
],
|
|
282
|
+
underlying,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
227
285
|
// ── approval (exit 10) ────────────────────────────────────────────────────────
|
|
228
286
|
/**
|
|
229
287
|
* A side-effecting action needs approval but cruxy can't ask (non-interactive,
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -52,6 +52,11 @@ export declare const ErrorCode: {
|
|
|
52
52
|
readonly TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND";
|
|
53
53
|
/** Carried inside a run_tests result (informational) — never fatal by itself. */
|
|
54
54
|
readonly TestIterationLimit: "CRUXY_E_TEST_ITERATION_LIMIT";
|
|
55
|
+
/** Sandbox was enabled but no container runtime is available — fail loud,
|
|
56
|
+
* NEVER fall back to un-sandboxed host execution. */
|
|
57
|
+
readonly SandboxUnavailable: "CRUXY_E_SANDBOX_UNAVAILABLE";
|
|
58
|
+
readonly SandboxImage: "CRUXY_E_SANDBOX_IMAGE";
|
|
59
|
+
readonly SandboxExec: "CRUXY_E_SANDBOX_EXEC";
|
|
55
60
|
};
|
|
56
61
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
57
62
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
package/dist/errors/types.js
CHANGED
|
@@ -64,6 +64,12 @@ export const ErrorCode = {
|
|
|
64
64
|
TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND",
|
|
65
65
|
/** Carried inside a run_tests result (informational) — never fatal by itself. */
|
|
66
66
|
TestIterationLimit: "CRUXY_E_TEST_ITERATION_LIMIT",
|
|
67
|
+
// sandbox (exit 12)
|
|
68
|
+
/** Sandbox was enabled but no container runtime is available — fail loud,
|
|
69
|
+
* NEVER fall back to un-sandboxed host execution. */
|
|
70
|
+
SandboxUnavailable: "CRUXY_E_SANDBOX_UNAVAILABLE",
|
|
71
|
+
SandboxImage: "CRUXY_E_SANDBOX_IMAGE",
|
|
72
|
+
SandboxExec: "CRUXY_E_SANDBOX_EXEC",
|
|
67
73
|
};
|
|
68
74
|
/**
|
|
69
75
|
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
@@ -112,6 +118,11 @@ const EXIT_CODES = {
|
|
|
112
118
|
// surfaces inside a run_tests result and is never fatal by itself.
|
|
113
119
|
[ErrorCode.TestCommandNotFound]: 2,
|
|
114
120
|
[ErrorCode.TestIterationLimit]: 11,
|
|
121
|
+
// A requested sandbox that can't be honored is fatal (fail loud, no host
|
|
122
|
+
// fallback) — its own exit code so CI/scripts can tell it apart.
|
|
123
|
+
[ErrorCode.SandboxUnavailable]: 12,
|
|
124
|
+
[ErrorCode.SandboxImage]: 12,
|
|
125
|
+
[ErrorCode.SandboxExec]: 12,
|
|
115
126
|
};
|
|
116
127
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
117
128
|
export function exitCodeFor(code) {
|
package/dist/onboarding/steps.js
CHANGED
|
@@ -93,7 +93,10 @@ export async function firstWinStep(io, deps) {
|
|
|
93
93
|
io.write(`\nRun a quick demo now — let cruxy summarize this repo? ${col.muted("[Y/n]")} `);
|
|
94
94
|
const key = (await io.readKey()).toLowerCase();
|
|
95
95
|
io.write("\n");
|
|
96
|
-
|
|
96
|
+
// Decline on an explicit `n`, and treat Ctrl-C / EOF / escape (readKey → "")
|
|
97
|
+
// as a clean cancel rather than "proceed": a cancel must never launch a task.
|
|
98
|
+
// Enter (readKey → "\n") keeps the `[Y/n]` default and runs the demo.
|
|
99
|
+
if (key === "n" || key === "")
|
|
97
100
|
return { status: "skipped" };
|
|
98
101
|
await deps.runTask(FIRST_WIN_PROMPT);
|
|
99
102
|
return { status: "ok" };
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { RenderCapabilities, RenderStream } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reduced-motion (U.11) — the ecosystem `NO_MOTION` signal, the explicit cruxy
|
|
4
|
+
* knob `CRUXY_REDUCED_MOTION`, and `CRUXY_NO_SPINNER` kept as an alias flowing
|
|
5
|
+
* through this one axis. A screen reader implies it (no live region to animate).
|
|
6
|
+
* The single source of the spinner gate.
|
|
7
|
+
*/
|
|
8
|
+
export declare function detectReducedMotion(env?: NodeJS.ProcessEnv): boolean;
|
|
2
9
|
/**
|
|
3
10
|
* Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
|
|
4
11
|
* given its inputs (stream + env are injectable), so every row of the
|
|
@@ -6,7 +13,8 @@ import type { RenderCapabilities, RenderStream } from "./types.js";
|
|
|
6
13
|
*
|
|
7
14
|
* Color reuses {@link shouldUseColor} (NO_COLOR / FORCE_COLOR / TTY) with one
|
|
8
15
|
* refinement: `TERM=dumb` terminals get no color even though they are TTYs.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
16
|
+
* The axes are independent (U.1/U.11): a NO_COLOR terminal still supports
|
|
17
|
+
* in-place status updates; a dumb terminal supports neither; reduced motion and
|
|
18
|
+
* screen-reader mode compose orthogonally with color and unicode.
|
|
11
19
|
*/
|
|
12
20
|
export declare function detectCapabilities(stream?: RenderStream, env?: NodeJS.ProcessEnv): RenderCapabilities;
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { shouldUseColor } from "../errors/index.js";
|
|
2
|
-
import { detectUnicode } from "../theme/index.js";
|
|
2
|
+
import { detectScreenReader, detectUnicode } from "../theme/index.js";
|
|
3
|
+
/** Set-and-non-empty (the NO_COLOR convention): any non-empty value counts. */
|
|
4
|
+
function isSet(value) {
|
|
5
|
+
return value !== undefined && value !== "";
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Reduced-motion (U.11) — the ecosystem `NO_MOTION` signal, the explicit cruxy
|
|
9
|
+
* knob `CRUXY_REDUCED_MOTION`, and `CRUXY_NO_SPINNER` kept as an alias flowing
|
|
10
|
+
* through this one axis. A screen reader implies it (no live region to animate).
|
|
11
|
+
* The single source of the spinner gate.
|
|
12
|
+
*/
|
|
13
|
+
export function detectReducedMotion(env = process.env) {
|
|
14
|
+
return (isSet(env.NO_MOTION) ||
|
|
15
|
+
isSet(env.CRUXY_REDUCED_MOTION) ||
|
|
16
|
+
isSet(env.CRUXY_NO_SPINNER) ||
|
|
17
|
+
detectScreenReader(env));
|
|
18
|
+
}
|
|
3
19
|
/**
|
|
4
20
|
* Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
|
|
5
21
|
* given its inputs (stream + env are injectable), so every row of the
|
|
@@ -7,20 +23,24 @@ import { detectUnicode } from "../theme/index.js";
|
|
|
7
23
|
*
|
|
8
24
|
* Color reuses {@link shouldUseColor} (NO_COLOR / FORCE_COLOR / TTY) with one
|
|
9
25
|
* refinement: `TERM=dumb` terminals get no color even though they are TTYs.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
26
|
+
* The axes are independent (U.1/U.11): a NO_COLOR terminal still supports
|
|
27
|
+
* in-place status updates; a dumb terminal supports neither; reduced motion and
|
|
28
|
+
* screen-reader mode compose orthogonally with color and unicode.
|
|
12
29
|
*/
|
|
13
30
|
export function detectCapabilities(stream = process.stdout, env = process.env) {
|
|
14
31
|
const tty = Boolean(stream.isTTY);
|
|
15
32
|
const dumb = env.TERM === "dumb";
|
|
16
33
|
const cursor = tty && !dumb;
|
|
34
|
+
const reducedMotion = detectReducedMotion(env);
|
|
17
35
|
return {
|
|
18
36
|
tty,
|
|
19
37
|
color: shouldUseColor(stream, env) && !dumb,
|
|
20
38
|
cursor,
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
// Motion is the single gate now: CRUXY_NO_SPINNER flows through it (alias),
|
|
40
|
+
// as do NO_MOTION / CRUXY_REDUCED_MOTION and an implied screen reader.
|
|
41
|
+
spinner: cursor && !reducedMotion,
|
|
42
|
+
reducedMotion,
|
|
43
|
+
screenReader: detectScreenReader(env),
|
|
24
44
|
// Unicode glyph safety (U.1) — independent of color. dumb / CRUXY_ASCII →
|
|
25
45
|
// ASCII glyphs; everything else (incl. pipes) keeps unicode.
|
|
26
46
|
unicode: detectUnicode(env),
|
package/dist/render/index.d.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import type { RenderStream, StreamRenderer } from "./types.js";
|
|
2
2
|
export type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, TokenUsage, ToolLifecycleEvent, } from "./types.js";
|
|
3
|
-
export { detectCapabilities } from "./capabilities.js";
|
|
3
|
+
export { detectCapabilities, detectReducedMotion } from "./capabilities.js";
|
|
4
4
|
export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
|
|
5
5
|
export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
6
6
|
export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
|
|
7
7
|
export { PlainRenderer } from "./plain-renderer.js";
|
|
8
|
+
export { ScreenReaderRenderer } from "./screen-reader-renderer.js";
|
|
8
9
|
export { TtyRenderer } from "./tty-renderer.js";
|
|
9
10
|
/**
|
|
10
|
-
* Build the renderer for the detected environment
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Build the renderer for the detected environment (U.11 adds the first branch):
|
|
12
|
+
* - `screenReader` → the linear, worded {@link ScreenReaderRenderer}, regardless
|
|
13
|
+
* of cursor support (a screen-reader TTY must not get the live region);
|
|
14
|
+
* - else cursor-safe → the managed-live-region {@link TtyRenderer} (static, no
|
|
15
|
+
* timer, when `reducedMotion`);
|
|
16
|
+
* - else → the append-only {@link PlainRenderer} (pipes, CI, `TERM=dumb`).
|
|
17
|
+
* Everything downstream talks to {@link StreamRenderer} and never re-probes.
|
|
14
18
|
*/
|
|
15
19
|
export declare function createRenderer(out?: RenderStream, err?: RenderStream, env?: NodeJS.ProcessEnv): StreamRenderer;
|
package/dist/render/index.js
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import { detectCapabilities } from "./capabilities.js";
|
|
2
2
|
import { PlainRenderer } from "./plain-renderer.js";
|
|
3
|
+
import { ScreenReaderRenderer } from "./screen-reader-renderer.js";
|
|
3
4
|
import { TtyRenderer } from "./tty-renderer.js";
|
|
4
|
-
export { detectCapabilities } from "./capabilities.js";
|
|
5
|
+
export { detectCapabilities, detectReducedMotion } from "./capabilities.js";
|
|
5
6
|
export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
|
|
6
7
|
export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
7
8
|
export { createStreamHighlighter, defaultLineHighlighter, } from "./highlight.js";
|
|
8
9
|
export { PlainRenderer } from "./plain-renderer.js";
|
|
10
|
+
export { ScreenReaderRenderer } from "./screen-reader-renderer.js";
|
|
9
11
|
export { TtyRenderer } from "./tty-renderer.js";
|
|
10
12
|
/**
|
|
11
|
-
* Build the renderer for the detected environment
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Build the renderer for the detected environment (U.11 adds the first branch):
|
|
14
|
+
* - `screenReader` → the linear, worded {@link ScreenReaderRenderer}, regardless
|
|
15
|
+
* of cursor support (a screen-reader TTY must not get the live region);
|
|
16
|
+
* - else cursor-safe → the managed-live-region {@link TtyRenderer} (static, no
|
|
17
|
+
* timer, when `reducedMotion`);
|
|
18
|
+
* - else → the append-only {@link PlainRenderer} (pipes, CI, `TERM=dumb`).
|
|
19
|
+
* Everything downstream talks to {@link StreamRenderer} and never re-probes.
|
|
15
20
|
*/
|
|
16
21
|
export function createRenderer(out = process.stdout, err = process.stderr, env = process.env) {
|
|
17
22
|
const caps = detectCapabilities(out, env);
|
|
23
|
+
if (caps.screenReader)
|
|
24
|
+
return new ScreenReaderRenderer(caps, out, err);
|
|
18
25
|
return caps.cursor
|
|
19
26
|
? new TtyRenderer(caps, out)
|
|
20
27
|
: new PlainRenderer(caps, out, err);
|