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 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 01:34:14 -04:00
1 parent 1ea040c0f0
commit 56b0594f91
36 files changed
+430 -100

No files matched your search

+2 -2
View File
@@ -27,7 +27,7 @@ export interface AgentRunLoopResult {
}
export interface AgentRunLoopDeps {
providers: Pick<ProviderService, "runToolTurn">;
providers: Pick<ProviderService, "runToolTurn" | "integrationKeys">;
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)) {
@@ -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<string, Promise<unknown>>();
constructor(private readonly db: DatabaseSync) {}
constructor(
private readonly db: DatabaseSync,
private readonly keys: IntegrationKeyStore = new IntegrationKeyStore(db),
) {}
listCatalog(): Promise<ConnectorCatalogEntry[]> {
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<ConnectorCatalogEntry[]> {
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;
}
}
@@ -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;
}
@@ -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<ToolDefinition[]> {
const apiKey = composioApiKey();
if (!apiKey) return [];
export async function loadComposioTools(db: DatabaseSync, keys: IntegrationKeyStore, orgId: string, agentId: string): Promise<ToolDefinition[]> {
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<string> {
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);
+4 -11
View File
@@ -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<string, { name: string; domain: string }> = {
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)}`;
}
@@ -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}".`);
}
}
@@ -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<string> {
}
/** Refresh an expired Antigravity access token, retaining the project id. */
export async function refreshAntigravity(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
export async function refreshAntigravity(refreshToken: string, projectId: string, clientSecret: string): Promise<OAuthCredentials> {
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<OAuthCredentials> {
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 };
+2
View File
@@ -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 {
@@ -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<string, AbortController>();
constructor(private readonly keys: IntegrationKeyStore) {}
async run(input: { providerKey: string; enterpriseDomain?: string | null }, hooks: ProviderOAuthHooks): Promise<string> {
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 {
@@ -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,
+4
View File
@@ -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,
@@ -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<OAuthCredentials> {
function refreshOAuth(providerKey: string, creds: StoredOAuth, keys: IntegrationKeyStore): Promise<OAuthCredentials> {
switch (providerKey) {
case "gpt-account":
return refreshCodex(creds.refresh);
@@ -61,7 +62,7 @@ function refreshOAuth(providerKey: string, creds: StoredOAuth): Promise<OAuthCre
return refreshClaudePro(creds.refresh);
case "google-antigravity":
if (!creds.projectId) throw new Error("Antigravity credential is missing its project id; reconnect the provider.");
return refreshAntigravity(creds.refresh, creds.projectId);
return refreshAntigravity(creds.refresh, creds.projectId, requireAntigravityClientSecret(keys.read("google_antigravity_client_secret")));
case "github-copilot":
return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl ?? undefined);
case "kimi-code":
@@ -110,6 +111,7 @@ function toTurnCredential(creds: StoredOAuth | OAuthCredentials): TurnCredential
export async function resolveTurnCredential(deps: {
authBroker: AuthBrokerService;
integrationKeys: IntegrationKeyStore;
orgId: string;
provider: ProviderRow;
}): Promise<TurnCredential> {
@@ -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,
+2
View File
@@ -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<string, unknown>;
@@ -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,
+1 -1
View File
@@ -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();