Compare commits

...
4 changed files with 393 additions and 17 deletions
+46 -16
View File
@@ -264,6 +264,24 @@ const layer = Layer.effect(
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
const environmentConnections = new Map<ID, { name: string; value: string }>()
const resolveConnections = (
entry: { readonly methods: readonly Method[] } | undefined,
saved: readonly Credential.Info[],
) => {
const credentials = saved
.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
.toReversed()
const env = (entry?.methods ?? [])
.filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name]))
.map((name) => ({ type: "env" as const, name }))
return [...credentials, ...env]
}
const state = State.create<Data, Draft>({
name: "integration",
initial: () => ({ integrations: new Map<ID, Entry>() }),
@@ -326,24 +344,36 @@ const layer = Layer.effect(
},
},
}),
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
finalize: (draft) =>
Effect.gen(function* () {
const current = new Map(
draft.list().flatMap((integration) => {
const connection = resolveConnections({ methods: draft.method.list(integration.id) }, [])[0]
return connection?.type === "env"
? [[integration.id, { name: connection.name, value: process.env[connection.name] ?? "" }] as const]
: []
}),
)
const changed = Array.from(new Set([...environmentConnections.keys(), ...current.keys()])).filter((id) => {
const previous = environmentConnections.get(id)
const next = current.get(id)
return previous?.name !== next?.name || previous?.value !== next?.value
})
environmentConnections.clear()
current.forEach((connection, id) => environmentConnections.set(id, connection))
yield* Effect.forEach(
changed,
(integrationID) =>
Effect.gen(function* () {
if ((yield* credentials.list(integrationID)).length > 0) return
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
}),
{ discard: true },
)
yield* bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid)
}),
})
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
const credentials = saved
.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
.toReversed()
const env = (entry?.methods ?? [])
.filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name]))
.map((name) => ({ type: "env" as const, name }))
return [...credentials, ...env]
}
const project = (entry: Entry, connections: IntegrationConnection.Info[]): Info =>
Info.make({
id: entry.ref.id,
+198 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -62,6 +62,203 @@ describe("Integration", () => {
}),
)
it.effect("publishes effective environment connection changes after state commits", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_INTEGRATION_CONNECTION_TEST
process.env.OPENCODE_INTEGRATION_CONNECTION_TEST = "secret"
return previous
}),
() =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const bus = yield* Bus.Service
const integrationID = Integration.ID.make("environment-test")
const observed: Array<{ id: Integration.ID; active: string | undefined }> = []
const unsubscribe = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type !== Integration.Event.ConnectionUpdated.type) return
const id = Schema.decodeUnknownSync(Integration.Event.ConnectionUpdated)(event).data.integrationID
const active = yield* integrations.connection.active(id)
observed.push({ id, active: active?.type === "env" ? active.name : active?.id })
}),
)
const scope = yield* Scope.fork(yield* Scope.Scope)
yield* integrations
.transform((draft) =>
draft.method.update({
integrationID,
method: { type: "env", names: ["OPENCODE_INTEGRATION_CONNECTION_TEST"] },
}),
)
.pipe(Scope.provide(scope))
expect(observed).toEqual([{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" }])
yield* integrations.transform((draft) =>
draft.update(integrationID, (integration) => (integration.name = "Renamed")),
)
expect(observed).toHaveLength(1)
process.env.OPENCODE_INTEGRATION_CONNECTION_TEST = "rotated-secret"
yield* integrations.transform((draft) =>
draft.update(integrationID, (integration) => (integration.name = "Rotated")),
)
expect(observed).toEqual([
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
])
const removal = yield* Scope.fork(yield* Scope.Scope)
yield* integrations.transform((draft) => draft.remove(integrationID)).pipe(Scope.provide(removal))
expect(observed).toEqual([
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
{ id: integrationID, active: undefined },
])
yield* Scope.close(removal, Exit.void)
yield* Scope.close(scope, Exit.void)
expect(observed).toEqual([
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
{ id: integrationID, active: undefined },
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
{ id: integrationID, active: undefined },
])
yield* unsubscribe
}),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_INTEGRATION_CONNECTION_TEST
else process.env.OPENCODE_INTEGRATION_CONNECTION_TEST = previous
}),
),
)
it.effect("announces reordered environment connections only when the active variable changes", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
first: process.env.OPENCODE_INTEGRATION_FIRST_TEST,
second: process.env.OPENCODE_INTEGRATION_SECOND_TEST,
}
process.env.OPENCODE_INTEGRATION_FIRST_TEST = "first"
process.env.OPENCODE_INTEGRATION_SECOND_TEST = "second"
return previous
}),
() =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const bus = yield* Bus.Service
const integrationID = Integration.ID.make("ordered-test")
const observed: string[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type !== Integration.Event.ConnectionUpdated.type) return
const active = yield* integrations.connection.active(integrationID)
if (active?.type === "env") observed.push(active.name)
}),
)
const first = yield* Scope.fork(yield* Scope.Scope)
const second = yield* Scope.fork(yield* Scope.Scope)
yield* integrations
.transform((draft) =>
draft.method.update({
integrationID,
method: {
type: "env",
names: ["OPENCODE_INTEGRATION_FIRST_TEST", "OPENCODE_INTEGRATION_SECOND_TEST"],
},
}),
)
.pipe(Scope.provide(first))
yield* integrations
.transform((draft) =>
draft.method.update({
integrationID,
method: {
type: "env",
names: ["OPENCODE_INTEGRATION_SECOND_TEST", "OPENCODE_INTEGRATION_FIRST_TEST"],
},
}),
)
.pipe(Scope.provide(second))
yield* Scope.close(second, Exit.void)
expect(observed).toEqual([
"OPENCODE_INTEGRATION_FIRST_TEST",
"OPENCODE_INTEGRATION_SECOND_TEST",
"OPENCODE_INTEGRATION_FIRST_TEST",
])
yield* unsubscribe
}),
(previous) =>
Effect.sync(() => {
if (previous.first === undefined) delete process.env.OPENCODE_INTEGRATION_FIRST_TEST
else process.env.OPENCODE_INTEGRATION_FIRST_TEST = previous.first
if (previous.second === undefined) delete process.env.OPENCODE_INTEGRATION_SECOND_TEST
else process.env.OPENCODE_INTEGRATION_SECOND_TEST = previous.second
}),
),
)
it.effect("does not announce environment changes while a stored credential remains active", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_INTEGRATION_PRIORITY_TEST
process.env.OPENCODE_INTEGRATION_PRIORITY_TEST = "environment-secret"
return previous
}),
() =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
const integrationID = Integration.ID.make("priority-test")
yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "stored-secret" }),
})
const observed: Integration.ID[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type === Integration.Event.ConnectionUpdated.type) {
observed.push(Schema.decodeUnknownSync(Integration.Event.ConnectionUpdated)(event).data.integrationID)
}
}),
)
const scope = yield* Scope.fork(yield* Scope.Scope)
yield* integrations
.transform((draft) =>
draft.method.update({
integrationID,
method: { type: "env", names: ["OPENCODE_INTEGRATION_PRIORITY_TEST"] },
}),
)
.pipe(Scope.provide(scope))
expect(observed).toEqual([])
expect((yield* integrations.connection.active(integrationID))?.type).toBe("credential")
process.env.OPENCODE_INTEGRATION_PRIORITY_TEST = "rotated-environment-secret"
yield* integrations.transform((draft) =>
draft.update(integrationID, (integration) => (integration.name = "Rotated")),
)
expect(observed).toEqual([])
yield* Scope.close(scope, Exit.void)
expect(observed).toEqual([])
yield* unsubscribe
}),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_INTEGRATION_PRIORITY_TEST
else process.env.OPENCODE_INTEGRATION_PRIORITY_TEST = previous
}),
),
)
it.effect("reveals the previous registration when an override closes", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+84
View File
@@ -18,11 +18,13 @@ import {
Stream,
} from "effect"
import { TestClock } from "effect/testing"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { Global } from "@opencode-ai/util/global"
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
import { LocationActivity } from "@opencode-ai/core/location-activity"
@@ -53,6 +55,40 @@ const it = testEffect(
[Global.node, tempGlobalLayer],
]),
)
const consoleRequests: Array<{ url: string; authorization: string | undefined }> = []
const itWithConsole = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
[
LayerNodePlatform.httpClient,
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
consoleRequests.push({ url: request.url, authorization: request.headers.authorization })
if (request.url !== "https://opencode.ai/console/api/config") {
return HttpClientResponse.fromWeb(request, new Response("Not found", { status: 404 }))
}
return HttpClientResponse.fromWeb(
request,
Response.json({
config: {
provider: {
"console-openai-test": {
name: "Console OpenAI",
npm: "@ai-sdk/openai-compatible",
models: { "solstice-alpha": { name: "Solstice Alpha" } },
},
},
},
}),
)
}),
),
),
],
]),
)
const itWithSdk = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
@@ -157,6 +193,54 @@ describe("LocationServiceMap", () => {
),
)
itWithConsole.live("discovers Console models during clean supervisor boot with only an environment service key", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_API_KEY
process.env.OPENCODE_API_KEY = "supervisor-service-account-secret"
consoleRequests.length = 0
return previous
}),
() =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service
const context = yield* locations.contextEffect(
Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
)
const catalog = yield* Catalog.Service.pipe(Effect.provide(context))
const discovered = yield* bus.subscribe(Catalog.Event.Updated).pipe(
Stream.mapEffect(() =>
catalog.model.get(Provider.ID.make("console-openai-test"), Model.ID.make("solstice-alpha")),
),
Stream.filter((model): model is Model.Info => model !== undefined),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
expect(consoleRequests).toContainEqual({
url: "https://opencode.ai/console/api/config",
authorization: "Bearer supervisor-service-account-secret",
})
expect((yield* Fiber.join(discovered).pipe(Effect.timeout("3 seconds"))).valueOrUndefined?.name).toBe(
"Solstice Alpha",
)
}),
),
),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_API_KEY
else process.env.OPENCODE_API_KEY = previous
}),
),
)
itWithSdk.live("preserves embedded SDK plugins after Location eviction", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Bus } from "@opencode-ai/core/bus"
@@ -92,6 +93,70 @@ describe("OpencodePlugin", () => {
}),
)
it.live("discovers Console models when a later plugin materializes service-account authentication", () =>
withEnv({ OPENCODE_API_KEY: "service-account-secret" }, () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const bus = yield* Bus.Service
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
const requests: Array<{ url: string; authorization: string | undefined }> = []
const http = HttpClient.make((request) =>
Effect.sync(() => {
requests.push({ url: request.url, authorization: request.headers.authorization })
return HttpClientResponse.fromWeb(
request,
Response.json({
config: {
provider: {
"console-openai-test": {
name: "Console OpenAI",
npm: "@ai-sdk/openai-compatible",
models: { "solstice-alpha": { name: "Solstice Alpha" } },
},
},
},
}),
)
}),
)
yield* plugins.activate([
{
id: OpencodePlugin.id,
version: "1",
effect: (host) =>
OpencodePlugin.effect(host).pipe(
Effect.provideService(Bus.Service, bus),
Effect.provideService(HttpClient.HttpClient, http),
),
},
{
id: "later-sdk-integration",
version: "1",
effect: (host) =>
host.integration.transform((draft) =>
draft.method.update({
integrationID: "opencode",
method: { type: "env", names: ["OPENCODE_API_KEY"] },
}),
),
},
])
const model = yield* eventually(
catalog.model.get(Provider.ID.make("console-openai-test"), Model.ID.make("solstice-alpha")),
(current) => current !== undefined,
)
expect(model?.name).toBe("Solstice Alpha")
expect(requests).toEqual([
{ url: "https://opencode.ai/console/api/config", authorization: "Bearer service-account-secret" },
])
expect((yield* integrations.connection.active(Integration.ID.make("opencode")))?.type).toBe("env")
}),
),
)
it.live("uses a canonical custom server throughout device authorization", () =>
Effect.acquireUseRelease(
Effect.sync(() => {