diff --git a/packages/core/src/control-plane/workspace.sql.ts b/packages/core/src/control-plane/workspace.sql.ts index ef5195216a..8ecf9c4a0b 100644 --- a/packages/core/src/control-plane/workspace.sql.ts +++ b/packages/core/src/control-plane/workspace.sql.ts @@ -1,10 +1,10 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" +import { Workspace } from "@opencode-ai/schema/workspace" import { ProjectTable } from "../project/sql" import { ProjectV2 } from "../project" -import { WorkspaceV2 } from "../workspace" export const WorkspaceTable = sqliteTable("workspace", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), type: text().notNull(), name: text().notNull().default(""), branch: text(), diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 47e3f6dd2f..76751b76ef 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -16,6 +16,7 @@ import { Image } from "./image" import { LocationWatcher } from "./filesystem/location-watcher" import { Integration } from "./integration" import { Location } from "./location" +import { WorkspaceEnvironment } from "./workspace/environment" import { LocationMutation } from "./location-mutation" import { LocationServiceMap } from "./location-service-map" import { MCP } from "./mcp/index" @@ -50,6 +51,7 @@ export { LocationServiceMap } from "./location-service-map" const locationServiceNodes = [ Location.node, + WorkspaceEnvironment.node, Config.node, AgentV2.node, CommandV2.node, @@ -115,7 +117,10 @@ export function buildLocationServiceMap( LayerMap.make( (ref: Location.Ref) => { const startedAt = performance.now() - const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) + const allReplacements = replacements.concat([ + [Location.node, Location.boundNode(ref)], + [WorkspaceEnvironment.node, WorkspaceEnvironment.boundNode(ref)], + ]) // Apply replacements during hoist, not afterward: replacements can // introduce new tagged dependencies (Location.boundNode depends on // Project), and the hoist walk is the only pass that can still slice diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 8228b8599e..699280f291 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,8 +1,10 @@ import { Context, Effect, Layer } from "effect" import { Info, Ref, response } from "@opencode-ai/schema/location" +import path from "path" import { Project } from "./project" import { LayerNode } from "./effect/layer-node" import { makeLocationNode, tags } from "./effect/app-node" +import { WorkspaceV2 } from "./workspace" export * as Location from "./location" @@ -16,7 +18,7 @@ export class Service extends Context.Service()("@opencode/Lo export const node = LayerNode.unbound(Service, tags.values.location) -const layer = (ref: Ref) => +const localLayer = (ref: Ref) => Layer.effect( Service, Effect.gen(function* () { @@ -31,9 +33,38 @@ const layer = (ref: Ref) => }), ) -export const boundNode = (ref: Ref) => - makeLocationNode({ +const hostedLayer = (ref: Ref & { readonly workspaceID: WorkspaceV2.ID }) => + Layer.effect( + Service, + Effect.gen(function* () { + const workspace = yield* WorkspaceV2.Service + const info = yield* workspace.get(ref.workspaceID) + const relative = path.posix.relative(info.directory, ref.directory) + if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) { + return yield* new WorkspaceV2.InvalidError({ + id: ref.workspaceID, + message: `Location directory is outside Workspace root: ${ref.directory}`, + }) + } + return Service.of({ + directory: ref.directory, + workspaceID: ref.workspaceID, + project: info.project, + }) + }), + ).pipe(Layer.orDie) + +export const boundNode = (ref: Ref) => { + if (ref.workspaceID) { + return makeLocationNode({ + service: Service, + layer: hostedLayer({ ...ref, workspaceID: ref.workspaceID }), + deps: [WorkspaceV2.node], + }) + } + return makeLocationNode({ service: Service, - layer: layer(ref), + layer: localLayer(ref), deps: [Project.node], }) +} diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts index d85bbe4ba4..b4089ca3b4 100644 --- a/packages/core/src/workspace.ts +++ b/packages/core/src/workspace.ts @@ -1,6 +1,106 @@ export * as WorkspaceV2 from "./workspace" +import { Context, Effect, Equal, Exit, Layer, RcMap, Schema, Scope } from "effect" import { Workspace } from "@opencode-ai/schema/workspace" +import { eq } from "drizzle-orm" +import { Database } from "./database/database" +import { makeGlobalNode } from "./effect/app-node" +import { AbsolutePath } from "./schema" +import { WorkspaceTable } from "./control-plane/workspace.sql" +import { Sandbox } from "./workspace/sandbox" +import type { WorkspaceEnvironment } from "./workspace/environment" export const ID = Workspace.ID export type ID = typeof ID.Type + +export const Info = Workspace.Info +export type Info = Workspace.Info + +export class NotFoundError extends Schema.TaggedErrorClass()("Workspace.NotFoundError", { + id: ID, +}) {} + +export class InvalidError extends Schema.TaggedErrorClass()("Workspace.InvalidError", { + id: ID, + message: Schema.String, +}) {} + +export interface Interface { + readonly get: (id: ID) => Effect.Effect + readonly borrow: ( + id: ID, + ) => Effect.Effect< + WorkspaceEnvironment.Interface, + NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError, + Scope.Scope + > +} + +export class Service extends Context.Service()("@opencode/Workspace") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const db = (yield* Database.Service).db + const registry = yield* Sandbox.RegistryService + + const row = Effect.fn("Workspace.row")(function* (id: ID) { + const value = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie) + if (!value) return yield* new NotFoundError({ id }) + if (!value.directory) return yield* new InvalidError({ id, message: "Workspace has no directory" }) + return { ...value, directory: value.directory } + }) + + const get = Effect.fn("Workspace.get")(function* (id: ID) { + const value = yield* row(id) + const directory = AbsolutePath.make(value.directory) + return Info.make({ + id, + name: value.name, + directory, + project: { id: value.project_id, directory }, + }) + }) + + const persistBinding = Effect.fnUntraced(function* (id: ID, previous: Sandbox.Binding, next: Sandbox.Binding) { + if (Equal.equals(previous, next)) return + yield* db + .update(WorkspaceTable) + .set({ extra: Sandbox.Placement.make({ kind: "sandbox", version: 1, binding: next }) }) + .where(eq(WorkspaceTable.id, id)) + .run() + .pipe(Effect.orDie) + }) + + const connections = yield* RcMap.make({ + idleTimeToLive: "1 minute", + lookup: Effect.fn("Workspace.connect")(function* (id: ID) { + const placement = yield* row(id) + if (!Schema.is(Sandbox.Placement)(placement.extra)) { + return yield* new InvalidError({ id, message: "Workspace has no sandbox binding" }) + } + const provider = yield* registry.get(placement.type) + const binding = yield* provider.decode(placement.extra.binding) + const connection = yield* provider.connect(binding) + yield* persistBinding(id, binding, connection.binding) + yield* persistBinding(id, connection.binding, yield* provider.reconcile(connection.binding)) + return connection + }), + }) + + return Service.of({ + get, + borrow: (id) => + RcMap.get(connections, id).pipe( + Effect.onExit((exit) => (Exit.isFailure(exit) ? RcMap.invalidate(connections, id) : Effect.void)), + Effect.map((connection) => connection.environment), + ), + }) + }), +) + +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [Database.node, Sandbox.registryNode], +}) diff --git a/packages/core/src/workspace/environment.ts b/packages/core/src/workspace/environment.ts new file mode 100644 index 0000000000..c4ccfa5868 --- /dev/null +++ b/packages/core/src/workspace/environment.ts @@ -0,0 +1,271 @@ +export * as WorkspaceEnvironment from "./environment" + +import { Context, Effect, FileSystem, Layer, Schema } from "effect" +import { PlatformError, systemError } from "effect/PlatformError" +import { ChildProcessSpawner, make } from "effect/unstable/process/ChildProcessSpawner" +import { AppProcess } from "../process" +import { makeLocationNode, tags } from "../effect/app-node" +import { LayerNode } from "../effect/layer-node" +import { FSUtil } from "../fs-util" +import { KeyedMutex } from "../effect/keyed-mutex" +import { Location } from "../location" +import { RipgrepBinary } from "../ripgrep/binary" +import { ShellSelect } from "../shell/select" +import { WorkspaceV2 } from "../workspace" +import path from "path" + +export class Error extends Schema.TaggedErrorClass()("WorkspaceEnvironment.Error", { + operation: Schema.String, + path: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), +}) {} + +export class StaleContentError extends Schema.TaggedErrorClass()( + "WorkspaceEnvironment.StaleContentError", + { path: Schema.String }, +) {} + +export interface FileInfo { + readonly type: FileSystem.File.Type +} + +export interface ResolvedPath extends FileInfo { + readonly canonical: string + readonly directory: string +} + +export interface DirectoryEntry { + readonly name: string + readonly type: "file" | "directory" | "symlink" | "other" +} + +export interface FileBackend { + readonly inspect: (path: string) => Effect.Effect + readonly resolve: (path: string) => Effect.Effect + readonly read: (path: string) => Effect.Effect + readonly list: (path: string) => Effect.Effect + readonly ensureDirectory: (path: string) => Effect.Effect + readonly createExclusive: (path: string, content: Uint8Array) => Effect.Effect + readonly write: (path: string, content: Uint8Array) => Effect.Effect + readonly writeIfUnchanged: ( + path: string, + expected: Uint8Array, + content: Uint8Array, + ) => Effect.Effect + readonly remove: (path: string) => Effect.Effect +} + +export interface Shell { + readonly executable: string + readonly args: (command: string) => readonly string[] + readonly environmentOverrides: Readonly> + readonly detached: boolean +} + +export interface Interface { + readonly platform: NodeJS.Platform + readonly directory: string + readonly files: FileBackend + readonly process: ChildProcessSpawner["Service"] + readonly shell: Shell + readonly ripgrep: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/WorkspaceEnvironment") {} + +export const node = LayerNode.unbound(Service, tags.values.location) + +const wrap = (operation: string, path: string | undefined, effect: Effect.Effect) => + effect.pipe(Effect.mapError((cause) => new Error({ operation, path, cause }))) + +const sameBytes = (left: Uint8Array, right: Uint8Array) => + left.length === right.length && left.every((byte, index) => byte === right[index]) + +const local = Effect.fnUntraced(function* (directory: string) { + const fs = yield* FSUtil.Service + const proc = yield* AppProcess.Service + const ripgrep = yield* RipgrepBinary.Service + const locks = KeyedMutex.makeUnsafe() + const mutate = (path: string, effect: Effect.Effect) => + locks.withLock(path)(Effect.uninterruptible(effect)) + const notFound = (effect: Effect.Effect) => + effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) + const resolve = Effect.fn("WorkspaceEnvironment.resolve")(function* (absolute: string) { + const existing = yield* notFound(fs.realPath(absolute)) + if (existing) { + const info = yield* fs.stat(existing) + return { + canonical: existing, + directory: info.type === "Directory" ? existing : path.dirname(existing), + type: info.type, + } + } + + let anchor = path.dirname(absolute) + while (true) { + const canonical = yield* notFound(fs.realPath(anchor)) + if (canonical) { + const info = yield* fs.stat(canonical) + if (info.type !== "Directory") { + return yield* new Error({ operation: "resolve", path: absolute, cause: "Non-directory ancestor" }) + } + return { + canonical: path.resolve(canonical, path.relative(anchor, absolute)), + directory: canonical, + type: "Unknown" as const, + } + } + const parent = path.dirname(anchor) + if (parent === anchor) return yield* new Error({ operation: "resolve", path: absolute, cause: "No ancestor" }) + anchor = parent + } + }) + const files: FileBackend = { + inspect: (path) => wrap("inspect", path, fs.stat(path)), + resolve: (path) => + resolve(path).pipe( + Effect.mapError((cause) => (cause instanceof Error ? cause : new Error({ operation: "resolve", path, cause }))), + ), + read: (path) => wrap("read", path, fs.readFile(path)), + list: (path) => wrap("list", path, fs.readDirectoryEntries(path)), + ensureDirectory: (path) => wrap("ensureDirectory", path, fs.ensureDir(path)), + createExclusive: (path, content) => + wrap("createExclusive", path, mutate(path, fs.writeFile(path, content, { flag: "wx" }))), + write: (path, content) => wrap("write", path, mutate(path, fs.writeFile(path, content))), + writeIfUnchanged: (path, expected, content) => + mutate( + path, + Effect.gen(function* () { + const current = yield* fs.readFile(path) + if (!sameBytes(current, expected)) return yield* new StaleContentError({ path }) + yield* fs.writeFile(path, content) + }), + ).pipe( + Effect.mapError((cause) => + cause instanceof StaleContentError ? cause : new Error({ operation: "writeIfUnchanged", path, cause }), + ), + ), + remove: (path) => wrap("remove", path, mutate(path, fs.remove(path))), + } + const executable = ShellSelect.preferred() ?? "/bin/sh" + return Service.of({ + platform: process.platform, + directory, + files, + process: proc, + shell: { + executable, + args: (command) => ShellSelect.args(executable, command), + environmentOverrides: { + TERM: "xterm-256color", + OPENCODE_TERMINAL: "1", + }, + detached: process.platform !== "win32", + }, + ripgrep: ripgrep.filepath.pipe(Effect.mapError((cause) => new Error({ operation: "ripgrep", cause }))), + }) +}) + +const borrow = ( + workspace: WorkspaceV2.Interface, + id: WorkspaceV2.ID, + use: (environment: Interface) => Effect.Effect, +) => + Effect.scoped( + workspace.borrow(id).pipe( + Effect.mapError((cause) => new Error({ operation: "connect", cause })), + Effect.flatMap(use), + ), + ) + +const contains = (root: string, target: string) => { + const relative = path.posix.relative(root, target) + return relative !== ".." && !relative.startsWith("../") && !path.posix.isAbsolute(relative) +} + +const localLayer = (ref: Location.Ref) => Layer.effect(Service, local(ref.directory)) + +const hostedLayer = (ref: Location.Ref & { readonly workspaceID: WorkspaceV2.ID }) => + Layer.effect( + Service, + Effect.gen(function* () { + const location = yield* Location.Service + const workspace = yield* WorkspaceV2.Service + const id = ref.workspaceID + const useFile = ( + target: string, + use: (files: FileBackend, resolved: ResolvedPath) => Effect.Effect, + ) => + borrow(workspace, id, (environment) => + Effect.gen(function* () { + const absolute = path.posix.resolve(location.directory, target) + const [root, resolved] = yield* Effect.all([ + environment.files.resolve(location.project.directory), + environment.files.resolve(absolute), + ]) + if (!contains(root.canonical, resolved.canonical)) { + return yield* new Error({ operation: "containment", path: target }) + } + return yield* use(environment.files, resolved) + }), + ) + const files: FileBackend = { + inspect: (path) => useFile(path, (files, resolved) => files.inspect(resolved.canonical)), + resolve: (path) => useFile(path, (_files, resolved) => Effect.succeed(resolved)), + read: (path) => useFile(path, (files, resolved) => files.read(resolved.canonical)), + list: (path) => useFile(path, (files, resolved) => files.list(resolved.canonical)), + ensureDirectory: (path) => useFile(path, (files, resolved) => files.ensureDirectory(resolved.canonical)), + createExclusive: (path, content) => + useFile(path, (files, resolved) => files.createExclusive(resolved.canonical, content)), + write: (path, content) => useFile(path, (files, resolved) => files.write(resolved.canonical, content)), + writeIfUnchanged: (path, expected, content) => + useFile(path, (files, resolved) => files.writeIfUnchanged(resolved.canonical, expected, content)), + remove: (path) => useFile(path, (files, resolved) => files.remove(resolved.canonical)), + } + return Service.of({ + platform: "linux", + directory: location.directory, + files, + process: make((command) => + workspace.borrow(id).pipe( + Effect.flatMap((environment) => environment.process.spawn(command)), + Effect.mapError((cause) => + cause instanceof PlatformError + ? cause + : systemError({ + _tag: "Unknown", + module: "WorkspaceEnvironment", + method: "spawn", + cause, + }), + ), + ), + ), + shell: { + executable: "/bin/sh", + args: (command) => ["-c", command], + environmentOverrides: { + TERM: "xterm-256color", + OPENCODE_TERMINAL: "1", + }, + detached: false, + }, + ripgrep: borrow(workspace, id, (environment) => environment.ripgrep), + }) + }), + ) + +export const boundNode = (ref: Location.Ref) => { + if (ref.workspaceID) { + return makeLocationNode({ + service: Service, + layer: hostedLayer({ ...ref, workspaceID: ref.workspaceID }), + deps: [Location.node, WorkspaceV2.node], + }) + } + return makeLocationNode({ + service: Service, + layer: localLayer(ref), + deps: [FSUtil.node, AppProcess.node, RipgrepBinary.node], + }) +} diff --git a/packages/core/src/workspace/sandbox.ts b/packages/core/src/workspace/sandbox.ts new file mode 100644 index 0000000000..de02d17d94 --- /dev/null +++ b/packages/core/src/workspace/sandbox.ts @@ -0,0 +1,70 @@ +export * as Sandbox from "./sandbox" + +import { Context, Effect, Layer, Schema, Scope } from "effect" +import { makeGlobalNode } from "../effect/app-node" +import type { WorkspaceEnvironment } from "./environment" + +export const Binding = Schema.Record(Schema.String, Schema.Json).annotate({ identifier: "Sandbox.Binding" }) +export type Binding = typeof Binding.Type + +export const Placement = Schema.Struct({ + kind: Schema.Literal("sandbox"), + version: Schema.Literal(1), + binding: Binding, +}).annotate({ identifier: "Sandbox.Placement" }) +export type Placement = typeof Placement.Type + +export class Error extends Schema.TaggedErrorClass()("Sandbox.Error", { + provider: Schema.String, + operation: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +export interface Connection { + readonly binding: Binding + readonly environment: WorkspaceEnvironment.Interface +} + +export interface Provider { + readonly key: string + readonly decode: (binding: Binding) => Effect.Effect + readonly connect: (binding: Binding) => Effect.Effect + readonly reconcile: (binding: Binding) => Effect.Effect +} + +export class DuplicateProviderError extends Schema.TaggedErrorClass()( + "Sandbox.DuplicateProviderError", + { provider: Schema.String }, +) {} + +export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "Sandbox.ProviderNotFoundError", + { provider: Schema.String }, +) {} + +export interface Registry { + readonly register: (provider: Provider) => Effect.Effect + readonly get: (key: string) => Effect.Effect +} + +export class RegistryService extends Context.Service()("@opencode/SandboxRegistry") {} + +export const registryLayer = Layer.sync(RegistryService, () => { + const providers = new Map() + return RegistryService.of({ + register: (provider) => + Effect.acquireRelease( + Effect.sync(() => { + if (providers.has(provider.key)) return new DuplicateProviderError({ provider: provider.key }) + providers.set(provider.key, provider) + }).pipe(Effect.flatMap((error) => (error ? Effect.fail(error) : Effect.void))), + () => Effect.sync(() => providers.delete(provider.key)), + ), + get: (key) => { + const provider = providers.get(key) + return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ provider: key })) + }, + }) +}) + +export const registryNode = makeGlobalNode({ service: RegistryService, layer: registryLayer, deps: [] }) diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index d012a0463f..629a04c1e6 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -1,14 +1,20 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import fs from "fs/promises" +import path from "path" +import { Effect, Exit, Layer } from "effect" +import { make } from "effect/unstable/process/ChildProcessSpawner" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment" +import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" const workspaceID = WorkspaceV2.ID.make("wrk_test") -const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID } +const ref = { directory: AbsolutePath.make("/repo/packages/app") } const projectLayer = Layer.succeed( Project.Service, Project.Service.of({ @@ -31,7 +37,7 @@ describe("Location", () => { const location = yield* Location.Service expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app")) - expect(location.workspaceID).toBe(workspaceID) + expect(location.workspaceID).toBeUndefined() expect(location.project.id).toBe(Project.ID.make("project")) expect(location.project.directory).toBe(AbsolutePath.make("/repo")) expect(location.vcs).toEqual({ @@ -40,4 +46,117 @@ describe("Location", () => { }) }), ) + + it.live("resolves hosted metadata without reading the host path", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const directory = AbsolutePath.make(path.join(tmp.path, "hosted-checkout")) + const connections = { count: 0 } + const reads = { count: 0 } + const unsupported = () => Effect.die("Unsupported fake environment operation") + const providerEnvironment = WorkspaceEnvironment.Service.of({ + platform: "linux", + directory, + process: make(() => unsupported()), + shell: { + executable: "/bin/sh", + args: (command) => ["-c", command], + environmentOverrides: {}, + detached: false, + }, + ripgrep: Effect.succeed("/usr/bin/rg"), + files: { + resolve: (target) => + Effect.succeed({ + canonical: target.includes("symlink") ? "/outside/secret" : target, + directory: path.posix.dirname(target), + type: "File", + }), + inspect: unsupported, + read: () => + Effect.sync(() => { + reads.count++ + return new Uint8Array([1]) + }), + list: unsupported, + ensureDirectory: unsupported, + createExclusive: unsupported, + write: unsupported, + writeIfUnchanged: unsupported, + remove: unsupported, + }, + }) + const workspaceLayer = Layer.succeed( + WorkspaceV2.Service, + WorkspaceV2.Service.of({ + get: () => + Effect.succeed( + WorkspaceV2.Info.make({ + id: workspaceID, + name: "Hosted", + directory, + project: { + id: Project.ID.make("hosted-project"), + directory, + }, + }), + ), + borrow: () => + Effect.sync(() => { + connections.count++ + return providerEnvironment + }), + }), + ) + const hostedRef = { directory, workspaceID } + const layer = AppNodeBuilder.build(LayerNode.group([Location.node, WorkspaceEnvironment.node]), [ + [Location.node, Location.boundNode(hostedRef)], + [WorkspaceEnvironment.node, WorkspaceEnvironment.boundNode(hostedRef)], + [WorkspaceV2.node, workspaceLayer], + ]) + const invalidLayer = AppNodeBuilder.build( + Location.boundNode({ directory: AbsolutePath.make(path.join(tmp.path, "outside")), workspaceID }), + [[WorkspaceV2.node, workspaceLayer]], + ) + return Effect.gen(function* () { + expect( + yield* Effect.promise(() => + fs.stat(directory).then( + () => true, + () => false, + ), + ), + ).toBe(false) + + const location = yield* Location.Service + const environment = yield* WorkspaceEnvironment.Service + expect(location.directory).toBe(directory) + expect(location.workspaceID).toBe(workspaceID) + expect(location.project).toEqual({ + id: Project.ID.make("hosted-project"), + directory, + }) + expect(environment.directory).toBe(directory) + expect(environment.platform).toBe("linux") + expect(connections.count).toBe(0) + + expect(yield* environment.files.read(path.posix.join(directory, "file.txt"))).toEqual(new Uint8Array([1])) + expect(connections.count).toBe(1) + expect(reads.count).toBe(1) + + const outsideFile = yield* environment.files.read("/outside/secret").pipe(Effect.flip) + expect(outsideFile.operation).toBe("containment") + const symlink = yield* environment.files.read(path.posix.join(directory, "symlink")).pipe(Effect.flip) + expect(symlink.operation).toBe("containment") + expect(reads.count).toBe(1) + + const invalid = yield* Location.Service.pipe(Effect.provide(invalidLayer), Effect.exit) + expect(Exit.isFailure(invalid)).toBe(true) + }).pipe(Effect.provide(layer)) + }), + ), + ) }) diff --git a/packages/core/test/workspace.test.ts b/packages/core/test/workspace.test.ts new file mode 100644 index 0000000000..6356da8ff6 --- /dev/null +++ b/packages/core/test/workspace.test.ts @@ -0,0 +1,171 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit } from "effect" +import { adjust } from "effect/testing/TestClock" +import { eq } from "drizzle-orm" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Database } from "@opencode-ai/core/database/database" +import { AppProcess } from "@opencode-ai/core/process" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" +import { Sandbox } from "@opencode-ai/core/workspace/sandbox" +import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment" +import { testEffect } from "./lib/effect" + +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, Sandbox.registryNode, WorkspaceV2.node, AppProcess.node])), +) + +describe("WorkspaceV2", () => { + it.effect("loads metadata without connecting and shares a scoped connection", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const process = yield* AppProcess.Service + const registry = yield* Sandbox.RegistryService + const workspace = yield* WorkspaceV2.Service + const id = WorkspaceV2.ID.make("wrk_hosted") + const projectID = Project.ID.make("hosted-project") + const directory = AbsolutePath.make("/workspace/repo") + const lifecycle = { connected: 0, reconciled: 0, released: 0 } + const unsupported = (operation: string) => Effect.fail(new WorkspaceEnvironment.Error({ operation })) + const environment = WorkspaceEnvironment.Service.of({ + platform: "linux", + directory, + process, + shell: { + executable: "/bin/sh", + args: (command) => ["-c", command], + environmentOverrides: {}, + detached: false, + }, + ripgrep: Effect.succeed("/usr/bin/rg"), + files: { + inspect: () => unsupported("inspect"), + resolve: () => unsupported("resolve"), + read: () => unsupported("read"), + list: () => unsupported("list"), + ensureDirectory: () => unsupported("ensureDirectory"), + createExclusive: () => unsupported("createExclusive"), + write: () => unsupported("write"), + writeIfUnchanged: () => unsupported("writeIfUnchanged"), + remove: () => unsupported("remove"), + }, + }) + + yield* db + .insert(ProjectTable) + .values({ + id: projectID, + worktree: directory, + sandboxes: [], + time_created: 1, + time_updated: 1, + }) + .run() + yield* db + .insert(WorkspaceTable) + .values({ + id, + type: "fake", + name: "Hosted", + directory, + extra: { kind: "sandbox", version: 1, binding: { sandbox: "one" } }, + project_id: projectID, + time_used: 1, + }) + .run() + yield* registry.register({ + key: "fake", + decode: Effect.succeed, + connect: () => + Effect.acquireRelease( + Effect.sync(() => { + lifecycle.connected++ + return { binding: { sandbox: "live", retired: "one" }, environment } + }), + () => Effect.sync(() => lifecycle.released++), + ), + reconcile: () => + Effect.sync(() => { + lifecycle.reconciled++ + return { sandbox: "live" } + }), + }) + + expect(yield* workspace.get(id)).toEqual({ + id, + name: "Hosted", + directory, + project: { id: projectID, directory }, + }) + expect(lifecycle.connected).toBe(0) + + const borrowed = yield* Effect.all([workspace.borrow(id), workspace.borrow(id)]).pipe(Effect.scoped) + expect(borrowed[0]).toBe(environment) + expect(borrowed[1]).toBe(environment) + expect(lifecycle.connected).toBe(1) + expect(lifecycle.reconciled).toBe(1) + expect(lifecycle.released).toBe(0) + const placement = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get() + expect(placement?.extra).toEqual({ + kind: "sandbox", + version: 1, + binding: { sandbox: "live" }, + }) + + yield* adjust("1 minute") + yield* Effect.yieldNow + expect(lifecycle.released).toBe(1) + + const invalidID = WorkspaceV2.ID.make("wrk_invalid") + yield* db + .insert(WorkspaceTable) + .values({ + id: invalidID, + type: "fake", + name: "Legacy", + directory, + extra: { sandbox: "legacy-adapter-state" }, + project_id: projectID, + time_used: 1, + }) + .run() + const invalid = yield* workspace.borrow(invalidID).pipe(Effect.scoped, Effect.flip) + expect(invalid._tag).toBe("Workspace.InvalidError") + expect(lifecycle.connected).toBe(1) + + const retryID = WorkspaceV2.ID.make("wrk_retry") + const retry = { attempts: 0 } + yield* db + .insert(WorkspaceTable) + .values({ + id: retryID, + type: "flaky", + name: "Retry", + directory, + extra: { kind: "sandbox", version: 1, binding: { sandbox: "retry" } }, + project_id: projectID, + time_used: 1, + }) + .run() + yield* registry.register({ + key: "flaky", + decode: Effect.succeed, + connect: (binding) => + Effect.sync(() => ++retry.attempts).pipe( + Effect.flatMap((attempt) => + attempt === 1 ? Effect.die("Transient provider defect") : Effect.succeed({ binding, environment }), + ), + ), + reconcile: Effect.succeed, + }) + + expect(Exit.isFailure(yield* workspace.borrow(retryID).pipe(Effect.scoped, Effect.exit))).toBe(true) + expect(yield* workspace.borrow(retryID).pipe(Effect.scoped)).toBe(environment) + expect(retry.attempts).toBe(2) + }), + ) +}) diff --git a/packages/schema/src/workspace.ts b/packages/schema/src/workspace.ts index f52cc3024f..26106d5ea9 100644 --- a/packages/schema/src/workspace.ts +++ b/packages/schema/src/workspace.ts @@ -2,8 +2,19 @@ export * as Workspace from "./workspace.js" import { WorkspaceEvent } from "./workspace-event.js" import { WorkspaceID } from "./workspace-id.js" +import { Project } from "./project.js" +import { AbsolutePath } from "./schema.js" +import { Schema } from "effect" export const ID = WorkspaceID export type ID = WorkspaceID +export const Info = Schema.Struct({ + id: ID, + name: Schema.String, + directory: AbsolutePath, + project: Project.Current, +}).annotate({ identifier: "Workspace.Info" }) +export interface Info extends Schema.Schema.Type {} + export const Event = WorkspaceEvent diff --git a/specs/v2/remote-workspace-execution.md b/specs/v2/remote-workspace-execution.md index 880833b05b..6dfd5b0b1f 100644 --- a/specs/v2/remote-workspace-execution.md +++ b/specs/v2/remote-workspace-execution.md @@ -149,7 +149,7 @@ existing Core policy services: ```ts interface WorkspaceFileBackend { readonly inspect: (path: string) => Effect.Effect - readonly realPath: (path: string) => Effect.Effect + readonly resolve: (path: string) => Effect.Effect readonly read: (path: string) => Effect.Effect readonly list: (path: string) => Effect.Effect readonly ensureDirectory: (path: string) => Effect.Effect @@ -168,9 +168,9 @@ interface WorkspaceFileBackend { not emulate it with an unlocked client-side read followed by write. `FileMutation` remains the owner of create/write/remove semantics, stale-edit errors, parent-directory creation, result metadata, BOM handling, and -OpenCode-side mutation ordering. `LocationMutation` retains symlink-safe path -containment by composing `realPath` and `inspect`, including its nearest -existing ancestor resolution for new targets. +OpenCode-side mutation ordering. `resolve` returns a provider-canonical path +for an existing target or through the nearest existing ancestor for a new +target. `LocationMutation` uses it to retain symlink-safe containment. Provider process transports adapt native command handles to Effect's scoped `ChildProcessSpawner` contract. Scope finalization interrupts an unfinished @@ -265,28 +265,31 @@ or when detached process reconnection and richer runtime semantics justify it. ## Implementation Sequence -The first integration PR is a narrow vertical slice across stages 1 through 4: +The first integration PR establishes the narrow seam needed by stages 1 +through 4: -- add the provider-neutral file, process, environment, and lifecycle contracts; +- add provider-neutral file, process, environment, connection, and binding + reconciliation contracts; - add the local environment and a test-only fake hosted provider; -- replace the experimental Workspace storage shape with public metadata plus - private placement state; -- add the Workspace connection manager and Location-scoped +- add browser-safe Workspace metadata while keeping provider placement private + in the existing experimental row; +- add a scoped, idle-expiring Workspace connection cache and Location-scoped `WorkspaceEnvironment.Service`; - make `Location.boundNode` resolve hosted project/root metadata without `Project.resolve` or any host-path access; and - prove lazy connection and scoped release using a hosted directory that does not exist on the test host. -It does not add Vercel or migrate every tool. Its purpose is to establish the -placement seam and prevent cloud-provider details from shaping later Core -changes. +It does not add Vercel, lifecycle mutation, or migrate existing tools. Its +purpose is to establish the placement seam and prevent cloud-provider details +from shaping later Core changes. The lifecycle gate and create, suspend, and +remove orchestration land with stage 3 rather than as unused first-PR methods. ### 1. Define Behavioral Contracts - Add internal Sandbox provider, binding, file-backend, process-transport, and environment services in Core. -- Include provider-side real-path and directory primitives plus explicit +- Include provider-side canonical-path and directory primitives plus explicit Workspace shell/environment policy in the contract. - Keep provider registration process-global and provider implementations out of tool modules. @@ -364,7 +367,7 @@ requires a host checkout. Node filesystem use. - Adapt `FileMutation` to the Workspace file backend while preserving its current policy and result surface. -- Adapt `LocationMutation` to provider-side `realPath` and `inspect` so existing +- Adapt `LocationMutation` to provider-side `resolve` and `inspect` so existing targets, missing-target ancestors, and symlink escapes retain current containment behavior. - Adapt hosted process transport once to `ChildProcessSpawner`, then reuse