mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-02 15:00:39 +08:00
Compare commits
9 Commits
kit/migrat
...
kit/instan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
481f8667a4 | ||
|
|
d9252a09dd | ||
|
|
74373f85c7 | ||
|
|
1b146ad094 | ||
|
|
8a63cbe79c | ||
|
|
c565bd54e2 | ||
|
|
f0136f947b | ||
|
|
f1470c1a88 | ||
|
|
f5398e7e1e |
@@ -1,9 +1,11 @@
|
||||
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()
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
@@ -76,6 +77,7 @@ export const rpc = {
|
||||
async checkUpgrade(input: { directory: string }) {
|
||||
await Instance.provide({
|
||||
directory: input.directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: async () => {
|
||||
await upgrade().catch(() => {})
|
||||
},
|
||||
|
||||
@@ -23,7 +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 { InstanceRef } from "@/effect/instance-ref"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
|
||||
import { ConfigAgent } from "./agent"
|
||||
@@ -459,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 (containsPath(source, ctx)) return "local"
|
||||
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
|
||||
return "global"
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { containsPath } from "../project/instance-context"
|
||||
import { Instance } from "../project/instance"
|
||||
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 (!containsPath(full, ctx)) {
|
||||
if (!Instance.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 (!containsPath(resolved, ctx)) {
|
||||
if (!Instance.containsPath(resolved, ctx)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { containsPath } from "@/project/instance-context"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
|
||||
@@ -221,7 +221,12 @@ export const layer = Layer.effect(
|
||||
|
||||
const getClients = Effect.fnUntraced(function* (file: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (!containsPath(file, ctx)) return [] as LSPClient.Info[]
|
||||
if (
|
||||
!AppFileSystem.contains(ctx.directory, file) &&
|
||||
(ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))
|
||||
) {
|
||||
return [] as LSPClient.Info[]
|
||||
}
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
|
||||
@@ -8,71 +8,37 @@ 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 { Context, Effect, Layer } from "effect"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Config } from "@/config/config"
|
||||
|
||||
export interface Interface {
|
||||
readonly run: Effect.Effect<void>
|
||||
}
|
||||
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 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"
|
||||
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"))
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import type * as Project from "./project"
|
||||
|
||||
export interface InstanceContext {
|
||||
@@ -9,16 +8,3 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
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 { type InstanceContext } from "./instance-context"
|
||||
import { context, type InstanceContext } from "./instance-context"
|
||||
import * as Project from "./project"
|
||||
|
||||
export interface LoadInput<R = never> {
|
||||
export interface LoadInput {
|
||||
directory: string
|
||||
/**
|
||||
* 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>
|
||||
init?: () => Promise<unknown>
|
||||
worktree?: string
|
||||
project?: Project.Info
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
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 load: (input: LoadInput) => Effect.Effect<InstanceContext>
|
||||
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
|
||||
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
|
||||
readonly disposeAll: () => Effect.Effect<void>
|
||||
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") {}
|
||||
@@ -43,25 +34,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 = <R>(input: LoadInput<R> & { directory: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx: InstanceContext =
|
||||
input.project && input.worktree
|
||||
? {
|
||||
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) => ({
|
||||
directory: input.directory,
|
||||
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"))
|
||||
worktree: result.sandbox,
|
||||
project: result.project,
|
||||
})),
|
||||
)
|
||||
const init = input.init
|
||||
if (init) yield* Effect.promise(() => context.provide(ctx, init))
|
||||
return ctx
|
||||
})
|
||||
|
||||
const removeEntry = (directory: string, entry: Entry) =>
|
||||
Effect.sync(() => {
|
||||
@@ -70,12 +61,11 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||
return true
|
||||
})
|
||||
|
||||
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 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 emitDisposed = (input: { directory: string; project?: string }) =>
|
||||
Effect.sync(() =>
|
||||
@@ -106,9 +96,9 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||
return true
|
||||
})
|
||||
|
||||
const load = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
|
||||
const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) {
|
||||
const directory = AppFileSystem.resolve(input.directory)
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = cache.get(directory)
|
||||
if (existing) return yield* restore(Deferred.await(existing.deferred))
|
||||
@@ -121,12 +111,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 = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
|
||||
const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) {
|
||||
const directory = AppFileSystem.resolve(input.directory)
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const previous = cache.get(directory)
|
||||
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
|
||||
@@ -142,8 +132,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)
|
||||
@@ -178,12 +168,6 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||
return yield* cachedDisposeAll
|
||||
})
|
||||
|
||||
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))
|
||||
|
||||
return Service.of({
|
||||
@@ -191,7 +175,6 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||
reload,
|
||||
dispose,
|
||||
disposeAll,
|
||||
provide,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Effect } from "effect"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import * as Project from "./project"
|
||||
import { context, type InstanceContext } from "./instance-context"
|
||||
import { InstanceStore } from "./instance-store"
|
||||
@@ -7,40 +6,11 @@ import { InstanceStore } from "./instance-store"
|
||||
export type { InstanceContext } from "./instance-context"
|
||||
export type { LoadInput } from "./instance-store"
|
||||
|
||||
type LegacyLoadInput = {
|
||||
directory: string
|
||||
init?: Effect.Effect<void>
|
||||
project?: Project.Info
|
||||
worktree?: string
|
||||
}
|
||||
|
||||
// Bind ALS around init so legacy code reachable through it (Instance.directory reads, etc.)
|
||||
// stays bound. The Effect-typed init also gets InstanceRef provided by the store.
|
||||
const liftLegacyInput = (input: LegacyLoadInput): InstanceStore.LoadInput => {
|
||||
const { init, ...rest } = input
|
||||
if (!init) return rest
|
||||
return {
|
||||
...rest,
|
||||
init: Effect.gen(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* init
|
||||
yield* Effect.callback<void>((resume) => {
|
||||
context.provide(ctx, () => {
|
||||
Effect.runPromise(init).then(
|
||||
() => resume(Effect.void),
|
||||
(err) => resume(Effect.die(err)),
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export const Instance = {
|
||||
load(input: LegacyLoadInput): Promise<InstanceContext> {
|
||||
return InstanceStore.runtime.runPromise((store) => store.load(liftLegacyInput(input)))
|
||||
load(input: InstanceStore.LoadInput): Promise<InstanceContext> {
|
||||
return InstanceStore.runtime.runPromise((store) => store.load(input))
|
||||
},
|
||||
async provide<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }): Promise<R> {
|
||||
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(),
|
||||
@@ -59,6 +29,19 @@ export const Instance = {
|
||||
return context.use().project
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a path is within the project boundary.
|
||||
* Returns true if path is inside Instance.directory OR Instance.worktree.
|
||||
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
|
||||
*/
|
||||
containsPath(filepath: string, ctx?: InstanceContext) {
|
||||
const instance = ctx ?? Instance
|
||||
if (AppFileSystem.contains(instance.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 (instance.worktree === "/") return false
|
||||
return AppFileSystem.contains(instance.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
|
||||
@@ -76,8 +59,8 @@ export const Instance = {
|
||||
restore<R>(ctx: InstanceContext, fn: () => R): R {
|
||||
return context.provide(ctx, fn)
|
||||
},
|
||||
async reload(input: LegacyLoadInput) {
|
||||
return InstanceStore.runtime.runPromise((store) => store.reload(liftLegacyInput(input)))
|
||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
return InstanceStore.runtime.runPromise((store) => store.reload(input))
|
||||
},
|
||||
async dispose() {
|
||||
return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current))
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Instance Route Parity
|
||||
|
||||
This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned.
|
||||
|
||||
- When adding, removing, or changing a legacy Hono route, update the matching Effect HttpApi group and handler in `httpapi/` in the same change unless the route is intentionally unsupported.
|
||||
- When changing an Effect HttpApi route, verify the legacy Hono route has the same public behavior, request shape, response shape, status codes, and instance/workspace routing semantics.
|
||||
- Keep OpenAPI/SDK-visible schemas aligned. If a difference is only an OpenAPI generation artifact, prefer fixing the source schema first; use `httpapi/public.ts` normalization only for compatibility shims that cannot be represented cleanly in the source schema.
|
||||
- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress.
|
||||
@@ -9,7 +9,10 @@ import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/experimental/workspace"
|
||||
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
|
||||
export const CreatePayload = Schema.Struct({
|
||||
...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]),
|
||||
extra: Schema.optional(Workspace.CreateInput.fields.extra),
|
||||
})
|
||||
export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
|
||||
export const SessionRestoreResponse = Schema.Struct({
|
||||
total: NonNegativeInt,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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"
|
||||
@@ -28,6 +29,7 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.directory,
|
||||
project: next,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
||||
return yield* workspace
|
||||
.create({
|
||||
...ctx.payload,
|
||||
extra: ctx.payload.extra ?? null,
|
||||
projectID: instance.project.id,
|
||||
})
|
||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
@@ -21,16 +23,26 @@ function decode(input: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function makeInstanceContext(
|
||||
store: InstanceStore.Interface,
|
||||
directory: string,
|
||||
): Effect.Effect<InstanceContext> {
|
||||
return store.load({
|
||||
directory: decode(directory),
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
})
|
||||
}
|
||||
|
||||
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: bootstrap.run },
|
||||
effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)),
|
||||
const ctx = yield* makeInstanceContext(store, route.directory)
|
||||
return yield* effect.pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
Effect.provideService(WorkspaceRef, route.workspaceID),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -39,15 +51,13 @@ export const instanceContextLayer = Layer.effect(
|
||||
InstanceContextMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* InstanceStore.Service
|
||||
const bootstrap = yield* InstanceBootstrap.Service
|
||||
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store, bootstrap))
|
||||
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
|
||||
}),
|
||||
)
|
||||
|
||||
export const instanceRouterMiddleware = HttpRouter.middleware()(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* InstanceStore.Service
|
||||
const bootstrap = yield* InstanceBootstrap.Service
|
||||
return (effect) => provideInstanceContext(effect, store, bootstrap)
|
||||
return (effect) => provideInstanceContext(effect, store)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getAdapter } from "@/control-plane/adapters"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import type { Target } from "@/control-plane/types"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Session } from "@/session/session"
|
||||
import { HttpApiProxy } from "./proxy"
|
||||
import * as Fence from "@/server/fence"
|
||||
@@ -42,6 +43,14 @@ export class WorkspaceRoutingMiddleware extends HttpApiMiddleware.Service<
|
||||
}
|
||||
>()("@opencode/ExperimentalHttpApiWorkspaceRouting") {}
|
||||
|
||||
function currentDirectory(): string {
|
||||
try {
|
||||
return Instance.directory
|
||||
} catch {
|
||||
return process.cwd()
|
||||
}
|
||||
}
|
||||
|
||||
function requestURL(request: HttpServerRequest.HttpServerRequest): URL {
|
||||
return new URL(request.url, "http://localhost")
|
||||
}
|
||||
@@ -56,7 +65,7 @@ function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): Worksp
|
||||
}
|
||||
|
||||
function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string {
|
||||
return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || process.cwd()
|
||||
return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || currentDirectory()
|
||||
}
|
||||
|
||||
function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, url: URL): boolean {
|
||||
|
||||
@@ -39,7 +39,6 @@ type OpenApiSchema = {
|
||||
maximum?: number
|
||||
minimum?: number
|
||||
oneOf?: OpenApiSchema[]
|
||||
pattern?: string
|
||||
prefixItems?: OpenApiSchema[]
|
||||
properties?: Record<string, OpenApiSchema>
|
||||
required?: string[]
|
||||
@@ -75,18 +74,9 @@ const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
|
||||
const QueryBooleanParameters = new Set(["roots", "archived"])
|
||||
const QueryParameterSchemas = {
|
||||
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
|
||||
"GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" },
|
||||
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||
} satisfies Record<string, OpenApiSchema>
|
||||
|
||||
const PathParameterSchemas = {
|
||||
sessionID: { type: "string", pattern: "^ses.*" },
|
||||
messageID: { type: "string", pattern: "^msg.*" },
|
||||
partID: { type: "string", pattern: "^prt.*" },
|
||||
permissionID: { type: "string", pattern: "^per.*" },
|
||||
ptyID: { type: "string", pattern: "^pty.*" },
|
||||
} satisfies Record<string, OpenApiSchema>
|
||||
|
||||
const LegacyComponentDescriptions = {
|
||||
LogLevel: "Log level",
|
||||
ServerConfig: "Server configuration for opencode serve and web commands",
|
||||
@@ -438,11 +428,6 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) {
|
||||
|
||||
/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */
|
||||
function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema {
|
||||
if (schema.allOf?.length === 1) {
|
||||
const [constraint] = schema.allOf
|
||||
delete schema.allOf
|
||||
return stripOptionalNull({ ...schema, ...constraint })
|
||||
}
|
||||
if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} }
|
||||
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
|
||||
if (options) {
|
||||
@@ -491,40 +476,25 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] |
|
||||
}
|
||||
|
||||
function normalizeParameter(param: OpenApiParameter, route: string) {
|
||||
if (!param.schema || typeof param.schema !== "object") return
|
||||
if (param.in === "path") {
|
||||
param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema)
|
||||
if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return
|
||||
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
|
||||
if (override) {
|
||||
param.schema = override
|
||||
return
|
||||
}
|
||||
if (param.in === "query") {
|
||||
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
|
||||
if (override) {
|
||||
param.schema = override
|
||||
return
|
||||
}
|
||||
if (QueryNumberParameters.has(param.name)) {
|
||||
param.schema = { type: "number" }
|
||||
return
|
||||
}
|
||||
if (QueryBooleanParameters.has(param.name)) {
|
||||
param.schema = {
|
||||
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
|
||||
}
|
||||
return
|
||||
if (QueryNumberParameters.has(param.name)) {
|
||||
param.schema = { type: "number" }
|
||||
return
|
||||
}
|
||||
if (QueryBooleanParameters.has(param.name)) {
|
||||
param.schema = {
|
||||
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
|
||||
}
|
||||
return
|
||||
}
|
||||
param.schema = stripOptionalNull(param.schema)
|
||||
}
|
||||
|
||||
function pathParameterSchema(route: string, name: string) {
|
||||
if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas]
|
||||
if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
|
||||
if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
|
||||
if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" }
|
||||
if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" }
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const PublicApi = OpenCodeHttpApi.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode",
|
||||
|
||||
@@ -11,16 +11,13 @@ 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"
|
||||
@@ -35,9 +32,7 @@ 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"
|
||||
@@ -148,15 +143,12 @@ 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,
|
||||
@@ -171,8 +163,6 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
||||
SessionRunState.defaultLayer,
|
||||
SessionStatus.defaultLayer,
|
||||
SessionSummary.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
Skill.defaultLayer,
|
||||
Todo.defaultLayer,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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"
|
||||
@@ -23,6 +24,7 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler
|
||||
async fn() {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
async fn() {
|
||||
return next()
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
|
||||
@@ -85,6 +86,7 @@ export const ProjectRoutes = lazy(() =>
|
||||
directory: dir,
|
||||
worktree: dir,
|
||||
project: next,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
})
|
||||
return c.json(next)
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -99,6 +100,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
fn: () =>
|
||||
Instance.provide({
|
||||
directory: target.directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
async fn() {
|
||||
return next()
|
||||
},
|
||||
|
||||
@@ -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 { containsPath, type InstanceContext } from "../project/instance-context"
|
||||
import { Instance, type InstanceContext } from "../project/instance"
|
||||
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 || containsPath(resolved, instance)) continue
|
||||
if (!resolved || Instance.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 (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd)
|
||||
if (!Instance.containsPath(cwd, executeInstance)) scan.dirs.add(cwd)
|
||||
yield* ask(ctx, scan)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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 { containsPath } from "../project/instance-context"
|
||||
import { Instance } from "../project/instance"
|
||||
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 (containsPath(full, ins)) return
|
||||
if (Instance.containsPath(full, ins)) return
|
||||
|
||||
const kind = options?.kind ?? "file"
|
||||
const dir = kind === "directory" ? full : path.dirname(full)
|
||||
|
||||
@@ -10,7 +10,7 @@ import DESCRIPTION from "./read.txt"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { Instruction } from "../session/instruction"
|
||||
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
||||
import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
@@ -18,7 +18,6 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
const MAX_BYTES = 50 * 1024
|
||||
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
||||
const SAMPLE_BYTES = 4096
|
||||
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||
|
||||
// `offset` and `limit` were originally `z.coerce.number()` — the runtime
|
||||
// coercion was useful when the tool was called from a shell but serves no
|
||||
@@ -221,9 +220,7 @@ export const ReadTool = Tool.define(
|
||||
const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)
|
||||
|
||||
const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath))
|
||||
const isImage = SUPPORTED_IMAGE_MIMES.has(mime)
|
||||
|
||||
if (isImage || isPdfAttachment(mime)) {
|
||||
if (isImageAttachment(mime) || isPdfAttachment(mime)) {
|
||||
const bytes = yield* fs.readFile(filepath)
|
||||
const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully"
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
@@ -254,6 +255,7 @@ export const layer: Layer.Layer<
|
||||
const booted = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: info.directory,
|
||||
init: () => BootstrapRuntime.runPromise(InstanceBootstrap),
|
||||
fn: () => undefined,
|
||||
})
|
||||
.then(() => true)
|
||||
|
||||
@@ -5,7 +5,6 @@ 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>) =>
|
||||
@@ -122,15 +121,15 @@ describe("File.list path traversal protection", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("containsPath", () => {
|
||||
describe("Instance.containsPath", () => {
|
||||
test("returns true for path inside directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
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)
|
||||
expect(Instance.containsPath(path.join(tmp.path, "foo.txt"))).toBe(true)
|
||||
expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -144,11 +143,11 @@ describe("containsPath", () => {
|
||||
directory: subdir,
|
||||
fn: () => {
|
||||
// .opencode at worktree root, but we're running from packages/lib
|
||||
expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true)
|
||||
expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"))).toBe(true)
|
||||
// sibling package should also be accessible
|
||||
expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true)
|
||||
expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"))).toBe(true)
|
||||
// worktree root itself
|
||||
expect(containsPath(tmp.path, Instance.current)).toBe(true)
|
||||
expect(Instance.containsPath(tmp.path)).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -159,8 +158,8 @@ describe("containsPath", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
|
||||
expect(containsPath("/tmp/other-project", Instance.current)).toBe(false)
|
||||
expect(Instance.containsPath("/etc/passwd")).toBe(false)
|
||||
expect(Instance.containsPath("/tmp/other-project")).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -171,7 +170,7 @@ describe("containsPath", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false)
|
||||
expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"))).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -183,8 +182,8 @@ describe("containsPath", () => {
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(Instance.directory).toBe(Instance.worktree)
|
||||
expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
|
||||
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
|
||||
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
|
||||
expect(Instance.containsPath("/etc/passwd")).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -196,9 +195,9 @@ describe("containsPath", () => {
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
|
||||
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)
|
||||
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
|
||||
expect(Instance.containsPath("/etc/passwd")).toBe(false)
|
||||
expect(Instance.containsPath("/tmp/other")).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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"
|
||||
@@ -27,7 +26,7 @@ describe("InstanceStore", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("runs load init with InstanceRef provided", () =>
|
||||
it.live("runs load init inside the loaded legacy instance context", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const store = yield* InstanceStore.Service
|
||||
@@ -35,9 +34,9 @@ describe("InstanceStore", () => {
|
||||
|
||||
yield* store.load({
|
||||
directory: dir,
|
||||
init: Effect.gen(function* () {
|
||||
initializedDirectory = (yield* InstanceRef)?.directory
|
||||
}),
|
||||
init: async () => {
|
||||
initializedDirectory = Instance.directory
|
||||
},
|
||||
})
|
||||
|
||||
expect(initializedDirectory).toBe(dir)
|
||||
@@ -53,15 +52,15 @@ describe("InstanceStore", () => {
|
||||
|
||||
const first = yield* store.load({
|
||||
directory: dir,
|
||||
init: Effect.sync(() => {
|
||||
init: async () => {
|
||||
initialized++
|
||||
}),
|
||||
},
|
||||
})
|
||||
const second = yield* store.load({
|
||||
directory: dir,
|
||||
init: Effect.sync(() => {
|
||||
init: async () => {
|
||||
initialized++
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(second).toBe(first)
|
||||
@@ -80,11 +79,11 @@ describe("InstanceStore", () => {
|
||||
const first = yield* store
|
||||
.load({
|
||||
directory: dir,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
initialized++
|
||||
started.resolve()
|
||||
await release.promise
|
||||
}),
|
||||
},
|
||||
})
|
||||
.pipe(Effect.forkScoped)
|
||||
|
||||
@@ -93,9 +92,9 @@ describe("InstanceStore", () => {
|
||||
const second = yield* store
|
||||
.load({
|
||||
directory: dir,
|
||||
init: Effect.sync(() => {
|
||||
init: async () => {
|
||||
initialized++
|
||||
}),
|
||||
},
|
||||
})
|
||||
.pipe(Effect.forkScoped)
|
||||
|
||||
@@ -117,10 +116,10 @@ describe("InstanceStore", () => {
|
||||
const failed = yield* store
|
||||
.load({
|
||||
directory: dir,
|
||||
init: Effect.sync(() => {
|
||||
init: async () => {
|
||||
attempts++
|
||||
throw new Error("init failed")
|
||||
}),
|
||||
},
|
||||
})
|
||||
.pipe(
|
||||
Effect.as(false),
|
||||
@@ -131,9 +130,9 @@ describe("InstanceStore", () => {
|
||||
|
||||
const ctx = yield* store.load({
|
||||
directory: dir,
|
||||
init: Effect.sync(() => {
|
||||
init: async () => {
|
||||
attempts++
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
expect(ctx.directory).toBe(dir)
|
||||
@@ -171,10 +170,10 @@ describe("InstanceStore", () => {
|
||||
const reload = yield* store
|
||||
.reload({
|
||||
directory: dir,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
reloading.resolve()
|
||||
await releaseReload.promise
|
||||
}),
|
||||
},
|
||||
})
|
||||
.pipe(Effect.forkScoped)
|
||||
|
||||
|
||||
@@ -45,10 +45,10 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("AWS_REGION", "us-east-1")
|
||||
set("AWS_PROFILE", "default")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
@@ -70,10 +70,10 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async ()
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("AWS_REGION", "eu-west-1")
|
||||
set("AWS_PROFILE", "default")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
@@ -125,11 +125,11 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("AWS_PROFILE", "")
|
||||
set("AWS_ACCESS_KEY_ID", "")
|
||||
set("AWS_BEARER_TOKEN_BEDROCK", "")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
@@ -171,10 +171,10 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("AWS_PROFILE", "default")
|
||||
set("AWS_ACCESS_KEY_ID", "test-key-id")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
@@ -203,9 +203,9 @@ test("Bedrock: includes custom endpoint in options when specified", async () =>
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("AWS_PROFILE", "default")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
@@ -236,12 +236,12 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
|
||||
set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
|
||||
set("AWS_PROFILE", "")
|
||||
set("AWS_ACCESS_KEY_ID", "")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
@@ -279,9 +279,9 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () =>
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("AWS_PROFILE", "default")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
@@ -316,9 +316,9 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("AWS_PROFILE", "default")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
@@ -352,9 +352,9 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () =>
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("AWS_PROFILE", "default")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
@@ -388,9 +388,9 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("AWS_PROFILE", "default")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
|
||||
@@ -82,9 +82,9 @@ test("provider loaded from env variable", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
@@ -137,9 +137,9 @@ test("disabled_providers excludes provider", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeUndefined()
|
||||
@@ -161,10 +161,10 @@ test("enabled_providers restricts to only listed providers", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
set("OPENAI_API_KEY", "test-openai-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
@@ -191,9 +191,9 @@ test("model whitelist filters models for provider", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
@@ -222,9 +222,9 @@ test("model blacklist excludes specific models", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
@@ -257,9 +257,9 @@ test("custom model alias via config", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
@@ -394,9 +394,9 @@ test("env variable takes precedence, config merges options", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "env-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
@@ -420,9 +420,9 @@ test("getModel returns model for valid provider/model", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
|
||||
expect(model).toBeDefined()
|
||||
@@ -447,9 +447,9 @@ test("getModel throws ModelNotFoundError for invalid model", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow()
|
||||
},
|
||||
@@ -500,9 +500,9 @@ test("defaultModel returns first available model when no config set", async () =
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await defaultModel()
|
||||
expect(model.providerID).toBeDefined()
|
||||
@@ -525,9 +525,9 @@ test("defaultModel respects config model setting", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await defaultModel()
|
||||
expect(String(model.providerID)).toBe("anthropic")
|
||||
@@ -640,9 +640,9 @@ test("model options are merged from existing model", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
@@ -669,9 +669,9 @@ test("provider removed when all models filtered out", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeUndefined()
|
||||
@@ -692,9 +692,9 @@ test("closest finds model by partial match", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const result = await closest(ProviderID.anthropic, ["sonnet-4"])
|
||||
expect(result).toBeDefined()
|
||||
@@ -747,9 +747,9 @@ test("getModel uses realIdByKey for aliased models", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined()
|
||||
@@ -862,9 +862,9 @@ test("model inherits properties from existing database model", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
@@ -890,9 +890,9 @@ test("disabled_providers prevents loading even with env var", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("OPENAI_API_KEY", "test-openai-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.openai]).toBeUndefined()
|
||||
@@ -914,10 +914,10 @@ test("enabled_providers with empty array allows no providers", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
set("OPENAI_API_KEY", "test-openai-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(Object.keys(providers).length).toBe(0)
|
||||
@@ -944,9 +944,9 @@ test("whitelist and blacklist can be combined", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
@@ -1053,9 +1053,9 @@ test("getSmallModel returns appropriate small model", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await getSmallModel(ProviderID.anthropic)
|
||||
expect(model).toBeDefined()
|
||||
@@ -1078,9 +1078,9 @@ test("getSmallModel respects config small_model override", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await getSmallModel(ProviderID.anthropic)
|
||||
expect(model).toBeDefined()
|
||||
@@ -1126,10 +1126,10 @@ test("multiple providers can be configured simultaneously", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-anthropic-key")
|
||||
set("OPENAI_API_KEY", "test-openai-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
@@ -1205,9 +1205,9 @@ test("model alias name defaults to alias key when id differs", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet")
|
||||
@@ -1245,9 +1245,9 @@ test("provider with multiple env var options only includes apiKey when single en
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("MULTI_ENV_KEY_1", "test-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.make("multi-env")]).toBeDefined()
|
||||
@@ -1287,9 +1287,9 @@ test("provider with single env var includes apiKey automatically", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("SINGLE_ENV_KEY", "my-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.make("single-env")]).toBeDefined()
|
||||
@@ -1324,9 +1324,9 @@ test("model cost overrides existing cost values", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
@@ -1403,11 +1403,11 @@ test("disabled_providers and enabled_providers interaction", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-anthropic")
|
||||
set("OPENAI_API_KEY", "test-openai")
|
||||
set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
// anthropic: in enabled, not in disabled = allowed
|
||||
@@ -1561,10 +1561,10 @@ test("provider env fallback - second env var used if first missing", async () =>
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
// Only set fallback, not primary
|
||||
set("FALLBACK_KEY", "fallback-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
// Provider should load because fallback env var is set
|
||||
@@ -1586,9 +1586,9 @@ test("getModel returns consistent results", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
|
||||
const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
|
||||
@@ -1647,9 +1647,9 @@ test("ModelNotFoundError includes suggestions for typos", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
try {
|
||||
await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
|
||||
@@ -1675,9 +1675,9 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
try {
|
||||
await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
|
||||
@@ -1723,9 +1723,9 @@ test("getProvider returns provider info", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const provider = await getProvider(ProviderID.anthropic)
|
||||
expect(provider).toBeDefined()
|
||||
@@ -1747,9 +1747,9 @@ test("closest returns undefined when no partial match found", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"])
|
||||
expect(result).toBeUndefined()
|
||||
@@ -1770,9 +1770,9 @@ test("closest checks multiple query terms in order", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
// First term won't match, second will
|
||||
const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"])
|
||||
@@ -1842,9 +1842,9 @@ test("provider options are deeply merged", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
// Custom options should be merged
|
||||
@@ -1880,9 +1880,9 @@ test("custom model inherits npm package from models.dev provider config", async
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("OPENAI_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.openai].models["my-custom-model"]
|
||||
@@ -1915,9 +1915,9 @@ test("custom model inherits api.url from models.dev provider", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("OPENROUTER_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.openrouter]).toBeDefined()
|
||||
@@ -2048,9 +2048,9 @@ test("model variants are generated for reasoning models", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
// Claude sonnet 4 has reasoning capability
|
||||
@@ -2086,9 +2086,9 @@ test("model variants can be disabled via config", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
@@ -2129,9 +2129,9 @@ test("model variants can be customized via config", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
@@ -2168,9 +2168,9 @@ test("disabled key is stripped from variant config", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
@@ -2206,9 +2206,9 @@ test("all variants can be disabled via config", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
@@ -2244,9 +2244,9 @@ test("variant config merges with generated variants", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
@@ -2282,9 +2282,9 @@ test("variants filtered in second pass for database models", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("OPENAI_API_KEY", "test-api-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.openai].models["gpt-5"]
|
||||
@@ -2386,9 +2386,9 @@ test("Google Vertex: retains baseURL for custom proxy", async () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
|
||||
@@ -2431,9 +2431,9 @@ test("Google Vertex: supports OpenAI compatible models", async () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
|
||||
@@ -2457,11 +2457,11 @@ test("cloudflare-ai-gateway loads with env variables", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("CLOUDFLARE_ACCOUNT_ID", "test-account")
|
||||
set("CLOUDFLARE_GATEWAY_ID", "test-gateway")
|
||||
set("CLOUDFLARE_API_TOKEN", "test-token")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
|
||||
@@ -2489,11 +2489,11 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("CLOUDFLARE_ACCOUNT_ID", "test-account")
|
||||
set("CLOUDFLARE_GATEWAY_ID", "test-gateway")
|
||||
set("CLOUDFLARE_API_TOKEN", "test-token")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
|
||||
@@ -2592,10 +2592,10 @@ test("plugin config enabled and disabled providers are honored", async () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(async () => {
|
||||
init: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-anthropic-key")
|
||||
set("OPENAI_API_KEY", "test-openai-key")
|
||||
}).pipe(Effect.asVoid),
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
|
||||
@@ -119,23 +119,7 @@ type RequestBody = {
|
||||
function parameterKey(param: unknown): string | undefined {
|
||||
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
|
||||
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
|
||||
return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema(
|
||||
"schema" in param ? param.schema : undefined,
|
||||
)}`
|
||||
}
|
||||
|
||||
function stableSchema(input: unknown): string {
|
||||
return JSON.stringify(sortSchema(input))
|
||||
}
|
||||
|
||||
function sortSchema(input: unknown): unknown {
|
||||
if (Array.isArray(input)) return input.map(sortSchema)
|
||||
if (!input || typeof input !== "object") return input
|
||||
return Object.fromEntries(
|
||||
Object.entries(input)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => [key, sortSchema(value)]),
|
||||
)
|
||||
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
|
||||
}
|
||||
|
||||
function parameterSchema(input: {
|
||||
|
||||
@@ -11,7 +11,6 @@ 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"
|
||||
@@ -42,7 +41,6 @@ const it = testEffect(
|
||||
testStateLayer,
|
||||
NodeHttpServer.layerTest,
|
||||
NodeServices.layer,
|
||||
InstanceBootstrap.defaultLayer,
|
||||
InstanceStore.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
|
||||
@@ -27,9 +27,9 @@ const it = testEffect(
|
||||
Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer),
|
||||
)
|
||||
|
||||
function request(path: string, directory: string, init: RequestInit = {}) {
|
||||
function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) {
|
||||
return Effect.promise(() => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpApi
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return Promise.resolve(Server.Default().app.request(path, { ...init, headers }))
|
||||
@@ -195,6 +195,55 @@ describe("workspace HttpApi", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates workspace with the TUI payload shape", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
|
||||
|
||||
const created = yield* request(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "local-test", branch: null }),
|
||||
})
|
||||
|
||||
expect(created.status).toBe(200)
|
||||
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
|
||||
type: "local-test",
|
||||
name: "local-test",
|
||||
extra: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("documents legacy Hono accepting the TUI payload shape", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
|
||||
|
||||
const created = yield* request(
|
||||
WorkspacePaths.list,
|
||||
dir,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "local-test", branch: null }),
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
||||
expect(created.status).toBe(200)
|
||||
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
|
||||
type: "local-test",
|
||||
name: "local-test",
|
||||
extra: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("routes local workspace requests through the workspace target directory", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
|
||||
@@ -440,24 +440,6 @@ root_type Monster;`
|
||||
expect(result.output).toContain("table Monster")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("falls through unsupported image mime types to text", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const cases = [
|
||||
["image.bmp", "BM text content"],
|
||||
["photo.tiff", "II text content"],
|
||||
["photo.avif", "avif text content"],
|
||||
] as const
|
||||
|
||||
for (const item of cases) {
|
||||
yield* put(path.join(dir, item[0]), item[1])
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, item[0]) })
|
||||
expect(result.attachments).toBeUndefined()
|
||||
expect(result.output).toContain(item[1])
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("tool.read loaded instructions", () => {
|
||||
|
||||
Reference in New Issue
Block a user