From 56b0594f91a83e660a0c4c7c5b415bf04bc02049 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 01:34:14 -0400 Subject: [PATCH] Move Composio, logo.dev and Antigravity keys into Settings The three third-party keys were hardcoded or read from the environment. Operators now enter them in Settings > Integrations; they are stored encrypted in the app-wide integration_keys table (migration 058). An agent holding connector grants fails loudly when no Composio key is saved. The goal-review test no longer depends on absolute dates. Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-handlers.ts | 8 +- .../migrations/058-integration-keys.ts | 10 +++ src/main/database/schema.ts | 2 + src/main/services/agent-run-loop.ts | 4 +- .../composio/composio-connection-service.ts | 22 +++-- .../services/composio/composio-credentials.ts | 16 ---- .../services/composio/composio-tool-bridge.ts | 17 ++-- .../services/composio/connector-brands.ts | 15 +--- src/main/services/integration-key-store.ts | 56 ++++++++++++ .../services/oauth/google-antigravity-flow.ts | 17 ++-- src/main/services/oauth/oauth-types.ts | 2 + src/main/services/provider-oauth-service.ts | 4 + src/main/services/provider-response-runner.ts | 4 +- src/main/services/provider-service.ts | 4 + .../credential-resolver.ts | 10 ++- src/main/services/snapshot-service.ts | 2 + src/main/services/task-execution-service.ts | 2 +- src/preload/index.ts | 2 + src/renderer/src/i18n/zh.ts | 13 +++ src/renderer/src/pages/SettingsPage.tsx | 5 +- .../pages/settings/IntegrationKeysPanel.tsx | 90 +++++++++++++++++++ src/shared/commands/index.ts | 1 + src/shared/commands/integration-keys.ts | 18 ++++ src/shared/desktop-api.ts | 4 + src/shared/ipc.ts | 2 + src/shared/snapshot-domain.ts | 2 + tests/unit/agent-instructions.test.ts | 1 + tests/unit/agent-run-loop.test.ts | 13 +-- tests/unit/composio-credentials.test.ts | 17 ---- tests/unit/composio-tool-bridge.test.ts | 48 ++++++++++ tests/unit/connector-brands.test.ts | 14 +-- tests/unit/domain-services.test.ts | 2 +- tests/unit/goal-review-service.test.ts | 10 ++- tests/unit/integration-key-store.test.ts | 59 ++++++++++++ tests/unit/provider-oauth.test.ts | 10 +-- tests/unit/provider-tool-turns.test.ts | 24 ++++- 36 files changed, 430 insertions(+), 100 deletions(-) create mode 100644 src/main/database/migrations/058-integration-keys.ts delete mode 100644 src/main/services/composio/composio-credentials.ts create mode 100644 src/main/services/integration-key-store.ts create mode 100644 src/renderer/src/pages/settings/IntegrationKeysPanel.tsx create mode 100644 src/shared/commands/integration-keys.ts delete mode 100644 tests/unit/composio-credentials.test.ts create mode 100644 tests/unit/composio-tool-bridge.test.ts create mode 100644 tests/unit/integration-key-store.test.ts diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index f317aa1e..79716aa4 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -28,6 +28,7 @@ import { AutomationService } from "../services/automation-service"; import { MeetingService } from "../services/meeting-service"; import { SprintService } from "../services/sprint-service"; import { ComposioConnectionService } from "../services/composio/composio-connection-service"; +import { IntegrationKeyStore } from "../services/integration-key-store"; import { importStagedSkills, stageBuiltinSkillsLibrary } from "../services/builtin-skills"; import { coalesce } from "./coalesce"; import { normalizeExternalBrowserUrl } from "./external-url"; @@ -48,7 +49,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { const approvals = new ApprovalService(deps.connection.db); const chat = new ChatService(deps.connection.db); const providers = new ProviderService(deps.connection.db); - const providerOAuth = new ProviderOAuthService(); + const integrationKeys = new IntegrationKeyStore(deps.connection.db); + const providerOAuth = new ProviderOAuthService(integrationKeys); const skills = new SkillService(deps.connection.db, deps.artifactRoot); const notifyDataChanged = coalesce(() => { try { @@ -137,6 +139,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.updateAgent, (_event, input) => mutate(() => organization.updateAgent(input))); ipcMain.handle(IPC_CHANNELS.updateEmployeeProfile, (_event, input) => mutate(() => organization.updateEmployeeProfile(input))); ipcMain.handle(IPC_CHANNELS.setAutonomousPaused, (_event, input) => mutate(() => organization.setAutonomousPaused(Boolean(input.paused)))); + ipcMain.handle(IPC_CHANNELS.setIntegrationKey, (_event, input) => mutate(() => integrationKeys.write(input.name, input.value))); + ipcMain.handle(IPC_CHANNELS.clearIntegrationKey, (_event, input) => mutate(() => integrationKeys.clear(input.name))); ipcMain.handle(IPC_CHANNELS.createTask, (_event, input) => mutate(() => organization.createTask(input))); ipcMain.handle(IPC_CHANNELS.updateTask, (_event, input) => mutate(() => organization.updateTask(input))); ipcMain.handle(IPC_CHANNELS.deleteTask, (_event, input) => mutate(() => organization.deleteTask(input))); @@ -344,7 +348,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); - const composioConnections = new ComposioConnectionService(deps.connection.db); + const composioConnections = new ComposioConnectionService(deps.connection.db, integrationKeys); ipcMain.handle(IPC_CHANNELS.listConnectorCatalog, async () => { try { diff --git a/src/main/database/migrations/058-integration-keys.ts b/src/main/database/migrations/058-integration-keys.ts new file mode 100644 index 00000000..d7fc78b3 --- /dev/null +++ b/src/main/database/migrations/058-integration-keys.ts @@ -0,0 +1,10 @@ +export const integrationKeysSchema = ` + create table if not exists integration_keys ( + name text primary key, + ciphertext blob not null, + updated_at text not null + ); + + insert into schema_migrations(version, name, applied_at) + values (58, 'integration_keys', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index fd17abc4..e041bf80 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -56,6 +56,7 @@ import { closeStaleWakeTasksSchema } from "./migrations/054-close-stale-wake-tas import { taskOriginSchema } from "./migrations/055-task-origin"; import { kimiProvidersSchema } from "./migrations/056-kimi-providers"; import { kimiCodeProviderSchema } from "./migrations/057-kimi-code-provider"; +import { integrationKeysSchema } from "./migrations/058-integration-keys"; interface Migration { version: number; @@ -121,6 +122,7 @@ const MIGRATIONS: Migration[] = [ { version: 55, name: "task_origin", sql: taskOriginSchema }, { version: 56, name: "kimi_providers", sql: kimiProvidersSchema }, { version: 57, name: "kimi_code_provider", sql: kimiCodeProviderSchema }, + { version: 58, name: "integration_keys", sql: integrationKeysSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/agent-run-loop.ts b/src/main/services/agent-run-loop.ts index 95aaeac3..2cc87f82 100644 --- a/src/main/services/agent-run-loop.ts +++ b/src/main/services/agent-run-loop.ts @@ -27,7 +27,7 @@ export interface AgentRunLoopResult { } export interface AgentRunLoopDeps { - providers: Pick; + providers: Pick; tools: AgentToolDeps; /** Called after each iteration's tool calls have written their effects. */ onToolResults?: () => void; @@ -72,7 +72,7 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn toolCallCount += 1; let output: string; if (isComposioTool(call.name)) { - output = await executeComposioTool(deps.tools.db, input.orgId, input.toolContext.agent.id, call); + output = await executeComposioTool(deps.tools.db, deps.providers.integrationKeys, input.orgId, input.toolContext.agent.id, call); } else if (isWorkspaceTool(call.name)) { output = await executeWorkspaceTool(input.toolContext, call); } else if (isWebTool(call.name)) { diff --git a/src/main/services/composio/composio-connection-service.ts b/src/main/services/composio/composio-connection-service.ts index fc129d08..655c14ad 100644 --- a/src/main/services/composio/composio-connection-service.ts +++ b/src/main/services/composio/composio-connection-service.ts @@ -1,14 +1,14 @@ import type { DatabaseSync } from "node:sqlite"; import type { ConnectorCatalogEntry, ConnectComposioToolkitResult } from "../../../shared/commands"; import { ComposioClient } from "./composio-client"; -import { composioApiKey } from "./composio-credentials"; +import { IntegrationKeyStore } from "../integration-key-store"; import { getComposioUserId } from "./composio-tool-bridge"; import { connectorDisplayName, connectorLogoUrl } from "./connector-brands"; import { normalizeSlug } from "./connector-grants"; const CATALOG_TTL_MS = 10 * 60 * 1000; -let catalogCache: { fetchedAt: number; entries: ConnectorCatalogEntry[] } | null = null; +let catalogCache: { fetchedAt: number; keys: string; entries: ConnectorCatalogEntry[] } | null = null; /** * Company-level Composio connections. The catalog mirrors the auth configs @@ -25,7 +25,10 @@ let catalogCache: { fetchedAt: number; entries: ConnectorCatalogEntry[] } | null export class ComposioConnectionService { private static queues = new Map>(); - constructor(private readonly db: DatabaseSync) {} + constructor( + private readonly db: DatabaseSync, + private readonly keys: IntegrationKeyStore = new IntegrationKeyStore(db), + ) {} listCatalog(): Promise { return this.catalog(this.requireClient()); @@ -149,13 +152,16 @@ export class ComposioConnectionService { } private requireClient(): ComposioClient { - const apiKey = composioApiKey(); - if (!apiKey) throw new Error("The Composio integration is not configured in this build."); + const apiKey = this.keys.read("composio"); + if (!apiKey) throw new Error("No Composio API key is saved. Add one in Settings > Integrations."); return new ComposioClient(apiKey); } private async catalog(client: ComposioClient): Promise { - if (catalogCache && Date.now() - catalogCache.fetchedAt < CATALOG_TTL_MS) { + const logoDevToken = this.keys.read("logo_dev"); + const keys = `${this.keys.read("composio")} +${logoDevToken}`; + if (catalogCache && catalogCache.keys === keys && Date.now() - catalogCache.fetchedAt < CATALOG_TTL_MS) { return catalogCache.entries; } const configs = await client.listAuthConfigs(); @@ -167,11 +173,11 @@ export class ComposioConnectionService { slug: config.toolkitSlug, name: connectorDisplayName(config.toolkitSlug), authScheme: config.authScheme, - logoUrl: connectorLogoUrl(config.toolkitSlug), + logoUrl: connectorLogoUrl(config.toolkitSlug, logoDevToken), }); } const entries = [...bySlug.values()].sort((a, b) => a.name.localeCompare(b.name)); - catalogCache = { fetchedAt: Date.now(), entries }; + catalogCache = { fetchedAt: Date.now(), keys, entries }; return entries; } } diff --git a/src/main/services/composio/composio-credentials.ts b/src/main/services/composio/composio-credentials.ts deleted file mode 100644 index 4b3004e9..00000000 --- a/src/main/services/composio/composio-credentials.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * The Composio integration key is hardcoded on purpose: the repo is private - * and every checkout must build a configured app, with no credential file or - * build-time injection involved. Rotating the key = editing this constant. - */ -const COMPOSIO_API_KEY = "***REMOVED***"; - -export function composioApiKey(): string | null { - return normalizeComposioApiKey(COMPOSIO_API_KEY); -} - -/** Null for blank input so callers surface "not configured" explicitly. */ -export function normalizeComposioApiKey(raw: string): string | null { - const key = raw.trim(); - return key ? key : null; -} diff --git a/src/main/services/composio/composio-tool-bridge.ts b/src/main/services/composio/composio-tool-bridge.ts index 113d4a42..e6d837f7 100644 --- a/src/main/services/composio/composio-tool-bridge.ts +++ b/src/main/services/composio/composio-tool-bridge.ts @@ -1,7 +1,7 @@ import type { DatabaseSync } from "node:sqlite"; import type { ToolDefinition, ToolCall } from "../provider-tool-turn"; import { ComposioClient, type ComposioToolSchema } from "./composio-client"; -import { composioApiKey } from "./composio-credentials"; +import type { IntegrationKeyStore } from "../integration-key-store"; import { ConnectorGrantsService } from "./connector-grants"; const COMPOSIO_TOOL_PREFIX = "composio_"; @@ -52,14 +52,16 @@ export function composioSchemaToToolDefinition(schema: ComposioToolSchema): Tool * intersection of the company's active connections and the toolkits the CEO * granted this agent. */ -export async function loadComposioTools(db: DatabaseSync, orgId: string, agentId: string): Promise { - const apiKey = composioApiKey(); - if (!apiKey) return []; +export async function loadComposioTools(db: DatabaseSync, keys: IntegrationKeyStore, orgId: string, agentId: string): Promise { const grants = new ConnectorGrantsService(db); const toolkits = grants.toolkitsForAgent(orgId, agentId); if (toolkits.length === 0) return []; + const apiKey = keys.read("composio"); + if (!apiKey) { + throw new Error(`Agent ${agentId} holds connector grants (${toolkits.join(", ")}) but no Composio API key is saved in Settings > Integrations.`); + } const toolkitsKey = toolkits.join(","); - const cacheKey = `${orgId}:${toolkitsKey}`; + const cacheKey = `${apiKey}:${orgId}:${toolkitsKey}`; const cached = toolCache.get(cacheKey); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { return cached.tools; @@ -80,12 +82,13 @@ export async function loadComposioTools(db: DatabaseSync, orgId: string, agentId export async function executeComposioTool( db: DatabaseSync, + keys: IntegrationKeyStore, orgId: string, agentId: string, call: ToolCall, ): Promise { - const apiKey = composioApiKey(); - if (!apiKey) return "ERROR: The Composio integration is not configured in this build."; + const apiKey = keys.read("composio"); + if (!apiKey) return "ERROR: No Composio API key is saved. Ask the board to add one in Settings > Integrations."; const toolSlug = originalToolName(call.name); const toolkit = toolkitForTool(toolSlug); const grants = new ConnectorGrantsService(db); diff --git a/src/main/services/composio/connector-brands.ts b/src/main/services/composio/connector-brands.ts index b3150c0e..effc88a2 100644 --- a/src/main/services/composio/connector-brands.ts +++ b/src/main/services/composio/connector-brands.ts @@ -1,11 +1,4 @@ -/** - * Display names and logo domains for the toolkits configured in the Composio - * project. Logos come from img.logo.dev keyed by company domain (publishable - * token — safe to embed). A slug outside this table renders with a prettified - * name and the letter tile until its brand row is added here. - */ -const LOGO_DEV_TOKEN = "***REMOVED***"; - +/** Toolkit display names and logo.dev domains. */ const BRANDS: Record = { airtable: { name: "Airtable", domain: "airtable.com" }, attio: { name: "Attio", domain: "attio.com" }, @@ -60,8 +53,8 @@ export function connectorDisplayName(slug: string): string { .join(" "); } -export function connectorLogoUrl(slug: string): string | null { +export function connectorLogoUrl(slug: string, logoDevToken: string | null): string | null { const brand = BRANDS[slug]; - if (!brand) return null; - return `https://img.logo.dev/${brand.domain}?token=${LOGO_DEV_TOKEN}`; + if (!brand || !logoDevToken) return null; + return `https://img.logo.dev/${brand.domain}?token=${encodeURIComponent(logoDevToken)}`; } diff --git a/src/main/services/integration-key-store.ts b/src/main/services/integration-key-store.ts new file mode 100644 index 00000000..7fdd6e47 --- /dev/null +++ b/src/main/services/integration-key-store.ts @@ -0,0 +1,56 @@ +import type { DatabaseSync } from "node:sqlite"; +import { + INTEGRATION_KEY_NAMES, + type IntegrationKeyName, + type IntegrationKeyStatus, +} from "../../shared/commands"; +import type { ProviderSecretVault } from "./auth-broker-service"; +import { SecretVault } from "./secret-vault"; + +/** Operator-entered third-party keys, encrypted. */ +export class IntegrationKeyStore { + constructor( + private readonly db: DatabaseSync, + private readonly vault: ProviderSecretVault = new SecretVault(), + ) {} + + read(name: IntegrationKeyName): string | null { + const row = this.db.prepare("select ciphertext from integration_keys where name = ?") + .get(name) as { ciphertext: Uint8Array } | undefined; + return row ? this.vault.decryptSecret(Buffer.from(row.ciphertext)) : null; + } + + write(name: IntegrationKeyName, value: string): void { + requireKnownName(name); + const key = value.trim(); + if (!key) throw new Error(`The ${name} key cannot be blank.`); + this.db.prepare(` + insert into integration_keys(name, ciphertext, updated_at) values (?, ?, ?) + on conflict(name) do update set ciphertext = excluded.ciphertext, updated_at = excluded.updated_at + `).run(name, this.vault.encryptSecret(key), new Date().toISOString()); + console.info(`[integration-keys] ${name} saved`); + } + + clear(name: IntegrationKeyName): void { + requireKnownName(name); + this.db.prepare("delete from integration_keys where name = ?").run(name); + console.info(`[integration-keys] ${name} cleared`); + } + + statuses(): IntegrationKeyStatus[] { + const rows = this.db.prepare("select name, updated_at from integration_keys") + .all() as Array<{ name: string; updated_at: string }>; + const saved = new Map(rows.map((row) => [row.name, row.updated_at])); + return INTEGRATION_KEY_NAMES.map((name) => ({ + name, + configured: saved.has(name), + updatedAt: saved.get(name) ?? null, + })); + } +} + +function requireKnownName(name: string): asserts name is IntegrationKeyName { + if (!(INTEGRATION_KEY_NAMES as readonly string[]).includes(name)) { + throw new Error(`Unknown integration key "${name}".`); + } +} diff --git a/src/main/services/oauth/google-antigravity-flow.ts b/src/main/services/oauth/google-antigravity-flow.ts index ef734f2e..2e2dcddd 100644 --- a/src/main/services/oauth/google-antigravity-flow.ts +++ b/src/main/services/oauth/google-antigravity-flow.ts @@ -9,7 +9,6 @@ import { generatePkce } from "./pkce"; import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types"; const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; -const CLIENT_SECRET_ENV = "AGENTCOMPANY_GOOGLE_ANTIGRAVITY_CLIENT_SECRET"; const CALLBACK_PORT = 51121; const CALLBACK_PATH = "/oauth-callback"; const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; @@ -24,9 +23,8 @@ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_URL = "https://oauth2.googleapis.com/token"; const CODE_ASSIST_ENDPOINTS = ["https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com"]; -export function resolveAntigravityClientSecret(env: NodeJS.ProcessEnv = process.env): string { - const secret = env[CLIENT_SECRET_ENV]?.trim(); - if (!secret) throw new Error(`${CLIENT_SECRET_ENV} is required for Google Antigravity OAuth.`); +export function requireAntigravityClientSecret(secret: string | null | undefined): string { + if (!secret) throw new Error("Google Antigravity sign-in needs its OAuth client secret. Add it in Settings > Integrations."); return secret; } @@ -45,13 +43,13 @@ function buildAuthorizeUrl(challenge: string, state: string): string { return `${AUTH_URL}?${params.toString()}`; } -async function exchangeCode(code: string, verifier: string): Promise<{ access: string; refresh: string; expires: number }> { +async function exchangeCode(code: string, verifier: string, clientSecret: string): Promise<{ access: string; refresh: string; expires: number }> { const response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: CLIENT_ID, - client_secret: resolveAntigravityClientSecret(), + client_secret: clientSecret, code, grant_type: "authorization_code", redirect_uri: REDIRECT_URI, @@ -105,13 +103,13 @@ async function discoverProject(accessToken: string): Promise { } /** Refresh an expired Antigravity access token, retaining the project id. */ -export async function refreshAntigravity(refreshToken: string, projectId: string): Promise { +export async function refreshAntigravity(refreshToken: string, projectId: string, clientSecret: string): Promise { const response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: CLIENT_ID, - client_secret: resolveAntigravityClientSecret(), + client_secret: clientSecret, refresh_token: refreshToken, grant_type: "refresh_token", }), @@ -129,13 +127,14 @@ export async function refreshAntigravity(refreshToken: string, projectId: string export const antigravityFlow: OAuthFlow = { providerKey: "google-antigravity", async login(callbacks: OAuthLoginCallbacks): Promise { + const clientSecret = requireAntigravityClientSecret(callbacks.clientSecret); const { verifier, challenge } = generatePkce(); const server = await startOAuthCallbackServer({ port: CALLBACK_PORT, path: CALLBACK_PATH, signal: callbacks.signal }); try { callbacks.onAuth({ url: buildAuthorizeUrl(challenge, verifier), instructions: "Complete the Google sign-in in your browser." }); const result = await server.waitForCode(); if (result.state !== verifier) throw new Error("OAuth state mismatch."); - const tokens = await exchangeCode(result.code, verifier); + const tokens = await exchangeCode(result.code, verifier, clientSecret); callbacks.onProgress?.("Resolving project"); const projectId = await discoverProject(tokens.access); return { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, projectId }; diff --git a/src/main/services/oauth/oauth-types.ts b/src/main/services/oauth/oauth-types.ts index 011c585b..d5a95add 100644 --- a/src/main/services/oauth/oauth-types.ts +++ b/src/main/services/oauth/oauth-types.ts @@ -22,6 +22,8 @@ export interface OAuthLoginCallbacks { signal?: AbortSignal; /** GitHub Enterprise domain for Copilot; null/empty means github.com. */ enterpriseDomain?: string | null; + /** Client secret for flows that need one. */ + clientSecret?: string | null; } export interface OAuthFlow { diff --git a/src/main/services/provider-oauth-service.ts b/src/main/services/provider-oauth-service.ts index 911683f1..0c5fe419 100644 --- a/src/main/services/provider-oauth-service.ts +++ b/src/main/services/provider-oauth-service.ts @@ -1,4 +1,5 @@ import { getOAuthFlow } from "./oauth/oauth-flows"; +import type { IntegrationKeyStore } from "./integration-key-store"; export interface ProviderOAuthHooks { onAuth: (info: { url: string; instructions?: string }) => void; @@ -14,6 +15,8 @@ export interface ProviderOAuthHooks { export class ProviderOAuthService { private readonly controllers = new Map(); + constructor(private readonly keys: IntegrationKeyStore) {} + async run(input: { providerKey: string; enterpriseDomain?: string | null }, hooks: ProviderOAuthHooks): Promise { const flow = getOAuthFlow(input.providerKey); if (this.controllers.has(input.providerKey)) { @@ -27,6 +30,7 @@ export class ProviderOAuthService { onProgress: hooks.onProgress, signal: controller.signal, enterpriseDomain: input.enterpriseDomain ?? null, + clientSecret: input.providerKey === "google-antigravity" ? this.keys.read("google_antigravity_client_secret") : null, }); return JSON.stringify(credentials); } finally { diff --git a/src/main/services/provider-response-runner.ts b/src/main/services/provider-response-runner.ts index 3b183464..2703afdd 100644 --- a/src/main/services/provider-response-runner.ts +++ b/src/main/services/provider-response-runner.ts @@ -1,3 +1,4 @@ +import type { IntegrationKeyStore } from "./integration-key-store"; import type { DatabaseSync } from "node:sqlite"; import type { CancelProviderResponseInput, CreateProviderResponseInput, StreamProviderResponseInput } from "../../shared/commands"; import { FEATURE_FLAG_KEY, PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE, type ProviderExecutionMode } from "../../shared/domain"; @@ -35,6 +36,7 @@ export interface ProviderResponseRunnerDeps { adapters: ProviderAdapterRegistry; audit: AuditService; authBroker: AuthBrokerService; + integrationKeys: IntegrationKeyStore; operations: OperationsService; rateLimitRegistry: ProviderRateLimitRegistry; responseObserver: ProviderResponseObserver; @@ -114,7 +116,7 @@ export class ProviderResponseRunner { if (!model) throw new Error("Provider model is required."); const owner: ProviderRow & { model: string } = { ...provider, model }; const turn = this.deps.toolTurn ?? selectToolTurn(provider.provider_key); - const credential = await resolveTurnCredential({ authBroker: this.deps.authBroker, orgId: input.orgId, provider }); + const credential = await resolveTurnCredential({ authBroker: this.deps.authBroker, integrationKeys: this.deps.integrationKeys, orgId: input.orgId, provider }); const promptChars = JSON.stringify(input.messages).length; const requestId = this.deps.requestStore.create({ orgId: input.orgId, diff --git a/src/main/services/provider-service.ts b/src/main/services/provider-service.ts index 88b7ba31..7c790249 100644 --- a/src/main/services/provider-service.ts +++ b/src/main/services/provider-service.ts @@ -7,6 +7,7 @@ import type { ProviderStreamRendererEvent } from "../../shared/provider-request- import { AuditService } from "./audit-service"; import { AuthBrokerService, type ProviderSecretVault } from "./auth-broker-service"; import { OperationsService } from "./operations-service"; +import { IntegrationKeyStore } from "./integration-key-store"; import { SecretVault } from "./secret-vault"; import { providerAdapters, providerRateLimitStateFromError, type ProviderAdapterRegistry } from "./provider-adapters"; import { ProviderBackgroundService } from "./provider-background-service"; @@ -44,6 +45,7 @@ export class ProviderService { private readonly requestStore: ProviderRequestStore; private readonly background: ProviderBackgroundService; private readonly runner: ProviderResponseRunner; + readonly integrationKeys: IntegrationKeyStore; constructor( private readonly db: DatabaseSync, @@ -54,6 +56,7 @@ export class ProviderService { ) { this.audit = new AuditService(db); this.authBroker = new AuthBrokerService(db, vault); + this.integrationKeys = new IntegrationKeyStore(db, vault); this.operations = new OperationsService(db); this.rateLimitRegistry = new ProviderRateLimitRegistry(db); this.requestStore = new ProviderRequestStore(db); @@ -64,6 +67,7 @@ export class ProviderService { adapters, audit: this.audit, authBroker: this.authBroker, + integrationKeys: this.integrationKeys, operations: this.operations, rateLimitRegistry: this.rateLimitRegistry, responseObserver: this.responseObserver, diff --git a/src/main/services/provider-tool-turns/credential-resolver.ts b/src/main/services/provider-tool-turns/credential-resolver.ts index cebb7317..9c954ff9 100644 --- a/src/main/services/provider-tool-turns/credential-resolver.ts +++ b/src/main/services/provider-tool-turns/credential-resolver.ts @@ -26,7 +26,8 @@ import type { TurnCredential } from "../provider-tool-turn"; import type { OAuthCredentials } from "../oauth/oauth-types"; import { refreshCodex } from "../oauth/openai-codex-flow"; import { refreshClaudePro } from "../oauth/anthropic-claude-flow"; -import { refreshAntigravity } from "../oauth/google-antigravity-flow"; +import { refreshAntigravity, requireAntigravityClientSecret } from "../oauth/google-antigravity-flow"; +import type { IntegrationKeyStore } from "../integration-key-store"; import { refreshGitHubCopilotToken } from "../oauth/github-copilot-flow"; import { refreshKimiCode } from "../oauth/kimi-code-flow"; @@ -53,7 +54,7 @@ function parseStoredOAuth(secret: string): StoredOAuth { return creds; } -function refreshOAuth(providerKey: string, creds: StoredOAuth): Promise { +function refreshOAuth(providerKey: string, creds: StoredOAuth, keys: IntegrationKeyStore): Promise { switch (providerKey) { case "gpt-account": return refreshCodex(creds.refresh); @@ -61,7 +62,7 @@ function refreshOAuth(providerKey: string, creds: StoredOAuth): Promise { @@ -123,7 +125,7 @@ export async function resolveTurnCredential(deps: { const creds = parseStoredOAuth(secret); if (Date.now() < creds.expires) return toTurnCredential(creds); if (!deps.provider.credential_id) throw new Error("Provider credential id is missing; reconnect the provider."); - const refreshed = await refreshOAuth(deps.provider.provider_key, creds); + const refreshed = await refreshOAuth(deps.provider.provider_key, creds, deps.integrationKeys); deps.authBroker.updateProviderSecret({ orgId: deps.orgId, providerKey: deps.provider.provider_key, diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index 45d8bed2..fb1bad5e 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -1,6 +1,7 @@ import type { DatabaseSync, StatementSync } from "node:sqlite"; import type { AppSnapshot } from "../../shared/domain"; import { PROVIDER_CATALOG } from "../../shared/provider-catalog"; +import { IntegrationKeyStore } from "./integration-key-store"; import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain"; type Row = Record; @@ -87,6 +88,7 @@ export class SnapshotService { activeOrgId, uiPreferences: this.resolveUiPreferences(), autonomousPaused: this.resolveAutonomousPaused(), + integrationKeys: new IntegrationKeyStore(this.db).statuses(), organizations, employees: list(this.db, "employees", "created_at asc"), agents, diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index 088d160f..69e577bf 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -151,7 +151,7 @@ export class TaskExecutionService { .toolkitsForAgent(orgId, agent.id) .map(connectorDisplayName); const language = this.appLanguage(); - const externalTools = await loadComposioTools(this.db, orgId, agent.id); + const externalTools = await loadComposioTools(this.db, this.providers.integrationKeys, orgId, agent.id); // Watermark for the reply-delivery check below: anything the agent posts // into the source thread from here on counts as its reply. const runStartedAt = new Date().toISOString(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 103663b9..fbea2950 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,8 @@ const api: DesktopApi = { updateAgent: (input) => invoke(IPC_CHANNELS.updateAgent, input), updateEmployeeProfile: (input) => invoke(IPC_CHANNELS.updateEmployeeProfile, input), setAutonomousPaused: (input) => invoke(IPC_CHANNELS.setAutonomousPaused, input), + setIntegrationKey: (input) => invoke(IPC_CHANNELS.setIntegrationKey, input), + clearIntegrationKey: (input) => invoke(IPC_CHANNELS.clearIntegrationKey, input), createTask: (input) => invoke(IPC_CHANNELS.createTask, input), updateTask: (input) => invoke(IPC_CHANNELS.updateTask, input), deleteTask: (input) => invoke(IPC_CHANNELS.deleteTask, input), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index db805cb0..cd7611cc 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -26,6 +26,19 @@ export const zh: Record = { "Add workspace": "添加工作区", "Advance": "推进", "Advanced": "高级", + "Integrations": "集成密钥", + "Keys are stored on this device, encrypted by the operating system, and shared by every company.": "密钥保存在本机,由操作系统加密,所有公司共用。", + "Composio API key": "Composio API 密钥", + "Powers the Connectors page and agent connector tools. Create one at platform.composio.dev.": "用于连接器页面和 Agent 的连接器工具。在 platform.composio.dev 创建。", + "logo.dev publishable key": "logo.dev 公开密钥", + "Shows brand logos for connectors. Create one at logo.dev.": "为连接器显示品牌图标。在 logo.dev 创建。", + "Google Antigravity OAuth client secret": "Google Antigravity OAuth 客户端密钥", + "Needed to sign in to and refresh the Google Antigravity provider.": "登录和续期 Google Antigravity 时需要。", + "Saved": "已保存", + "Not set": "未设置", + "Replace key": "替换密钥", + "Key": "密钥", + "Remove": "移除", "Agent": "智能体", "Agent could not be reached": "无法连接到智能体", "Agent runs": "智能体运行", diff --git a/src/renderer/src/pages/SettingsPage.tsx b/src/renderer/src/pages/SettingsPage.tsx index 7200d2a5..7643a1cd 100644 --- a/src/renderer/src/pages/SettingsPage.tsx +++ b/src/renderer/src/pages/SettingsPage.tsx @@ -2,11 +2,12 @@ import { useMemo, useState } from "react"; import { ProvidersPanel } from "./settings/ProvidersPanel"; import { WorkspacePanel } from "./settings/WorkspacePanel"; import { AdvancedPanel } from "./settings/AdvancedPanel"; +import { IntegrationKeysPanel } from "./settings/IntegrationKeysPanel"; import { t } from "../i18n/i18n"; import type { PageProps } from "./types"; import { activeOrgId, requireActiveEmployee, requireActiveOrganization } from "./types"; -type SettingsTab = "general" | "providers" | "advanced"; +type SettingsTab = "general" | "providers" | "integrations" | "advanced"; interface NavSpec { id: SettingsTab; @@ -16,6 +17,7 @@ interface NavSpec { const TABS: NavSpec[] = [ { id: "general", label: t("General") }, { id: "providers", label: t("Providers") }, + { id: "integrations", label: t("Integrations") }, { id: "advanced", label: t("Advanced") }, ]; @@ -56,6 +58,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) { actions={actions} /> )} + {tab === "integrations" && } {tab === "advanced" && ( = { + composio: { + label: t("Composio API key"), + hint: t("Powers the Connectors page and agent connector tools. Create one at platform.composio.dev."), + placeholder: "ak_…", + }, + logo_dev: { + label: t("logo.dev publishable key"), + hint: t("Shows brand logos for connectors. Create one at logo.dev."), + placeholder: "pk_…", + }, + google_antigravity_client_secret: { + label: t("Google Antigravity OAuth client secret"), + hint: t("Needed to sign in to and refresh the Google Antigravity provider."), + placeholder: "GOCSPX-…", + }, +}; + +export function IntegrationKeysPanel({ keys, actions }: { keys: IntegrationKeyStatus[]; actions: SnapshotActions }) { + return ( +
+

{t("Integrations")}

+

+ {t("Keys are stored on this device, encrypted by the operating system, and shared by every company.")} +

+ {keys.map((status) => )} +
+ ); +} + +function KeyRow({ status, actions }: { status: IntegrationKeyStatus; actions: SnapshotActions }) { + const copy = KEY_COPY[status.name]; + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const perform = async (operation: () => Promise) => { + setBusy(true); + setError(null); + try { + await operation(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + setBusy(false); + } + }; + + const save = (event: FormEvent) => { + event.preventDefault(); + const form = event.currentTarget; + const value = String(new FormData(form).get("value") ?? ""); + void perform(async () => { + await actions.run(() => window.agentCompany.setIntegrationKey({ name: status.name, value })); + form.reset(); + }); + }; + + return ( +
+
+ {copy.label} + {status.configured ? {t("Saved")} : {t("Not set")}} +
+

{copy.hint}

+
+
+ + + +
+ + {status.configured && ( + + )} +
+ {error &&
{error}
} +
+ ); +} diff --git a/src/shared/commands/index.ts b/src/shared/commands/index.ts index 67ccfd39..a86a56f1 100644 --- a/src/shared/commands/index.ts +++ b/src/shared/commands/index.ts @@ -8,3 +8,4 @@ export * from "./governance"; export * from "./collaboration"; export * from "./onboarding"; export * from "./composio"; +export * from "./integration-keys"; diff --git a/src/shared/commands/integration-keys.ts b/src/shared/commands/integration-keys.ts new file mode 100644 index 00000000..d143d001 --- /dev/null +++ b/src/shared/commands/integration-keys.ts @@ -0,0 +1,18 @@ +export const INTEGRATION_KEY_NAMES = ["composio", "logo_dev", "google_antigravity_client_secret"] as const; + +export type IntegrationKeyName = (typeof INTEGRATION_KEY_NAMES)[number]; + +export interface IntegrationKeyStatus { + name: IntegrationKeyName; + configured: boolean; + updatedAt: string | null; +} + +export interface SetIntegrationKeyInput { + name: IntegrationKeyName; + value: string; +} + +export interface ClearIntegrationKeyInput { + name: IntegrationKeyName; +} diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 3a55973f..31011742 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -7,6 +7,8 @@ import type { CancelProviderResponseInput, CreateAgentInput, SetAutonomousPausedInput, + SetIntegrationKeyInput, + ClearIntegrationKeyInput, UpdateAgentInput, UpdateEmployeeProfileInput, CreateApprovalInput, @@ -111,6 +113,8 @@ export interface DesktopApi { updateAgent(input: UpdateAgentInput): Promise>; updateEmployeeProfile(input: UpdateEmployeeProfileInput): Promise>; setAutonomousPaused(input: SetAutonomousPausedInput): Promise>; + setIntegrationKey(input: SetIntegrationKeyInput): Promise>; + clearIntegrationKey(input: ClearIntegrationKeyInput): Promise>; createTask(input: CreateTaskInput): Promise>; updateTask(input: UpdateTaskInput): Promise>; deleteTask(input: DeleteTaskInput): Promise>; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 2fc0b737..b182984d 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -9,6 +9,8 @@ export const IPC_CHANNELS = { updateAgent: "agentcompany:update-agent", updateEmployeeProfile: "agentcompany:update-employee-profile", setAutonomousPaused: "agentcompany:set-autonomous-paused", + setIntegrationKey: "agentcompany:set-integration-key", + clearIntegrationKey: "agentcompany:clear-integration-key", createTask: "agentcompany:create-task", updateTask: "agentcompany:update-task", deleteTask: "agentcompany:delete-task", diff --git a/src/shared/snapshot-domain.ts b/src/shared/snapshot-domain.ts index cb220a94..b5621dd6 100644 --- a/src/shared/snapshot-domain.ts +++ b/src/shared/snapshot-domain.ts @@ -1,4 +1,5 @@ import type { AuditReplayRunRecord } from "./audit-domain"; +import type { IntegrationKeyStatus } from "./commands/integration-keys"; import type { BoardInterventionRecord as BoardInterventionRecordModel } from "./board-domain"; import type { ChatAttachmentRecord, @@ -71,6 +72,7 @@ export interface AppSnapshot { uiPreferences: UiPreferences; /** True while the operator has paused the autonomous run engine. */ autonomousPaused: boolean; + integrationKeys: IntegrationKeyStatus[]; organizations: OrganizationRecord[]; employees: EmployeeRecord[]; agents: AgentRecord[]; diff --git a/tests/unit/agent-instructions.test.ts b/tests/unit/agent-instructions.test.ts index 2e640620..664af15e 100644 --- a/tests/unit/agent-instructions.test.ts +++ b/tests/unit/agent-instructions.test.ts @@ -125,6 +125,7 @@ describe("agent instruction wiring", () => { }); const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; providers.saveCredential({ orgId, providerId, secret: "sk-test", model: "gpt-5.4" }); + providers.integrationKeys.write("composio", "ak_test"); agentId = organization.createAgent({ orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: null, diff --git a/tests/unit/agent-run-loop.test.ts b/tests/unit/agent-run-loop.test.ts index afa75e48..c566d21d 100644 --- a/tests/unit/agent-run-loop.test.ts +++ b/tests/unit/agent-run-loop.test.ts @@ -3,7 +3,9 @@ import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; +import type { DatabaseSync } from "node:sqlite"; import { DatabaseConnection } from "../../src/main/database/connection"; +import { IntegrationKeyStore } from "../../src/main/services/integration-key-store"; import { OrganizationService } from "../../src/main/services/organization-service"; import { ChatService } from "../../src/main/services/chat-service"; import { runAgentLoop } from "../../src/main/services/agent-run-loop"; @@ -16,9 +18,10 @@ interface ScriptedTurn { toolCalls: ToolCall[]; } -function fakeProvider(script: ScriptedTurn[]) { +function fakeProvider(script: ScriptedTurn[], db: DatabaseSync) { let index = 0; return { + integrationKeys: new IntegrationKeyStore(db), runToolTurn: async (): Promise => { const turn = script[index] ?? { text: "done", toolCalls: [] }; index += 1; @@ -85,7 +88,7 @@ describe("agent run loop (real tool dispatch)", () => { let toolResultNotifications = 0; const result = await runAgentLoop( { - providers: fakeProvider(script), + providers: fakeProvider(script, connection.db), tools: { db: connection.db, organization, chat: new ChatService(connection.db) }, onToolResults: () => { toolResultNotifications += 1; }, }, @@ -143,7 +146,7 @@ describe("agent run loop (real tool dispatch)", () => { { text: "done", toolCalls: [] }, ]; const result = await runAgentLoop( - { providers: fakeProvider(script), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } }, + { providers: fakeProvider(script, connection.db), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } }, { orgId, providerId, workspaceId: null, instructions: "i", initialUserTurn: "u", toolContext: ctx }, ); // The tool returns an error string to the model; nothing is written outside the root. @@ -180,7 +183,7 @@ describe("agent run loop (real tool dispatch)", () => { ]; await runAgentLoop( - { providers: fakeProvider(script), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } }, + { providers: fakeProvider(script, connection.db), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } }, { orgId, providerId, workspaceId: null, instructions: "i", initialUserTurn: "u", toolContext: ctx }, ); @@ -226,7 +229,7 @@ describe("agent run loop (real tool dispatch)", () => { taskProjectId: null, taskGoalId: null, workspace: null, }; await expect(runAgentLoop( - { providers: fakeProvider([{ text: "", toolCalls: [] }]), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } }, + { providers: fakeProvider([{ text: "", toolCalls: [] }], connection.db), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } }, { orgId, providerId, workspaceId: null, instructions: "i", initialUserTurn: "u", toolContext: ctx }, )).rejects.toThrow(/empty turn/); connection.close(); diff --git a/tests/unit/composio-credentials.test.ts b/tests/unit/composio-credentials.test.ts deleted file mode 100644 index 11644c18..00000000 --- a/tests/unit/composio-credentials.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { composioApiKey, normalizeComposioApiKey } from "../../src/main/services/composio/composio-credentials"; - -describe("composio credential resolution", () => { - test("trims the embedded key", () => { - expect(normalizeComposioApiKey(" ak_live ")).toBe("ak_live"); - }); - - test("a blank key resolves to null so callers report the integration as unconfigured", () => { - expect(normalizeComposioApiKey("")).toBeNull(); - expect(normalizeComposioApiKey(" ")).toBeNull(); - }); - - test("the hardcoded key resolves in every build", () => { - expect(composioApiKey()).toBe("***REMOVED***"); - }); -}); diff --git a/tests/unit/composio-tool-bridge.test.ts b/tests/unit/composio-tool-bridge.test.ts new file mode 100644 index 00000000..e64d3b15 --- /dev/null +++ b/tests/unit/composio-tool-bridge.test.ts @@ -0,0 +1,48 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { loadComposioTools } from "../../src/main/services/composio/composio-tool-bridge"; +import { IntegrationKeyStore } from "../../src/main/services/integration-key-store"; + +const plainVault = { + encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), + decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"), +}; + +function ceoHarness() { + const connection = new DatabaseConnection(join(tmpdir(), `ac-composio-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let ceoId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + ceoId = organization.createAgent({ + orgId, name: "CEO", role: "CEO", department: "Executive", + modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null, roleLevel: "c_suite", + }); + }); + return { connection, orgId, ceoId }; +} + +describe("composio tool loading", () => { + test("an agent without connector access loads no tools and needs no key", async () => { + const { connection, orgId, ceoId } = ceoHarness(); + await expect(loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).resolves.toEqual([]); + connection.close(); + }); + + test("an agent with connector access fails loudly when the Composio key is missing", async () => { + const { connection, orgId, ceoId } = ceoHarness(); + const now = new Date().toISOString(); + connection.db.prepare(` + insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) + values ('ca_1', ?, 'gmail', 'Gmail', 'ac_1', 'ACTIVE', ?, ?) + `).run(orgId, now, now); + await expect(loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).rejects.toThrow("no Composio API key is saved"); + connection.close(); + }); +}); diff --git a/tests/unit/connector-brands.test.ts b/tests/unit/connector-brands.test.ts index 8eaab8af..c000180d 100644 --- a/tests/unit/connector-brands.test.ts +++ b/tests/unit/connector-brands.test.ts @@ -2,17 +2,21 @@ import { describe, expect, test } from "vitest"; import { connectorDisplayName, connectorLogoUrl } from "../../src/main/services/composio/connector-brands"; describe("connector brands", () => { - test("known toolkits resolve a display name and a logo.dev URL", () => { + test("known toolkits resolve a display name and a logo.dev URL with the saved token", () => { expect(connectorDisplayName("google_analytics")).toBe("Google Analytics"); - expect(connectorLogoUrl("google_analytics")).toBe( - "https://img.logo.dev/analytics.google.com?token=***REMOVED***", + expect(connectorLogoUrl("google_analytics", "pk_test")).toBe( + "https://img.logo.dev/analytics.google.com?token=pk_test", ); expect(connectorDisplayName("share_point")).toBe("SharePoint"); - expect(connectorLogoUrl("stripe")).toContain("img.logo.dev/stripe.com"); + expect(connectorLogoUrl("stripe", "pk_a&b")).toBe("https://img.logo.dev/stripe.com?token=pk_a%26b"); + }); + + test("no saved token means no logo URL", () => { + expect(connectorLogoUrl("stripe", null)).toBeNull(); }); test("unknown toolkits prettify the slug and carry no logo", () => { expect(connectorDisplayName("acme_billing")).toBe("Acme Billing"); - expect(connectorLogoUrl("acme_billing")).toBeNull(); + expect(connectorLogoUrl("acme_billing", "pk_test")).toBeNull(); }); }); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 05c4d148..4301c3f8 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]); + 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]); connection.close(); }); diff --git a/tests/unit/goal-review-service.test.ts b/tests/unit/goal-review-service.test.ts index 31958caf..ac913207 100644 --- a/tests/unit/goal-review-service.test.ts +++ b/tests/unit/goal-review-service.test.ts @@ -54,6 +54,10 @@ function addDoneTask(h: Harness, title: string, origin: string | null, updatedAt return taskId; } +function hoursFromNow(hours: number): string { + return new Date(Date.now() + hours * 3_600_000).toISOString(); +} + function reviews(h: Harness): Array<{ id: string; status: string }> { return h.connection.db.prepare("select id, status from tasks where goal_id = ? and origin = 'goal_review' order by created_at") .all(h.goalId) as Array<{ id: string; status: string }>; @@ -84,15 +88,15 @@ describe("goal review service", () => { const [reviewId] = h.connection.transaction(() => spawnGoalReviews(h.connection.db, h.organization)); // CEO closes the review without creating tasks or completing the goal: silence, not a loop. - setTaskStatus(h, reviewId, TASK_STATUS.DONE, "2026-08-01T10:00:00.000Z"); + setTaskStatus(h, reviewId, TASK_STATUS.DONE, hoursFromNow(1)); expect(h.connection.transaction(() => spawnGoalReviews(h.connection.db, h.organization))).toEqual([]); // Chatter never counts as progress. - addDoneTask(h, "Reply to CEO", "chat_wake", "2026-08-01T12:00:00.000Z"); + addDoneTask(h, "Reply to CEO", "chat_wake", hoursFromNow(2)); expect(h.connection.transaction(() => spawnGoalReviews(h.connection.db, h.organization))).toEqual([]); // A real completed task after the last review re-arms the checkpoint. - addDoneTask(h, "Implement tenant module", null, "2026-08-01T13:00:00.000Z"); + addDoneTask(h, "Implement tenant module", null, hoursFromNow(3)); expect(h.connection.transaction(() => spawnGoalReviews(h.connection.db, h.organization))).toHaveLength(1); expect(reviews(h)).toHaveLength(2); h.connection.close(); diff --git a/tests/unit/integration-key-store.test.ts b/tests/unit/integration-key-store.test.ts new file mode 100644 index 00000000..8e8a6ae5 --- /dev/null +++ b/tests/unit/integration-key-store.test.ts @@ -0,0 +1,59 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { IntegrationKeyStore } from "../../src/main/services/integration-key-store"; +import type { IntegrationKeyName } from "../../src/shared/commands"; + +const reversingVault = { + encryptSecret: (secret: string) => Buffer.from([...secret].reverse().join(""), "utf8"), + decryptSecret: (ciphertext: Buffer) => [...ciphertext.toString("utf8")].reverse().join(""), +}; + +function openStore() { + const connection = new DatabaseConnection(join(tmpdir(), `ac-keys-${randomUUID()}.db`)); + return { connection, store: new IntegrationKeyStore(connection.db, reversingVault) }; +} + +describe("integration key store", () => { + test("saves trimmed keys through the vault and reads them back", () => { + const { connection, store } = openStore(); + store.write("composio", " ak_123 "); + const row = connection.db.prepare("select ciphertext from integration_keys where name = 'composio'").get() as { ciphertext: Uint8Array }; + expect(Buffer.from(row.ciphertext).toString("utf8")).toBe("321_ka"); + expect(store.read("composio")).toBe("ak_123"); + expect(store.read("logo_dev")).toBeNull(); + connection.close(); + }); + + test("statuses list every known key in order with configured flags", () => { + const { connection, store } = openStore(); + store.write("logo_dev", "pk_1"); + const statuses = store.statuses(); + expect(statuses.map((status) => [status.name, status.configured])).toEqual([ + ["composio", false], + ["logo_dev", true], + ["google_antigravity_client_secret", false], + ]); + expect(statuses[1].updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + connection.close(); + }); + + test("clear removes a key and overwrite replaces it", () => { + const { connection, store } = openStore(); + store.write("composio", "ak_old"); + store.write("composio", "ak_new"); + expect(store.read("composio")).toBe("ak_new"); + store.clear("composio"); + expect(store.read("composio")).toBeNull(); + connection.close(); + }); + + test("rejects blank values and unknown names", () => { + const { connection, store } = openStore(); + expect(() => store.write("composio", " ")).toThrow("cannot be blank"); + expect(() => store.write("stripe" as IntegrationKeyName, "x")).toThrow('Unknown integration key "stripe"'); + connection.close(); + }); +}); diff --git a/tests/unit/provider-oauth.test.ts b/tests/unit/provider-oauth.test.ts index 43fd88e3..c8d9dea1 100644 --- a/tests/unit/provider-oauth.test.ts +++ b/tests/unit/provider-oauth.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { interpretPollResponse } from "../../src/main/services/oauth/github-copilot-flow"; import { interpretKimiPollResponse } from "../../src/main/services/oauth/kimi-code-flow"; import { extractAccountId } from "../../src/main/services/oauth/openai-codex-flow"; -import { readProjectId, resolveAntigravityClientSecret } from "../../src/main/services/oauth/google-antigravity-flow"; +import { readProjectId, requireAntigravityClientSecret } from "../../src/main/services/oauth/google-antigravity-flow"; import { generatePkce } from "../../src/main/services/oauth/pkce"; import { parseAccessToken, parseAccountToken } from "../../src/main/services/provider-auth-profiles"; @@ -93,12 +93,12 @@ describe("antigravity project discovery", () => { }); describe("antigravity oauth configuration", () => { - test("reads the client secret from runtime environment", () => { - expect(resolveAntigravityClientSecret({ AGENTCOMPANY_GOOGLE_ANTIGRAVITY_CLIENT_SECRET: " live-secret " })).toBe("live-secret"); + test("passes a saved client secret through", () => { + expect(requireAntigravityClientSecret("live-secret")).toBe("live-secret"); }); - test("requires an explicit client secret", () => { - expect(() => resolveAntigravityClientSecret({})).toThrow("AGENTCOMPANY_GOOGLE_ANTIGRAVITY_CLIENT_SECRET"); + test("requires a client secret saved in Settings", () => { + expect(() => requireAntigravityClientSecret(null)).toThrow("Settings > Integrations"); }); }); diff --git a/tests/unit/provider-tool-turns.test.ts b/tests/unit/provider-tool-turns.test.ts index e24e63b4..2d43dcec 100644 --- a/tests/unit/provider-tool-turns.test.ts +++ b/tests/unit/provider-tool-turns.test.ts @@ -19,6 +19,7 @@ import { ProviderService } from "../../src/main/services/provider-service"; import { providerAdapters } from "../../src/main/services/provider-adapters"; import { AuthBrokerService } from "../../src/main/services/auth-broker-service"; import { loadConfiguredProvider } from "../../src/main/services/provider-internal"; +import { IntegrationKeyStore } from "../../src/main/services/integration-key-store"; const CONVO: AgentTurnMessage[] = [ { role: "user", text: "do it" }, @@ -228,6 +229,7 @@ describe("credential resolver", () => { const organization = new OrganizationService(connection.db); const providers = new ProviderService(connection.db, providerAdapters, testVault); const authBroker = new AuthBrokerService(connection.db, testVault); + const integrationKeys = new IntegrationKeyStore(connection.db, testVault); let orgId = ""; let openaiId = ""; let gptAccountId = ""; @@ -241,14 +243,32 @@ describe("credential resolver", () => { }); return (async () => { - const apiKeyCred = await resolveTurnCredential({ authBroker, orgId, provider: loadConfiguredProvider(connection.db, orgId, openaiId) }); + const apiKeyCred = await resolveTurnCredential({ authBroker, integrationKeys, orgId, provider: loadConfiguredProvider(connection.db, orgId, openaiId) }); expect(apiKeyCred).toEqual({ apiKey: "sk-live" }); - const oauthCred = await resolveTurnCredential({ authBroker, orgId, provider: loadConfiguredProvider(connection.db, orgId, gptAccountId) }); + const oauthCred = await resolveTurnCredential({ authBroker, integrationKeys, orgId, provider: loadConfiguredProvider(connection.db, orgId, gptAccountId) }); expect(oauthCred.accessToken).toBe("tok"); expect(oauthCred.accountId).toBe("acct_1"); connection.close(); })(); }); + + test("an expired Antigravity credential without a saved client secret points to Settings", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-cred-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const providers = new ProviderService(connection.db, providerAdapters, testVault); + const authBroker = new AuthBrokerService(connection.db, testVault); + const integrationKeys = new IntegrationKeyStore(connection.db, testVault); + let orgId = ""; + let antigravityId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + antigravityId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'google-antigravity'").get(orgId) as { id: string }).id; + providers.saveCredential({ orgId, providerId: antigravityId, secret: JSON.stringify({ access: "old", refresh: "r", expires: 0, projectId: "p" }), model: "gemini-3-pro" }); + }); + await expect(resolveTurnCredential({ authBroker, integrationKeys, orgId, provider: loadConfiguredProvider(connection.db, orgId, antigravityId) })) + .rejects.toThrow("Settings > Integrations"); + connection.close(); + }); }); describe("responses tool definitions", () => {