From b0070011ee575998ba868eefa60672439c15b7e1 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 13 Oct 2026 16:15:01 +0530 Subject: [PATCH 1/5] fix(cli): stop a run's created sandbox before the harness closes The run's scope wrapped the provided harness layer, so its finalizers, including stopping the sandbox the run created, ran after the database had closed. Scope inside the provide instead, and log to stderr so stdout carries only the response. Co-Authored-By: Claude Opus 5.5 --- packages/codework/src/cli/cmd/handlers/run.ts | 15 ++- .../cli.sandbox.live.daytona.json | 16 ++++ .../cli.sandbox.live.memory.json | 16 ++++ .../cli.sandbox.live.vercel.json | 16 ++++ .../codework/test/cli.sandbox.live.test.ts | 94 +++++++++++++++++++ 5 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 packages/codework/test/__artifacts__/cli.sandbox.live.daytona.json create mode 100644 packages/codework/test/__artifacts__/cli.sandbox.live.memory.json create mode 100644 packages/codework/test/__artifacts__/cli.sandbox.live.vercel.json create mode 100644 packages/codework/test/cli.sandbox.live.test.ts diff --git a/packages/codework/src/cli/cmd/handlers/run.ts b/packages/codework/src/cli/cmd/handlers/run.ts index 6225c20f..0574fc16 100644 --- a/packages/codework/src/cli/cmd/handlers/run.ts +++ b/packages/codework/src/cli/cmd/handlers/run.ts @@ -1,5 +1,5 @@ import { EventList, type EventSchema, Harness, Sandbox, Session } from "@codeworksh/harness/effect"; -import { Effect, Exit, Fiber, Option, Queue, Ref, Schema, Stream } from "effect"; +import { Effect, Exit, Fiber, Logger, Option, Queue, Ref, Schema, Stream } from "effect"; import { Runtime } from "../../../framework/runtime.ts"; import { Client } from "../../../server/client.ts"; import { InvalidInputError, renderError } from "../../error.ts"; @@ -210,12 +210,19 @@ export default Runtime.handler( yield* finish(renderState, columns); }); + // Scope inside the provided layer, so the run's finalizers (stopping a created sandbox) close + // while the harness and its database are still open. const execute = Effect.gen(function* () { - if (Option.isSome(server)) return yield* remote.pipe(Effect.provide(Client.layer(server.value))); - return yield* program.pipe(Effect.provide(Harness.layer(harnessOptions(shared)))); + if (Option.isSome(server)) + return yield* remote.pipe(Effect.scoped, Effect.provide(Client.layer(server.value))); + // Stdout carries only the response; harness logs go with the rest of the diagnostics. + return yield* program.pipe( + Effect.scoped, + Effect.provide(Harness.layer(harnessOptions(shared))), + Effect.provideService(Logger.LogToStderr, true), + ); }); return yield* execute.pipe( - Effect.scoped, Effect.catch((error) => writeError(renderError(error)).pipe( Effect.andThen( diff --git a/packages/codework/test/__artifacts__/cli.sandbox.live.daytona.json b/packages/codework/test/__artifacts__/cli.sandbox.live.daytona.json new file mode 100644 index 00000000..d43a8a4e --- /dev/null +++ b/packages/codework/test/__artifacts__/cli.sandbox.live.daytona.json @@ -0,0 +1,16 @@ +{ + "status": 0, + "bash": [ + "completed" + ], + "sandboxes": [ + { + "driver": "daytona", + "kind": "remote", + "ownership": "managed", + "status": "offline", + "provider_status": "stopped", + "last_error": null + } + ] +} diff --git a/packages/codework/test/__artifacts__/cli.sandbox.live.memory.json b/packages/codework/test/__artifacts__/cli.sandbox.live.memory.json new file mode 100644 index 00000000..f4ebb2ff --- /dev/null +++ b/packages/codework/test/__artifacts__/cli.sandbox.live.memory.json @@ -0,0 +1,16 @@ +{ + "status": 0, + "bash": [ + "completed" + ], + "sandboxes": [ + { + "driver": "memory", + "kind": "virtual", + "ownership": "managed", + "status": "offline", + "provider_status": "retained", + "last_error": null + } + ] +} diff --git a/packages/codework/test/__artifacts__/cli.sandbox.live.vercel.json b/packages/codework/test/__artifacts__/cli.sandbox.live.vercel.json new file mode 100644 index 00000000..48f8f4f5 --- /dev/null +++ b/packages/codework/test/__artifacts__/cli.sandbox.live.vercel.json @@ -0,0 +1,16 @@ +{ + "status": 0, + "bash": [ + "completed" + ], + "sandboxes": [ + { + "driver": "vercel", + "kind": "remote", + "ownership": "managed", + "status": "offline", + "provider_status": "stopped", + "last_error": null + } + ] +} diff --git a/packages/codework/test/cli.sandbox.live.test.ts b/packages/codework/test/cli.sandbox.live.test.ts new file mode 100644 index 00000000..6ed20079 --- /dev/null +++ b/packages/codework/test/cli.sandbox.live.test.ts @@ -0,0 +1,94 @@ +/* @effect-diagnostics nodeBuiltinImport:off -- this suite spawns the CLI as a child process. */ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vite-plus/test"; +import "./utils/env.ts"; + +/** + * A `codework run` that creates its own sandbox stops it on the way out, while the session + * database is still open, and exits 0. The sandbox's final state is read back from that database; + * for a remote driver that includes what the provider reported. A clean stderr also means probing + * for instruction files the sandbox does not have stays silent. + */ + +const cli = fileURLToPath(new URL("../src/index.ts", import.meta.url)); +const models = fileURLToPath(new URL("../../../models.gen.json", import.meta.url)); + +const MODEL = { provider: "openrouter", id: "deepseek/deepseek-v4.1-flash", env: "OPENROUTER_API_KEY" } as const; + +const DRIVERS = [ + { driver: "memory", env: [] }, + { driver: "daytona", env: ["DAYTONA_API_KEY"] }, + { driver: "vercel", env: ["VERCEL_OIDC_TOKEN"] }, +] as const; + +const PROMPT = "Run this bash command: echo hi. Reply with its output and nothing else."; + +describe.each(DRIVERS)("created $driver sandbox through the CLI", (live) => { + it.skipIf([MODEL.env, ...live.env].some((name) => !process.env[name]?.trim()))( + "stops the sandbox it created and exits 0", + async () => { + const root = mkdtempSync(join(tmpdir(), "codework-cli-sandbox-")); + try { + const project = join(root, "project"); + mkdirSync(project); + const home = join(root, "home"); + const database = join(root, "session.sqlite"); + const result = spawnSync( + process.execPath, + [ + "--conditions=development", + cli, + "--home", + home, + "--database", + database, + "run", + "--sandbox-driver", + live.driver, + "--provider", + MODEL.provider, + "--model", + MODEL.id, + PROMPT, + ], + { + encoding: "utf8", + cwd: project, + env: { ...process.env, HOME: home, CODEWORK_MODELS_FILE: models }, + timeout: 240_000, + }, + ); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout + result.stderr).not.toMatch(/ERROR|WARN/); + + const db = new DatabaseSync(database); + try { + const sandboxes = db + .prepare("SELECT driver, kind, ownership, status, provider_status, last_error FROM sandbox_instance") + .all() + .map((row) => ({ ...row })); + const bash = db + .prepare("SELECT status FROM session_entry_part WHERE type = 'toolCall' AND tool_name = 'bash'") + .all() + .map((row) => String(row["status"])); + expect(bash).toContain("completed"); + expect(result.stdout).toContain("hi"); + + await expect( + `${JSON.stringify({ status: result.status, bash: [...new Set(bash)], sandboxes }, null, "\t")}\n`, + ).toMatchFileSnapshot(`./__artifacts__/cli.sandbox.live.${live.driver}.json`); + } finally { + db.close(); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + 300_000, + ); +}); From 68fda9763ca69700f5978dc4506eb142878389e7 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 13 Oct 2026 16:15:01 +0530 Subject: [PATCH 2/5] fix(harness): report missing Daytona paths as ENOENT Co-Authored-By: Claude Opus 5.5 --- .../harness/src/sandboxes/daytona/provider.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/harness/src/sandboxes/daytona/provider.ts b/packages/harness/src/sandboxes/daytona/provider.ts index 2adbbf41..8e35df88 100644 --- a/packages/harness/src/sandboxes/daytona/provider.ts +++ b/packages/harness/src/sandboxes/daytona/provider.ts @@ -166,17 +166,30 @@ type RemoteFilesystemProvider = Pick< | "scanLines" >; +const enoent = (operation: string, path: string) => + Object.assign(new Error(`ENOENT: no such file or directory, ${operation} '${path}'`), { code: "ENOENT" }); + +/** The toolbox rejects a missing path with its own 404; the filesystem contract expects `ENOENT`. */ +const missing = + (operation: string, path: string) => + async (cause: unknown): Promise => { + const { DaytonaFileNotFoundError } = await import("@daytona/sdk"); + throw cause instanceof DaytonaFileNotFoundError ? Object.assign(enoent(operation, path), { cause }) : cause; + }; + const providerFrom = (sandbox: RemoteSandbox, options: Options) => { const run = async (argv: ReadonlyArray) => { const result = await sandbox.process.executeCommand(quoteArgv(argv), options.cwd, undefined, options.execTimeout); return { exitCode: result.exitCode, stdout: result.result ?? "" }; }; const filesystem: RemoteFilesystemProvider = { - readFile: async (path: string) => (await sandbox.fs.downloadFile(path)).toString("utf8"), - readFileBuffer: async (path: string) => new Uint8Array(await sandbox.fs.downloadFile(path)), + readFile: async (path: string) => + (await sandbox.fs.downloadFile(path).catch(missing("open", path))).toString("utf8"), + readFileBuffer: async (path: string) => + new Uint8Array(await sandbox.fs.downloadFile(path).catch(missing("open", path))), writeFile: (path: string, content: string | Uint8Array) => sandbox.fs.uploadFile(typeof content === "string" ? Buffer.from(content, "utf8") : Buffer.from(content), path), - stat: async (path: string) => statsFrom(await sandbox.fs.getFileDetails(path)), + stat: async (path: string) => statsFrom(await sandbox.fs.getFileDetails(path).catch(missing("stat", path))), // The toolbox file-details endpoint follows symlinks. Detect the entry // with the sandbox shell first so lstat never reports target metadata as // if it described the link itself. @@ -186,11 +199,13 @@ const providerFrom = (sandbox: RemoteSandbox, options: Options) => { if (result.exitCode === 0) { return { isFile: false, isDirectory: false, isSymbolicLink: true }; } - if (result.exitCode === 1) return statsFrom(await sandbox.fs.getFileDetails(path)); + if (result.exitCode === 1) + return statsFrom(await sandbox.fs.getFileDetails(path).catch(missing("lstat", path))); assertCommandSucceeded(command, result); throw new Error(`unreachable lstat result for ${path}`); }, - readdir: async (path: string) => (await sandbox.fs.listFiles(path)).map((entry) => entry.name), + readdir: async (path: string) => + (await sandbox.fs.listFiles(path).catch(missing("scandir", path))).map((entry) => entry.name), // Only a genuine 404 means "absent". Auth, rate-limit, and transport // failures propagate: a caller that deletes records on absence must not // be told a path is gone because the API was briefly unreachable. @@ -229,8 +244,7 @@ const providerFrom = (sandbox: RemoteSandbox, options: Options) => { const result = await sandbox.process.executeCommand(command, options.cwd, undefined, options.execTimeout); if (result.exitCode === 0) return (result.result ?? "").trimEnd(); } - // same shape `fs.stat` rejects with, so `isNotFoundError` recognises it - throw Object.assign(new Error(`ENOENT: no such file or directory, realpath '${path}'`), { code: "ENOENT" }); + throw enoent("realpath", path); }, scanLines: SandboxFileSystem.Scan.viaShell(run), readBytes: SandboxFileSystem.readBytesViaShell(run), From 28b883d65e3a39e3238a990e81492563b58fba14 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 13 Oct 2026 16:15:01 +0530 Subject: [PATCH 3/5] test: load credentials from the root .env.local Co-Authored-By: Claude Opus 5.5 --- packages/aikit/test/utils/env.ts | 2 +- packages/codework/test/utils/env.ts | 2 +- packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs | 2 +- packages/harness/test/tools.registry.remote.e2e.test.ts | 2 +- packages/harness/test/utils/env.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/aikit/test/utils/env.ts b/packages/aikit/test/utils/env.ts index 75b4482c..a12ebaf5 100644 --- a/packages/aikit/test/utils/env.ts +++ b/packages/aikit/test/utils/env.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -const envPath = fileURLToPath(new URL("../../.env.local", import.meta.url)); +const envPath = fileURLToPath(new URL("../../../../.env.local", import.meta.url)); if (existsSync(envPath)) { process.loadEnvFile(envPath); diff --git a/packages/codework/test/utils/env.ts b/packages/codework/test/utils/env.ts index 6257e57b..e4c3ce7f 100644 --- a/packages/codework/test/utils/env.ts +++ b/packages/codework/test/utils/env.ts @@ -4,7 +4,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -const envPath = fileURLToPath(new URL("../../.env.local", import.meta.url)); +const envPath = fileURLToPath(new URL("../../../../.env.local", import.meta.url)); if (existsSync(envPath)) { process.loadEnvFile(envPath); diff --git a/packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs b/packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs index 4706f954..9efeb6a6 100644 --- a/packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs +++ b/packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); const harness = resolve(root, "packages/harness"); const external = resolve(root, "extras/codework-sandbox-vercel"); -const envFile = resolve(harness, ".env.local"); +const envFile = resolve(root, ".env.local"); if (existsSync(envFile)) process.loadEnvFile(envFile); const manifest = JSON.parse(await readFile(resolve(harness, "package.json"), "utf8")); const effectVersion = manifest.dependencies?.effect; diff --git a/packages/harness/test/tools.registry.remote.e2e.test.ts b/packages/harness/test/tools.registry.remote.e2e.test.ts index b0e179e9..f7bb9a55 100644 --- a/packages/harness/test/tools.registry.remote.e2e.test.ts +++ b/packages/harness/test/tools.registry.remote.e2e.test.ts @@ -19,7 +19,7 @@ import "./utils/env.ts"; // Real-backend registry tests: the bash tool registered over an ACTUAL remote sandbox // (Daytona = buffered exec), executed through `Registry.resolve().handle` // with live progress flushed to a File IO temp sink. Gated on provider credentials from -// `.env.local` (see ./utils/env) — each suite skips when its key is absent. +// the root `.env.local` (see ./utils/env) — each suite skips when its key is absent. // // The Vercel streaming case lives in sandbox.vercel.e2e.test.ts so every Vercel // contract shares that file's single free-tier resource. diff --git a/packages/harness/test/utils/env.ts b/packages/harness/test/utils/env.ts index 486a10fc..26a12805 100644 --- a/packages/harness/test/utils/env.ts +++ b/packages/harness/test/utils/env.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; -const envPath = fileURLToPath(new URL("../../.env.local", import.meta.url)); +const envPath = fileURLToPath(new URL("../../../../.env.local", import.meta.url)); const modelsPath = fileURLToPath(new URL("../../../../models.gen.json", import.meta.url)); if (existsSync(envPath)) { From 6fc9fcb87431cdd67c7e490042a960d0e82fd234 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 13 Oct 2026 16:15:01 +0530 Subject: [PATCH 4/5] test(harness): make the runner cycle interrupt deterministic Co-Authored-By: Claude Opus 5.5 --- packages/harness/test/fixtures/runner.cycle.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/harness/test/fixtures/runner.cycle.spec.ts b/packages/harness/test/fixtures/runner.cycle.spec.ts index e8a077a1..b7482c5b 100644 --- a/packages/harness/test/fixtures/runner.cycle.spec.ts +++ b/packages/harness/test/fixtures/runner.cycle.spec.ts @@ -244,8 +244,10 @@ export const runnerCycleSpec = (resourceId: () => Promise) => }); yield* Effect.addFinalizer(() => removeDeltaListener); + // Long enough that it cannot finish streaming before the interrupt lands; a short reply + // can complete first and end the turn instead of failing it. const storyPrompt = - "Using every established detail about Velora, write a vivid story of exactly 100 words. Do not preface or explain it."; + "Using every established detail about Velora, write a vivid story of at least 3,000 words. Do not preface or explain it."; yield* control.prompt({ sessionId: session.id, prompt: { parts: [{ type: "text", text: storyPrompt }] }, From f9e07d184e860a1e3f8cf47dc29c4fb07fda591c Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 13 Oct 2026 16:15:01 +0530 Subject: [PATCH 5/5] docs: update the changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 605f5bee..0a31a53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,16 @@ This file is the canonical source for unreleased changes and published release n ### Fixed - `@codeworksh/plugin`: Fixed tools whose parameters are `Schema.Struct({})` being rejected by OpenAI and Anthropic. Their JSON Schema is now `{ "type": "object", "properties": {} }` instead of `{ "not": { "type": "null" } }`. +- `@codeworksh/cli`: Fixed `codework run` exiting 1 with `statement has been finalized` after printing the response when it created its own sandbox (`--sandbox-driver memory`, `daytona` or `vercel`). The sandbox was stopped after the database had closed, so the stop failed and a Daytona or Vercel sandbox was left running. It is now stopped before the database closes, and the run exits 0. +- `@codeworksh/cli`: `codework run` now writes logs to stderr, so stdout carries only the response. +- `@codeworksh/harness`: Fixed Daytona sandboxes logging a warning, with the full HTTP error, for every instruction file (`AGENTS.md`, `CLAUDE.md`, ...) a directory does not have. A missing path on Daytona now reads as not found, as on the other sandboxes, which also gives the `read` tool its usual not-found error there. + ### Internal - The `codework tui` E2E artifact now records the CLI version as ``, so it no longer goes stale on every version bump. +- Tests load provider and sandbox credentials from the root `.env.local` instead of one file per package. +- Added a live E2E test that runs `codework run` on a memory, Daytona and Vercel sandbox and checks the sandbox is stopped on exit. +- The live runner-cycle test now interrupts a reply long enough that it cannot finish first, so the interrupt no longer races the model. ## [@codeworksh/cli@0.2.0] - 2026-10-12