diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f65733..5b34d835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/harness/src/plugin/builtin/tool/bash.ts b/packages/harness/src/plugin/builtin/tool/bash.ts index a8fbc4e0..139baf32 100644 --- a/packages/harness/src/plugin/builtin/tool/bash.ts +++ b/packages/harness/src/plugin/builtin/tool/bash.ts @@ -53,6 +53,8 @@ const BashSuccess = Schema.Struct({ ...outputFields, exitCode: Schema.Finite }); class BashFailed extends Schema.TaggedError()("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. */ @@ -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; }; @@ -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 @@ -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; }); @@ -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; }); diff --git a/packages/harness/src/tool/output.ts b/packages/harness/src/tool/output.ts index 3ce95c5a..33c98126 100644 --- a/packages/harness/src/tool/output.ts +++ b/packages/harness/src/tool/output.ts @@ -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). @@ -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", }); /** @@ -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. */ @@ -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, + ], ); }; @@ -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]) }; }; /** diff --git a/packages/harness/test/__artifacts__/tools.bash.syntax.json b/packages/harness/test/__artifacts__/tools.bash.syntax.json new file mode 100644 index 00000000..e0495155 --- /dev/null +++ b/packages/harness/test/__artifacts__/tools.bash.syntax.json @@ -0,0 +1,9 @@ +{ + "status": "completed", + "text": "array b +1 2 3 +here-string +source x=1 +shell bash +", +} \ No newline at end of file diff --git a/packages/harness/test/fixtures/bash.spec.ts b/packages/harness/test/fixtures/bash.spec.ts index 54908d33..2c578eae 100644 --- a/packages/harness/test/fixtures/bash.spec.ts +++ b/packages/harness/test/fixtures/bash.spec.ts @@ -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"; @@ -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", () => diff --git a/packages/harness/test/tools.fixture.ts b/packages/harness/test/tools.fixture.ts index 0d1823f9..b5f50c29 100644 --- a/packages/harness/test/tools.fixture.ts +++ b/packages/harness/test/tools.fixture.ts @@ -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("; "); diff --git a/packages/harness/test/tools.output.test.ts b/packages/harness/test/tools.output.test.ts index 8a321500..90ef5f59 100644 --- a/packages/harness/test/tools.output.test.ts +++ b/packages/harness/test/tools.output.test.ts @@ -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"; @@ -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 @@ -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 { @@ -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; @@ -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], + [ + "buffers", + (shell: Layer.Layer) => + 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(); diff --git a/sites/www/content/docs/sandboxes/local.mdx b/sites/www/content/docs/sandboxes/local.mdx index dced2005..d9a1d761 100644 --- a/sites/www/content/docs/sandboxes/local.mdx +++ b/sites/www/content/docs/sandboxes/local.mdx @@ -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.