From 7cd386a38caaea6bcfe2e5338002e62e43f0a5be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 15:33:58 -0400 Subject: [PATCH] Q93: disconnect erases the secret here and revokes at the provider where it can Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-handlers.ts | 12 +- .../079-erase-retired-credentials.ts | 13 ++ src/main/database/schema.ts | 2 + src/main/services/auth-broker-service.ts | 24 +++- .../services/oauth/google-antigravity-flow.ts | 10 ++ src/main/services/provider-adapters.ts | 7 +- src/main/services/provider-auth-lifecycle.ts | 2 + src/main/services/provider-service.ts | 55 +++++--- src/renderer/src/i18n/zh.ts | 4 +- .../src/pages/settings/ProvidersPanel.tsx | 19 ++- src/shared/domain.ts | 2 + src/shared/provider-catalog.ts | 6 +- tests/contract/provider-contracts.test.ts | 3 +- tests/unit/domain-services.test.ts | 2 +- tests/unit/provider-disconnect.test.ts | 125 ++++++++++++++++++ 15 files changed, 257 insertions(+), 29 deletions(-) create mode 100644 src/main/database/migrations/079-erase-retired-credentials.ts create mode 100644 tests/unit/provider-disconnect.test.ts diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index fc7cb4f6..3ed89ddc 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -209,7 +209,17 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.deleteMessage, (_event, input) => mutate(() => chat.deleteMessage(input))); ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input))); ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input))); - ipcMain.handle(IPC_CHANNELS.revokeProviderCredential, (_event, input) => mutate(() => providers.revokeCredential(input))); + ipcMain.handle(IPC_CHANNELS.revokeProviderCredential, async (_event, input) => { + try { + await providers.revokeCredential(input); + notifyDataChanged(); + return ok(snapshot.load()); + } catch (error) { + // The local disconnect has happened even when the provider refused the revocation. + notifyDataChanged(); + return fail("provider_revoke_failed", error instanceof Error ? error.message : String(error)); + } + }); ipcMain.handle(IPC_CHANNELS.updateProviderPolicy, (_event, input) => mutate(() => providers.updatePolicy(input))); ipcMain.handle(IPC_CHANNELS.setProviderPrices, (_event, input) => mutate(() => providers.setPrices(input))); ipcMain.handle(IPC_CHANNELS.checkProviderHealth, async (_event, input) => { diff --git a/src/main/database/migrations/079-erase-retired-credentials.ts b/src/main/database/migrations/079-erase-retired-credentials.ts new file mode 100644 index 00000000..74689c80 --- /dev/null +++ b/src/main/database/migrations/079-erase-retired-credentials.ts @@ -0,0 +1,13 @@ +/** Credentials no provider uses any more lose their secret; their rows stay for history. */ +export const ERASE_RETIRED_CREDENTIALS_SQL = ` + update credentials set ciphertext = x'' + where id not in (select credential_id from providers where credential_id is not null) + and id not in (select credential_id from provider_auth_profiles where active = 1); +`; + +export const eraseRetiredCredentialsSchema = ` + ${ERASE_RETIRED_CREDENTIALS_SQL} + + insert into schema_migrations(version, name, applied_at) + values (79, 'erase_retired_credentials', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 1c4e4d74..d1778b89 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -77,6 +77,7 @@ import { providerCooldownsSchema } from "./migrations/075-provider-cooldowns"; import { runPinsSchema } from "./migrations/076-run-pins"; import { modelChecksSchema } from "./migrations/077-model-checks"; import { requestErrorCodesSchema } from "./migrations/078-request-error-codes"; +import { eraseRetiredCredentialsSchema } from "./migrations/079-erase-retired-credentials"; interface Migration { version: number; @@ -163,6 +164,7 @@ const MIGRATIONS: Migration[] = [ { version: 76, name: "run_pins", sql: runPinsSchema }, { version: 77, name: "model_checks", sql: modelChecksSchema }, { version: 78, name: "request_error_codes", sql: requestErrorCodesSchema }, + { version: 79, name: "erase_retired_credentials", sql: eraseRetiredCredentialsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/auth-broker-service.ts b/src/main/services/auth-broker-service.ts index f3c73ccd..99f71ab2 100644 --- a/src/main/services/auth-broker-service.ts +++ b/src/main/services/auth-broker-service.ts @@ -32,6 +32,8 @@ export interface RevokeProviderAuthProfileInput { orgId: string; providerId: string; providerKey: string; + /** The credential being disconnected; a newer connection made meanwhile is left alone. */ + credentialId: string; } export class AuthBrokerService { @@ -61,11 +63,14 @@ export class AuthBrokerService { insert into credentials(id, org_id, provider_key, kind, ciphertext, display_hint, created_at) values (?, ?, ?, ?, ?, ?, ?) `).run(credentialId, input.orgId, input.providerKey, input.authType, this.vault.encryptSecret(secret), displayHint, now); + const replaced = this.db.prepare("select credential_id from provider_auth_profiles where org_id = ? and provider_id = ? and active = 1") + .all(input.orgId, input.providerId) as Array<{ credential_id: string }>; this.db.prepare(` update provider_auth_profiles set active = 0 where org_id = ? and provider_id = ? and active = 1 `).run(input.orgId, input.providerId); + for (const row of replaced) this.eraseCredential(row.credential_id); this.db.prepare(` insert into provider_auth_profiles( id, org_id, provider_id, credential_id, provider_key, auth_type, @@ -134,26 +139,36 @@ export class AuthBrokerService { const secret = input.secret.trim(); if (!secret) throw new Error("Credential is required."); const displayHint = credentialDisplayHint(secret); + // Only a credential still in use may be written: a renewal that lands after a disconnect or replacement must not revive it. const result = this.db.prepare(` update credentials set ciphertext = ?, display_hint = ? where id = ? and org_id = ? and provider_key = ? + and id in (select credential_id from provider_auth_profiles where active = 1) `).run(this.vault.encryptSecret(secret), displayHint, input.credentialId, input.orgId, input.providerKey); - if (result.changes === 0) throw new Error("Provider credential was not found."); + if (result.changes === 0) throw new Error("The sign-in was disconnected or replaced while it was being renewed; the renewed token was discarded."); this.db.prepare("update provider_auth_profiles set display_hint = ? where credential_id = ? and org_id = ?") .run(displayHint, input.credentialId, input.orgId); } - revokeProviderProfile(input: RevokeProviderAuthProfileInput): void { + /** A retired credential keeps its row for history, never its secret. */ + private eraseCredential(credentialId: string): void { + this.db.prepare("update credentials set ciphertext = x'' where id = ?").run(credentialId); + } + + /** Retires one credential; false when a newer connection had already replaced it (it is erased either way). */ + revokeProviderProfile(input: RevokeProviderAuthProfileInput): boolean { + this.eraseCredential(input.credentialId); const row = this.db.prepare(` select id, credential_id from provider_auth_profiles where org_id = ? and provider_id = ? and provider_key = ? + and credential_id = ? and active = 1 - `).get(input.orgId, input.providerId, input.providerKey) as { id: string; credential_id: string } | undefined; - if (!row) throw new Error("Provider credential was not found."); + `).get(input.orgId, input.providerId, input.providerKey, input.credentialId) as { id: string; credential_id: string } | undefined; + if (!row) return false; this.db.prepare("update provider_auth_profiles set active = 0 where id = ? and org_id = ?") .run(row.id, input.orgId); this.audit.record({ @@ -169,6 +184,7 @@ export class AuthBrokerService { credentialId: row.credential_id, }, }); + return true; } } diff --git a/src/main/services/oauth/google-antigravity-flow.ts b/src/main/services/oauth/google-antigravity-flow.ts index 643fa962..1bbc225b 100644 --- a/src/main/services/oauth/google-antigravity-flow.ts +++ b/src/main/services/oauth/google-antigravity-flow.ts @@ -102,6 +102,16 @@ async function discoverProject(accessToken: string, cancel?: AbortSignal): Promi throw new Error("Antigravity project discovery failed."); } +/** Revokes the sign-in at Google; revoking the refresh token also ends its access tokens. */ +export async function revokeAntigravity(refreshToken: string): Promise { + const { response, text } = await oauthFetch("https://oauth2.googleapis.com/revoke", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: refreshToken }), + }); + if (!response.ok) throw httpFailure(`Google revocation failed: ${response.status} ${text}`, response, text); +} + /** Refresh an expired Antigravity access token, retaining the project id. */ export async function refreshAntigravity(refreshToken: string, projectId: string, clientSecret: string): Promise { const { response, text } = await oauthFetch(TOKEN_URL, { diff --git a/src/main/services/provider-adapters.ts b/src/main/services/provider-adapters.ts index 6fabc0e5..b821c602 100644 --- a/src/main/services/provider-adapters.ts +++ b/src/main/services/provider-adapters.ts @@ -26,6 +26,8 @@ import { fetchAnthropicModels, fetchOpenAiModels } from "./provider-models-fetch import { KIMI_CODE_BASE_URL } from "./oauth/kimi-code-flow"; import { FINISHED_STOP_REASONS, incompleteTurn, requireFinished } from "./provider-tool-turn"; import { incompleteReason } from "./provider-tool-turns/responses-format"; +import { revokeAntigravity } from "./oauth/google-antigravity-flow"; +import { parseStoredOAuth } from "./oauth/stored-oauth"; const ANTHROPIC_VERSION = "2023-06-01"; const CLAUDE_PRO_BETA = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14"; @@ -222,7 +224,10 @@ export const providerAdapters: ProviderAdapterRegistry = { healthUrl: "https://api.moonshot.cn/v1/models", }), "github-copilot": browserTokenAdapter("github-copilot", PROVIDER_AUTH_TYPE.OAUTH_PKCE), - "google-antigravity": browserTokenAdapter("google-antigravity", PROVIDER_AUTH_TYPE.OAUTH_PKCE), + "google-antigravity": { + ...browserTokenAdapter("google-antigravity", PROVIDER_AUTH_TYPE.OAUTH_PKCE), + revoke: async (input) => revokeAntigravity(parseStoredOAuth("google-antigravity", input.secret).refresh), + }, "gpt-account": browserTokenAdapter("gpt-account", PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION), "claude-pro": { ...browserTokenAdapter("claude-pro", PROVIDER_AUTH_TYPE.OAUTH_PKCE), diff --git a/src/main/services/provider-auth-lifecycle.ts b/src/main/services/provider-auth-lifecycle.ts index 554af7b4..159d3276 100644 --- a/src/main/services/provider-auth-lifecycle.ts +++ b/src/main/services/provider-auth-lifecycle.ts @@ -20,6 +20,8 @@ export interface ProviderRefreshResult { export interface ProviderRevokeInput { authProfile: ProviderAuthProfile; + /** The stored credential, for providers that revoke with the refresh token. */ + secret: string; } export type ProviderAuthorizeHandler = (input: ProviderAuthorizeInput) => Promise; diff --git a/src/main/services/provider-service.ts b/src/main/services/provider-service.ts index d501ab31..10a9ff2a 100644 --- a/src/main/services/provider-service.ts +++ b/src/main/services/provider-service.ts @@ -130,27 +130,40 @@ export class ProviderService { }); } - revokeCredential(input: RevokeProviderCredentialInput): void { + /** Revokes at the provider where it can, then always disconnects and erases the credential here; a remote failure is reported, not hidden. */ + async revokeCredential(input: RevokeProviderCredentialInput): Promise { const provider = this.db.prepare(` - select id, provider_key + select id, provider_key, auth_type, label, credential_id from providers where id = ? and org_id = ? - `).get(input.providerId, input.orgId) as { id: string; provider_key: string } | undefined; + `).get(input.providerId, input.orgId) as { id: string; provider_key: string; auth_type: ProviderAuthType; label: string; credential_id: string | null } | undefined; if (!provider) throw new Error("Provider was not found."); - this.authBroker.revokeProviderProfile({ - orgId: input.orgId, - providerId: input.providerId, - providerKey: provider.provider_key, - }); - this.db.prepare(` - update providers - set credential_id = null, enabled = 0, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null - where id = ? and org_id = ? - `).run(AUTH_STATE_REVOKED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId); - // Sessions mid-turn on this provider stop now instead of finishing on a revoked account. - const label = (this.db.prepare("select label from providers where id = ?").get(input.providerId) as { label: string }).label; - const running = this.db.prepare("select id from task_runs where provider_id = ? and state = 'running'").all(input.providerId) as Array<{ id: string }>; - for (const run of running) stopRun(run.id, `${label} was disconnected.`); + if (!provider.credential_id) throw new Error(`${provider.label} is not connected.`); + const credentialId = provider.credential_id; + const entry = requireCatalogEntry(provider.provider_key); + let remoteError: string | null = null; + if (entry.revokesRemotely) { + try { + const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type); + const secret = this.authBroker.loadProviderSecret({ orgId: input.orgId, providerId: provider.id, providerKey: provider.provider_key, credentialId }); + await adapter.revoke({ authProfile: adapter.parseAuthProfile(secret), secret }); + } catch (error) { + remoteError = error instanceof Error ? error.message : String(error); + } + } + // The credential read at the start is the one disconnected; a connection made while Google answered stays. + const disconnected = this.authBroker.revokeProviderProfile({ orgId: input.orgId, providerId: input.providerId, providerKey: provider.provider_key, credentialId }); + if (disconnected) { + this.db.prepare(` + update providers + set credential_id = null, enabled = 0, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null + where id = ? and org_id = ? and credential_id = ? + `).run(AUTH_STATE_REVOKED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId, credentialId); + // Sessions mid-turn on this provider stop now instead of finishing on a revoked account. + const running = this.db.prepare("select id from task_runs where provider_id = ? and state = 'running'").all(input.providerId) as Array<{ id: string }>; + for (const run of running) stopRun(run.id, `${provider.label} was disconnected.`); + } + const remote = !entry.revokesRemotely ? "not_supported" : remoteError ? "failed" : "revoked"; this.audit.record({ orgId: input.orgId, entityType: "provider", @@ -158,8 +171,14 @@ export class ProviderService { actorType: "system", actorId: null, action: "credential_revoked", - payload: { providerKey: provider.provider_key }, + payload: { providerKey: provider.provider_key, remote, remoteError, replacedMeanwhile: !disconnected }, }); + console.info(`[providers] ${provider.provider_key} disconnected; remote revocation ${remote}`); + const problems = [ + ...(disconnected ? [] : [`${provider.label} was reconnected while it was being disconnected; the old sign-in is erased and the new connection stays.`]), + ...(remoteError ? [`${disconnected ? "Disconnected on this computer, but " : "The old sign-in was not confirmed revoked: "}${provider.label} did not confirm revoking the sign-in (${remoteError}); revoke it in your account.`] : []), + ]; + if (problems.length > 0) throw new Error(problems.join(" ")); } /** diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 14ec32e7..efc8b492 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -32,6 +32,9 @@ export const zh: Record = { "superseded": "已被取代", "stalled": "已停滞", "Resume": "恢复", + "Disconnect": "断开连接", + "Revokes the sign-in at {provider} and erases it from this computer.": "在 {provider} 撤销登录授权,并从本机删除。", + "Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.": "仅从本机删除密钥。在你于 {provider} 账号中撤销之前,该密钥仍然有效。", "API key (if the server asks for one)": "API 密钥(服务需要时填写)", "Checking...": "检查中...", "Check for agent work": "检查能否胜任 Agent 工作", @@ -400,7 +403,6 @@ export const zh: Record = { "Restricted": "受限", "Retry": "重试", "Reviews deliverables": "交付物需审核", - "Revoke": "撤销", "Risk": "风险", "Role": "角色", "Roster": "名册", diff --git a/src/renderer/src/pages/settings/ProvidersPanel.tsx b/src/renderer/src/pages/settings/ProvidersPanel.tsx index 5f975d64..51db50a9 100644 --- a/src/renderer/src/pages/settings/ProvidersPanel.tsx +++ b/src/renderer/src/pages/settings/ProvidersPanel.tsx @@ -72,6 +72,7 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat onCheck={() => void actions.run(() => window.agentCompany.checkProviderHealth({ orgId, providerId: provider.id }))} onManage={() => setActive(provider)} onRevoke={() => void actions.run(() => window.agentCompany.revokeProviderCredential({ orgId, providerId: provider.id }))} + revokesRemotely={requireCatalogEntry(catalog, provider.providerKey).revokesRemotely} /> ))} @@ -317,6 +318,7 @@ function ProviderRow({ onCheck, onManage, onRevoke, + revokesRemotely, }: { provider: ProviderRecord; authProfile: ProviderAuthProfileRecord | null; @@ -324,6 +326,7 @@ function ProviderRow({ onCheck: () => void; onManage: () => void; onRevoke: () => void; + revokesRemotely: boolean; }) { const boundCount = agents.filter((agent) => agent.modelProviderId === provider.id).length; return ( @@ -353,7 +356,15 @@ function ProviderRow({ - + ); @@ -408,3 +419,9 @@ function requireProviderRateLimit( if (!rateLimit) throw new Error(`Provider rate limit state missing for ${provider.id}.`); return rateLimit; } + +function requireCatalogEntry(catalog: ProviderCatalogEntry[], providerKey: string): ProviderCatalogEntry { + const entry = catalog.find((candidate) => candidate.key === providerKey); + if (!entry) throw new Error(`Provider ${providerKey} is not in the catalog.`); + return entry; +} diff --git a/src/shared/domain.ts b/src/shared/domain.ts index c3dac344..97eca699 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -324,6 +324,8 @@ export interface ProviderCatalogEntry { /** Set for user-configured OpenAI/Anthropic-compatible endpoints: the UI * collects a base URL + API key + model instead of a fixed provider form. */ customEndpoint?: "openai" | "anthropic"; + /** True when disconnecting also revokes the sign-in at the provider; otherwise it only erases it from this computer. */ + revokesRemotely: boolean; } export interface SkillDefinitionRecord { diff --git a/src/shared/provider-catalog.ts b/src/shared/provider-catalog.ts index 4c2227e5..3de4549f 100644 --- a/src/shared/provider-catalog.ts +++ b/src/shared/provider-catalog.ts @@ -1,7 +1,7 @@ import { PROVIDER_AUTH_TYPE, type ProviderCatalogEntry } from "./domain"; import { listProviderModels } from "./provider-models"; -const RAW_CATALOG: Omit[] = [ +const RAW_CATALOG: Omit[] = [ { key: "openai", label: "OpenAI", @@ -192,8 +192,12 @@ const CURATED_MODEL_PROVIDERS = new Set([ "custom-anthropic", ]); +// Google documents a token revocation endpoint (RFC 7009); the others offer none for what we hold. +const REMOTE_REVOCATION = new Set(["google-antigravity"]); + export const PROVIDER_CATALOG: ProviderCatalogEntry[] = RAW_CATALOG.map((entry) => ({ ...entry, models: listProviderModels(entry.key), supportsLiveModels: !CURATED_MODEL_PROVIDERS.has(entry.key), + revokesRemotely: REMOTE_REVOCATION.has(entry.key), })); diff --git a/tests/contract/provider-contracts.test.ts b/tests/contract/provider-contracts.test.ts index 2b00b497..c5ea157e 100644 --- a/tests/contract/provider-contracts.test.ts +++ b/tests/contract/provider-contracts.test.ts @@ -134,7 +134,8 @@ describe("provider contracts", () => { const secret = PROVIDER_SECRET_SAMPLES[provider.key]; const profile = adapter.parseAuthProfile(secret); await expect(adapter.authorize({ secret })).resolves.toEqual({ authProfile: profile }); - await expect(adapter.revoke({ authProfile: profile })).resolves.toBeUndefined(); + // Remote revocation makes a network call; provider-disconnect.test covers it. + if (!provider.revokesRemotely) await expect(adapter.revoke({ authProfile: profile, secret })).resolves.toBeUndefined(); await expect(adapter.refresh({ authProfile: profile })).rejects.toThrow("does not support token refresh"); } }); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 69ea8937..208a86be 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79]); connection.close(); }); diff --git a/tests/unit/provider-disconnect.test.ts b/tests/unit/provider-disconnect.test.ts new file mode 100644 index 00000000..9782228b --- /dev/null +++ b/tests/unit/provider-disconnect.test.ts @@ -0,0 +1,125 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ProviderService } from "../../src/main/services/provider-service"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; +import { PROVIDER_CATALOG } from "../../src/shared/provider-catalog"; +import { ERASE_RETIRED_CREDENTIALS_SQL } from "../../src/main/database/migrations/079-erase-retired-credentials"; +import { AuthBrokerService } from "../../src/main/services/auth-broker-service"; +import { IntegrationKeyStore } from "../../src/main/services/integration-key-store"; +import { loadConfiguredProvider } from "../../src/main/services/provider-internal"; +import { resolveTurnCredential } from "../../src/main/services/provider-tool-turns/credential-resolver"; + +const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") }; + +afterEach(() => vi.unstubAllGlobals()); + +function company(providerKey: string, secret: string, model: string) { + const connection = new DatabaseConnection(join(tmpdir(), `ac-disconnect-${randomUUID()}.db`)); + const providers = new ProviderService(connection.db, providerAdapters, vault); + let orgId = ""; + let providerId = ""; + connection.transaction(() => { + orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = ?").get(orgId, providerKey) as { id: string }).id; + providers.saveCredential({ orgId, providerId, secret, model }); + }); + const ciphertexts = () => (connection.db.prepare("select length(ciphertext) as bytes from credentials order by created_at, rowid").all() as Array<{ bytes: number }>).map((row) => row.bytes); + const lastRevocation = () => JSON.parse((connection.db.prepare("select payload_json from audit_events where action = 'credential_revoked' order by rowid desc").get() as { payload_json: string }).payload_json) as Record; + return { connection, providers, orgId, providerId, ciphertexts, lastRevocation }; +} + +describe("disconnecting a provider", () => { + test("only Google Antigravity's sign-in can be revoked at the provider; the catalog says so", () => { + expect(PROVIDER_CATALOG.filter((entry) => entry.revokesRemotely).map((entry) => entry.key)).toEqual(["google-antigravity"]); + }); + + test("an API key is erased from this computer on disconnect, and a replaced key is erased too", async () => { + vi.stubGlobal("fetch", async () => { throw new Error("no network call expected"); }); + const h = company("openai", "sk-first", "gpt-5.4"); + h.connection.transaction(() => h.providers.saveCredential({ orgId: h.orgId, providerId: h.providerId, secret: "sk-second", model: "gpt-5.4" })); + expect(h.ciphertexts()).toEqual([0, 9]); + await h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId }); + expect(h.ciphertexts()).toEqual([0, 0]); + expect(h.lastRevocation()).toMatchObject({ providerKey: "openai", remote: "not_supported" }); + h.connection.close(); + }); + + test("a Google sign-in is revoked at Google before it is erased here", async () => { + const revoked: string[] = []; + vi.stubGlobal("fetch", async (url: string, init: RequestInit) => { + revoked.push(`${url} ${String(init.body)}`); + return new Response("", { status: 200 }); + }); + const h = company("google-antigravity", JSON.stringify({ access: "ya29", refresh: "1//refresh", expires: 1_900_000_000_000, projectId: "p" }), "gemini-3-pro"); + await h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId }); + expect(revoked).toEqual(["https://oauth2.googleapis.com/revoke token=1%2F%2Frefresh"]); + expect(h.ciphertexts()).toEqual([0]); + expect(h.lastRevocation()).toMatchObject({ remote: "revoked" }); + h.connection.close(); + }); + + test("when Google does not confirm, the sign-in is still disconnected and erased here, and the failure is said plainly", async () => { + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ error: "invalid_token" }), { status: 400, headers: { "content-type": "application/json" } })); + const h = company("google-antigravity", JSON.stringify({ access: "ya29", refresh: "1//refresh", expires: 1_900_000_000_000, projectId: "p" }), "gemini-3-pro"); + await expect(h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId })) + .rejects.toThrow("Disconnected on this computer, but Google Antigravity did not confirm revoking the sign-in"); + expect(h.connection.db.prepare("select enabled, credential_id from providers where id = ?").get(h.providerId)).toEqual({ enabled: 0, credential_id: null }); + expect(h.ciphertexts()).toEqual([0]); + expect(h.lastRevocation()).toMatchObject({ remote: "failed", remoteError: expect.stringContaining("400") }); + h.connection.close(); + }); + + test("a renewal that lands after the disconnect is discarded, not written back", async () => { + let answer: (response: Response) => void = () => {}; + vi.stubGlobal("fetch", () => new Promise((resolve) => { answer = resolve; })); + const h = company("claude-pro", JSON.stringify({ access: "a", refresh: "r", expires: 0 }), "claude-sonnet-4-5"); + const renewal = resolveTurnCredential({ authBroker: new AuthBrokerService(h.connection.db, vault), integrationKeys: new IntegrationKeyStore(h.connection.db, vault), orgId: h.orgId, provider: loadConfiguredProvider(h.connection.db, h.orgId, h.providerId) }); + await h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId }); + answer(new Response(JSON.stringify({ access_token: "new", refresh_token: "new-r", expires_in: 3600 }), { status: 200 })); + await expect(renewal).rejects.toThrow("disconnected or replaced while it was being renewed"); + expect(h.ciphertexts()).toEqual([0]); + h.connection.close(); + }); + + test("a reconnect made while Google answers keeps the new connection and erases only the old sign-in", async () => { + let answer: (response: Response) => void = () => {}; + vi.stubGlobal("fetch", () => new Promise((resolve) => { answer = resolve; })); + const signIn = (refresh: string) => JSON.stringify({ access: "ya29", refresh, expires: 1_900_000_000_000, projectId: "p" }); + const h = company("google-antigravity", signIn("1//old"), "gemini-3-pro"); + const disconnecting = h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId }); + h.connection.transaction(() => h.providers.saveCredential({ orgId: h.orgId, providerId: h.providerId, secret: signIn("1//new"), model: "gemini-3-pro" })); + answer(new Response("", { status: 200 })); + await expect(disconnecting).rejects.toThrow("was reconnected while it was being disconnected"); + expect(h.connection.db.prepare("select enabled from providers where id = ?").get(h.providerId)).toEqual({ enabled: 1 }); + expect(h.ciphertexts()).toEqual([0, signIn("1//new").length]); + h.connection.close(); + }); + + test("a reconnect and a refused revocation at once are both reported", async () => { + let answer: (response: Response) => void = () => {}; + vi.stubGlobal("fetch", () => new Promise((resolve) => { answer = resolve; })); + const signIn = (refresh: string) => JSON.stringify({ access: "ya29", refresh, expires: 1_900_000_000_000, projectId: "p" }); + const h = company("google-antigravity", signIn("1//old"), "gemini-3-pro"); + const disconnecting = h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId }); + h.connection.transaction(() => h.providers.saveCredential({ orgId: h.orgId, providerId: h.providerId, secret: signIn("1//new"), model: "gemini-3-pro" })); + answer(new Response(JSON.stringify({ error: "invalid_token" }), { status: 400, headers: { "content-type": "application/json" } })); + const failure = (await disconnecting.catch((error: unknown) => error)) as Error; + expect(failure.message).toContain("was reconnected while it was being disconnected"); + expect(failure.message).toContain("did not confirm revoking the sign-in (Google revocation failed: 400"); + expect(failure.message).toContain("revoke it in your account"); + h.connection.close(); + }); + + test("credentials retired before this release are erased by the migration's statement", () => { + const h = company("openai", "sk-old", "gpt-5.4"); + h.connection.db.prepare("update providers set credential_id = null where id = ?").run(h.providerId); + h.connection.db.prepare("update provider_auth_profiles set active = 0").run(); + h.connection.db.exec(ERASE_RETIRED_CREDENTIALS_SQL); + expect(h.ciphertexts()).toEqual([0]); + h.connection.close(); + }); +});