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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<version>`, 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

Expand Down
2 changes: 1 addition & 1 deletion packages/aikit/test/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 11 additions & 4 deletions packages/codework/src/cli/cmd/handlers/run.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions packages/codework/test/__artifacts__/cli.sandbox.live.daytona.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"status": 0,
"bash": [
"completed"
],
"sandboxes": [
{
"driver": "daytona",
"kind": "remote",
"ownership": "managed",
"status": "offline",
"provider_status": "stopped",
"last_error": null
}
]
}
16 changes: 16 additions & 0 deletions packages/codework/test/__artifacts__/cli.sandbox.live.memory.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"status": 0,
"bash": [
"completed"
],
"sandboxes": [
{
"driver": "memory",
"kind": "virtual",
"ownership": "managed",
"status": "offline",
"provider_status": "retained",
"last_error": null
}
]
}
16 changes: 16 additions & 0 deletions packages/codework/test/__artifacts__/cli.sandbox.live.vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"status": 0,
"bash": [
"completed"
],
"sandboxes": [
{
"driver": "vercel",
"kind": "remote",
"ownership": "managed",
"status": "offline",
"provider_status": "stopped",
"last_error": null
}
]
}
94 changes: 94 additions & 0 deletions packages/codework/test/cli.sandbox.live.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
2 changes: 1 addition & 1 deletion packages/codework/test/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 21 additions & 7 deletions packages/harness/src/sandboxes/daytona/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never> => {
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<string>) => {
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.
Expand All @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion packages/harness/test/fixtures/runner.cycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,10 @@ export const runnerCycleSpec = (resourceId: () => Promise<string>) =>
});
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 }] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/harness/test/tools.registry.remote.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/harness/test/utils/env.ts
Original file line number Diff line number Diff line change
@@ -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)) {
Expand Down
Loading