summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/cli/bootstrap.ts2
-rw-r--r--packages/opencode/src/cli/cmd/tui/worker.ts2
-rw-r--r--packages/opencode/src/config/config.ts3
-rw-r--r--packages/opencode/src/file/index.ts6
-rw-r--r--packages/opencode/src/lsp/lsp.ts9
-rw-r--r--packages/opencode/src/project/bootstrap.ts94
-rw-r--r--packages/opencode/src/project/instance-context.ts14
-rw-r--r--packages/opencode/src/project/instance-store.ts87
-rw-r--r--packages/opencode/src/project/instance.ts53
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts2
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts10
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts10
-rw-r--r--packages/opencode/src/server/routes/instance/middleware.ts2
-rw-r--r--packages/opencode/src/server/routes/instance/project.ts2
-rw-r--r--packages/opencode/src/server/workspace.ts2
-rw-r--r--packages/opencode/src/tool/bash.ts6
-rw-r--r--packages/opencode/src/tool/external-directory.ts4
-rw-r--r--packages/opencode/src/worktree/index.ts2
-rw-r--r--packages/opencode/test/file/path-traversal.test.ts29
-rw-r--r--packages/opencode/test/project/instance.test.ts37
-rw-r--r--packages/opencode/test/server/httpapi-instance-context.test.ts2
21 files changed, 224 insertions, 154 deletions
diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts
index 2604e703e..3190fda62 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<T>(directory: string, cb: () => Promise<T>) {
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 adb7453a7..8b62c5038 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 bfc3567bf..9e9a6e381 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 4a474881c..4dd6a3ae7 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 5fcff772e..5110eccbf 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 ae52ac550..9f77de2d4 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"))
-
- 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 interface Interface {
+ readonly run: Effect.Effect<void>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@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<Service> = 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 22ceb28b3..b281f492d 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<InstanceContext>("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 7abb0bb7e..74df60ada 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<R = never> {
directory: string
- init?: () => Promise<unknown>
+ /**
+ * 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<void, never, R>
worktree?: string
project?: Project.Info
}
export interface Interface {
- readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
- readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
+ readonly load: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
+ readonly reload: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
readonly disposeAll: () => Effect.Effect<void>
- readonly provide: <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
+ readonly provide: <A, E, R, R2 = never>(
+ input: LoadInput<R2>,
+ effect: Effect.Effect<A, E, R>,
+ ) => Effect.Effect<A, E, R | R2>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
@@ -36,25 +43,25 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
const scope = yield* Scope.Scope
const cache = new Map<string, Entry>()
- 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 = <R>(input: LoadInput<R> & { 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<Service, never, Project.Service> = 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 = <R>(directory: string, input: LoadInput<R>, 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<Service, never, Project.Service> = Layer.effect(
return true
})
- const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) {
+ const load = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
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<Service, never, Project.Service> = 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 = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
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<InstanceContext>() }
@@ -134,8 +142,8 @@ export const layer: Layer.Layer<Service, never, Project.Service> = 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<Service, never, Project.Service> = Layer.effect(
return yield* cachedDisposeAll
})
- const provide = <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
+ const provide = <A, E, R, R2>(
+ input: LoadInput<R2>,
+ effect: Effect.Effect<A, E, R>,
+ ): Effect.Effect<A, E, R | R2> =>
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 af7672872..549df4b75 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<unknown>
+ 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<InstanceContext> {
- return InstanceStore.runtime.runPromise((store) => store.load(input))
+ load(input: LegacyLoadInput): Promise<InstanceContext> {
+ return InstanceStore.runtime.runPromise((store) => store.load(liftLegacyInput(input)))
},
- async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
- return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () =>
- input.fn(),
+ async provide<R>(input: { directory: string; init?: () => Promise<unknown>; fn: () => R }): Promise<R> {
+ return context.provide(
+ await Instance.load({ directory: input.directory, init: input.init }),
+ async () => input.fn(),
)
},
get current() {
@@ -29,18 +54,6 @@ export const 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.
- */
- 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
* instance async context (native addons, event emitters, timers, etc.).
@@ -57,8 +70,8 @@ export const Instance = {
restore<R>(ctx: InstanceContext, fn: () => R): R {
return context.provide(ctx, fn)
},
- async reload(input: { directory: string; init?: () => Promise<any>; 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 ae2761ac3..3c1dd350d 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 bf0093bd2..0e82da31b 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<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
store: InstanceStore.Interface,
+ bootstrap: InstanceBootstrap.Interface,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
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 783f84ec8..3ac0298c6 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 19918b8b4..622d6296f 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 b9f86b183..14c8c87b0 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 f75713748..06930d07c 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 fe3e45d66..bf0008250 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 0dd9a1af3..23d416b53 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 6a7ccb961..2e9b6736f 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 2d306f60b..3a5ce2323 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 = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
@@ -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 f9fb6dca4..2e3da29a7 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 6098ad9aa..15d3facd3 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,