Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ This file is the canonical source for unreleased changes and published release n

- Add in-progress work under `## [Unreleased]

## [Unreleased]

### Fixed

- The `bash` tool now runs the model's command under bash on every sandbox. It ran under `sh`, which is dash on Debian and Ubuntu, so bash syntax such as arrays, `[[ ]]`, `{1..3}`, here-strings and `source` worked on a Mac but failed on Linux and in Debian-based containers, and broke the tool's own output handling, so the model got an error pointing into CodeWork's script rather than its command. Where bash is not on `PATH`, the command runs under `sh`, and a failure says so: `Command exited with code 2 (ran under sh: bash is not on PATH in this sandbox)`.

### Internal

- Added E2E tests that run bash-only syntax through the `bash` tool on the host, just-bash, Daytona and Vercel sandboxes and check they print the same output, and that a host without bash runs the command under `sh` and says so when it fails.

## [@codeworksh/cli@0.3.0] - 2026-10-13

### Added
Expand Down
10 changes: 7 additions & 3 deletions packages/harness/src/plugin/builtin/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const BashSuccess = Schema.Struct({ ...outputFields, exitCode: Schema.Finite });
class BashFailed extends Schema.TaggedError<BashFailed>()("BashFailed", {
...outputFields,
exitCode: Schema.Finite,
/** The shell that ran the command, when the sandbox reported it. */
shell: Schema.optional(Schema.Literals(["bash", "sh"])),
}) {}

/** Deadline exceeded — expected, model-visible. Carries the partial output produced so far. */
Expand All @@ -71,7 +73,7 @@ const withStatus = (failure: BashFailureError): string => {
? `Command timed out after ${failure.timeoutSeconds} seconds`
: failure.exitCode === -1
? "Command terminated without an exit code"
: `Command exited with code ${failure.exitCode}`;
: `Command exited with code ${failure.exitCode}${failure.shell === "sh" ? " (ran under sh: bash is not on PATH in this sandbox)" : ""}`;
return failure.output ? `${failure.output.replace(/\n$/, "")}\n\n${status}` : status;
};

Expand Down Expand Up @@ -134,6 +136,8 @@ const present = (snap: OutputSnapshot, lastLineBytes: number, saved: string | un

const utf8 = new TextEncoder();

const shellOf = (report: Output.Report | undefined) => (report !== undefined ? { shell: report.shell } : {});

/**
* A command cut off by its deadline never reached the wrapper's own cleanup: kill
* what is left of it and remove its side files, keeping the output file only when
Expand Down Expand Up @@ -195,7 +199,7 @@ const runBuffered = (
acc.finish();
const presented = present(acc.snapshot(), acc.getLastLineBytes(), window?.kept === true ? file : undefined);
if (result.exitCode !== 0) {
return yield* new BashFailed({ exitCode: result.exitCode, ...presented });
return yield* new BashFailed({ exitCode: result.exitCode, ...presented, ...shellOf(window) });
}
return { exitCode: result.exitCode, ...presented } satisfies typeof BashSuccess.Type;
});
Expand Down Expand Up @@ -275,7 +279,7 @@ const runStreaming = (
const code = yield* Ref.get(exitCode);
// No Exit event (e.g. killed before reporting one) → treat as a failure.
if (code === null || code !== 0) {
return yield* new BashFailed({ exitCode: code ?? -1, ...presented });
return yield* new BashFailed({ exitCode: code ?? -1, ...presented, ...shellOf(report) });
}
return { exitCode: code, ...presented } satisfies typeof BashSuccess.Type;
});
Expand Down
28 changes: 20 additions & 8 deletions packages/harness/src/tool/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,15 @@ const script = (
) =>
[
`f=${quote(file)}`,
// The command stays a quoted string, so syntax `sh` lacks cannot break the
// wrapper: it runs under bash where the sandbox has one, else under `sh`.
`c=${quote(command)}`,
"if command -v bash >/dev/null 2>&1; then s=bash; else s=sh; fi",
// A function whose body is a subshell: `exit` in the command ends only it,
// and the command is written once for both branches below.
// and the command is written once for both branches below. No `exec`:
// just-bash carries on past it.
"run() (",
command,
'\tif [ "$s" = bash ]; then bash -c "$c"; else eval "$c"; fi',
")",
// The side files are created the same exclusive way as the output file.
// `umask` may be missing where modes mean nothing (an in-process shell over a virtual filesystem).
Expand Down Expand Up @@ -83,21 +88,24 @@ const script = (
"run 2>&1",
].join("\n");

/** The sandbox's own account of the output: its raw size, and whether the file was kept. */
/** The sandbox's own account of the output: its raw size, whether the file was kept, and the shell that ran it. */
export interface Report {
/** Bytes and newlines in the whole output. */
readonly bytes: number;
readonly newlines: number;
/** Whether the sandbox kept the output file — decided on raw bytes, which the harness only sees decoded. */
readonly kept: boolean;
/** `sh` when the sandbox has no bash. */
readonly shell: "bash" | "sh";
}

const header = new RegExp(`^${HEADER} (\\d+) (\\d+) ([01])(?: (\\d+))?$`, "m");
const header = new RegExp(`^${HEADER} (\\d+) (\\d+) ([01]) (bash|sh)(?: (\\d+))?$`, "m");

const parse = (match: RegExpExecArray): Report => ({
bytes: Number(match[1]),
newlines: Number(match[2]),
kept: match[3] === "1",
shell: match[4] === "sh" ? "sh" : "bash",
});

/**
Expand All @@ -107,7 +115,7 @@ const parse = (match: RegExpExecArray): Report => ({
*/
export const streaming = (command: string, file: string, limits: Limits) =>
script(command, file, limits, (source) => `${source} | tee -a "$f"`, [
`printf '${HEADER} %s %s %s\\n' "$b" "$n" "$keep" >&2`,
`printf '${HEADER} %s %s %s %s\\n' "$b" "$n" "$keep" "$s" >&2`,
]);

/** Read {@link streaming}'s stderr: the report, if it got that far, and anything else the wrapper said. */
Expand All @@ -134,7 +142,11 @@ export const buffered = (command: string, file: string, limits: Limits) => {
// The redirect sits on the whole group, not on `cat`: an in-process shell
// (just-bash) re-encodes text that a command reading a pipe writes to a file.
(source) => `{ ${source} | cat; } >> "$f"`,
[`w=$(${tail} | wc -c | awk '{ print $1 }')`, `printf '${HEADER} %s %s %s %s\\n' "$b" "$n" "$keep" "$w"`, tail],
[
`w=$(${tail} | wc -c | awk '{ print $1 }')`,
`printf '${HEADER} %s %s %s %s %s\\n' "$b" "$n" "$keep" "$s" "$w"`,
tail,
],
);
};

Expand All @@ -149,8 +161,8 @@ export interface Window extends Report {
export const window = (stdout: string): Window | undefined => {
const newline = stdout.indexOf("\n");
const match = header.exec(newline === -1 ? stdout : stdout.slice(0, newline));
if (match === null || match[4] === undefined) return undefined;
return { ...parse(match), text: stdout.slice(newline + 1), textBytes: Number(match[4]) };
if (match === null || match[5] === undefined) return undefined;
return { ...parse(match), text: stdout.slice(newline + 1), textBytes: Number(match[5]) };
};

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/harness/test/__artifacts__/tools.bash.syntax.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"status": "completed",
"text": "array b
1 2 3
here-string
source x=1
shell bash
",
}
16 changes: 15 additions & 1 deletion packages/harness/test/fixtures/bash.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { SandboxDriver } from "../../src/sandbox/driver.ts";
import { SandboxIO } from "../../src/sandbox/io.ts";
import { bashPlugin } from "../../src/plugin/builtin/tool/bash.ts";
import { defaultPromptPlugin } from "../../src/plugin/builtin/prompt/default.ts";
import { pendingCall } from "../tools.fixture.ts";
import { BASH_SYNTAX, pendingCall } from "../tools.fixture.ts";
import { toolTurn } from "./llm.ts";
import { remoteSuite } from "./live.ts";
import { withSettings } from "./settings.ts";
Expand Down Expand Up @@ -112,6 +112,20 @@ export const bashPluginSpec = (options: {
180_000,
);

it(
"runs bash-only syntax under bash, as every local backend does",
() =>
withSettings(async (input) => {
const { call } = await exchange(input, BASH_SYNTAX);
if (call.status !== "completed" && call.status !== "error") throw new Error("bash was not settled");
const text = call.result.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
await expect({ status: call.status, text }).toMatchFileSnapshot(
"./__artifacts__/tools.bash.syntax.json",
);
}),
180_000,
);

it(
"truncates real output and preserves the entire spill file",
() =>
Expand Down
9 changes: 9 additions & 0 deletions packages/harness/test/tools.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,12 @@ export const pendingCall = (
status: "pending",
time: { start: 1, end: 1 },
});

/** Syntax that `sh` (dash, ash) rejects: every backend must run it under bash. */
export const BASH_SYNTAX = [
'arr=(a b c); [[ ${#arr[@]} -eq 3 ]] && echo "array ${arr[1]}"',
"echo {1..3}",
"cat <<< here-string",
`printf 'x=1\\n' > bash-syntax.env && source bash-syntax.env && echo "source x=$x"`,
'echo "shell ${BASH_VERSION:+bash}"',
].join("; ");
59 changes: 58 additions & 1 deletion packages/harness/test/tools.output.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Effect, Fiber, Layer, Stream } from "effect";
import type { ChildProcessSpawner } from "effect/process";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import { basename } from "node:path";
import { describe, expect, it } from "vite-plus/test";
Expand All @@ -14,7 +15,7 @@ import * as Executor from "../src/tool/executor.ts";
import { fromSandboxShell, ToolShell } from "../src/tool/shell.ts";
import * as Tool from "../src/tool/tool.ts";
import { tmpdir } from "./fixtures/tempdir.ts";
import { pendingCall } from "./tools.fixture.ts";
import { BASH_SYNTAX, pendingCall } from "./tools.fixture.ts";

/**
* The bash tool end to end over two real backends — the host shell, which
Expand All @@ -33,6 +34,8 @@ import { pendingCall } from "./tools.fixture.ts";
* - a backend that cannot cut a command short leaves it running after the deadline
* - a backend that hands text rather than bytes reports a file the sandbox removed,
* or numbers the shown lines wrongly
* - bash-only syntax fails where `sh` is not bash, or breaks the wrapper itself
* - a sandbox without bash fails the command without saying it ran under `sh`
*/

interface Details {
Expand Down Expand Up @@ -172,6 +175,23 @@ describe.each([
if (backend !== "justbash") await fs.rm(result.details.fullOutputPath!);
});

it("runs bash-only syntax under bash", async () => {
const { dir, layer } = await mount();
await using _ = dir;
const result = await run(layer, BASH_SYNTAX);
await expect({ status: result.status, text: result.text }).toMatchFileSnapshot(
"./__artifacts__/tools.bash.syntax.json",
);
});

it("keeps a syntax error inside the command", async () => {
const { dir, layer } = await mount();
await using _ = dir;
const result = await run(layer, "echo before; if then");
expect(result.details).toMatchObject({ _tag: "BashFailed", exitCode: 2, shell: "bash" });
expect(result.text).toMatch(/\n\nCommand exited with code 2$/);
});

it("tells the model when there is no output, and why a silent command failed", async () => {
const { dir, layer } = await mount();
await using _ = dir;
Expand Down Expand Up @@ -291,6 +311,43 @@ describe.each([
}
});

// A host without bash: the tools the wrapper needs, and nothing else.
const withoutBash = async (dir: string) => {
const bin = `${dir}/bin`;
await fs.mkdir(bin);
for (const tool of ["awk", "cat", "mkdir", "ps", "rm", "tail", "tee", "wc"]) {
const found = (process.env.PATH ?? "")
.split(":")
.map((path) => `${path}/${tool}`)
.find((path) => existsSync(path));
if (found === undefined) throw new Error(`${tool} not on PATH`);
await fs.symlink(found, `${bin}/${tool}`);
}
return HostExe.layer({ shell: "/bin/sh", env: { PATH: bin } });
};

describe.each([
["streams", (shell: Layer.Layer<Shell, never, ChildProcessSpawner.ChildProcessSpawner>) => shell],
[
"buffers",
(shell: Layer.Layer<Shell, never, ChildProcessSpawner.ChildProcessSpawner>) =>
Layer.effect(
Shell,
Effect.map(Shell, (inner) => Shell.of({ exec: inner.exec, execArgv: inner.execArgv })),
).pipe(Layer.provide(shell)),
],
] as const)("bash tool on a host without bash (%s)", (_mode, backend) => {
it("runs the command under sh, and says so when it fails", async () => {
await using dir = await tmpdir();
const layer = host(dir.path, backend(await withoutBash(dir.path)));
const ok = await run(layer, "echo ok");
expect(ok).toMatchObject({ status: "completed", text: "ok\n" });
const failed = await run(layer, "echo no; exit 3");
expect(failed.details).toMatchObject({ _tag: "BashFailed", exitCode: 3, shell: "sh" });
expect(failed.text).toBe("no\n\nCommand exited with code 3 (ran under sh: bash is not on PATH in this sandbox)");
});
});

describe("ToolShell over the host sandbox shell", () => {
it("force-kills a command that ignores SIGTERM at its deadline", async () => {
await using dir = await tmpdir();
Expand Down
2 changes: 1 addition & 1 deletion sites/www/content/docs/sandboxes/local.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: The default sandbox. Your folder and your real shell.

## How It Works

`local` is the default driver. It binds the agent directly to your machine: file tools use your real filesystem, starting in the current directory or in `--cwd`, and `bash` runs real commands through `sh` with the environment CodeWork started with.
`local` is the default driver. It binds the agent directly to your machine: file tools use your real filesystem, starting in the current directory or in `--cwd`, and `bash` runs real commands through bash (`sh` when bash is not on `PATH`) with the environment CodeWork started with.

Anything you have installed, such as `git`, `node`, or your test runner, the agent can use. There's nothing to provision, so it's the fastest sandbox, and the only one that works on your repository without copying anything.

Expand Down
Loading