feat(tui): support multiple integration accounts (#45072)

This commit is contained in:
Dax
2026-08-25 17:03:16 -04:00
committed by GitHub
parent d2100a51f1
commit eb1ac54d73
39 changed files with 1441 additions and 138 deletions
+10
View File
@@ -1333,6 +1333,15 @@ export type CredentialUpdateOperation<E = never> = (
input: CredentialUpdateInput,
) => Effect.Effect<CredentialUpdateOutput, E>
export type CredentialActivateInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type CredentialActivateOutput = void
export type CredentialActivateOperation<E = never> = (
input: CredentialActivateInput,
) => Effect.Effect<CredentialActivateOutput, E>
export type CredentialRemoveInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -1344,6 +1353,7 @@ export type CredentialRemoveOperation<E = never> = (
export interface CredentialApi<E = never> {
readonly update: CredentialUpdateOperation<E>
readonly activate: CredentialActivateOperation<E>
readonly remove: CredentialRemoveOperation<E>
}
@@ -138,6 +138,8 @@ import type {
McpResourceCatalogOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
CredentialActivateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
@@ -935,6 +937,14 @@ const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointCredentialActivate = (raw: RawClient["server.credential"]) => (input: CredentialActivateInput) =>
preserveEffect<CredentialActivateOutput>()(
raw["credential.activate"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input: CredentialRemoveInput) =>
preserveEffect<CredentialRemoveOutput>()(
raw["credential.remove"]({
@@ -945,6 +955,7 @@ const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input
const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
update: EndpointCredentialUpdate(raw),
activate: EndpointCredentialActivate(raw),
remove: EndpointCredentialRemove(raw),
})
@@ -132,6 +132,8 @@ import type {
McpResourceCatalogOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
CredentialActivateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
@@ -1270,6 +1272,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
activate: (input: CredentialActivateInput, requestOptions?: RequestOptions) =>
request<CredentialActivateOutput>(
{
method: "POST",
path: `/api/credential/${encodeURIComponent(input.credentialID)}/activate`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
remove: (input: CredentialRemoveInput, requestOptions?: RequestOptions) =>
request<CredentialRemoveOutput>(
{
+29 -10
View File
@@ -823,6 +823,24 @@ export type ModelsDevRefreshed = {
data: {}
}
export type CredentialUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "credential.updated"
location?: LocationRef
data: {}
}
export type CredentialSwitched = {
id: string
created: number
metadata?: { [x: string]: any }
type: "credential.switched"
location?: LocationRef
data: { integrationID: string; credentialID: string | null }
}
export type IntegrationUpdated = {
id: string
created: number
@@ -832,15 +850,6 @@ export type IntegrationUpdated = {
data: {}
}
export type IntegrationConnectionUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "integration.connection.updated"
location?: LocationRef
data: { integrationID: string }
}
export type CatalogUpdated = {
id: string
created: number
@@ -2129,8 +2138,9 @@ export type IntegrationInfo = {
export type V2Event =
| ModelsDevRefreshed
| CredentialUpdated
| CredentialSwitched
| IntegrationUpdated
| IntegrationConnectionUpdated
| CatalogUpdated
| AgentUpdated
| SessionCreated
@@ -4371,6 +4381,15 @@ export type CredentialUpdateInput = {
export type CredentialUpdateOutput = void
export type CredentialActivateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type CredentialActivateOutput = void
export type CredentialRemoveInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: {
+30
View File
@@ -1048,6 +1048,36 @@ export function createData(config: CreateDataInput) {
return
}
if (event.type === "credential.updated" || event.type === "credential.switched") {
Object.keys(store.location).forEach((key) => {
const ref = JSON.parse(key) as [string, string | null]
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
if (event.type === "credential.updated") {
result.location.integration.invalidate(location)
void result.location.integration.sync(location)
return
}
setStore("location", key, (data) => ({
...data,
integration: data?.integration?.map((integration) => {
if (integration.id !== event.data.integrationID) return integration
const active = integration.connections.find(
(connection) => connection.type === "credential" && connection.id === event.data.credentialID,
)
if (!active) return integration
return {
...integration,
connections: [active, ...integration.connections.filter((connection) => connection !== active)],
}
}),
}))
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
})
return
}
if (!event.location) return
const location = event.location
switch (event.type) {
+19
View File
@@ -26,6 +26,7 @@ test("exposes every standard HTTP API group", () => {
"skill",
"event",
"pty",
"experimental",
"shell",
"reference",
"worktree",
@@ -187,6 +188,24 @@ test("experimental wellknown integration add uses the public HTTP contract", asy
expect(await request?.json()).toEqual({ url: "https://example.com" })
})
test("credential.activate uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
await client.credential.activate({ credentialID: "cred_work", location: { directory: "/tmp/project" } })
expect(request?.method).toBe("POST")
expect(request?.url).toBe(
"http://localhost:3000/api/credential/cred_work/activate?location%5Bdirectory%5D=%2Ftmp%2Fproject",
)
})
test("integration connections optionally submit a form answer", async () => {
const requests: Request[] = []
const client = OpenCode.make({
+97
View File
@@ -154,6 +154,103 @@ test("updates authoritative cached project metadata from live events", async ()
}
})
test("refreshes global credential events across every loaded location and workspace", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: URL[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url)
const directory = url.searchParams.get("location[directory]") ?? "/project"
return Response.json({
location: {
directory,
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
project: { id: "project", directory, canonical: directory },
},
data: [],
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => "connected" },
}),
dispose,
}))
const locations = [{ directory: "/project" }, { directory: "/other", workspaceID: "workspace-other" }]
try {
await Promise.all(
locations.flatMap((location) => [
setup.data.location.integration.sync(location),
setup.data.location.model.sync(location),
setup.data.location.provider.sync(location),
]),
)
requests.length = 0
const updated: OpenCodeEvent = {
id: "evt_credential.updated",
created: 1,
type: "credential.updated",
data: {},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
await wait(() => requests.length === 2)
expect(
requests.map((url) => [
url.pathname,
url.searchParams.get("location[directory]"),
url.searchParams.get("location[workspace]"),
]),
).toEqual([
["/api/integration", "/project", null],
["/api/integration", "/other", "workspace-other"],
])
requests.length = 0
for (const credentialID of ["credential", null]) {
const switched: OpenCodeEvent = {
id: `evt_credential.switched.${credentialID}`,
created: 2,
type: "credential.switched",
data: { credentialID, integrationID: "integration" },
}
listeners.forEach((listener) => listener({ name: switched.type, details: switched }))
await wait(() => requests.length === 4)
expect(
requests.map((url) => [
url.pathname,
url.searchParams.get("location[directory]"),
url.searchParams.get("location[workspace]"),
]),
).toEqual(
expect.arrayContaining([
["/api/model", "/project", null],
["/api/provider", "/project", null],
["/api/model", "/other", "workspace-other"],
["/api/provider", "/other", "workspace-other"],
]),
)
requests.length = 0
}
} finally {
setup.dispose()
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
+13 -10
View File
@@ -89,6 +89,7 @@ export interface PublishOptions {
readonly id?: Event.ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
readonly global?: boolean
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
readonly commit?: (seq: number) => Effect.Effect<void>
}
@@ -446,11 +447,12 @@ export function configured(options?: Options) {
function publish<D extends Event.Definition>(definition: D, data: Event.Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
const location = options?.global
? undefined
: (options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined))
return yield* publishEvent(
definition,
{
@@ -480,11 +482,12 @@ export function configured(options?: Options) {
}),
)
}
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
const location = options?.global
? undefined
: (options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined))
return {
definition,
aggregateID,
+2 -5
View File
@@ -16,7 +16,6 @@ import {
type Entry,
Event,
} from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Credential } from "./credential.js"
import { Bus } from "./bus.js"
@@ -159,9 +158,7 @@ export const layer = (options?: Options) =>
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).findLast(
(credential) => credential.value.type === "key",
)
const credential = (yield* credentials.list(entry.integrationID)).at(-1)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
@@ -343,7 +340,7 @@ export const layer = (options?: Options) =>
),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filterEffect((event) =>
wellknown.entries().pipe(
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
+105 -8
View File
@@ -1,10 +1,11 @@
export * as Credential from "./credential.js"
import { asc, eq } from "drizzle-orm"
import { asc, desc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database.js"
import { Bus } from "./bus.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { CredentialTable } from "./credential/sql.js"
@@ -20,6 +21,8 @@ export type Key = Credential.Key
export const Value = Credential.Value
export type Value = Credential.Value
export const Event = Credential.Event
export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID,
integrationID: Integration.ID,
@@ -34,12 +37,14 @@ export interface Interface {
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Replaces any credential for an integration and returns the new record. */
/** Creates a credential for an integration and returns the new record. */
readonly create: (input: {
readonly integrationID: Integration.ID
readonly value: Value
readonly label?: string
}) => Effect.Effect<Info>
/** Selects a stored credential for its integration. */
readonly activate: (id: ID) => Effect.Effect<void>
/** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
/** Removes a stored credential. */
@@ -52,6 +57,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const decode = Schema.decodeUnknownSync(Value)
const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return
@@ -73,7 +79,7 @@ const layer = Layer.effect(
db
.select()
.from(CredentialTable)
.orderBy(asc(CredentialTable.time_created))
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
.all()
.pipe(Effect.orDie, Effect.map(storedRows)),
),
@@ -82,7 +88,7 @@ const layer = Layer.effect(
.select()
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, integrationID))
.orderBy(asc(CredentialTable.time_created))
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
.all()
.pipe(Effect.orDie, Effect.map(storedRows)),
),
@@ -101,7 +107,8 @@ const layer = Layer.effect(
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.delete(CredentialTable)
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integrationID))
.run()
yield* tx
@@ -111,27 +118,117 @@ const layer = Layer.effect(
integration_id: credential.integrationID,
label: credential.label,
value: credential.value,
active: true,
})
.run()
}),
)
.pipe(Effect.orDie)
yield* bus.publish(Event.Updated, {}, { global: true })
yield* bus.publish(
Event.Switched,
{ integrationID: credential.integrationID, credentialID: credential.id },
{ global: true },
)
return credential
}),
activate: Effect.fn("Credential.activate")(function* (id) {
const integrationID = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
if (!credential?.integration_id) return
const active = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integration_id))
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
if (active?.id === id) return
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integration_id))
.run()
yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, id)).run()
return credential.integration_id
}),
)
.pipe(Effect.orDie)
if (integrationID) yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
}),
update: Effect.fn("Credential.update")(function* (id, updates) {
if (!updates.label && !updates.value) return
if (updates.label === undefined && updates.value === undefined) return
const credential = yield* db
.select({ integrationID: CredentialTable.integration_id, label: CredentialTable.label })
.from(CredentialTable)
.where(eq(CredentialTable.id, id))
.get()
.pipe(Effect.orDie)
if (!credential?.integrationID) return
if (updates.label === credential.label && updates.value === undefined) return
yield* db
.update(CredentialTable)
.set({ label: updates.label, value: updates.value })
.where(eq(CredentialTable.id, id))
.run()
.pipe(Effect.orDie)
if (updates.label !== undefined && updates.label !== credential.label)
yield* bus.publish(Event.Updated, {}, { global: true })
}),
remove: Effect.fn("Credential.remove")(function* (id) {
yield* db.delete(CredentialTable).where(eq(CredentialTable.id, id)).run().pipe(Effect.orDie)
const removed = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
if (!credential) return
const active = credential.integration_id
? yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integration_id))
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
: undefined
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
if (!credential.integration_id || active?.id !== id) return { switched: false as const }
const replacement = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integration_id))
.orderBy(desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
if (replacement) {
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integration_id))
.run()
yield* tx
.update(CredentialTable)
.set({ active: true })
.where(eq(CredentialTable.id, replacement.id))
.run()
}
return {
switched: true as const,
integrationID: credential.integration_id,
credentialID: replacement?.id ?? null,
}
}),
)
.pipe(Effect.orDie)
if (!removed) return
yield* bus.publish(Event.Updated, {}, { global: true })
if (removed.switched)
yield* bus.publish(
Event.Switched,
{ integrationID: removed.integrationID, credentialID: removed.credentialID },
{ global: true },
)
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, Bus.node] })
+32 -30
View File
@@ -166,6 +166,8 @@ export interface Interface extends State.Transformable<Draft> {
/** User-facing label for the stored credential. */
readonly label?: string
}) => Effect.Effect<void, AuthorizationError>
/** Selects a stored credential as the active integration connection. */
readonly activate: (credentialID: Credential.ID) => Effect.Effect<void>
/** Updates a stored credential exposed as a connection. */
readonly update: (
credentialID: Credential.ID,
@@ -329,6 +331,17 @@ const layer = Layer.effect(
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
if (input.label !== undefined) return yield* credentials.create(input)
const name = state.get().integrations.get(input.integrationID)?.ref.name ?? input.integrationID
const labels = new Set((yield* credentials.list(input.integrationID)).map((credential) => credential.label))
const label =
Array.from({ length: labels.size + 1 }, (_, index) => (index === 0 ? name : `${name} ${index + 1}`)).find(
(candidate) => !labels.has(candidate),
) ?? name
return yield* credentials.create({ ...input, label })
})
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
const credentials = saved
.map((credential) => ({
@@ -394,7 +407,7 @@ const layer = Layer.effect(
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
Effect.flatMap((label) =>
credentials.create({
createCredential({
integrationID: attempt.integrationID,
label,
value: exit.value,
@@ -421,8 +434,6 @@ const layer = Layer.effect(
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}).pipe(Effect.ensuring(close(attempt.scope)))
}),
)
@@ -452,13 +463,11 @@ const layer = Layer.effect(
return
}
const persistence = yield* credentials
.create({
integrationID: attempt.integrationID,
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
})
.pipe(Effect.asVoid, Effect.exit)
const persistence = yield* createCredential({
integrationID: attempt.integrationID,
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
}).pipe(Effect.asVoid, Effect.exit)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
? {
@@ -476,9 +485,6 @@ const layer = Layer.effect(
}
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
yield* close(attempt.scope)
if (Exit.isFailure(persistence)) return
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}),
)
})
@@ -702,7 +708,7 @@ const layer = Layer.effect(
if (!method.form && Object.keys(answer).length > 0) {
return yield* new AuthorizationError({ cause: new Error("Key method does not accept a form answer") })
}
yield* credentials.create({
yield* createCredential({
integrationID: input.integrationID,
label: input.label,
value: Credential.Key.make({
@@ -711,25 +717,21 @@ const layer = Layer.effect(
...(Object.keys(answer).length > 0 ? { configuration: answer } : {}),
}),
})
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}),
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
activate: Effect.fn("Integration.connection.activate")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
yield* credentials.update(credentialID, updates)
if (credential) {
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* bus.publish(Integration.Event.Updated, {})
}),
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
yield* credentials.remove(credentialID)
if (credential) {
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* bus.publish(Integration.Event.Updated, {})
if (!credential) return
const active = resolveConnections(
state.get().integrations.get(credential.integrationID),
yield* credentials.list(credential.integrationID),
)[0]
if (active?.type === "credential" && active.id === credentialID) return
yield* credentials.activate(credentialID)
}),
update: Effect.fn("Integration.connection.update")((credentialID, updates) =>
credentials.update(credentialID, updates),
),
remove: Effect.fn("Integration.connection.remove")((credentialID) => credentials.remove(credentialID)),
},
oauth: {
connect: connectOAuth,
+6 -10
View File
@@ -140,8 +140,7 @@ export type Draft = {
remove: (server: ServerName | string) => void
}
const cloneConfig = (config: Mcp.ServerConfig) =>
structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
const cloneConfig = (config: Mcp.ServerConfig) => structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
export interface Interface extends State.Transformable<Draft> {
readonly servers: () => Effect.Effect<ServerInfo[]>
@@ -264,8 +263,7 @@ export const layer = (options?: Options) =>
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
onRedirect: () => {},
}
const stored = yield* credentials.list(entry.integrationID)
const found = stored.find((credential) => credential.value.type === "oauth")
const found = (yield* credentials.list(entry.integrationID)).at(-1)
if (!found || found.value.type !== "oauth")
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
@@ -288,8 +286,8 @@ export const layer = (options?: Options) =>
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
// connection may have already replaced ours; deleting then would discard the newer valid credential and
// strand every connection in needs_auth until a manual re-auth. Uses the raw credential service (no
// integration event) to avoid re-triggering the reconnect subscriber mid-connect.
// strand every connection in needs_auth until a manual re-auth. Credential deletion notifies all locations;
// reconnects remain serialized by the server lock.
invalidate: async (scope) => {
if (scope === "verifier" || scope === "discovery") return
const oauth = await readOAuthCredential()
@@ -673,7 +671,7 @@ export const layer = (options?: Options) =>
}).pipe(locks.withLock(name))
})
fork(
bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => owned.has(event.data.integrationID)),
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
Effect.ignore,
@@ -687,9 +685,7 @@ export const layer = (options?: Options) =>
config === false ? [] : [[name, cloneConfig(config)] as const],
),
),
removed: new Set(
Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : [])),
),
removed: new Set(Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : []))),
}),
draft: (draft) => ({
list: () => Array.from(draft.servers),
@@ -221,7 +221,7 @@ export const GithubCopilotPlugin = define({
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("github-copilot")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
+1 -1
View File
@@ -301,7 +301,7 @@ export const OpenAIPlugin = define({
{ providerID: Provider.ID.openai },
)
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
@@ -193,7 +193,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
+17
View File
@@ -149,6 +149,23 @@ describe("Bus", () => {
}),
)
it.effect("omits ambient and explicit locations for global events", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const event = yield* bus.publish(
GlobalMessage,
{ text: "hello" },
{
global: true,
location: { directory: AbsolutePath.make("explicit"), workspaceID: Workspace.ID.make("wrk_explicit") },
},
)
expect(event).not.toHaveProperty("location")
expect(event.type).toBe("test.global")
}),
)
itWithoutLocation.effect("omits location when no location is available", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
+7 -1
View File
@@ -393,6 +393,7 @@ describe("Config", () => {
]),
get: () => Effect.die("unused Credential.get"),
create: () => Effect.die("unused Credential.create"),
activate: () => Effect.die("unused Credential.activate"),
update: () => Effect.die("unused Credential.update"),
remove: () => Effect.die("unused Credential.remove"),
}),
@@ -437,7 +438,11 @@ describe("Config", () => {
yield* Effect.yieldNow
available = true
key = "next"
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
yield* bus.publish(
Credential.Event.Switched,
{ credentialID: Credential.ID.create(), integrationID },
{ global: true },
)
expect(yield* Fiber.join(updated)).toHaveLength(1)
const refreshed = yield* config.entries()
expect(Config.latest(refreshed, "shell")).toBe("project")
@@ -496,6 +501,7 @@ describe("Config", () => {
]),
get: () => Effect.die("unused Credential.get"),
create: () => Effect.die("unused Credential.create"),
activate: () => Effect.die("unused Credential.activate"),
update: () => Effect.die("unused Credential.update"),
remove: () => Effect.die("unused Credential.remove"),
}),
+170 -4
View File
@@ -1,11 +1,20 @@
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { CredentialTable } from "@opencode-ai/core/credential/sql"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { Event } from "@opencode-ai/schema/event"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Integration } from "@opencode-ai/core/integration"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(Credential.node))
const it = testEffect(LayerNode.compile(LayerNode.group([Credential.node, Bus.node, Database.node])))
describe("Credential", () => {
it.effect("stores, updates, lists, and removes credentials", () =>
@@ -22,15 +31,172 @@ describe("Credential", () => {
yield* credentials.update(created.id, { label: "Personal" })
expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal")
const additional = yield* credentials.create({
integrationID,
label: "Additional",
value: Credential.Key.make({ type: "key", key: "additional" }),
})
expect(yield* credentials.list(integrationID)).toEqual([
expect.objectContaining({ id: created.id, label: "Personal" }),
additional,
])
yield* credentials.remove(additional.id)
expect(yield* credentials.list(integrationID)).toEqual([
expect.objectContaining({ id: created.id, label: "Personal" }),
])
}),
)
it.effect("publishes global events only for observable credential mutations", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
const integrationID = Integration.ID.make("openai")
const events = new Array<Event.Payload>()
yield* bus.listen((event) => Effect.sync(() => events.push(event)))
const older = yield* credentials
.create({ integrationID, value: Credential.Key.make({ type: "key", key: "older" }) })
.pipe(
Effect.provideService(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make("project"), workspaceID: Workspace.ID.make("wrk_test") }),
),
),
)
const newer = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "newer" }),
})
yield* credentials.activate(newer.id)
yield* credentials.activate(Credential.ID.create())
yield* credentials.activate(older.id)
yield* credentials.activate(older.id)
yield* credentials.update(older.id, {})
yield* credentials.update(Credential.ID.create(), { label: "Missing" })
yield* credentials.update(older.id, { label: "default" })
yield* credentials.update(older.id, { value: Credential.Key.make({ type: "key", key: "refreshed" }) })
yield* credentials.update(older.id, { label: "Renamed" })
yield* credentials.remove(Credential.ID.create())
yield* credentials.remove(newer.id)
yield* credentials.remove(newer.id)
expect((yield* credentials.list(integrationID)).at(-1)?.id).toBe(older.id)
const replacement = yield* credentials.create({
integrationID,
label: "Replacement",
value: Credential.Key.make({ type: "key", key: "replacement" }),
})
expect(yield* credentials.list(integrationID)).toEqual([replacement])
yield* credentials.remove(replacement.id)
expect((yield* credentials.list(integrationID)).at(-1)?.id).toBe(older.id)
yield* credentials.remove(older.id)
expect(yield* credentials.list(integrationID)).toEqual([])
expect(events.map((event) => ({ type: event.type, data: event.data }))).toEqual([
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: older.id } },
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: newer.id } },
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: older.id } },
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: replacement.id } },
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: older.id } },
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: null } },
])
expect(events.every((event) => !("location" in event))).toBeTrue()
}),
)
it.effect("promotes the newest remaining legacy credential when the effective selection is removed", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
const database = yield* Database.Service
const integrationID = Integration.ID.make("openai")
const oldest = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "oldest" }),
})
const newer = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "newer" }),
})
const newest = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "newest" }),
})
yield* database.db
.update(CredentialTable)
.set({ active: null })
.where(eq(CredentialTable.integration_id, integrationID))
.run()
.pipe(Effect.orDie)
const events = new Array<Event.Payload>()
yield* bus.listen((event) => Effect.sync(() => events.push(event)))
yield* credentials.activate(newest.id)
yield* credentials.remove(oldest.id)
yield* credentials.remove(newest.id)
expect(yield* credentials.list(integrationID)).toEqual([newer])
expect(
yield* database.db
.select({ active: CredentialTable.active })
.from(CredentialTable)
.where(eq(CredentialTable.id, newer.id))
.get()
.pipe(Effect.orDie),
).toEqual({ active: true })
expect(events.map((event) => ({ type: event.type, data: event.data }))).toEqual([
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: newer.id } },
])
}),
)
it.effect("activates older credentials without affecting other integrations", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const otherIntegrationID = Integration.ID.make("anthropic")
const older = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "older" }),
})
const newer = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "newer" }),
})
const otherOlder = yield* credentials.create({
integrationID: otherIntegrationID,
value: Credential.Key.make({ type: "key", key: "other-older" }),
})
const otherNewer = yield* credentials.create({
integrationID: otherIntegrationID,
value: Credential.Key.make({ type: "key", key: "other-newer" }),
})
yield* credentials.activate(older.id)
expect(yield* credentials.list(integrationID)).toEqual([newer, older])
expect(yield* credentials.list(otherIntegrationID)).toEqual([otherOlder, otherNewer])
yield* credentials.activate(Credential.ID.create())
expect((yield* credentials.list(integrationID)).at(-1)).toEqual(older)
yield* credentials.activate(otherOlder.id)
expect(yield* credentials.list(otherIntegrationID)).toEqual([otherNewer, otherOlder])
expect((yield* credentials.list(integrationID)).at(-1)).toEqual(older)
yield* credentials.remove(older.id)
expect((yield* credentials.list(integrationID)).at(-1)).toEqual(newer)
expect((yield* credentials.list(otherIntegrationID)).at(-1)).toEqual(otherOlder)
}),
)
})
@@ -12,6 +12,7 @@ export const emptyCredentialNode = makeGlobalNode({
list: () => Effect.succeed([]),
get: () => Effect.undefined,
create: () => Effect.die("unused Credential.create"),
activate: () => Effect.die("unused Credential.activate"),
update: () => Effect.die("unused Credential.update"),
remove: () => Effect.die("unused Credential.remove"),
}),
+1
View File
@@ -38,6 +38,7 @@ const integrations = Layer.mock(Integration.Service, {
active: () => Effect.undefined,
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
activate: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
},
+95 -6
View File
@@ -19,6 +19,7 @@ const failingCredentialNode = makeGlobalNode({
list: () => Effect.succeed([]),
get: () => Effect.undefined,
create: () => Effect.die(new Error("credential persistence failed")),
activate: () => Effect.void,
update: () => Effect.void,
remove: () => Effect.void,
}),
@@ -147,9 +148,9 @@ describe("Integration", () => {
},
}),
)
const updated = yield* bus
.subscribe(Integration.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const created = yield* bus
.subscribe([Credential.Event.Updated, Credential.Event.Switched])
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
expect(
@@ -166,14 +167,42 @@ describe("Integration", () => {
label: "Work",
})
expect(yield* credentials.list(integrationID)).toEqual([
const stored = yield* credentials.list(integrationID)
expect(stored).toEqual([
expect.objectContaining({
integrationID,
label: "Work",
value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
}),
])
expect((yield* Fiber.join(updated)).length).toBe(1)
expect(Array.from(yield* Fiber.join(created), (event) => ({ type: event.type, data: event.data }))).toEqual([
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { credentialID: stored[0]?.id, integrationID } },
])
}),
)
it.effect("names unlabeled credentials after the integration", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
yield* integrations.transform((editor) => {
editor.update(integrationID, (integration) => (integration.name = "OpenAI"))
editor.method.update({ integrationID, method: { type: "key", label: "API key" } })
})
yield* integrations.connection.key({ integrationID, key: "first" })
yield* integrations.connection.key({ integrationID, key: "second" })
yield* integrations.connection.key({ integrationID, key: "work", label: "Work" })
yield* integrations.connection.key({ integrationID, key: "third" })
expect((yield* credentials.list(integrationID)).map((credential) => credential.label)).toEqual([
"OpenAI",
"OpenAI 2",
"Work",
"OpenAI 3",
])
}),
)
@@ -482,6 +511,11 @@ describe("Integration", () => {
},
}),
)
const archived = yield* credentials.create({
integrationID,
label: "Archived",
value: Credential.Key.make({ type: "key", key: "c" }),
})
const work = yield* credentials.create({
integrationID,
label: "Work",
@@ -500,6 +534,16 @@ describe("Integration", () => {
id: personal.id,
label: "Personal",
},
{
type: "credential",
id: work.id,
label: "Work",
},
{
type: "credential",
id: archived.id,
label: "Archived",
},
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
])
expect(yield* integrations.connection.active(integrationID)).toEqual({
@@ -507,7 +551,52 @@ describe("Integration", () => {
id: personal.id,
label: "Personal",
})
expect(work.id).not.toBe(personal.id)
const bus = yield* Bus.Service
const events = new Array<{ type: string; data: unknown }>()
yield* bus.listen((event) => Effect.sync(() => events.push({ type: event.type, data: event.data })))
yield* integrations.connection.activate(work.id)
expect(yield* integrations.connection.active(integrationID)).toEqual({
type: "credential",
id: work.id,
label: "Work",
})
expect((yield* integrations.get(integrationID))?.connections.map((connection) => connection.type)).toEqual([
"credential",
"credential",
"credential",
"env",
])
expect(events).toEqual([
{ type: Credential.Event.Switched.type, data: { credentialID: work.id, integrationID } },
])
yield* integrations.connection.remove(archived.id)
expect(events).toEqual([
{ type: Credential.Event.Switched.type, data: { credentialID: work.id, integrationID } },
{ type: Credential.Event.Updated.type, data: {} },
])
yield* integrations.connection.remove(work.id)
expect(yield* integrations.connection.active(integrationID)).toEqual({
type: "credential",
id: personal.id,
label: "Personal",
})
yield* integrations.connection.remove(personal.id)
expect(yield* integrations.connection.active(integrationID)).toEqual({
type: "env",
name: "INTEGRATION_TEST_ACME_KEY",
})
expect(events).toEqual([
{ type: Credential.Event.Switched.type, data: { credentialID: work.id, integrationID } },
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { credentialID: personal.id, integrationID } },
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { credentialID: null, integrationID } },
])
}),
(previous) =>
Effect.sync(() => {
+2 -3
View File
@@ -234,6 +234,7 @@ function resourceMcpLayer(
active: unusedIntegration,
resolve: unusedIntegration,
key: unusedIntegration,
activate: unusedIntegration,
update: unusedIntegration,
remove: unusedIntegration,
},
@@ -1024,9 +1025,7 @@ test("restores runtime MCP config when a transform is disposed", async () => {
yield* transformed.dispose
expect(observed).toBe("original")
}).pipe(
Effect.provide(
resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })),
),
Effect.provide(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))),
),
),
)
+3 -11
View File
@@ -312,6 +312,7 @@ describe("ModelResolver", () => {
active: () => Effect.undefined,
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
activate: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
},
@@ -885,12 +886,7 @@ describe("ModelResolver", () => {
{ reasoning: { effort: "high" } },
{ reasoning: { effort: "high" } },
],
[
"@ai-sdk/xai",
"@opencode-ai/ai/providers/xai",
{ reasoningEffort: "high" },
{ reasoningEffort: "high" },
],
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", { reasoningEffort: "high" }, { reasoningEffort: "high" }],
] as const
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
@@ -939,11 +935,7 @@ describe("ModelResolver", () => {
["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"],
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
[
"@ai-sdk/google-vertex/anthropic",
"@opencode-ai/ai/providers/google-vertex/messages",
"claude-sonnet-4-6",
],
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
@@ -279,7 +279,7 @@ describe("OpencodePlugin", () => {
})
draft.model.update(Provider.ID.make("remote"), Model.ID.make("stale"), () => {})
})
yield* credentials.create({
const initial = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
@@ -332,6 +332,29 @@ describe("OpencodePlugin", () => {
required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled,
).toBe(false)
expect(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("stale"))).toBeDefined()
yield* credentials.update(initial.id, { label: "Renamed" })
yield* Effect.yieldNow
expect(authorization).toEqual(["Bearer secret"])
const replacement = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "replacement",
metadata: { server: server.url.origin },
}),
})
yield* eventually(
Effect.sync(() => authorization.length),
(count) => count === 2,
)
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
yield* credentials.remove(initial.id)
yield* Effect.yieldNow
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
expect((yield* credentials.list(Integration.ID.make("opencode"))).at(-1)?.id).toBe(replacement.id)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
+191 -1
View File
@@ -6749,6 +6749,90 @@
"summary": "Remove credential"
}
},
"/api/credential/{credentialID}/activate": {
"post": {
"tags": ["credential"],
"operationId": "v2.credential.activate",
"parameters": [
{
"name": "credentialID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
},
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Activate a stored integration credential.",
"summary": "Activate credential"
}
},
"/api/project": {
"get": {
"tags": ["project"],
@@ -6794,6 +6878,89 @@
"summary": "List projects"
}
},
"/api/project/{projectID}": {
"patch": {
"tags": ["project"],
"operationId": "v2.project.update",
"parameters": [
{
"name": "projectID",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "Project",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Project"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "ProjectNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
}
}
}
}
},
"description": "Update project display metadata and workspace commands.",
"summary": "Update project",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"icon": {
"$ref": "#/components/schemas/Project.Icon"
},
"commands": {
"$ref": "#/components/schemas/Project.Commands"
}
},
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/project/current": {
"get": {
"tags": ["project"],
@@ -11193,7 +11360,7 @@
}
}
},
"description": "Create a worktree for a project.",
"description": "Create a worktree for a project and run its configured setup script.",
"summary": "Create worktree",
"requestBody": {
"content": {
@@ -15194,6 +15361,9 @@
},
"requireFinishReason": {
"type": "boolean"
},
"requireAssistantAfterTool": {
"type": "boolean"
}
},
"additionalProperties": false
@@ -15436,6 +15606,9 @@
},
"source": {
"$ref": "#/components/schemas/Permission.Source"
},
"message": {
"type": "string"
}
},
"required": ["id", "sessionID", "action", "resources"],
@@ -15914,6 +16087,23 @@
"type": "string",
"enum": ["git", "hg"]
},
"ProjectNotFoundErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["ProjectNotFoundError"]
},
"projectID": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "projectID", "message"],
"additionalProperties": false
},
"Prompt.AgentAttachment": {
"type": "object",
"properties": {
@@ -21,6 +21,21 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
),
)
.annotateMerge(OpenApi.annotations({ title: "credential" }))
.add(
HttpApiEndpoint.post("credential.activate", "/api/credential/:credentialID/activate", {
params: { credentialID: Credential.ID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.credential.activate",
summary: "Activate credential",
description: "Activate a stored integration credential.",
}),
),
)
.add(
HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", {
params: { credentialID: Credential.ID },
+16 -1
View File
@@ -2,7 +2,8 @@ export * as Credential from "./credential.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { IntegrationMethodID } from "./integration-id.js"
import { ephemeral, inventory } from "./event.js"
import { IntegrationID, IntegrationMethodID } from "./integration-id.js"
import { ascending } from "./identifier.js"
import { NonNegativeInt, statics } from "./schema.js"
import { Form } from "./form.js"
@@ -13,6 +14,20 @@ export const ID = Schema.String.pipe(
)
export type ID = typeof ID.Type
const Updated = ephemeral({
type: "credential.updated",
schema: {},
})
const Switched = ephemeral({
type: "credential.switched",
schema: { integrationID: IntegrationID, credentialID: Schema.NullOr(ID) },
})
export const Event = {
Updated,
Switched,
Definitions: inventory(Updated, Switched),
}
export interface OAuth extends Schema.Schema.Type<typeof OAuth> {}
export const OAuth = Schema.Struct({
type: Schema.Literal("oauth"),
+2
View File
@@ -5,6 +5,7 @@ import { Agent } from "./agent.js"
import { Catalog } from "./catalog.js"
import { Command } from "./command.js"
import { Config } from "./config.js"
import { Credential } from "./credential.js"
import { Durable } from "./durable-event-manifest.js"
import { Event } from "./event.js"
import { FileSystem } from "./filesystem.js"
@@ -39,6 +40,7 @@ const coreDefinitions = Event.inventory(...SessionEvent.Definitions)
const foundationDefinitions = Event.inventory(
...ModelsDev.Event.Definitions,
...Credential.Event.Definitions,
...Integration.Event.Definitions,
...Catalog.Event.Definitions,
...Agent.Event.Definitions,
+1 -5
View File
@@ -53,11 +53,7 @@ const Updated = ephemeral({
type: "integration.updated",
schema: {},
})
const ConnectionUpdated = ephemeral({
type: "integration.connection.updated",
schema: { integrationID: ID },
})
export const Event = { Updated, ConnectionUpdated, Definitions: inventory(Updated, ConnectionUpdated) }
export const Event = { Updated, Definitions: inventory(Updated) }
export interface Ref extends Schema.Schema.Type<typeof Ref> {}
export const Ref = Schema.Struct({
+32 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import {
Agent,
Config,
Credential,
FileSystem,
Form,
Integration,
@@ -59,11 +60,12 @@ describe("public event manifest", () => {
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated])
expect(Credential.Event.Definitions).toEqual([Credential.Event.Updated, Credential.Event.Switched])
expect(Project.Event.Definitions).toEqual([Project.Event.Updated])
expect(Config.Event.Definitions).toEqual([Config.Event.Updated])
expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Changed])
expect(FileSystemV1.Event.Definitions).toEqual([FileSystemV1.Event.Edited])
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated])
expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
expect(PersistentPty.Event.Definitions).toEqual([PersistentPty.Event.Added, PersistentPty.Event.Removed])
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
@@ -77,6 +79,35 @@ describe("public event manifest", () => {
expect(EventManifest.Durable.has("session.step.ended.2")).toBe(false)
})
test("keeps credential events public, canonical, and ephemeral", () => {
const credentialID = Credential.ID.make("cred_test")
const integrationID = Integration.ID.make("integration_test")
for (const definition of Credential.Event.Definitions) {
expect(EventManifest.ServerDefinitions).toContain(definition)
expect(EventManifest.Definitions).toContain(definition)
expect(EventManifest.Server.get(definition.type)).toBe(definition)
expect(EventManifest.Latest.get(definition.type)).toBe(definition)
expect(definition.durability).toBe("ephemeral")
expect(EventManifest.Durable.has(definition.type)).toBe(false)
}
expect(Credential.Event.Updated.data.make({})).toEqual({})
expect(Credential.Event.Switched.data.make({ integrationID, credentialID })).toEqual({
integrationID,
credentialID,
})
expect(Credential.Event.Switched.data.make({ integrationID, credentialID: null })).toEqual({
integrationID,
credentialID: null,
})
expect(EventManifest.Server.has("credential.created")).toBe(false)
expect(EventManifest.Server.has("credential.activated")).toBe(false)
expect(EventManifest.Server.has("credential.deleted")).toBe(false)
expect(EventManifest.Server.has("integration.connection.updated")).toBe(false)
expect(EventManifest.Latest.has("integration.connection.updated")).toBe(false)
})
test("derives durable definitions from explicit definition durability", () => {
expect(Array.from(EventManifest.Durable.keys()).toSorted()).toEqual(
[
@@ -13,6 +13,14 @@ export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential",
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"credential.activate",
Effect.fn(function* (ctx) {
const integration = yield* Integration.Service
yield* integration.connection.activate(ctx.params.credentialID)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"credential.remove",
Effect.fn(function* (ctx) {
+23
View File
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/schema/credential"
import { Event } from "@opencode-ai/schema/event"
import { IntegrationID } from "@opencode-ai/schema/integration-id"
import { Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
import { it } from "../../core/test/lib/effect"
import { EventFeed } from "../src/event-feed"
@@ -111,6 +113,27 @@ describe("EventFeed", () => {
}),
)
it.effect("delivers global credential events to public subscribers", () =>
Effect.gen(function* () {
const source = makeSource()
const feed = yield* EventFeed.make(source.observe, { encode: (event) => event.type })
const stream = yield* feed.subscribe
const received = yield* stream.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* source.publish({
id: Event.ID.create(),
created: Date.now(),
type: Credential.Event.Switched.type,
data: {
credentialID: Credential.ID.make("cred_test"),
integrationID: IntegrationID.make("openai"),
},
})
expect(Array.from(yield* Fiber.join(received))).toEqual([Credential.Event.Switched.type])
}),
)
it.effect("filters internal events before they consume subscriber capacity", () =>
Effect.gen(function* () {
const source = makeSource()
+10
View File
@@ -64,6 +64,16 @@ it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
}).pipe(Effect.scoped),
)
it.live("activates credentials through the HttpApi", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
const response = yield* Effect.promise(() =>
handler(new Request("http://opencode.local/api/credential/cred_missing/activate", { method: "POST" })),
)
expect(response.status).toBe(204)
}).pipe(Effect.scoped),
)
it.live("serves unauthenticated and answers CORS preflight when no password is configured", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
@@ -150,29 +150,101 @@ function manageConnections(
const data = useData()
const client = useClient()
const toast = useToast()
const theme = useTheme("elevated")
const shortcuts = Keymap.useShortcuts()
const [deleting, setDeleting] = createSignal<string>()
const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id)
const current = createMemo(() => data.location.integration.list()?.find((item) => item.id === integration.id))
return (
<DialogSelect
title={integration.name}
current={credentialConnections(current() ?? integration)[0]?.id}
focusCurrent={false}
preserveSelection
onMove={(option) => {
setSelected(option.value)
setDeleting(undefined)
}}
options={[
...(methods.length
? [
{
title: "Add connection",
title: "Add account",
value: "add",
onSelect: () => selectMethod(integration, methods, dialog, onConnected),
onSelect: () => selectMethod(current() ?? integration, methods, dialog, onConnected),
},
]
: []),
...credentialConnections(integration).map((connection) => ({
title: `Disconnect ${connection.label}`,
value: connection.id,
onSelect: () => {
void client.api.credential
.remove({ credentialID: connection.id, location: location(data) })
.then(() => disconnected(integration.name, data, dialog, toast))
.catch(toast.error)
...credentialConnections(current() ?? integration)
.toSorted((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
.map((connection) => {
const confirming = deleting() === connection.id
return {
title: confirming
? `Press ${shortcuts.get("dialog.integration.delete")} again to confirm`
: connection.label,
value: connection.id,
category: "Connected accounts",
bg: confirming ? theme.background.action.destructive.focused : undefined,
fg: confirming ? theme.text.action.destructive.focused : undefined,
onSelect: () => {
if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return
void client.api.credential
.activate({ credentialID: connection.id, location: location(data) })
.catch(toast.error)
},
}
}),
]}
actions={[
{
command: "dialog.integration.rename",
title: "rename",
hidden: selected() === "add",
disabled: (option) => !option || option.value === "add",
onTrigger: (option) => {
dialog.replace(() => (
<DialogPrompt
title="Rename account"
placeholder="Account name"
value={
credentialConnections(current() ?? integration).find((item) => item.id === option.value)?.label
}
onConfirm={(value) => {
const label = value.trim()
if (!label) return
void client.api.credential
.update({ credentialID: option.value, label, location: location(data) })
.then(() => manageConnections(integration, methods, dialog, onConnected))
.catch(toast.error)
}}
/>
))
},
})),
},
{
command: "dialog.integration.delete",
title: "delete",
hidden: selected() === "add",
disabled: (option) => !option || option.value === "add",
onTrigger: (option) => {
if (deleting() !== option.value) return setDeleting(option.value)
const final = credentialConnections(current() ?? integration).length === 1
void client.api.credential
.remove({ credentialID: option.value, location: location(data) })
.then(() => {
setDeleting(undefined)
if (!final) return
toast.show({ variant: "success", message: `Disconnected ${integration.name}` })
dialog.clear()
})
.catch((error) => {
setDeleting(undefined)
toast.error(error)
})
},
},
]}
/>
)
@@ -923,20 +995,6 @@ function providerID(data: ReturnType<typeof useData>, integrationID: string) {
)
}
async function disconnected(
name: string,
data: ReturnType<typeof useData>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
) {
data.location.integration.invalidate()
data.location.model.invalidate()
data.location.provider.invalidate()
await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()])
toast.show({ variant: "success", message: `Disconnected ${name}` })
dialog.clear()
}
function location(data: ReturnType<typeof useData>) {
const current = data.location.default()
return { directory: current.directory, workspace: current.workspaceID }
+2
View File
@@ -243,6 +243,8 @@ export const Definitions = {
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.integration.rename": keybind("ctrl+r", "Rename integration account"),
"dialog.integration.delete": keybind("ctrl+d", "Delete integration account"),
"dialog.worktree.generate": keybind("tab", "Generate worktree name"),
"dialog.move_session.new": keybind("ctrl+m", "New worktree"),
"dialog.move_session.delete": keybind("ctrl+d", "Delete worktree"),
@@ -365,6 +365,7 @@ function messageIDFromEvent(id: string) {
const catalogEvents = new Set([
"catalog.updated",
"integration.updated",
"credential.switched",
"agent.updated",
"command.updated",
"skill.updated",
+11
View File
@@ -1728,6 +1728,7 @@ test("refreshes integrations after integration updates", async () => {
id: "openai",
name: "OpenAI",
methods: [{ type: "key" }],
connections: [{ type: "credential", id: "cred_openai", label: "OpenAI" }],
},
],
})
@@ -1766,6 +1767,16 @@ test("refreshes integrations after integration updates", async () => {
await wait(() => data.location.integration.list()?.length === 1)
await wait(() => requests.model > before.model && requests.provider > before.provider)
expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
const previous = { ...requests }
events.emit({
id: "evt_credential",
created: 0,
type: "credential.switched",
data: { credentialID: "cred_openai", integrationID: "openai" },
})
await wait(() => requests.model > previous.model && requests.provider > previous.provider)
expect(requests.integration).toBe(previous.integration)
} finally {
app.renderer.destroy()
}
@@ -0,0 +1,339 @@
/** @jsxImportSource @opentui/solid */
import { InputRenderable, TextareaRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onMount } from "solid-js"
import { DialogIntegration } from "../../../src/component/dialog-integration"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider } from "../../../src/context/location"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { emptyThemeSource } from "../../fixture/fixture"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("renders account management with an uncategorized add row and marks the active credential", async () => {
const fixture = await renderIntegration()
try {
const frame = fixture.app.captureCharFrame()
const lines = frame.split("\n")
expect(frame.indexOf("Add account")).toBeLessThan(frame.indexOf("Connected accounts"))
expect(frame.indexOf("Connected accounts")).toBeLessThan(frame.indexOf("Personal"))
expect(frame.indexOf("Personal")).toBeLessThan(frame.indexOf("Work"))
expect(lines.find((line) => line.includes("Personal"))).toContain("\u25cf")
expect(lines.find((line) => line.includes("Work"))).not.toContain("\u25cf")
expect(frame).not.toContain("OPENAI_API_KEY")
} finally {
fixture.app.renderer.destroy()
}
})
test("opens the key connection prompt from the initially focused add account row", async () => {
const fixture = await renderIntegration()
try {
fixture.app.mockInput.pressEnter()
await fixture.app.waitForFrame((frame) => frame.includes("API key") && !frame.includes("Connected accounts"))
expect(fixture.requests).toEqual([])
} finally {
fixture.app.renderer.destroy()
}
})
test("switches the selected account with enter and keeps the reactive account manager open", async () => {
const fixture = await renderIntegration()
try {
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressEnter()
await fixture.app.waitForFrame((frame) => {
const line = frame.split("\n").find((entry) => entry.includes("Work"))
return frame.includes("Connected accounts") && line?.includes("\u25cf") === true
})
expect(fixture.requests).toEqual([{ method: "POST", path: "/api/credential/cred_work/activate" }])
expect(fixture.accounts.map((account) => account.id)).toEqual(["cred_work", "cred_personal"])
const frame = fixture.app.captureCharFrame()
expect(frame.indexOf("Personal")).toBeLessThan(frame.indexOf("Work"))
expect(fixture.reads.integration).toBe(1)
expect(fixture.reads.model).toBeGreaterThan(0)
expect(fixture.reads.provider).toBeGreaterThan(0)
} finally {
fixture.app.renderer.destroy()
}
})
test("does not refetch when selecting the already-active account", async () => {
const fixture = await renderIntegration()
try {
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressEnter()
expect(fixture.requests).toEqual([])
expect(fixture.reads.model).toBe(0)
expect(fixture.reads.provider).toBe(0)
} finally {
fixture.app.renderer.destroy()
}
})
test("renames the selected account through a prefilled prompt and reopens the account manager", async () => {
const fixture = await renderIntegration()
try {
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressKey("r", { ctrl: true })
await fixture.app.waitFor(() => fixture.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
const textarea = fixture.app.renderer.currentFocusedEditor
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused rename prompt")
expect(textarea.plainText).toBe("Work")
await fixture.app.mockInput.typeText(" Account")
fixture.app.mockInput.pressEnter()
await fixture.app.waitForFrame((frame) => frame.includes("Connected accounts") && frame.includes("Work Account"))
expect(fixture.requests).toEqual([
{ method: "PATCH", path: "/api/credential/cred_work", body: { label: "Work Account" } },
])
expect(fixture.accounts.find((account) => account.id === "cred_work")?.label).toBe("Work Account")
} finally {
fixture.app.renderer.destroy()
}
})
test("requires delete confirmation and preserves the account manager when another credential remains", async () => {
const fixture = await renderIntegration()
try {
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressKey("d", { ctrl: true })
expect(fixture.requests).toEqual([])
fixture.app.mockInput.pressKey("d", { ctrl: true })
await fixture.app.waitForFrame((frame) => frame.includes("Connected accounts") && !frame.includes("Work"))
expect(fixture.requests).toEqual([{ method: "DELETE", path: "/api/credential/cred_work" }])
expect(fixture.accounts).toEqual([{ type: "credential", id: "cred_personal", label: "Personal" }])
expect(fixture.reads.model).toBe(0)
expect(fixture.reads.provider).toBe(0)
expect(fixture.app.captureCharFrame()).toContain("Add account")
expect(fixture.app.captureCharFrame()).toContain("Personal")
} finally {
fixture.app.renderer.destroy()
}
})
test("renames the account label rather than its delete-confirmation message", async () => {
const fixture = await renderIntegration()
try {
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressKey("d", { ctrl: true })
await fixture.app.waitForFrame((frame) => frame.includes("again to confirm"))
fixture.app.mockInput.pressKey("r", { ctrl: true })
await fixture.app.waitFor(() => fixture.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
const textarea = fixture.app.renderer.currentFocusedEditor
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused rename prompt")
expect(textarea.plainText).toBe("Work")
} finally {
fixture.app.renderer.destroy()
}
})
test("marks the remaining account active after deleting the active credential", async () => {
const fixture = await renderIntegration()
try {
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressKey("d", { ctrl: true })
fixture.app.mockInput.pressKey("d", { ctrl: true })
await fixture.app.waitForFrame((frame) => {
const line = frame.split("\n").find((entry) => entry.includes("Work"))
return !frame.includes("Personal") && line?.includes("\u25cf") === true
})
expect(fixture.requests).toEqual([{ method: "DELETE", path: "/api/credential/cred_personal" }])
expect(fixture.accounts).toEqual([{ type: "credential", id: "cred_work", label: "Work" }])
} finally {
fixture.app.renderer.destroy()
}
})
test("hides account rename and delete actions while the add account row is selected", async () => {
const fixture = await renderIntegration()
try {
expect(fixture.app.captureCharFrame()).not.toContain("rename")
expect(fixture.app.captureCharFrame()).not.toContain("delete")
fixture.app.mockInput.pressKey("r", { ctrl: true })
fixture.app.mockInput.pressKey("d", { ctrl: true })
fixture.app.mockInput.pressKey("d", { ctrl: true })
expect(fixture.requests).toEqual([])
expect(fixture.app.renderer.currentFocusedEditor).toBeInstanceOf(InputRenderable)
expect(fixture.app.captureCharFrame()).toContain("Connected accounts")
fixture.app.mockInput.pressArrow("down")
await fixture.app.waitForFrame((frame) => frame.includes("rename") && frame.includes("delete"))
fixture.app.mockInput.pressArrow("up")
await fixture.app.waitForFrame((frame) => !frame.includes("rename") && !frame.includes("delete"))
} finally {
fixture.app.renderer.destroy()
}
})
async function renderIntegration() {
const events = createEventStream()
const requests: Array<{ method: string; path: string; body?: { label: string } }> = []
const reads = { integration: 0, model: 0, provider: 0 }
let accounts = [
{ type: "credential" as const, id: "cred_personal", label: "Personal" },
{ type: "credential" as const, id: "cred_work", label: "Work" },
]
const calls = createFetch(async (url, request) => {
const location = {
directory: process.cwd(),
project: { id: "proj_test", directory: process.cwd(), canonical: process.cwd() },
}
if (url.pathname === "/api/integration") {
reads.integration++
return json({
location,
data: [
{
id: "openai",
name: "OpenAI",
methods: [{ type: "key", label: "API key" }],
connections: [...accounts, { type: "env", name: "OPENAI_API_KEY" }],
},
],
})
}
if (url.pathname === "/api/model") {
reads.model++
return json({ location, data: [] })
}
if (url.pathname === "/api/provider") {
reads.provider++
return json({ location, data: [] })
}
if (request.method === "POST" && /^\/api\/credential\/[^/]+\/activate$/.test(url.pathname)) {
const id = url.pathname.split("/")[3]
const active = accounts.find((account) => account.id === id)
if (!active) throw new Error(`unknown credential: ${id}`)
accounts = [active, ...accounts.filter((account) => account.id !== id)]
requests.push({ method: request.method, path: url.pathname })
events.emit({
id: `evt_switched_${id}`,
created: Date.now(),
type: "credential.switched",
data: { integrationID: "openai", credentialID: id },
})
return new Response(null, { status: 204 })
}
if (request.method === "PATCH" && /^\/api\/credential\/[^/]+$/.test(url.pathname)) {
const id = url.pathname.split("/")[3]
const body = (await request.json()) as { label: string }
accounts = accounts.map((account) => (account.id === id ? { ...account, label: body.label } : account))
requests.push({ method: request.method, path: url.pathname, body })
events.emit({ id: `evt_updated_${id}`, created: Date.now(), type: "credential.updated", data: {} })
return new Response(null, { status: 204 })
}
if (request.method === "DELETE" && /^\/api\/credential\/[^/]+$/.test(url.pathname)) {
const id = url.pathname.split("/")[3]
const active = accounts[0]?.id === id
accounts = accounts.filter((account) => account.id !== id)
requests.push({ method: request.method, path: url.pathname })
events.emit({ id: `evt_deleted_${id}`, created: Date.now(), type: "credential.updated", data: {} })
if (active)
events.emit({
id: `evt_switched_${id}`,
created: Date.now(),
type: "credential.switched",
data: { integrationID: "openai", credentialID: accounts[0]?.id ?? null },
})
return new Response(null, { status: 204 })
}
return undefined
}, events)
function Probe() {
const data = useData()
const dialog = useDialog()
onMount(() => {
void data.location.integration
.sync()
.then(() => dialog.replace(() => <DialogIntegration integrationID="openai" autoConnect />))
})
return null
}
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ToastProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
<Probe />
</DialogProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 100, height: 30, kittyKeyboard: true },
)
app.renderer.start()
await app.waitForFrame(
(frame) => frame.includes("Add account") && frame.includes("Personal") && frame.includes("Work"),
)
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
return {
app,
reads,
requests,
get accounts() {
return accounts
},
}
}
@@ -2996,7 +2996,7 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("refreshes catalogs on connection and location-scoped invalidations", async () => {
test("refreshes catalogs on connection, location-scoped invalidations, and global credential switches", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
@@ -3030,6 +3030,19 @@ describe("V2 mini transport", () => {
location: { directory: "/project", workspaceID: "work-1" },
data: {},
})
events.push({
id: "evt_credential.updated",
created: 0,
type: "credential.updated",
data: {},
})
for (const credentialID of ["credential", null])
events.push({
id: `evt_credential.switched.${credentialID}`,
created: 0,
type: "credential.switched",
data: { credentialID, integrationID: "integration" },
})
events.push({
id: "evt_foreign_catalog",
created: 0,
@@ -3044,10 +3057,10 @@ describe("V2 mini transport", () => {
location: { directory: "/project", workspaceID: "work-2" },
data: {},
})
while (refreshes < 7) await Bun.sleep(0)
while (refreshes < 9) await Bun.sleep(0)
await Bun.sleep(0)
expect(refreshes).toBe(7)
expect(refreshes).toBe(9)
await transport.close()
})