From f33aec1139afa5e9741cc19e9f2d1b60558d4861 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 00:02:52 -0400 Subject: [PATCH] Convert LoadInput.init to Effect + extract InstanceBootstrap as a Service (#25376) --- packages/opencode/src/cli/bootstrap.ts | 2 - packages/opencode/src/cli/cmd/tui/worker.ts | 2 - packages/opencode/src/config/config.ts | 3 +- packages/opencode/src/file/index.ts | 6 +- packages/opencode/src/lsp/lsp.ts | 9 +- packages/opencode/src/project/bootstrap.ts | 92 +++++++++++++------ .../opencode/src/project/instance-context.ts | 14 +++ .../opencode/src/project/instance-store.ts | 87 ++++++++++-------- packages/opencode/src/project/instance.ts | 53 +++++++---- .../instance/httpapi/handlers/project.ts | 2 - .../httpapi/middleware/instance-context.ts | 10 +- .../server/routes/instance/httpapi/server.ts | 10 ++ .../src/server/routes/instance/middleware.ts | 2 - .../src/server/routes/instance/project.ts | 2 - packages/opencode/src/server/workspace.ts | 2 - packages/opencode/src/tool/bash.ts | 6 +- .../opencode/src/tool/external-directory.ts | 4 +- packages/opencode/src/worktree/index.ts | 2 - .../opencode/test/file/path-traversal.test.ts | 29 +++--- .../opencode/test/project/instance.test.ts | 37 ++++---- .../server/httpapi-instance-context.test.ts | 2 + 21 files changed, 223 insertions(+), 153 deletions(-) diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index 2604e703ea..3190fda62f 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,11 +1,9 @@ import { AppRuntime } from "@/effect/app-runtime" -import { InstanceBootstrap } from "../project/bootstrap" import { Instance } from "../project/instance" export async function bootstrap(directory: string, cb: () => Promise) { return Instance.provide({ directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), fn: async () => { try { const result = await cb() diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index adb7453a72..8b62c5038b 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -2,7 +2,6 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" @@ -77,7 +76,6 @@ export const rpc = { async checkUpgrade(input: { directory: string }) { await Instance.provide({ directory: input.directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), fn: async () => { await upgrade().catch(() => {}) }, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index bfc3567bf5..9e9a6e3810 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -23,6 +23,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { containsPath } from "../project/instance-context" import { zod } from "@/util/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" import { ConfigAgent } from "./agent" @@ -458,7 +459,7 @@ export const layer = Layer.effect( const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source === "OPENCODE_CONFIG_CONTENT") return "local" - if (Instance.containsPath(source, ctx)) return "local" + if (containsPath(source, ctx)) return "local" return "global" }) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 4a474881cb..4dd6a3ae7a 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -10,7 +10,7 @@ import fuzzysort from "fuzzysort" import ignore from "ignore" import path from "path" import { Global } from "@opencode-ai/core/global" -import { Instance } from "../project/instance" +import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" @@ -507,7 +507,7 @@ export const layer = Layer.effect( const ctx = yield* InstanceState.context const full = path.join(ctx.directory, file) - if (!Instance.containsPath(full, ctx)) { + if (!containsPath(full, ctx)) { throw new Error("Access denied: path escapes project directory") } @@ -587,7 +587,7 @@ export const layer = Layer.effect( } const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory - if (!Instance.containsPath(resolved, ctx)) { + if (!containsPath(resolved, ctx)) { throw new Error("Access denied: path escapes project directory") } diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 5fcff772ec..5110eccbf8 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -12,7 +12,7 @@ import { Process } from "@/util/process" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { containsPath } from "@/project/instance-context" import { NonNegativeInt, withStatics } from "@/util/schema" import { zod, ZodOverride } from "@/util/effect-zod" @@ -221,12 +221,7 @@ export const layer = Layer.effect( const getClients = Effect.fnUntraced(function* (file: string) { const ctx = yield* InstanceState.context - if ( - !AppFileSystem.contains(ctx.directory, file) && - (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file)) - ) { - return [] as LSPClient.Info[] - } + if (!containsPath(file, ctx)) return [] as LSPClient.Info[] const s = yield* InstanceState.get(state) return yield* Effect.promise(async () => { const extension = path.parse(file).ext || file diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index ae52ac5503..9f77de2d4d 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -8,37 +8,71 @@ import * as Vcs from "./vcs" import { Bus } from "../bus" import { Command } from "../command" import { InstanceState } from "@/effect/instance-state" -import * as Log from "@opencode-ai/core/util/log" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" -import * as Effect from "effect/Effect" +import { Context, Effect, Layer } from "effect" import { Config } from "@/config/config" -export const InstanceBootstrap = Effect.gen(function* () { - const ctx = yield* InstanceState.context - Log.Default.info("bootstrapping", { directory: ctx.directory }) - // everything depends on config so eager load it for nice traces - yield* Config.Service.use((svc) => svc.get()) - // Plugin can mutate config so it has to be initialized before anything else. - yield* Plugin.Service.use((svc) => svc.init()) - yield* Effect.all( - [ - LSP.Service, - ShareNext.Service, - Format.Service, - File.Service, - FileWatcher.Service, - Vcs.Service, - Snapshot.Service, - ].map((s) => Effect.forkDetach(s.use((i) => i.init()))), - ).pipe(Effect.withSpan("InstanceBootstrap.init")) +export interface Interface { + readonly run: Effect.Effect +} - const projectID = ctx.project.id - yield* Bus.Service.use((svc) => - svc.subscribeCallback(Command.Event.Executed, async (payload) => { - if (payload.properties.name === Command.Default.INIT) { - Project.setInitialized(projectID) - } - }), - ) -}).pipe(Effect.withSpan("InstanceBootstrap")) +export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + // Yield each bootstrap dep at layer init so `run` itself has R = never. + // This breaks the circular declaration loop through Config → Instance → InstanceStore + // (instance-store.ts only yields this Service tag, never the impl-side services). + const bus = yield* Bus.Service + const config = yield* Config.Service + const file = yield* File.Service + const fileWatcher = yield* FileWatcher.Service + const format = yield* Format.Service + const lsp = yield* LSP.Service + const plugin = yield* Plugin.Service + const shareNext = yield* ShareNext.Service + const snapshot = yield* Snapshot.Service + const vcs = yield* Vcs.Service + + const run = Effect.gen(function* () { + const ctx = yield* InstanceState.context + yield* Effect.logInfo("bootstrapping", { directory: ctx.directory }) + // everything depends on config so eager load it for nice traces + yield* config.get() + // Plugin can mutate config so it has to be initialized before anything else. + yield* plugin.init() + yield* Effect.all( + [lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())), + ).pipe(Effect.withSpan("InstanceBootstrap.init")) + + const projectID = ctx.project.id + yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => { + if (payload.properties.name === Command.Default.INIT) { + Project.setInitialized(projectID) + } + }) + }).pipe(Effect.withSpan("InstanceBootstrap")) + + return Service.of({ run }) + }), +) + +export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide([ + Bus.layer, + Config.defaultLayer, + File.defaultLayer, + FileWatcher.defaultLayer, + Format.defaultLayer, + LSP.defaultLayer, + Plugin.defaultLayer, + Project.defaultLayer, + ShareNext.defaultLayer, + Snapshot.defaultLayer, + Vcs.defaultLayer, + ]), +) + +export * as InstanceBootstrap from "./bootstrap" diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts index 22ceb28b33..b281f492d4 100644 --- a/packages/opencode/src/project/instance-context.ts +++ b/packages/opencode/src/project/instance-context.ts @@ -1,4 +1,5 @@ import { LocalContext } from "@/util/local-context" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import type * as Project from "./project" export interface InstanceContext { @@ -8,3 +9,16 @@ export interface InstanceContext { } export const context = LocalContext.create("instance") + +/** + * Check if a path is within the project boundary. + * Returns true if path is inside ctx.directory OR ctx.worktree. + * Paths within the worktree but outside the working directory should not trigger external_directory permission. + */ +export function containsPath(filepath: string, ctx: InstanceContext): boolean { + if (AppFileSystem.contains(ctx.directory, filepath)) return true + // Non-git projects set worktree to "/" which would match ANY absolute path. + // Skip worktree check in this case to preserve external_directory permissions. + if (ctx.worktree === "/") return false + return AppFileSystem.contains(ctx.worktree, filepath) +} diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 7abb0bb7e3..74df60ada7 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -5,22 +5,29 @@ import { disposeInstance } from "@/effect/instance-registry" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" -import { context, type InstanceContext } from "./instance-context" +import { type InstanceContext } from "./instance-context" import * as Project from "./project" -export interface LoadInput { +export interface LoadInput { directory: string - init?: () => Promise + /** + * Additional setup to run after the default InstanceBootstrap. + * Mainly used by tests for env-var setup or file writes that need the instance ALS context. + */ + init?: Effect.Effect worktree?: string project?: Project.Info } export interface Interface { - readonly load: (input: LoadInput) => Effect.Effect - readonly reload: (input: LoadInput) => Effect.Effect + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect readonly disposeAll: () => Effect.Effect - readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect + readonly provide: ( + input: LoadInput, + effect: Effect.Effect, + ) => Effect.Effect } export class Service extends Context.Service()("@opencode/InstanceStore") {} @@ -36,25 +43,25 @@ export const layer: Layer.Layer = Layer.effect( const scope = yield* Scope.Scope const cache = new Map() - const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : yield* project.fromDirectory(input.directory).pipe( - Effect.map((result) => ({ + const boot = (input: LoadInput & { directory: string }) => + Effect.gen(function* () { + const ctx: InstanceContext = + input.project && input.worktree + ? { directory: input.directory, - worktree: result.sandbox, - project: result.project, - })), - ) - const init = input.init - if (init) yield* Effect.promise(() => context.provide(ctx, init)) - return ctx - }) + worktree: input.worktree, + project: input.project, + } + : yield* project.fromDirectory(input.directory).pipe( + Effect.map((result) => ({ + directory: input.directory, + worktree: result.sandbox, + project: result.project, + })), + ) + if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx)) + return ctx + }).pipe(Effect.withSpan("InstanceStore.boot")) const removeEntry = (directory: string, entry: Entry) => Effect.sync(() => { @@ -63,11 +70,12 @@ export const layer: Layer.Layer = Layer.effect( return true }) - const completeLoad = Effect.fnUntraced(function* (directory: string, input: LoadInput, entry: Entry) { - const exit = yield* Effect.exit(boot({ ...input, directory })) - if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) - yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) - }) + const completeLoad = (directory: string, input: LoadInput, entry: Entry) => + Effect.gen(function* () { + const exit = yield* Effect.exit(boot({ ...input, directory })) + if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) + yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) + }) const emitDisposed = (input: { directory: string; project?: string }) => Effect.sync(() => @@ -98,9 +106,9 @@ export const layer: Layer.Layer = Layer.effect( return true }) - const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) { + const load = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) - return yield* Effect.uninterruptibleMask((restore) => + return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const existing = cache.get(directory) if (existing) return yield* restore(Deferred.await(existing.deferred)) @@ -113,12 +121,12 @@ export const layer: Layer.Layer = Layer.effect( }).pipe(Effect.forkIn(scope, { startImmediately: true })) return yield* restore(Deferred.await(entry.deferred)) }), - ) - }) + ).pipe(Effect.withSpan("InstanceStore.load")) + } - const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) { + const reload = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) - return yield* Effect.uninterruptibleMask((restore) => + return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const previous = cache.get(directory) const entry: Entry = { deferred: Deferred.makeUnsafe() } @@ -134,8 +142,8 @@ export const layer: Layer.Layer = Layer.effect( }).pipe(Effect.forkIn(scope, { startImmediately: true })) return yield* restore(Deferred.await(entry.deferred)) }), - ) - }) + ).pipe(Effect.withSpan("InstanceStore.reload")) + } const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) { const entry = cache.get(ctx.directory) @@ -170,7 +178,10 @@ export const layer: Layer.Layer = Layer.effect( return yield* cachedDisposeAll }) - const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => + const provide = ( + input: LoadInput, + effect: Effect.Effect, + ): Effect.Effect => load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index af7672872c..549df4b751 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,4 +1,5 @@ -import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect } from "effect" +import { InstanceRef } from "@/effect/instance-ref" import * as Project from "./project" import { context, type InstanceContext } from "./instance-context" import { InstanceStore } from "./instance-store" @@ -6,13 +7,37 @@ import { InstanceStore } from "./instance-store" export type { InstanceContext } from "./instance-context" export type { LoadInput } from "./instance-store" +type LegacyLoadInput = { + directory: string + init?: () => Promise + project?: Project.Info + worktree?: string +} + +// Promise-style legacy inits often read Instance.directory etc. from the ALS context. +// The new Effect-typed init path doesn't bind ALS — it provides InstanceRef. To keep +// legacy inits working without forcing every test to convert, bind ALS around the +// Promise call here using the instance ctx that the store provides via InstanceRef. +const liftLegacyInput = (input: LegacyLoadInput): InstanceStore.LoadInput => { + const { init, ...rest } = input + if (!init) return rest + return { + ...rest, + init: Effect.gen(function* () { + const ctx = yield* InstanceRef + yield* Effect.promise(() => (ctx ? context.provide(ctx, init) : init())) + }), + } +} + export const Instance = { - load(input: InstanceStore.LoadInput): Promise { - return InstanceStore.runtime.runPromise((store) => store.load(input)) + load(input: LegacyLoadInput): Promise { + return InstanceStore.runtime.runPromise((store) => store.load(liftLegacyInput(input))) }, - async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => - input.fn(), + async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { + return context.provide( + await Instance.load({ directory: input.directory, init: input.init }), + async () => input.fn(), ) }, get current() { @@ -28,18 +53,6 @@ export const Instance = { return context.use().project }, - /** - * Check if a path is within the project boundary. - * Returns true if path is inside ctx.directory OR ctx.worktree. - * Paths within the worktree but outside the working directory should not trigger external_directory permission. - */ - containsPath(filepath: string, ctx: InstanceContext) { - if (AppFileSystem.contains(ctx.directory, filepath)) return true - // Non-git projects set worktree to "/" which would match ANY absolute path. - // Skip worktree check in this case to preserve external_directory permissions. - if (ctx.worktree === "/") return false - return AppFileSystem.contains(ctx.worktree, filepath) - }, /** * Captures the current instance ALS context and returns a wrapper that * restores it when called. Use this for callbacks that fire outside the @@ -57,8 +70,8 @@ export const Instance = { restore(ctx: InstanceContext, fn: () => R): R { return context.provide(ctx, fn) }, - async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - return InstanceStore.runtime.runPromise((store) => store.reload(input)) + async reload(input: LegacyLoadInput) { + return InstanceStore.runtime.runPromise((store) => store.reload(liftLegacyInput(input))) }, async dispose() { return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts index ae2761ac32..3c1dd350db 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -1,6 +1,5 @@ import { AppRuntime } from "@/effect/app-runtime" import * as InstanceState from "@/effect/instance-state" -import { InstanceBootstrap } from "@/project/bootstrap" import { Project } from "@/project/project" import { ProjectID } from "@/project/schema" import { Effect } from "effect" @@ -29,7 +28,6 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", directory: ctx.directory, worktree: ctx.directory, project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), }) return next }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index bf0093bd2b..0e82da31b3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,5 +1,4 @@ import { WorkspaceRef } from "@/effect/instance-ref" -import { AppRuntime } from "@/effect/app-runtime" import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" @@ -25,11 +24,12 @@ function decode(input: string): string { function provideInstanceContext( effect: Effect.Effect, store: InstanceStore.Interface, + bootstrap: InstanceBootstrap.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext return yield* store.provide( - { directory: decode(route.directory), init: () => AppRuntime.runPromise(InstanceBootstrap) }, + { directory: decode(route.directory), init: bootstrap.run }, effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), ) }) @@ -39,13 +39,15 @@ export const instanceContextLayer = Layer.effect( InstanceContextMiddleware, Effect.gen(function* () { const store = yield* InstanceStore.Service - return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store)) + const bootstrap = yield* InstanceBootstrap.Service + return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store, bootstrap)) }), ) export const instanceRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { const store = yield* InstanceStore.Service - return (effect) => provideInstanceContext(effect, store) + const bootstrap = yield* InstanceBootstrap.Service + return (effect) => provideInstanceContext(effect, store, bootstrap) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 783f84ec82..3ac0298c6b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -11,13 +11,16 @@ import { Config } from "@/config/config" import { Command } from "@/command" import * as Observability from "@opencode-ai/core/effect/observability" import { File } from "@/file" +import { FileWatcher } from "@/file/watcher" import { Ripgrep } from "@/file/ripgrep" import { Format } from "@/format" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" +import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" +import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" import { Provider } from "@/provider/provider" @@ -32,7 +35,9 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { SessionShare } from "@/share/session" +import { ShareNext } from "@/share/share-next" import { Skill } from "@/skill" +import { Snapshot } from "@/snapshot" import { SyncEvent } from "@/sync" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" @@ -143,12 +148,15 @@ export function createRoutes(corsOptions?: CorsOptions) { Command.defaultLayer, Config.defaultLayer, File.defaultLayer, + FileWatcher.defaultLayer, Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, + InstanceBootstrap.defaultLayer, InstanceStore.defaultLayer, MCP.defaultLayer, Permission.defaultLayer, + Plugin.defaultLayer, Project.defaultLayer, ProviderAuth.defaultLayer, Provider.defaultLayer, @@ -163,6 +171,8 @@ export function createRoutes(corsOptions?: CorsOptions) { SessionRunState.defaultLayer, SessionStatus.defaultLayer, SessionSummary.defaultLayer, + ShareNext.defaultLayer, + Snapshot.defaultLayer, SyncEvent.defaultLayer, Skill.defaultLayer, Todo.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index 19918b8b48..622d6296f0 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,6 +1,5 @@ import type { MiddlewareHandler } from "hono" import { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" import { AppRuntime } from "@/effect/app-runtime" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -24,7 +23,6 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler async fn() { return Instance.provide({ directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), async fn() { return next() }, diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index b9f86b1839..14c8c87b09 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -7,7 +7,6 @@ import z from "zod" import { ProjectID } from "@/project/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { InstanceBootstrap } from "@/project/bootstrap" import { AppRuntime } from "@/effect/app-runtime" import { jsonRequest, runRequest } from "./trace" @@ -86,7 +85,6 @@ export const ProjectRoutes = lazy(() => directory: dir, worktree: dir, project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), }) return c.json(next) }, diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index f757137483..06930d07ca 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -5,7 +5,6 @@ import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" -import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" @@ -100,7 +99,6 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware fn: () => Instance.provide({ directory: target.directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), async fn() { return next() }, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index fe3e45d66f..bf00082505 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -6,7 +6,7 @@ import * as Tool from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" import * as Log from "@opencode-ai/core/util/log" -import { Instance, type InstanceContext } from "../project/instance" +import { containsPath, type InstanceContext } from "../project/instance-context" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" @@ -386,7 +386,7 @@ export const BashTool = Tool.define( for (const arg of pathArgs(command, ps)) { const resolved = yield* argPath(arg, cwd, ps, shell) log.info("resolved path", { arg, resolved }) - if (!resolved || Instance.containsPath(resolved, instance)) continue + if (!resolved || containsPath(resolved, instance)) continue const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved) scan.dirs.add(dir) } @@ -612,7 +612,7 @@ export const BashTool = Tool.define( Effect.sync(() => tree.delete()), ) const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance) - if (!Instance.containsPath(cwd, executeInstance)) scan.dirs.add(cwd) + if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd) yield* ask(ctx, scan) }), ) diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 0dd9a1af30..23d416b53e 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import * as EffectLogger from "@opencode-ai/core/effect/logger" import { InstanceState } from "@/effect/instance-state" import type * as Tool from "./tool" -import { Instance } from "../project/instance" +import { containsPath } from "../project/instance-context" import { AppFileSystem } from "@opencode-ai/core/filesystem" type Kind = "file" | "directory" @@ -24,7 +24,7 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec const ins = yield* InstanceState.context const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target - if (Instance.containsPath(full, ins)) return + if (containsPath(full, ins)) return const kind = options?.kind ?? "file" const dir = kind === "directory" ? full : path.dirname(full) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 6a7ccb9614..2e9b6736f5 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -2,7 +2,6 @@ import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" import { Instance } from "../project/instance" -import { InstanceBootstrap } from "../project/bootstrap" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" @@ -255,7 +254,6 @@ export const layer: Layer.Layer< const booted = yield* Effect.promise(() => Instance.provide({ directory: info.directory, - init: () => BootstrapRuntime.runPromise(InstanceBootstrap), fn: () => undefined, }) .then(() => true) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 2d306f60ba..3a5ce2323e 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { containsPath } from "../../src/project/instance-context" import { provideInstance, tmpdir } from "../fixture/fixture" const run = (eff: Effect.Effect) => @@ -121,15 +122,15 @@ describe("File.list path traversal protection", () => { }) }) -describe("Instance.containsPath", () => { +describe("containsPath", () => { test("returns true for path inside directory", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) - expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true) }, }) }) @@ -143,11 +144,11 @@ describe("Instance.containsPath", () => { directory: subdir, fn: () => { // .opencode at worktree root, but we're running from packages/lib - expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true) // sibling package should also be accessible - expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true) // worktree root itself - expect(Instance.containsPath(tmp.path, Instance.current)).toBe(true) + expect(containsPath(tmp.path, Instance.current)).toBe(true) }, }) }) @@ -158,8 +159,8 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) - expect(Instance.containsPath("/tmp/other-project", Instance.current)).toBe(false) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath("/tmp/other-project", Instance.current)).toBe(false) }, }) }) @@ -170,7 +171,7 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) + expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) }, }) }) @@ -182,8 +183,8 @@ describe("Instance.containsPath", () => { directory: tmp.path, fn: () => { expect(Instance.directory).toBe(Instance.worktree) - expect(Instance.containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) - expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) }, }) }) @@ -195,9 +196,9 @@ describe("Instance.containsPath", () => { directory: tmp.path, fn: () => { // worktree is "/" for non-git projects, but containsPath should NOT allow all paths - expect(Instance.containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) - expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) - expect(Instance.containsPath("/tmp/other", Instance.current)).toBe(false) + expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath("/tmp/other", Instance.current)).toBe(false) }, }) }) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index f9fb6dca4e..2e3da29a7a 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Fiber, Layer } from "effect" +import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" @@ -26,7 +27,7 @@ describe("InstanceStore", () => { }), ) - it.live("runs load init inside the loaded legacy instance context", () => + it.live("runs load init with InstanceRef provided", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service @@ -34,9 +35,9 @@ describe("InstanceStore", () => { yield* store.load({ directory: dir, - init: async () => { - initializedDirectory = Instance.directory - }, + init: Effect.gen(function* () { + initializedDirectory = (yield* InstanceRef)?.directory + }), }) expect(initializedDirectory).toBe(dir) @@ -52,15 +53,15 @@ describe("InstanceStore", () => { const first = yield* store.load({ directory: dir, - init: async () => { + init: Effect.sync(() => { initialized++ - }, + }), }) const second = yield* store.load({ directory: dir, - init: async () => { + init: Effect.sync(() => { initialized++ - }, + }), }) expect(second).toBe(first) @@ -79,11 +80,11 @@ describe("InstanceStore", () => { const first = yield* store .load({ directory: dir, - init: async () => { + init: Effect.promise(async () => { initialized++ started.resolve() await release.promise - }, + }), }) .pipe(Effect.forkScoped) @@ -92,9 +93,9 @@ describe("InstanceStore", () => { const second = yield* store .load({ directory: dir, - init: async () => { + init: Effect.sync(() => { initialized++ - }, + }), }) .pipe(Effect.forkScoped) @@ -116,10 +117,10 @@ describe("InstanceStore", () => { const failed = yield* store .load({ directory: dir, - init: async () => { + init: Effect.sync(() => { attempts++ throw new Error("init failed") - }, + }), }) .pipe( Effect.as(false), @@ -130,9 +131,9 @@ describe("InstanceStore", () => { const ctx = yield* store.load({ directory: dir, - init: async () => { + init: Effect.sync(() => { attempts++ - }, + }), }) expect(ctx.directory).toBe(dir) @@ -170,10 +171,10 @@ describe("InstanceStore", () => { const reload = yield* store .reload({ directory: dir, - init: async () => { + init: Effect.promise(async () => { reloading.resolve() await releaseReload.promise - }, + }), }) .pipe(Effect.forkScoped) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 6098ad9aaf..15d3facd30 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -11,6 +11,7 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" @@ -41,6 +42,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, + InstanceBootstrap.defaultLayer, InstanceStore.defaultLayer, Project.defaultLayer, Workspace.defaultLayer,