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 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/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, + ); +}); 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/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), 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 }] }, 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)) {