@cruxy/cli 0.29.3 → 0.29.4
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/agent/loop.js +6 -0
- package/dist/agent/prompts.d.ts +8 -0
- package/dist/agent/prompts.js +26 -0
- package/dist/cli/commands/test.js +1 -0
- package/dist/config/schema.d.ts +45 -0
- package/dist/config/schema.js +16 -0
- package/dist/errors/types.d.ts +6 -0
- package/dist/errors/types.js +11 -0
- package/dist/lsp/transport.js +5 -5
- package/dist/mcp/transport.js +6 -6
- package/dist/sandbox/docker-runtime.js +7 -15
- package/dist/testing/run-tests-tool.js +1 -0
- package/dist/testing/runner.js +7 -5
- package/dist/testing/types.d.ts +6 -1
- package/dist/tools/shell/exec.js +8 -5
- package/dist/tools/shell/resolve-shell.d.ts +81 -0
- package/dist/tools/shell/resolve-shell.js +158 -0
- package/dist/utils/process-tree.js +6 -3
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CruxyError, providerUnsupported } from "../errors/index.js";
|
|
2
2
|
import { resolveTaskModel, } from "../routing/index.js";
|
|
3
3
|
import { buildSystemPrompt } from "./prompts.js";
|
|
4
|
+
import { resolveShell } from "../tools/shell/resolve-shell.js";
|
|
4
5
|
/** Tools whose successful call is a file change (drives the `on-file-change`
|
|
5
6
|
* hook). Kept in sync with the file-mutating tool set. */
|
|
6
7
|
const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "apply_patch"]);
|
|
@@ -45,6 +46,10 @@ async function driveLoop(args, renderer, routed) {
|
|
|
45
46
|
let messages = [...args.messages];
|
|
46
47
|
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
47
48
|
const maxIterations = config.agent.maxIterations;
|
|
49
|
+
// Resolve the host shell up front (JC#8) so a Windows box with no usable shell
|
|
50
|
+
// fails loud NOW (CRUXY_E_NO_SHELL) rather than mid-task, and the model gets the
|
|
51
|
+
// dialect directive from turn 1. Memoized, so run_command / run_tests reuse it.
|
|
52
|
+
const shellPlan = resolveShell(config.shell);
|
|
48
53
|
// The tool catalogue and environment are stable across the loop, so build the
|
|
49
54
|
// system prompt once. The builder degrades gracefully when git is null.
|
|
50
55
|
const system = buildSystemPrompt({
|
|
@@ -60,6 +65,7 @@ async function driveLoop(args, renderer, routed) {
|
|
|
60
65
|
recalledMemory: args.recalledMemory ?? null,
|
|
61
66
|
planMode: args.planMode ?? false,
|
|
62
67
|
subagent: args.subagent ?? false,
|
|
68
|
+
shellDialect: shellPlan.dialect,
|
|
63
69
|
});
|
|
64
70
|
let iterations = 0;
|
|
65
71
|
for (let i = 0; i < maxIterations; i++) {
|
package/dist/agent/prompts.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* dynamic environment block, so the model always knows where it is, what it
|
|
7
7
|
* can do, and how it's expected to behave.
|
|
8
8
|
*/
|
|
9
|
+
import type { ShellDialect } from "../tools/shell/resolve-shell.js";
|
|
9
10
|
export interface ToolSummary {
|
|
10
11
|
name: string;
|
|
11
12
|
}
|
|
@@ -38,6 +39,13 @@ export interface PromptContext {
|
|
|
38
39
|
planMode?: boolean;
|
|
39
40
|
/** Subagent run (C.14): inject the bounded-subtask directive. */
|
|
40
41
|
subagent?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* The resolved shell's command dialect (from resolve-shell.ts). When it is not
|
|
44
|
+
* a POSIX shell — e.g. PowerShell, because no Git Bash was found on Windows —
|
|
45
|
+
* a directive tells the model to emit that dialect instead of bash. Absent /
|
|
46
|
+
* "posix" → no directive (bash-isms are correct there; a nudge would be wrong).
|
|
47
|
+
*/
|
|
48
|
+
shellDialect?: ShellDialect;
|
|
41
49
|
}
|
|
42
50
|
/** Assemble the full system prompt for a session. */
|
|
43
51
|
export declare function buildSystemPrompt(ctx: PromptContext): string;
|
package/dist/agent/prompts.js
CHANGED
|
@@ -70,6 +70,29 @@ function renderEnvironment(ctx) {
|
|
|
70
70
|
}
|
|
71
71
|
return `## Environment\n${lines.join("\n")}`;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* The command-dialect directive (JC#4). Emitted ONLY for a non-POSIX shell:
|
|
75
|
+
* under bash / Git Bash the model's default bash syntax is correct, so a "use
|
|
76
|
+
* PowerShell" nudge would be actively wrong. Keeps the model from emitting
|
|
77
|
+
* bash-isms that a Windows PowerShell/cmd shell would silently mis-run.
|
|
78
|
+
*/
|
|
79
|
+
function renderShellDialect(dialect) {
|
|
80
|
+
if (!dialect || dialect === "posix")
|
|
81
|
+
return null;
|
|
82
|
+
if (dialect === "powershell") {
|
|
83
|
+
return `## Shell dialect
|
|
84
|
+
\`run_command\` and \`run_tests\` execute through **PowerShell**, not a POSIX/bash shell. Emit native PowerShell:
|
|
85
|
+
- Sequence with \`;\`; run-on-success with \`if ($?) { ... }\` — NOT bash \`&&\` / \`||\`.
|
|
86
|
+
- Environment variables are \`$env:NAME\` (read) and \`$env:NAME = "v"\` (set) — never \`$NAME\` or \`export NAME=\`.
|
|
87
|
+
- Discard output with \`2>$null\` / \`| Out-Null\`, not \`2>/dev/null\`; no heredocs or single-quote-escaping tricks.
|
|
88
|
+
Cross-platform tools (npm, pnpm, git, cargo, node, python) take the same arguments here as anywhere.`;
|
|
89
|
+
}
|
|
90
|
+
return `## Shell dialect
|
|
91
|
+
\`run_command\` and \`run_tests\` execute through **cmd.exe**, not a POSIX/bash shell. Emit native cmd:
|
|
92
|
+
- Sequence with \`&\`; environment variables are \`%NAME%\`; set them with \`set NAME=value\`.
|
|
93
|
+
- No bash constructs (\`export\`, single-quoted strings, \`2>/dev/null\`, heredocs).
|
|
94
|
+
Cross-platform tools (npm, git, cargo, node) take the same arguments here as anywhere.`;
|
|
95
|
+
}
|
|
73
96
|
function renderTools(tools) {
|
|
74
97
|
if (tools.length === 0) {
|
|
75
98
|
return "## Tools\nNo tools are available this session; respond in text only.";
|
|
@@ -89,6 +112,9 @@ export function buildSystemPrompt(ctx) {
|
|
|
89
112
|
const approval = "Side-effecting actions (file writes, shell commands) require the user's approval; destructive or irreversible actions are flagged distinctly.";
|
|
90
113
|
const core = CORE.replace("${APPROVAL_CLAUSE}", approval);
|
|
91
114
|
const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
|
|
115
|
+
const dialect = renderShellDialect(ctx.shellDialect);
|
|
116
|
+
if (dialect)
|
|
117
|
+
sections.push(dialect);
|
|
92
118
|
if (ctx.planMode)
|
|
93
119
|
sections.push(PLAN_MODE_SECTION);
|
|
94
120
|
if (ctx.subagent)
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -105,13 +105,34 @@ export declare const ShellConfigSchema: z.ZodObject<{
|
|
|
105
105
|
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
106
106
|
/** Cap on combined stdout+stderr bytes captured; the rest is truncated. */
|
|
107
107
|
maxOutputBytes: z.ZodDefault<z.ZodNumber>;
|
|
108
|
+
/**
|
|
109
|
+
* Explicit shell to run commands through, overriding platform detection. A
|
|
110
|
+
* path (`/bin/bash`, `C:\\Program Files\\Git\\bin\\bash.exe`, `pwsh`). When
|
|
111
|
+
* set it WINS over the Git Bash → PowerShell tiering (see resolve-shell.ts):
|
|
112
|
+
* the user owns the choice. Unset → POSIX uses the default shell; Windows
|
|
113
|
+
* probes for Git Bash, then PowerShell, then fails loud (CRUXY_E_NO_SHELL).
|
|
114
|
+
*/
|
|
115
|
+
executable: z.ZodOptional<z.ZodString>;
|
|
116
|
+
/**
|
|
117
|
+
* The command dialect of `executable`, so the model is told which syntax to
|
|
118
|
+
* emit. Only sniffed automatically for unambiguous basenames (bash/sh →
|
|
119
|
+
* posix, pwsh/powershell → powershell, cmd → cmd); set this for anything else
|
|
120
|
+
* or the override is rejected (CRUXY_E_CONFIG_INVALID). Ignored without
|
|
121
|
+
* `executable`.
|
|
122
|
+
*/
|
|
123
|
+
dialect: z.ZodOptional<z.ZodEnum<["posix", "powershell", "cmd"]>>;
|
|
108
124
|
}, "strict", z.ZodTypeAny, {
|
|
109
125
|
timeoutMs: number;
|
|
110
126
|
maxOutputBytes: number;
|
|
127
|
+
executable?: string | undefined;
|
|
128
|
+
dialect?: "posix" | "powershell" | "cmd" | undefined;
|
|
111
129
|
}, {
|
|
112
130
|
timeoutMs?: number | undefined;
|
|
113
131
|
maxOutputBytes?: number | undefined;
|
|
132
|
+
executable?: string | undefined;
|
|
133
|
+
dialect?: "posix" | "powershell" | "cmd" | undefined;
|
|
114
134
|
}>;
|
|
135
|
+
export type ShellConfig = z.infer<typeof ShellConfigSchema>;
|
|
115
136
|
/** Context-window management: when to compact the running conversation. */
|
|
116
137
|
export declare const ContextConfigSchema: z.ZodObject<{
|
|
117
138
|
/** Approximate model context budget, in tokens (heuristic estimate). */
|
|
@@ -1096,12 +1117,32 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1096
1117
|
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
1097
1118
|
/** Cap on combined stdout+stderr bytes captured; the rest is truncated. */
|
|
1098
1119
|
maxOutputBytes: z.ZodDefault<z.ZodNumber>;
|
|
1120
|
+
/**
|
|
1121
|
+
* Explicit shell to run commands through, overriding platform detection. A
|
|
1122
|
+
* path (`/bin/bash`, `C:\\Program Files\\Git\\bin\\bash.exe`, `pwsh`). When
|
|
1123
|
+
* set it WINS over the Git Bash → PowerShell tiering (see resolve-shell.ts):
|
|
1124
|
+
* the user owns the choice. Unset → POSIX uses the default shell; Windows
|
|
1125
|
+
* probes for Git Bash, then PowerShell, then fails loud (CRUXY_E_NO_SHELL).
|
|
1126
|
+
*/
|
|
1127
|
+
executable: z.ZodOptional<z.ZodString>;
|
|
1128
|
+
/**
|
|
1129
|
+
* The command dialect of `executable`, so the model is told which syntax to
|
|
1130
|
+
* emit. Only sniffed automatically for unambiguous basenames (bash/sh →
|
|
1131
|
+
* posix, pwsh/powershell → powershell, cmd → cmd); set this for anything else
|
|
1132
|
+
* or the override is rejected (CRUXY_E_CONFIG_INVALID). Ignored without
|
|
1133
|
+
* `executable`.
|
|
1134
|
+
*/
|
|
1135
|
+
dialect: z.ZodOptional<z.ZodEnum<["posix", "powershell", "cmd"]>>;
|
|
1099
1136
|
}, "strict", z.ZodTypeAny, {
|
|
1100
1137
|
timeoutMs: number;
|
|
1101
1138
|
maxOutputBytes: number;
|
|
1139
|
+
executable?: string | undefined;
|
|
1140
|
+
dialect?: "posix" | "powershell" | "cmd" | undefined;
|
|
1102
1141
|
}, {
|
|
1103
1142
|
timeoutMs?: number | undefined;
|
|
1104
1143
|
maxOutputBytes?: number | undefined;
|
|
1144
|
+
executable?: string | undefined;
|
|
1145
|
+
dialect?: "posix" | "powershell" | "cmd" | undefined;
|
|
1105
1146
|
}>>;
|
|
1106
1147
|
context: z.ZodDefault<z.ZodObject<{
|
|
1107
1148
|
/** Approximate model context budget, in tokens (heuristic estimate). */
|
|
@@ -1830,6 +1871,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1830
1871
|
shell: {
|
|
1831
1872
|
timeoutMs: number;
|
|
1832
1873
|
maxOutputBytes: number;
|
|
1874
|
+
executable?: string | undefined;
|
|
1875
|
+
dialect?: "posix" | "powershell" | "cmd" | undefined;
|
|
1833
1876
|
};
|
|
1834
1877
|
agent: {
|
|
1835
1878
|
maxIterations: number;
|
|
@@ -1985,6 +2028,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1985
2028
|
shell?: {
|
|
1986
2029
|
timeoutMs?: number | undefined;
|
|
1987
2030
|
maxOutputBytes?: number | undefined;
|
|
2031
|
+
executable?: string | undefined;
|
|
2032
|
+
dialect?: "posix" | "powershell" | "cmd" | undefined;
|
|
1988
2033
|
} | undefined;
|
|
1989
2034
|
agent?: {
|
|
1990
2035
|
maxIterations?: number | undefined;
|
package/dist/config/schema.js
CHANGED
|
@@ -85,6 +85,22 @@ export const ShellConfigSchema = z
|
|
|
85
85
|
timeoutMs: z.number().int().positive().default(120000),
|
|
86
86
|
/** Cap on combined stdout+stderr bytes captured; the rest is truncated. */
|
|
87
87
|
maxOutputBytes: z.number().int().positive().default(102400),
|
|
88
|
+
/**
|
|
89
|
+
* Explicit shell to run commands through, overriding platform detection. A
|
|
90
|
+
* path (`/bin/bash`, `C:\\Program Files\\Git\\bin\\bash.exe`, `pwsh`). When
|
|
91
|
+
* set it WINS over the Git Bash → PowerShell tiering (see resolve-shell.ts):
|
|
92
|
+
* the user owns the choice. Unset → POSIX uses the default shell; Windows
|
|
93
|
+
* probes for Git Bash, then PowerShell, then fails loud (CRUXY_E_NO_SHELL).
|
|
94
|
+
*/
|
|
95
|
+
executable: z.string().min(1).optional(),
|
|
96
|
+
/**
|
|
97
|
+
* The command dialect of `executable`, so the model is told which syntax to
|
|
98
|
+
* emit. Only sniffed automatically for unambiguous basenames (bash/sh →
|
|
99
|
+
* posix, pwsh/powershell → powershell, cmd → cmd); set this for anything else
|
|
100
|
+
* or the override is rejected (CRUXY_E_CONFIG_INVALID). Ignored without
|
|
101
|
+
* `executable`.
|
|
102
|
+
*/
|
|
103
|
+
dialect: z.enum(["posix", "powershell", "cmd"]).optional(),
|
|
88
104
|
})
|
|
89
105
|
.strict();
|
|
90
106
|
/** Context-window management: when to compact the running conversation. */
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -207,6 +207,12 @@ export declare const ErrorCode: {
|
|
|
207
207
|
* `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
|
|
208
208
|
* rather than pretending there are simply no jobs. */
|
|
209
209
|
readonly JobsDisabled: "CRUXY_E_JOBS_DISABLED";
|
|
210
|
+
/** No usable host shell was found to run commands through. On Windows this
|
|
211
|
+
* means neither Git Bash nor PowerShell could be located and no
|
|
212
|
+
* `shell.executable` override was set. Fail loud at session start — NEVER
|
|
213
|
+
* silently fall back to cmd.exe running bash-shaped commands (which
|
|
214
|
+
* mis-parses `;`/`&&`/quoting and can report a false success). */
|
|
215
|
+
readonly NoShell: "CRUXY_E_NO_SHELL";
|
|
210
216
|
/** A one-shot `cruxy run` ended WITHOUT completing the task: the agent loop hit
|
|
211
217
|
* a hard stop (the iteration cap or a token budget) or was cancelled, rather
|
|
212
218
|
* than finishing on its own. Fail loud with a non-zero exit so CI never reads a
|
package/dist/errors/types.js
CHANGED
|
@@ -228,6 +228,13 @@ export const ErrorCode = {
|
|
|
228
228
|
* `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
|
|
229
229
|
* rather than pretending there are simply no jobs. */
|
|
230
230
|
JobsDisabled: "CRUXY_E_JOBS_DISABLED",
|
|
231
|
+
// host shell (exit 21) — the run_command / run_tests execution substrate
|
|
232
|
+
/** No usable host shell was found to run commands through. On Windows this
|
|
233
|
+
* means neither Git Bash nor PowerShell could be located and no
|
|
234
|
+
* `shell.executable` override was set. Fail loud at session start — NEVER
|
|
235
|
+
* silently fall back to cmd.exe running bash-shaped commands (which
|
|
236
|
+
* mis-parses `;`/`&&`/quoting and can report a false success). */
|
|
237
|
+
NoShell: "CRUXY_E_NO_SHELL",
|
|
231
238
|
// one-shot run outcome (exit 20)
|
|
232
239
|
/** A one-shot `cruxy run` ended WITHOUT completing the task: the agent loop hit
|
|
233
240
|
* a hard stop (the iteration cap or a token budget) or was cancelled, rather
|
|
@@ -360,6 +367,10 @@ const EXIT_CODES = {
|
|
|
360
367
|
// cancelled) without completing gets its own greppable exit code, so CI can tell
|
|
361
368
|
// "the agent gave up" apart from a provider/auth/config failure.
|
|
362
369
|
[ErrorCode.AgentIncomplete]: 20,
|
|
370
|
+
// Host shell. No usable shell to execute commands through — a fail-loud
|
|
371
|
+
// execution-substrate stop (kin to CRUXY_E_SANDBOX_UNAVAILABLE, but for the
|
|
372
|
+
// host path), with its own greppable exit code.
|
|
373
|
+
[ErrorCode.NoShell]: 21,
|
|
363
374
|
};
|
|
364
375
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
365
376
|
export function exitCodeFor(code) {
|
package/dist/lsp/transport.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnTree } from "../utils/process-tree.js";
|
|
2
2
|
import { killTree, killTrackedTrees, registerForCleanup, trackedTreeCount, } from "../utils/child-tree.js";
|
|
3
3
|
// Re-exported under their historical LSP names so callers and tests keep
|
|
4
4
|
// importing them from here; the machinery now lives in the shared child-tree
|
|
@@ -31,11 +31,11 @@ export class StdioTransport {
|
|
|
31
31
|
/** Deregisters this process from the process-exit kill-tree backstop. */
|
|
32
32
|
unregisterCleanup;
|
|
33
33
|
constructor(spec, root) {
|
|
34
|
-
// `
|
|
35
|
-
//
|
|
36
|
-
|
|
34
|
+
// `spawnTree` heads a killable tree with the platform-correct grouping —
|
|
35
|
+
// POSIX process group, or win32 `windowsHide` (NOT `detached`, which opens a
|
|
36
|
+
// console there) — so `killTree` reaps the whole server tree on either OS.
|
|
37
|
+
this.child = spawnTree(spec.command, spec.args, {
|
|
37
38
|
cwd: root,
|
|
38
|
-
detached: true,
|
|
39
39
|
stdio: ["pipe", "pipe", "pipe"],
|
|
40
40
|
});
|
|
41
41
|
this.unregisterCleanup = registerForCleanup(this.child.pid);
|
package/dist/mcp/transport.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnTree } from "../utils/process-tree.js";
|
|
2
2
|
import { killTree, registerForCleanup } from "../utils/child-tree.js";
|
|
3
3
|
/**
|
|
4
4
|
* JSON-RPC 2.0 over an MCP server's stdio (C.27). Owns the child process: spawns
|
|
@@ -30,12 +30,12 @@ export class McpStdioTransport {
|
|
|
30
30
|
disposed = false;
|
|
31
31
|
unregisterCleanup;
|
|
32
32
|
constructor(spec, root) {
|
|
33
|
-
// `
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
33
|
+
// `spawnTree` heads a killable tree with the platform-correct grouping —
|
|
34
|
+
// POSIX process group, or win32 `windowsHide` (NOT `detached`, which opens a
|
|
35
|
+
// console there) — same discipline as run_command (C.16) and the LSP
|
|
36
|
+
// transport (C.12); `killTree` reaps the whole server tree on either OS.
|
|
37
|
+
this.child = spawnTree(spec.command, spec.args, {
|
|
37
38
|
cwd: root,
|
|
38
|
-
detached: true,
|
|
39
39
|
stdio: ["pipe", "pipe", "pipe"],
|
|
40
40
|
env: { ...process.env, ...(spec.env ?? {}) },
|
|
41
41
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { killTree, spawnTree } from "../utils/process-tree.js";
|
|
3
4
|
import { sandboxExec, sandboxImage } from "../errors/index.js";
|
|
4
5
|
/**
|
|
5
6
|
* The shipped {@link SandboxRuntime}: shells out to the `docker` CLI (no SDK —
|
|
@@ -62,8 +63,11 @@ export class DockerRuntime {
|
|
|
62
63
|
const capture = new OutputCapture(opts.maxOutputBytes, opts.capture);
|
|
63
64
|
let child;
|
|
64
65
|
try {
|
|
65
|
-
// `
|
|
66
|
-
|
|
66
|
+
// `spawnTree` groups the docker client (POSIX process group / win32 OS
|
|
67
|
+
// tree) so a timeout reaps the whole tree via `killTree` on either OS —
|
|
68
|
+
// the docker CLIENT runs on the host, so on Windows this needs the
|
|
69
|
+
// taskkill walk, not a POSIX-only negative-PID signal.
|
|
70
|
+
child = spawnTree(this.bin, argv);
|
|
67
71
|
}
|
|
68
72
|
catch (err) {
|
|
69
73
|
reject(sandboxExec(err));
|
|
@@ -121,9 +125,8 @@ export class DockerRuntime {
|
|
|
121
125
|
/** Best-effort container teardown after a timeout kill. */
|
|
122
126
|
forceRemove(container) {
|
|
123
127
|
try {
|
|
124
|
-
const rm =
|
|
128
|
+
const rm = spawnTree(this.bin, ["rm", "-f", container], {
|
|
125
129
|
stdio: "ignore",
|
|
126
|
-
detached: true,
|
|
127
130
|
});
|
|
128
131
|
rm.on("error", () => { });
|
|
129
132
|
rm.unref();
|
|
@@ -253,14 +256,3 @@ class OutputCapture {
|
|
|
253
256
|
return { output: all.toString("utf8"), truncated: this.truncated };
|
|
254
257
|
}
|
|
255
258
|
}
|
|
256
|
-
/** Kill the docker client's process group (POSIX; matches run_command). */
|
|
257
|
-
function killTree(pid) {
|
|
258
|
-
if (pid === undefined)
|
|
259
|
-
return;
|
|
260
|
-
try {
|
|
261
|
-
process.kill(-pid, "SIGKILL");
|
|
262
|
-
}
|
|
263
|
-
catch {
|
|
264
|
-
// Already exited, or no group — nothing to kill.
|
|
265
|
-
}
|
|
266
|
-
}
|
|
@@ -141,6 +141,7 @@ export function makeRunTestsTool(deps = {}) {
|
|
|
141
141
|
cwd: ctx.cwd,
|
|
142
142
|
timeoutMs: ctx.config.shell.timeoutMs,
|
|
143
143
|
captureBytes: ctx.config.test.captureBytes,
|
|
144
|
+
shell: ctx.config.shell,
|
|
144
145
|
});
|
|
145
146
|
budget.record(result.passed);
|
|
146
147
|
const payload = renderResult(result, resolved, {
|
package/dist/testing/runner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { killTree
|
|
1
|
+
import { killTree } from "../utils/process-tree.js";
|
|
2
|
+
import { spawnShell } from "../tools/shell/resolve-shell.js";
|
|
2
3
|
import { parseFailures } from "./parse.js";
|
|
3
4
|
/**
|
|
4
5
|
* The shipped {@link TestRunner}: spawn the command via the system shell (the
|
|
@@ -15,10 +16,11 @@ export class CommandTestRunner {
|
|
|
15
16
|
const capture = new TailCapture(opts.captureBytes);
|
|
16
17
|
let child;
|
|
17
18
|
try {
|
|
18
|
-
// Same
|
|
19
|
-
// shell (
|
|
20
|
-
//
|
|
21
|
-
|
|
19
|
+
// Same seam as run_command: `spawnShell` routes through the resolved
|
|
20
|
+
// shell (Git Bash / PowerShell on Windows) and groups it as a killable
|
|
21
|
+
// tree (POSIX process group / win32 OS tree) so a timeout reaps the whole
|
|
22
|
+
// tree via `killTree`.
|
|
23
|
+
child = spawnShell(command, opts.shell, { cwd: opts.cwd });
|
|
22
24
|
}
|
|
23
25
|
catch (err) {
|
|
24
26
|
resolve(failed(null, err.message, startedAt));
|
package/dist/testing/types.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* under a hard cap. The cardinal rule lives in `runner.ts`: **passed is
|
|
5
5
|
* derived from the exit code and nothing else.**
|
|
6
6
|
*/
|
|
7
|
+
import type { ShellSelection } from "../tools/shell/resolve-shell.js";
|
|
7
8
|
/** A resolved test command and where it came from — shown, never guessed. */
|
|
8
9
|
export interface TestCommand {
|
|
9
10
|
command: string;
|
|
@@ -40,10 +41,14 @@ export interface TestRunOptions {
|
|
|
40
41
|
timeoutMs: number;
|
|
41
42
|
/** Cap on captured output bytes (tail-biased). */
|
|
42
43
|
captureBytes: number;
|
|
44
|
+
/** Shell selection, so the host runner routes through the resolved shell (Git
|
|
45
|
+
* Bash / PowerShell on Windows) — the same seam as run_command. The sandbox
|
|
46
|
+
* runner ignores it (the container has its own shell). */
|
|
47
|
+
shell: ShellSelection;
|
|
43
48
|
}
|
|
44
49
|
/**
|
|
45
50
|
* The swappable execution seam (same discipline as VectorStore/ForgeProvider):
|
|
46
|
-
* the shipped {@link CommandTestRunner} spawns the command via the
|
|
51
|
+
* the shipped {@link CommandTestRunner} spawns the command via the resolved
|
|
47
52
|
* shell; tests inject fakes, and a future framework-native runner (e.g. a
|
|
48
53
|
* vitest API runner) slots in without touching the tool.
|
|
49
54
|
*/
|
package/dist/tools/shell/exec.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { killTree
|
|
1
|
+
import { killTree } from "../../utils/process-tree.js";
|
|
2
|
+
import { spawnShell } from "./resolve-shell.js";
|
|
2
3
|
/**
|
|
3
4
|
* Gate `command` through `ctx.requestApproval`, then — only if allowed — execute
|
|
4
5
|
* it via the sandbox (when `ctx.sandbox` is set) or the bounded host spawn. A
|
|
@@ -50,10 +51,12 @@ async function runSandboxed(command, ctx) {
|
|
|
50
51
|
function runBounded(command, ctx) {
|
|
51
52
|
const { timeoutMs, maxOutputBytes } = ctx.config.shell;
|
|
52
53
|
return new Promise((resolve) => {
|
|
53
|
-
// `
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
54
|
+
// `spawnShell` routes through the resolved shell (POSIX default shell, Git
|
|
55
|
+
// Bash, or PowerShell — see resolve-shell.ts) as the head of a killable tree
|
|
56
|
+
// (its own process group on POSIX; the OS parent-PID tree on win32), so the
|
|
57
|
+
// whole tree — the shell plus anything it spawns — can be killed via
|
|
58
|
+
// `killTree`. A win32 host with no usable shell threw at session start.
|
|
59
|
+
const child = spawnShell(command, ctx.config.shell, { cwd: ctx.cwd });
|
|
57
60
|
const chunks = [];
|
|
58
61
|
let captured = 0;
|
|
59
62
|
let truncated = false;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* WHICH shell a host command runs through, and — just as important — WHICH
|
|
4
|
+
* dialect the model must emit for it. `run_command` and `run_tests` both spawn
|
|
5
|
+
* with the platform default shell (`{ shell: true }` → `/bin/sh` on POSIX,
|
|
6
|
+
* `%ComSpec%`/cmd.exe on win32). The model emits bash, so on Windows a command
|
|
7
|
+
* like `echo x; exit 1` runs as a single `echo`, exits 0, and reads as a false
|
|
8
|
+
* success. This module makes Windows host execution first-class instead:
|
|
9
|
+
*
|
|
10
|
+
* Tier 0 `shell.executable` override → use it verbatim (the user owns it).
|
|
11
|
+
* Tier 1 Git Bash (Git for Windows) → full POSIX parity, no model changes.
|
|
12
|
+
* Tier 2 PowerShell (pwsh, else powershell.exe) → the model is told, via a
|
|
13
|
+
* system-prompt directive keyed on {@link ShellPlan.dialect}, to emit
|
|
14
|
+
* native PowerShell.
|
|
15
|
+
* Tier 3 nothing usable → throw CRUXY_E_NO_SHELL. We NEVER hand bash-shaped
|
|
16
|
+
* strings to cmd.exe silently — that is the exact false-green above.
|
|
17
|
+
*
|
|
18
|
+
* A non-default shell is ALWAYS invoked with explicit argv (`bash -c <cmd>`,
|
|
19
|
+
* `powershell -NoProfile -Command <cmd>`), never Node's `shell: <path>` string
|
|
20
|
+
* form: Node builds that command line with cmd.exe quoting rules, which mangle a
|
|
21
|
+
* command bound for bash/PowerShell. POSIX with no override keeps `shell: true`
|
|
22
|
+
* verbatim — a zero-change path off Windows.
|
|
23
|
+
*/
|
|
24
|
+
export type ShellDialect = "posix" | "powershell" | "cmd";
|
|
25
|
+
export type ShellSource = "default" | "config" | "git-bash" | "powershell";
|
|
26
|
+
/**
|
|
27
|
+
* The shell-selection fields the resolver reads — a structural subset of the
|
|
28
|
+
* config's `ShellConfig` (which also carries `timeoutMs`/`maxOutputBytes`). Kept
|
|
29
|
+
* narrow so callers and tests pass only what selection needs.
|
|
30
|
+
*/
|
|
31
|
+
export interface ShellSelection {
|
|
32
|
+
executable?: string;
|
|
33
|
+
dialect?: ShellDialect;
|
|
34
|
+
}
|
|
35
|
+
/** How to spawn a host command, and which dialect the model should emit for it. */
|
|
36
|
+
export type ShellPlan =
|
|
37
|
+
/** POSIX default: `spawn(command, { shell: true })`, byte-identical to before. */
|
|
38
|
+
{
|
|
39
|
+
kind: "system";
|
|
40
|
+
dialect: "posix";
|
|
41
|
+
source: "default";
|
|
42
|
+
}
|
|
43
|
+
/** Explicit shell binary + fixed flags; the command is the final argv element. */
|
|
44
|
+
| {
|
|
45
|
+
kind: "explicit";
|
|
46
|
+
file: string;
|
|
47
|
+
flags: readonly string[];
|
|
48
|
+
dialect: ShellDialect;
|
|
49
|
+
source: ShellSource;
|
|
50
|
+
};
|
|
51
|
+
/** Injected so the resolver's win32 logic is unit-testable on a POSIX CI host. */
|
|
52
|
+
export interface ShellEnv {
|
|
53
|
+
platform: NodeJS.Platform;
|
|
54
|
+
env: NodeJS.ProcessEnv;
|
|
55
|
+
exists: (candidate: string) => boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Infer a dialect from a shell path's basename, but ONLY for the unambiguous
|
|
59
|
+
* names — anything exotic (`nu`, `fish`, `xonsh`, …) returns undefined so the
|
|
60
|
+
* caller demands an explicit `shell.dialect` rather than guessing wrong.
|
|
61
|
+
*/
|
|
62
|
+
export declare function sniffDialect(executable: string): ShellDialect | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the shell PLAN from config + environment. Pure over its injected
|
|
65
|
+
* {@link ShellEnv}, so the win32 tiers are exercised on any CI host. Throws
|
|
66
|
+
* CRUXY_E_NO_SHELL (no usable shell) or CRUXY_E_CONFIG_INVALID (an override
|
|
67
|
+
* whose dialect can't be determined) — both fail loud, never a silent fallback.
|
|
68
|
+
*/
|
|
69
|
+
export declare function computeShellPlan(shell: ShellSelection, env?: ShellEnv): ShellPlan;
|
|
70
|
+
/** The memoized production resolver. Throws (fail loud) exactly as compute does. */
|
|
71
|
+
export declare function resolveShell(shell: ShellSelection): ShellPlan;
|
|
72
|
+
/** Test hook: drop the memoized plan so the next resolve re-detects. */
|
|
73
|
+
export declare function __resetShellResolution(): void;
|
|
74
|
+
/**
|
|
75
|
+
* Spawn `command` through the resolved shell as a killable process tree — the
|
|
76
|
+
* single seam both `run_command` and `run_tests` route through. `system` keeps
|
|
77
|
+
* the exact `{ shell: true }` spawn; every other plan invokes the shell binary
|
|
78
|
+
* with explicit argv so the command reaches bash/PowerShell unmangled (see the
|
|
79
|
+
* module header).
|
|
80
|
+
*/
|
|
81
|
+
export declare function spawnShell(command: string, shell: ShellSelection, options?: SpawnOptions): ChildProcess;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnTree } from "../../utils/process-tree.js";
|
|
4
|
+
import { CruxyError, ErrorCode } from "../../errors/types.js";
|
|
5
|
+
const realEnv = {
|
|
6
|
+
platform: process.platform,
|
|
7
|
+
env: process.env,
|
|
8
|
+
exists: existsSync,
|
|
9
|
+
};
|
|
10
|
+
/** The invocation flags for each dialect (the command follows as one argv slot). */
|
|
11
|
+
function flagsFor(dialect) {
|
|
12
|
+
switch (dialect) {
|
|
13
|
+
case "powershell":
|
|
14
|
+
// `-NoProfile` skips slow/interfering user profiles; `-Command` takes the
|
|
15
|
+
// command as one string argument.
|
|
16
|
+
return ["-NoProfile", "-Command"];
|
|
17
|
+
case "cmd":
|
|
18
|
+
return ["/d", "/s", "/c"];
|
|
19
|
+
case "posix":
|
|
20
|
+
return ["-c"];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function explicit(file, dialect, source) {
|
|
24
|
+
return { kind: "explicit", file, flags: flagsFor(dialect), dialect, source };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Infer a dialect from a shell path's basename, but ONLY for the unambiguous
|
|
28
|
+
* names — anything exotic (`nu`, `fish`, `xonsh`, …) returns undefined so the
|
|
29
|
+
* caller demands an explicit `shell.dialect` rather than guessing wrong.
|
|
30
|
+
*/
|
|
31
|
+
export function sniffDialect(executable) {
|
|
32
|
+
// Split on either separator so a POSIX or a Windows path both reduce to the
|
|
33
|
+
// bare shell name, then drop a trailing `.exe`.
|
|
34
|
+
const base = executable.split(/[\\/]/).pop() ?? executable;
|
|
35
|
+
const name = base.toLowerCase().replace(/\.exe$/, "");
|
|
36
|
+
switch (name) {
|
|
37
|
+
case "bash":
|
|
38
|
+
case "sh":
|
|
39
|
+
return "posix";
|
|
40
|
+
case "pwsh":
|
|
41
|
+
case "powershell":
|
|
42
|
+
return "powershell";
|
|
43
|
+
case "cmd":
|
|
44
|
+
return "cmd";
|
|
45
|
+
default:
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** True for the WSL launcher (`C:\Windows\System32\bash.exe`) — a Linux shell
|
|
50
|
+
* with `/mnt/c` path semantics, never Git Bash. Explicitly rejected. */
|
|
51
|
+
function isWslBash(candidate) {
|
|
52
|
+
return /\\system32\\/i.test(candidate);
|
|
53
|
+
}
|
|
54
|
+
/** Probe the well-known Git-for-Windows install locations for `bash.exe`. */
|
|
55
|
+
function findGitBash(env) {
|
|
56
|
+
const roots = [
|
|
57
|
+
env.env["ProgramFiles"],
|
|
58
|
+
env.env["ProgramW6432"],
|
|
59
|
+
env.env["ProgramFiles(x86)"],
|
|
60
|
+
env.env["LOCALAPPDATA"] &&
|
|
61
|
+
path.win32.join(env.env["LOCALAPPDATA"], "Programs"),
|
|
62
|
+
].filter((r) => Boolean(r));
|
|
63
|
+
for (const root of roots) {
|
|
64
|
+
const candidate = path.win32.join(root, "Git", "bin", "bash.exe");
|
|
65
|
+
// Path-probe only — never `where bash`, which resolves the WSL launcher.
|
|
66
|
+
if (isWslBash(candidate))
|
|
67
|
+
continue;
|
|
68
|
+
if (env.exists(candidate))
|
|
69
|
+
return candidate;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
/** Resolve a bare executable name against PATH (+ the win32 PATH dir walk). */
|
|
74
|
+
function onWindowsPath(name, env) {
|
|
75
|
+
const raw = env.env["PATH"] ?? env.env["Path"] ?? "";
|
|
76
|
+
for (const dir of raw.split(";").filter(Boolean)) {
|
|
77
|
+
const full = path.win32.join(dir, name);
|
|
78
|
+
if (env.exists(full))
|
|
79
|
+
return full;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
/** PowerShell 7+ (`pwsh`) preferred; Windows PowerShell 5.1 the guaranteed floor. */
|
|
84
|
+
function findPowerShell(env) {
|
|
85
|
+
return onWindowsPath("pwsh.exe", env) ?? onWindowsPath("powershell.exe", env);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the shell PLAN from config + environment. Pure over its injected
|
|
89
|
+
* {@link ShellEnv}, so the win32 tiers are exercised on any CI host. Throws
|
|
90
|
+
* CRUXY_E_NO_SHELL (no usable shell) or CRUXY_E_CONFIG_INVALID (an override
|
|
91
|
+
* whose dialect can't be determined) — both fail loud, never a silent fallback.
|
|
92
|
+
*/
|
|
93
|
+
export function computeShellPlan(shell, env = realEnv) {
|
|
94
|
+
// Tier 0: explicit override always wins, on every platform.
|
|
95
|
+
if (shell.executable) {
|
|
96
|
+
const dialect = shell.dialect ?? sniffDialect(shell.executable);
|
|
97
|
+
if (!dialect) {
|
|
98
|
+
throw new CruxyError({
|
|
99
|
+
code: ErrorCode.ConfigInvalid,
|
|
100
|
+
title: "Cannot determine the dialect of the configured shell",
|
|
101
|
+
cause: `shell.executable is "${shell.executable}", but its command dialect could not be inferred from its name.`,
|
|
102
|
+
nextSteps: ["Set shell.dialect to one of: posix, powershell, cmd."],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return explicit(shell.executable, dialect, "config");
|
|
106
|
+
}
|
|
107
|
+
// POSIX with no override: the historical default shell, byte-for-byte.
|
|
108
|
+
if (env.platform !== "win32") {
|
|
109
|
+
return { kind: "system", dialect: "posix", source: "default" };
|
|
110
|
+
}
|
|
111
|
+
// Tier 1: Git Bash → full POSIX parity, model unchanged.
|
|
112
|
+
const bash = findGitBash(env);
|
|
113
|
+
if (bash)
|
|
114
|
+
return explicit(bash, "posix", "git-bash");
|
|
115
|
+
// Tier 2: PowerShell → the model is told to emit PowerShell (see prompts.ts).
|
|
116
|
+
const ps = findPowerShell(env);
|
|
117
|
+
if (ps)
|
|
118
|
+
return explicit(ps, "powershell", "powershell");
|
|
119
|
+
// Tier 3: nothing usable — fail loud, never cmd.exe-pretends-to-be-bash.
|
|
120
|
+
throw new CruxyError({
|
|
121
|
+
code: ErrorCode.NoShell,
|
|
122
|
+
title: "No usable shell found to run commands",
|
|
123
|
+
cause: "On Windows, cruxy runs commands through Git Bash or PowerShell; neither was found, and no shell.executable override is set.",
|
|
124
|
+
nextSteps: [
|
|
125
|
+
"Install Git for Windows (https://git-scm.com/download/win) to get Git Bash.",
|
|
126
|
+
"Or set shell.executable in your cruxy config to a shell of your choice (and shell.dialect if its name is non-standard).",
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// Resolve once per process (JC#8): detection touches the filesystem, and the
|
|
131
|
+
// answer is stable for a session. `computeShellPlan` stays the injectable,
|
|
132
|
+
// un-memoized unit the tests drive.
|
|
133
|
+
let cached;
|
|
134
|
+
/** The memoized production resolver. Throws (fail loud) exactly as compute does. */
|
|
135
|
+
export function resolveShell(shell) {
|
|
136
|
+
return (cached ??= computeShellPlan(shell));
|
|
137
|
+
}
|
|
138
|
+
/** Test hook: drop the memoized plan so the next resolve re-detects. */
|
|
139
|
+
export function __resetShellResolution() {
|
|
140
|
+
cached = undefined;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Spawn `command` through the resolved shell as a killable process tree — the
|
|
144
|
+
* single seam both `run_command` and `run_tests` route through. `system` keeps
|
|
145
|
+
* the exact `{ shell: true }` spawn; every other plan invokes the shell binary
|
|
146
|
+
* with explicit argv so the command reaches bash/PowerShell unmangled (see the
|
|
147
|
+
* module header).
|
|
148
|
+
*/
|
|
149
|
+
export function spawnShell(command, shell, options = {}) {
|
|
150
|
+
const plan = resolveShell(shell);
|
|
151
|
+
if (plan.kind === "system") {
|
|
152
|
+
return spawnTree(command, [], { ...options, shell: true });
|
|
153
|
+
}
|
|
154
|
+
return spawnTree(plan.file, [...plan.flags, command], {
|
|
155
|
+
...options,
|
|
156
|
+
shell: false,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -15,10 +15,13 @@ import { spawn, } from "node:child_process";
|
|
|
15
15
|
* could never do on Windows (it would kill only the direct child and orphan
|
|
16
16
|
* the grandchildren).
|
|
17
17
|
*
|
|
18
|
-
* This module exists because that pairing used to be copy-pasted —
|
|
18
|
+
* This module exists because that pairing used to be copy-pasted — four
|
|
19
19
|
* POSIX-only `killTree` variants (run_command, the test runner, the LSP/MCP
|
|
20
|
-
* backstop), each of which silently orphaned
|
|
21
|
-
* now one implementation; a change to the
|
|
20
|
+
* backstop, and the docker client), each of which silently orphaned
|
|
21
|
+
* grandchildren on Windows. There is now one implementation; a change to the
|
|
22
|
+
* kill discipline changes every path. Spawn sites likewise route through
|
|
23
|
+
* `spawnTree` (run_command, run_tests, LSP, MCP, docker) so none re-introduces
|
|
24
|
+
* the win32 `detached` console-flash the grouping logic exists to avoid.
|
|
22
25
|
*/
|
|
23
26
|
const isWindows = process.platform === "win32";
|
|
24
27
|
/**
|