diff --git a/packages/core/test/fixture/models.ts b/packages/core/test/fixture/models.ts new file mode 100644 index 00000000000..dfad11c4aef --- /dev/null +++ b/packages/core/test/fixture/models.ts @@ -0,0 +1,6 @@ +import { ModelsDev } from "@opencode-ai/core/models-dev" + +// Core is env-free, so the default ModelsDev node refreshes from models.dev +// unless the graph says otherwise. Real-Location fixtures opt out here; the +// test harness refuses any request that slips past. +export const offlineModels = ModelsDev.node.replace(ModelsDev.configured({ fetch: false })) diff --git a/packages/core/test/formatter.test.ts b/packages/core/test/formatter.test.ts index c9da6b68dfb..837813b2f22 100644 --- a/packages/core/test/formatter.test.ts +++ b/packages/core/test/formatter.test.ts @@ -15,12 +15,14 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Formatter } from "../src/formatter" import { Location } from "../src/location" import { tempGlobalLayer } from "./fixture/global" +import { offlineModels } from "./fixture/models" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ Global.node.replace(tempGlobalLayer), + offlineModels, ]), ) type ConfigInput = typeof Info.Encoded diff --git a/packages/core/test/instance-vanilla.test.ts b/packages/core/test/instance-vanilla.test.ts index 623f1e0c734..3db41381aef 100644 --- a/packages/core/test/instance-vanilla.test.ts +++ b/packages/core/test/instance-vanilla.test.ts @@ -14,6 +14,7 @@ import { Plugin } from "@opencode-ai/core/plugin" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "./fixture/tmpdir" import { tempGlobalLayer } from "./fixture/global" +import { offlineModels } from "./fixture/models" import { testEffect } from "./lib/effect" import { Database } from "../src/database/database" import { Bus } from "../src/bus" @@ -47,6 +48,7 @@ const instances = Layer.effect( ) const bindings: LayerNode.Replacements = [ Global.node.replace(tempGlobalLayer), + offlineModels, LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)), Instance.node.replace( Layer.succeed(Instance.Service, { @@ -61,6 +63,7 @@ const instances = Layer.effect( const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [ Global.node.replace(tempGlobalLayer), + offlineModels, LocationServiceMap.node.replace(instances), ]), ) diff --git a/packages/core/test/lib/effect.ts b/packages/core/test/lib/effect.ts index f179fcb206d..8cc23621b54 100644 --- a/packages/core/test/lib/effect.ts +++ b/packages/core/test/lib/effect.ts @@ -1,19 +1,48 @@ import { test, type TestOptions } from "bun:test" import { Cause, Effect, Exit, Layer, type Scope } from "effect" import { TestClock, TestConsole } from "effect/testing" +import { FetchHttpClient } from "effect/unstable/http" type Body = Effect.Effect | (() => Effect.Effect) const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) +const loopback = new Set(["127.0.0.1", "localhost", "[::1]"]) + +// Core is env-free, so nothing tells the default node graph to stay offline; +// a test that boots it would phone home through FetchHttpClient. Every +// FetchHttpClient reads this reference at request time, so refusing here covers +// each node that shares the default client, while an explicit HttpClient +// replacement never reaches it. Callers such as ModelsDev swallow request +// failures, so the harness also records the attempt and fails the test itself. +export const refuseNetwork = (violations: string[]): typeof fetch => + Object.assign( + (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (loopback.has(new URL(url).hostname)) return fetch(input, init) + const method = init?.method ?? (input instanceof Request ? input.method : "GET") + const message = `test attempted network request: ${method} ${url} — provide an explicit HttpClient or disable the fetch` + violations.push(message) + return Promise.reject(new Error(message)) + }, + { preconnect: fetch.preconnect }, + ) + const run = (value: Body, layer: Layer.Layer) => Effect.gen(function* () { - const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) + const violations: string[] = [] + const exit = yield* body(value).pipe( + Effect.scoped, + Effect.provide(layer), + Effect.provideService(FetchHttpClient.Fetch, refuseNetwork(violations)), + Effect.exit, + ) if (Exit.isFailure(exit)) { for (const err of Cause.prettyErrors(exit.cause)) { yield* Effect.logError(err) } } + if (violations.length > 0) return yield* Effect.fail(new Error(violations.join("\n"))) return yield* exit }).pipe(Effect.runPromise) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index dae14c3648a..355351bb77b 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -24,6 +24,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { tmpdir } from "./fixture/tmpdir" import { tempGlobalLayer } from "./fixture/global" +import { offlineModels } from "./fixture/models" import { testEffect } from "./lib/effect" import { toolDefinitions } from "./lib/tool" import { Database } from "../src/database/database" @@ -34,6 +35,7 @@ import { Tool } from "../src/tool" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [ Global.node.replace(tempGlobalLayer), + offlineModels, ]), ) const activityLocations = Layer.effect( diff --git a/packages/core/test/network-guard.test.ts b/packages/core/test/network-guard.test.ts new file mode 100644 index 00000000000..cf55ff07d78 --- /dev/null +++ b/packages/core/test/network-guard.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer } from "effect" +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform" +import { it, refuseNetwork } from "./lib/effect" + +describe("test harness network guard", () => { + test("refuses requests to hosts other than loopback", async () => { + const violations: string[] = [] + const refused = refuseNetwork(violations) + await expect(refused("https://models.opencode.ai/api.json")).rejects.toThrow( + "test attempted network request: GET https://models.opencode.ai/api.json — provide an explicit HttpClient or disable the fetch", + ) + await expect(refused(new Request("https://example.invalid/", { method: "POST" }))).rejects.toThrow( + "test attempted network request: POST https://example.invalid/", + ) + expect(violations).toHaveLength(2) + }) + + it.live("the default http client node requests through the harness fetch", () => + Effect.gen(function* () { + const seen: string[] = [] + const response = yield* HttpClient.get("https://example.invalid/catalog").pipe( + Effect.flatMap((response) => response.text), + Effect.provide(AppNodeBuilder.build(LayerNodePlatform.httpClient)), + Effect.provideService( + FetchHttpClient.Fetch, + Object.assign( + (input: string | URL | Request) => { + seen.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url) + return Promise.resolve(new Response("from fetch")) + }, + { preconnect: fetch.preconnect }, + ), + ), + ) + expect(response).toBe("from fetch") + expect(seen).toEqual(["https://example.invalid/catalog"]) + }), + ) + + it.live("an explicit HttpClient replacement is what the node sees", () => + Effect.gen(function* () { + const mock = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("from mock"))), + ) + const response = yield* HttpClient.get("https://example.invalid/catalog").pipe( + Effect.flatMap((response) => response.text), + Effect.provide( + AppNodeBuilder.build(LayerNodePlatform.httpClient, [ + LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, mock)), + ]), + ), + ) + expect(response).toBe("from mock") + }), + ) + + it.live("loopback requests pass through to the real fetch", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.sync(() => Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("local") })), + (server) => Effect.promise(() => server.stop(true)), + ) + const response = yield* HttpClient.get(`http://127.0.0.1:${server.port}/`).pipe( + Effect.flatMap((response) => response.text), + Effect.provide(AppNodeBuilder.build(LayerNodePlatform.httpClient)), + ) + expect(response).toBe("local") + }), + ) +}) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 5c90f8ca503..d0a507b7867 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -3,6 +3,7 @@ import { LLM } from "@opencode-ai/ai" import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route" import { Money } from "@opencode-ai/schema/money" import { Effect, Layer, Stream } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" @@ -608,7 +609,16 @@ describe("OpencodePlugin", () => { draft.cost = cost(1) }) }) - yield* addPlugin() + // An env credential has no server metadata, so the plugin would ask the + // default Console for remote config; answer 404 (no remote config) locally. + yield* addPlugin().pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))), + ), + ), + ) expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("paid"))).enabled).toBe(true) }), diff --git a/packages/core/test/plugin/supervisor.test.ts b/packages/core/test/plugin/supervisor.test.ts index 81c46c3e2ed..b4c0f0091f7 100644 --- a/packages/core/test/plugin/supervisor.test.ts +++ b/packages/core/test/plugin/supervisor.test.ts @@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { Database } from "../../src/database/database" import { Bus } from "../../src/bus" import { tempGlobalLayer } from "../fixture/global" +import { offlineModels } from "../fixture/models" import { tmpdirScoped } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" @@ -37,6 +38,7 @@ const instances = Layer.effect( ) const bindings: LayerNode.Replacements = [ Global.node.replace(tempGlobalLayer), + offlineModels, LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)), Instance.node.replace( Layer.succeed(Instance.Service, { @@ -51,6 +53,7 @@ const instances = Layer.effect( const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ Global.node.replace(tempGlobalLayer), + offlineModels, LocationServiceMap.node.replace(instances), ]), ) diff --git a/packages/core/test/preload.ts b/packages/core/test/preload.ts index 39b237d70a4..8a7fd8ca7f2 100644 --- a/packages/core/test/preload.ts +++ b/packages/core/test/preload.ts @@ -1,5 +1 @@ -import path from "path" - process.env.OPENCODE_DB = ":memory:" -process.env.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json") -process.env.OPENCODE_DISABLE_MODELS_FETCH = "true" diff --git a/packages/core/test/session-activation.test.ts b/packages/core/test/session-activation.test.ts index ab00e067e9c..b2ba2358bcd 100644 --- a/packages/core/test/session-activation.test.ts +++ b/packages/core/test/session-activation.test.ts @@ -13,6 +13,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Global } from "@opencode-ai/util/global" import { tempGlobalLayer } from "./fixture/global" +import { offlineModels } from "./fixture/models" import { tmpdirScoped } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -24,6 +25,7 @@ const it = testEffect( LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]), [ Global.node.replace(tempGlobalLayer), + offlineModels, Watcher.node.replace(Watcher.configured({ enabled: false })), SessionExecution.node.replace(SessionExecution.noopLayer), ], diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index e6b7cdac58c..ab49cbfae70 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -35,6 +35,7 @@ import { Workspace } from "@opencode-ai/core/workspace" import { Expected } from "./lib/session-message" import { testEffect } from "./lib/effect" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { offlineModels } from "./fixture/models" import { promptLocationNode } from "./fixture/prompt-location" import { globalProjectNode } from "./lib/project" import { tmpdirScoped } from "./fixture/tmpdir" @@ -61,7 +62,11 @@ const it = testEffect( const liveIt = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]), - [Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(SessionExecution.noopLayer)], + [ + Bus.node.replace(Bus.configured({ persist: true })), + SessionExecution.node.replace(SessionExecution.noopLayer), + offlineModels, + ], ), ) const projectIt = testEffect( diff --git a/packages/core/test/session-move.test.ts b/packages/core/test/session-move.test.ts index f465e53b296..2f297fbe5fa 100644 --- a/packages/core/test/session-move.test.ts +++ b/packages/core/test/session-move.test.ts @@ -18,6 +18,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionRunner } from "@opencode-ai/core/session/runner/index" import { SessionStore } from "@opencode-ai/core/session/store" import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { offlineModels } from "./fixture/models" import { tmpdirScoped } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" import { globalProjectNode } from "./lib/project" @@ -25,7 +26,7 @@ import { globalProjectNode } from "./lib/project" const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), - [Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer)], + [Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels], ), ) const itWithActiveExecution = testEffect( @@ -47,7 +48,7 @@ const itWithActiveExecution = testEffect( (ref: Location.Ref) => Layer.merge( LayerNode.compile(Location.boundNode(ref), { - replacements: [Project.node.replace(globalProjectNode)], + replacements: [Project.node.replace(globalProjectNode), offlineModels], }), Layer.succeed(SessionRunner.Service, { drain: () => Effect.never }), ) as unknown as Layer.Layer, diff --git a/packages/core/test/session-remove.test.ts b/packages/core/test/session-remove.test.ts index 30c20c2d367..42b4e7d49a7 100644 --- a/packages/core/test/session-remove.test.ts +++ b/packages/core/test/session-remove.test.ts @@ -17,6 +17,7 @@ import { SessionEnvironment } from "@opencode-ai/core/session/environment" import { LocationServiceMap } from "@opencode-ai/core/location-services" import { testEffect } from "./lib/effect" import { globalProjectNode } from "./lib/project" +import { offlineModels } from "./fixture/models" import { tmpdirScoped } from "./fixture/tmpdir" const closed: Session.ID[] = [] @@ -50,6 +51,7 @@ const it = testEffect( Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer), SessionModelTransport.node.replace(transport), + offlineModels, ], ), ) diff --git a/packages/core/test/session-revert.test.ts b/packages/core/test/session-revert.test.ts index 93afd034201..629a5f3693e 100644 --- a/packages/core/test/session-revert.test.ts +++ b/packages/core/test/session-revert.test.ts @@ -23,6 +23,7 @@ import { Money } from "@opencode-ai/schema/money" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Global } from "@opencode-ai/util/global" import { tempGlobalLayer } from "./fixture/global" +import { offlineModels } from "./fixture/models" import { tmpdirScoped } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -33,6 +34,7 @@ const it = testEffect( Bus.node.replace(Bus.configured({ persist: true })), Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), + offlineModels, ], ), ) diff --git a/packages/core/test/session-shell.test.ts b/packages/core/test/session-shell.test.ts index d95d17a3b64..ecff6a27693 100644 --- a/packages/core/test/session-shell.test.ts +++ b/packages/core/test/session-shell.test.ts @@ -14,6 +14,7 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator import { Shell } from "@opencode-ai/core/shell" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { location } from "./fixture/location" +import { offlineModels } from "./fixture/models" import { tmpdirScoped } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -59,6 +60,7 @@ const it = testEffect( AppNodeBuilder.build(LayerNode.group([Bus.node, Session.node, SessionExecution.node, LocationServiceMap.node]), [ Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(executionLayer.pipe(Layer.provide(controlLayer))), + offlineModels, ]).pipe(Layer.provideMerge(controlLayer)), ) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 02132ccfd2d..294c116a9c2 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -40,6 +40,7 @@ import { ToolOutput } from "@opencode-ai/core/tool-output" import { Tool } from "@opencode-ai/core/tool" import { tmpdir, tmpdirScoped } from "./fixture/tmpdir" import { tempGlobalLayer } from "./fixture/global" +import { offlineModels } from "./fixture/models" import { testEffect } from "./lib/effect" import { permissionLayer } from "./lib/permission" import { Expected } from "./lib/session-message" @@ -155,6 +156,7 @@ const replacements = [ SessionExecution.node.replace(executionNode), Permission.node.replace(permission), Global.node.replace(tempGlobalLayer), + offlineModels, ] satisfies LayerNode.Replacements const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements)) const it = testEffect( @@ -165,6 +167,7 @@ const permissionIt = testEffect( SessionExecution.node.replace(executionNode), Global.node.replace(tempGlobalLayer), PluginSupervisor.node.replace(shellPluginSupervisor), + offlineModels, ]), ) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index f767a27cc56..6b800e1c7a8 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -36,6 +36,7 @@ import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent" import { Tool } from "@opencode-ai/core/tool" import { tmpdir } from "./fixture/tmpdir" import { tempGlobalLayer } from "./fixture/global" +import { offlineModels } from "./fixture/models" import { testEffect } from "./lib/effect" import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" @@ -120,6 +121,7 @@ const nodes = LayerNode.group([ const replacements = [ SessionExecution.node.replace(executionNode), Global.node.replace(tempGlobalLayer), + offlineModels, ] satisfies LayerNode.Replacements const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements)) const it = testEffect( @@ -128,6 +130,7 @@ const it = testEffect( const completionIt = testEffect( AppNodeBuilder.build(LayerNode.group([nodes, SessionRestart.node, KV.node]), [ Global.node.replace(tempGlobalLayer), + offlineModels, PluginSupervisor.node.replace(subagentPluginSupervisor), LayerNodePlatform.llmClient.replace(TestLLM.testLayer({ fallback: TestLLM.text(childText, "completion") })), SessionRunnerModel.node.replace( diff --git a/packages/server/test/fixture/server.ts b/packages/server/test/fixture/server.ts index fb7767317be..8eeeece1860 100644 --- a/packages/server/test/fixture/server.ts +++ b/packages/server/test/fixture/server.ts @@ -11,6 +11,7 @@ export const startServer = Effect.fnUntraced(function* (directory: string) { database: { path: ":memory:" }, config: { directory }, fs: { filewatcher: false }, + models: { fetch: false }, }) return { base: HttpServer.formatAddress(server.address), diff --git a/packages/server/test/generate.test.ts b/packages/server/test/generate.test.ts index a61c4f0ec0f..cb0a8f57bbf 100644 --- a/packages/server/test/generate.test.ts +++ b/packages/server/test/generate.test.ts @@ -47,6 +47,7 @@ it.live("uses base configuration without depending on process.cwd()", () => database: { path: ":memory:" }, config: { directory: global }, fs: { filewatcher: false }, + models: { fetch: false }, }, { overrides: [Generate.node.replace(generate)] }, ) diff --git a/packages/server/test/session-message-update.test.ts b/packages/server/test/session-message-update.test.ts index 49b0b72eb2b..91f4c64c722 100644 --- a/packages/server/test/session-message-update.test.ts +++ b/packages/server/test/session-message-update.test.ts @@ -55,7 +55,12 @@ it.live("updates completed assistant message content through the session HTTP AP }), ) const handler = yield* ServerFetch.make( - { app: { version: "test-version" }, database: { path: ":memory:" }, fs: { filewatcher: false } }, + { + app: { version: "test-version" }, + database: { path: ":memory:" }, + fs: { filewatcher: false }, + models: { fetch: false }, + }, { overrides: [ SessionExecution.node.replace( diff --git a/packages/server/test/vcs.test.ts b/packages/server/test/vcs.test.ts index 8a52d94bb83..11ad26d1b84 100644 --- a/packages/server/test/vcs.test.ts +++ b/packages/server/test/vcs.test.ts @@ -81,7 +81,12 @@ it.live("maps a failing base provider to HTTP 503 instead of null metadata", () Effect.gen(function* () { const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-failure-"))) const handler = yield* ServerFetch.make( - { database: { path: ":memory:" }, config: { directory: tmp.path }, fs: { filewatcher: false } }, + { + database: { path: ":memory:" }, + config: { directory: tmp.path }, + fs: { filewatcher: false }, + models: { fetch: false }, + }, { overrides: [ SdkPlugins.node.replace(