Skip to content

Commit 6a7652a

Browse files
committed
fix(kernel): exec() falls back to node when sh is unavailable
Fixes #64 — exec() throws 'No shell available' when only NodeRuntime is mounted (which registers 'node', not 'sh'). Before this change: createKernel() → mount(createNodeRuntime()) kernel.exec('node -e "console.log(1)"') // throws: No shell available After this change: - If 'sh' is registered: routes through shell (existing behavior) - If only 'node' is registered: parses command string, strips 'node' prefix, spawns node directly with remaining args - If neither: throws improved error with actionable guidance Added: - #parseCommandArgs(): shell-like tokenizer (handles '...', "..." escapes) - #collectExecResult(): extracted common stdout/stderr collection logic - 3 new test cases in kernel-integration.test.ts
1 parent e03a2fa commit 6a7652a

2 files changed

Lines changed: 128 additions & 6 deletions

File tree

packages/core/src/kernel/kernel.ts

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -303,14 +303,99 @@ class KernelImpl implements Kernel {
303303

304304
// Route through shell
305305
const shell = this.commandRegistry.resolve("sh");
306-
if (!shell) {
307-
throw new Error(
308-
"No shell available. Mount a WasmVM runtime to enable exec().",
309-
);
306+
if (shell) {
307+
const proc = this.spawnInternal("sh", ["-c", command], options);
308+
return this.#collectExecResult(proc, options);
310309
}
311310

312-
const proc = this.spawnInternal("sh", ["-c", command], options);
311+
// No shell available. If 'node' is registered (e.g. NodeRuntime mounted),
312+
// fall back to direct node execution — parse command string into node args.
313+
// This makes the README example work out-of-the-box:
314+
// kernel.exec("node -e \"console.log('hello')\"")
315+
const nodeCmd = this.commandRegistry.resolve("node");
316+
if (nodeCmd) {
317+
// Parse command string into individual args (handles quotes)
318+
const args = this.#parseCommandArgs(command);
319+
if (args.length > 0 && args[0] === "node") {
320+
args.shift(); // strip 'node' prefix, keep the rest
321+
const proc = this.spawnInternal("node", args, options);
322+
return this.#collectExecResult(proc, options);
323+
}
324+
}
325+
326+
throw new Error(
327+
"No shell available. Mount a WasmVM runtime to enable exec(), " +
328+
"or mount a runtime that registers the 'node' command and use " +
329+
"`kernel.exec('node -e \"code\"')`.",
330+
);
331+
}
332+
333+
/**
334+
* Parse a command string into individual arguments.
335+
* Handles single quotes, double quotes, and basic shell tokenization.
336+
*/
337+
#parseCommandArgs(command: string): string[] {
338+
const args: string[] = [];
339+
let current = "";
340+
let inSingle = false;
341+
let inDouble = false;
342+
let i = 0;
343+
344+
while (i < command.length) {
345+
const ch = command[i];
346+
347+
if (inSingle) {
348+
if (ch === "'") {
349+
inSingle = false;
350+
} else {
351+
current += ch;
352+
}
353+
i++;
354+
} else if (inDouble) {
355+
if (ch === '"') {
356+
inDouble = false;
357+
} else if (ch === "\\" && i + 1 < command.length) {
358+
// Handle escape sequences in double quotes
359+
current += command[i + 1];
360+
i += 2;
361+
} else {
362+
current += ch;
363+
i++;
364+
}
365+
} else {
366+
if (ch === "'") {
367+
inSingle = true;
368+
i++;
369+
} else if (ch === '"') {
370+
inDouble = true;
371+
i++;
372+
} else if (ch === " ") {
373+
if (current.length > 0) {
374+
args.push(current);
375+
current = "";
376+
}
377+
i++;
378+
} else {
379+
current += ch;
380+
i++;
381+
}
382+
}
383+
}
384+
385+
if (current.length > 0) {
386+
args.push(current);
387+
}
388+
389+
return args;
390+
}
313391

392+
/**
393+
* Collect stdout/stderr from a spawned process for exec().
394+
*/
395+
async #collectExecResult(
396+
proc: InternalProcess,
397+
options?: ExecOptions,
398+
): Promise<ExecResult> {
314399
// Write stdin if provided
315400
if (options?.stdin) {
316401
const data =
@@ -347,7 +432,7 @@ class KernelImpl implements Kernel {
347432
new Promise<number>((_, reject) => {
348433
timer = setTimeout(() => {
349434
// Kill process and detach output callbacks
350-
this.log.warn({ command, timeout: options.timeout }, "exec timeout, sending SIGTERM");
435+
this.log.warn({ timeout: options.timeout }, "exec timeout, sending SIGTERM");
351436
proc.onStdout = null;
352437
proc.onStderr = null;
353438
proc.kill(SIGTERM);

packages/core/test/kernel/kernel-integration.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,43 @@ describe("kernel + MockRuntimeDriver integration", () => {
7171
expect(result.stderr).toBe("warn\n");
7272
});
7373

74+
it("exec falls back to node when sh is not available", async () => {
75+
// NodeRuntime only registers 'node', not 'sh'.
76+
// exec() should detect this and route through node directly.
77+
// This is the fix for: https://github.com/rivet-dev/secure-exec/issues/64
78+
const driver = new MockRuntimeDriver(["node"], {
79+
node: {
80+
exitCode: 0,
81+
stdout: "hello from node\n",
82+
stderr: "",
83+
// args are passed as-is; for node -e "code", args = ["-e", "code"]
84+
},
85+
});
86+
({ kernel } = await createTestKernel({ drivers: [driver] }));
87+
88+
const result = await kernel.exec("node -e \"console.log('hello from node')\"");
89+
expect(result.exitCode).toBe(0);
90+
expect(result.stdout).toBe("hello from node\n");
91+
});
92+
93+
it("exec of node command with single-quoted code", async () => {
94+
const driver = new MockRuntimeDriver(["node"], {
95+
node: { exitCode: 0, stdout: "42\n" },
96+
});
97+
({ kernel } = await createTestKernel({ drivers: [driver] }));
98+
99+
const result = await kernel.exec("node -e 'console.log(42)'");
100+
expect(result.exitCode).toBe(0);
101+
expect(result.stdout).toBe("42\n");
102+
});
103+
104+
it("exec throws descriptive error when neither sh nor node is available", async () => {
105+
// No drivers registered — neither sh nor node
106+
({ kernel } = await createTestKernel({ drivers: [] }));
107+
108+
await expect(kernel.exec("echo hello")).rejects.toThrow("No shell available");
109+
});
110+
74111
it("exec of unknown command throws ENOENT", async () => {
75112
const driver = new MockRuntimeDriver(["sh"], { sh: { exitCode: 0 } });
76113
({ kernel } = await createTestKernel({ drivers: [driver] }));

0 commit comments

Comments
 (0)