diff --git a/packages/server/src/workspace/modal.ts b/packages/server/src/workspace/modal.ts index 06df1fe076..0f64f365c4 100644 --- a/packages/server/src/workspace/modal.ts +++ b/packages/server/src/workspace/modal.ts @@ -119,7 +119,8 @@ const files = (sandbox: Sandbox): WorkspaceEnvironment.Files => { wrap("realPath", path, async () => { // Both streams piped: the SDK's "ignore" path needs ReadableStream.from, // which Bun does not implement. - const process = await sandbox.exec(["realpath", "--", path], { stdout: "pipe", stderr: "pipe" }) + // -e: every component must exist, matching node fs.realpath semantics. + const process = await sandbox.exec(["realpath", "-e", "--", path], { stdout: "pipe", stderr: "pipe" }) const [output, code] = await Promise.all([process.stdout.readText(), process.wait()]) if (code !== 0) throw new SandboxFilesystemNotFoundError(`realpath exited ${code}`) return output.trim() @@ -148,14 +149,16 @@ const files = (sandbox: Sandbox): WorkspaceEnvironment.Files => { } } +const spawnError = (method: string, options?: { description?: string; cause?: unknown }) => + systemError({ _tag: "Unknown", module: "ModalDriver", method, ...options }) + const spawn = (sandbox: Sandbox) => { let pids = 0 - return (command: Command) => { + return Effect.fnUntraced(function* (command: Command) { if (command._tag === "PipedCommand") - return Effect.fail( - systemError({ _tag: "Unknown", module: "ModalDriver", method: "spawn", description: "piped commands unsupported" }), - ) - return Effect.tryPromise({ + return yield* Effect.fail(spawnError("spawn", { description: "piped commands unsupported" })) + + const process = yield* Effect.tryPromise({ try: () => sandbox.exec([command.command, ...command.args], { mode: "binary", @@ -165,41 +168,37 @@ const spawn = (sandbox: Sandbox) => { env: compact(command.options.env), timeoutMs: EXEC_TIMEOUT_MS, }), - catch: (cause) => - systemError({ _tag: "Unknown", module: "ModalDriver", method: "spawn", cause }), - }).pipe( - Effect.map((process) => { - let exited = false - let waited: Promise | undefined - const wait = () => - (waited ??= process.wait().then((code) => { - exited = true - return code - })) - const onError = (cause: unknown) => - systemError({ _tag: "Unknown", module: "ModalDriver", method: "process", cause }) - return makeHandle({ - pid: ProcessId(++pids), - exitCode: Effect.tryPromise({ try: wait, catch: onError }).pipe(Effect.map(ExitCode)), - isRunning: Effect.sync(() => !exited), - // Modal has no per-process termination; EXEC_TIMEOUT_MS reaps orphans. - kill: () => Effect.logWarning("modal cannot kill sandbox commands; relying on exec timeout"), - stdin: Sink.forEach((chunk: Uint8Array) => - Effect.tryPromise({ try: () => process.stdin.writeBytes(chunk), catch: onError }), - ), - stdout: Stream.fromReadableStream({ evaluate: () => process.stdout, onError }), - stderr: Stream.fromReadableStream({ evaluate: () => process.stderr, onError }), - all: Stream.merge( - Stream.fromReadableStream({ evaluate: () => process.stdout, onError }), - Stream.fromReadableStream({ evaluate: () => process.stderr, onError }), - ), - getInputFd: () => Sink.fail(systemError({ _tag: "Unknown", module: "ModalDriver", method: "getInputFd", description: "unsupported" })), - getOutputFd: () => Stream.fail(systemError({ _tag: "Unknown", module: "ModalDriver", method: "getOutputFd", description: "unsupported" })), - unref: Effect.succeed(Effect.void), - }) - }), - ) - } + catch: (cause) => spawnError("spawn", { cause }), + }) + + let exited = false + let waited: Promise | undefined + const wait = () => + (waited ??= process.wait().then((code) => { + exited = true + return code + })) + const onError = (cause: unknown) => spawnError("process", { cause }) + return makeHandle({ + pid: ProcessId(++pids), + exitCode: Effect.tryPromise({ try: wait, catch: onError }).pipe(Effect.map(ExitCode)), + isRunning: Effect.sync(() => !exited), + // Modal has no per-process termination; EXEC_TIMEOUT_MS reaps orphans. + kill: () => Effect.logWarning("modal cannot kill sandbox commands; relying on exec timeout"), + stdin: Sink.forEach((chunk: Uint8Array) => + Effect.tryPromise({ try: () => process.stdin.writeBytes(chunk), catch: onError }), + ), + stdout: Stream.fromReadableStream({ evaluate: () => process.stdout, onError }), + stderr: Stream.fromReadableStream({ evaluate: () => process.stderr, onError }), + all: Stream.merge( + Stream.fromReadableStream({ evaluate: () => process.stdout, onError }), + Stream.fromReadableStream({ evaluate: () => process.stderr, onError }), + ), + getInputFd: () => Sink.fail(spawnError("getInputFd", { description: "unsupported" })), + getOutputFd: () => Stream.fail(spawnError("getOutputFd", { description: "unsupported" })), + unref: Effect.succeed(Effect.void), + }) + }) } const compact = (env: Record | undefined) => { diff --git a/packages/server/test/workspace-modal-graph.test.ts b/packages/server/test/workspace-modal-graph.test.ts new file mode 100644 index 0000000000..a7d9ec447e --- /dev/null +++ b/packages/server/test/workspace-modal-graph.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test" +import { existsSync, mkdtempSync } from "fs" +import { homedir, tmpdir } from "os" +import path from "path" +import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Bus } from "@opencode-ai/core/bus" +import { Database } from "@opencode-ai/core/database/database" +import { FileMutation } from "@opencode-ai/core/file-mutation" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { Location } from "@opencode-ai/core/location" +import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" +import { Shell } from "@opencode-ai/core/shell" +import { Workspace } from "@opencode-ai/core/workspace" +import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { ServerWorkspaceDrivers } from "../src/workspace/drivers" +import { ModalDriver } from "../src/workspace/modal" + +const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml")) + +const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-modal-graph-")), "graph.db") + +const layer = AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, Workspace.node, LocationServiceMap.node]), + [ + [Database.node, Database.configured({ path: databaseFile })], + [WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node], + ], +) + +// The complete hosted stack against real Modal: workspace creation, the full +// Location graph, file mutation, real bash in the sandbox, eviction, reconnect. +describe.skipIf(!hasCredentials)("hosted location graph on modal (live)", () => { + test( + "runs the location graph against a modal sandbox", + async () => { + await Effect.gen(function* () { + const workspaces = yield* Workspace.Service + const created = yield* workspaces.create({ provider: "modal" }) + const driver = yield* ModalDriver.make + const found = yield* workspaces.binding(created.id) + yield* Effect.addFinalizer(() => Effect.ignore(driver.destroy(found.binding))) + + const locations = yield* LocationServiceMap.Service + const ref = Location.Ref.make({ + directory: AbsolutePath.make(created.root), + workspaceID: created.id, + }) + + yield* Effect.gen(function* () { + const location = yield* Location.Service + expect(location.project.id).toBe(Project.ID.global) + + const mutation = yield* LocationMutation.Service + const mutations = yield* FileMutation.Service + const target = yield* mutation.resolve({ path: "hello.txt" }) + yield* mutations.write({ target, content: "hello from opencode\n" }) + + const shell = yield* Shell.Service + const command = yield* shell.create({ + command: "cat hello.txt && printf 'bash-made' > bash.txt", + timeout: 60_000, + }) + const finished = yield* shell.wait(command.id) + expect(finished.status).toBe("exited") + const output = yield* shell.output(command.id) + expect(output.output).toContain("hello from opencode") + + const filesystem = yield* FileSystem.Service + const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") }) + expect(new TextDecoder().decode(fromBash.content)).toBe("bash-made") + }).pipe(Effect.provide(locations.get(ref))) + + yield* locations.invalidate(ref) + + yield* Effect.gen(function* () { + const filesystem = yield* FileSystem.Service + const hello = yield* filesystem.read({ path: RelativePath.make("hello.txt") }) + expect(new TextDecoder().decode(hello.content)).toBe("hello from opencode\n") + }).pipe(Effect.provide(locations.get(ref))) + }).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) + }, + { timeout: 600_000 }, + ) +})