diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index aa1e4c17e7f..0d737b6b698 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1333,6 +1333,15 @@ export type CredentialUpdateOperation = ( input: CredentialUpdateInput, ) => Effect.Effect +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 = ( + input: CredentialActivateInput, +) => Effect.Effect + 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 = ( export interface CredentialApi { readonly update: CredentialUpdateOperation + readonly activate: CredentialActivateOperation readonly remove: CredentialRemoveOperation } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 05f67725a17..d00a3430a89 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -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()( + raw["credential.activate"]({ + params: { credentialID: input["credentialID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)), + ) + const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input: CredentialRemoveInput) => preserveEffect()( 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), }) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 47b1ef632e1..a1911eb959d 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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( + { + 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( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 2767da8b7c3..50a8624c5fe 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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?: { diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index ef634104e70..fff68de9588 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -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) { diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 0bc1e3642a6..3fa6334d8f0 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -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({ diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 267741f736f..520fcdff467 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -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[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() const api = OpenCode.make({ diff --git a/packages/core/src/bus.ts b/packages/core/src/bus.ts index ba5a9f9325d..dcb14cf2817 100644 --- a/packages/core/src/bus.ts +++ b/packages/core/src/bus.ts @@ -89,6 +89,7 @@ export interface PublishOptions { readonly id?: Event.ID readonly metadata?: Record 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 } @@ -446,11 +447,12 @@ export function configured(options?: Options) { function publish(definition: D, data: Event.Data, 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, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 00253ca7057..30d8a399c90 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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)), diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 7ccd1bcbafc..6d053f038b7 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -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("Credential.Info")({ id: ID, integrationID: Integration.ID, @@ -34,12 +37,14 @@ export interface Interface { readonly list: (integrationID: Integration.ID) => Effect.Effect /** Returns one stored credential by ID. */ readonly get: (id: ID) => Effect.Effect - /** 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 + /** Selects a stored credential for its integration. */ + readonly activate: (id: ID) => Effect.Effect /** Updates the label or secret value of a stored credential. */ readonly update: (id: ID, updates: Partial>) => Effect.Effect /** 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] }) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 65f3e009aaa..0fe1cb31826 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -166,6 +166,8 @@ export interface Interface extends State.Transformable { /** User-facing label for the stored credential. */ readonly label?: string }) => Effect.Effect + /** Selects a stored credential as the active integration connection. */ + readonly activate: (credentialID: Credential.ID) => Effect.Effect /** 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[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, diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index f282e785965..95d29acd1ef 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -140,8 +140,7 @@ export type Draft = { remove: (server: ServerName | string) => void } -const cloneConfig = (config: Mcp.ServerConfig) => - structuredClone(config) as Types.DeepMutable +const cloneConfig = (config: Mcp.ServerConfig) => structuredClone(config) as Types.DeepMutable export interface Interface extends State.Transformable { readonly servers: () => Effect.Effect @@ -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), diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 72e0fa09fa3..d7a60fda13d 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -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 }), diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 2e9f9c3a9c4..930d32d02f9 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -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 }), diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 50cb9fe1e33..d875aed629f 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -193,7 +193,7 @@ export const OpencodePlugin = define 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 }), diff --git a/packages/core/test/bus.test.ts b/packages/core/test/bus.test.ts index 0b296aacdbc..6c02c88aa9e 100644 --- a/packages/core/test/bus.test.ts +++ b/packages/core/test/bus.test.ts @@ -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 diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 5a54349f667..926a3859109 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -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"), }), diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index 3b74df5c2c0..7bc0f74680f 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -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() + 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() + 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) }), ) }) diff --git a/packages/core/test/fixture/config-nodes.ts b/packages/core/test/fixture/config-nodes.ts index 1fb59efa92c..71a1fd2acbb 100644 --- a/packages/core/test/fixture/config-nodes.ts +++ b/packages/core/test/fixture/config-nodes.ts @@ -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"), }), diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index 3d5e63d38a5..15f9a63cd85 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -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"), }, diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 9cabcec2569..edfdae01248 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -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(() => { diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 7515144b147..48cbdbf5f90 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -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 }))), ), ), ) diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 2ef8548c02b..63a6692f1f0 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -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"], diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 859064b9ccf..5b902c32cd5 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -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)), ), diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 206c7ceae7e..d9abcd5e4b8 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -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": "" + }, + "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": { diff --git a/packages/protocol/src/groups/credential.ts b/packages/protocol/src/groups/credential.ts index 414fc49c0de..92961eb0488 100644 --- a/packages/protocol/src/groups/credential.ts +++ b/packages/protocol/src/groups/credential.ts @@ -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 }, diff --git a/packages/schema/src/credential.ts b/packages/schema/src/credential.ts index 0c4fb8d55f0..93a16fd0151 100644 --- a/packages/schema/src/credential.ts +++ b/packages/schema/src/credential.ts @@ -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 {} export const OAuth = Schema.Struct({ type: Schema.Literal("oauth"), diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 574f2297383..2a828bf7140 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -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, diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index c64d6d37c07..0afb8199c67 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -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 {} export const Ref = Schema.Struct({ diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 47a731e01ed..92535fd4e2c 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -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( [ diff --git a/packages/server/src/handlers/credential.ts b/packages/server/src/handlers/credential.ts index 49e84d49baf..7ee746fbe13 100644 --- a/packages/server/src/handlers/credential.ts +++ b/packages/server/src/handlers/credential.ts @@ -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) { diff --git a/packages/server/test/event-feed.test.ts b/packages/server/test/event-feed.test.ts index a4fd0678f11..e7443b01222 100644 --- a/packages/server/test/event-feed.test.ts +++ b/packages/server/test/event-feed.test.ts @@ -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() diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 3c506d8f31e..7d7385a7381 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -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) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 382cde9fe60..b36202e7e67 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -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() + 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 ( { + 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(() => ( + 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, integrationID: string) { ) } -async function disconnected( - name: string, - data: ReturnType, - dialog: ReturnType, - toast: ReturnType, -) { - 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) { const current = data.location.default() return { directory: current.directory, workspace: current.workspaceID } diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 0064949532a..7b57b0f1b80 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -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"), diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index 1ae231b2a6a..5de3b8936c6 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -365,6 +365,7 @@ function messageIDFromEvent(id: string) { const catalogEvents = new Set([ "catalog.updated", "integration.updated", + "credential.switched", "agent.updated", "command.updated", "skill.updated", diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 81c55fa2af5..72b1cd4c733 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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() } diff --git a/packages/tui/test/cli/tui/dialog-integration.test.tsx b/packages/tui/test/cli/tui/dialog-integration.test.tsx new file mode 100644 index 00000000000..88d97c07825 --- /dev/null +++ b/packages/tui/test/cli/tui/dialog-integration.test.tsx @@ -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(() => )) + }) + return null + } + + const app = await testRender( + () => ( + + + + + + + + + + + + + + + + + + + + ), + { 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 + }, + } +} diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 0ea9b248976..54a5334060d 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -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() })