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:
1 parent
1ea040c0f0
commit
56b0594f91
36 files changed
+430
-100
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -26,6 +26,19 @@ export const zh: Record<string, string> = {
|
||||
"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": "智能体运行",
|
||||
|
||||
@@ -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" && <IntegrationKeysPanel keys={snapshot.integrationKeys} actions={actions} />}
|
||||
{tab === "advanced" && (
|
||||
<AdvancedPanel
|
||||
orgId={orgId}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import type { IntegrationKeyName, IntegrationKeyStatus } from "../../../../shared/commands";
|
||||
import { Button, Field, Pill } from "../../shared/ui";
|
||||
import { t } from "../../i18n/i18n";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
|
||||
const KEY_COPY: Record<IntegrationKeyName, { label: string; hint: string; placeholder: string }> = {
|
||||
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 (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">{t("Integrations")}</h1>
|
||||
<p style={{ margin: "8px 0 24px", color: "var(--text-2)" }}>
|
||||
{t("Keys are stored on this device, encrypted by the operating system, and shared by every company.")}
|
||||
</p>
|
||||
{keys.map((status) => <KeyRow key={status.name} status={status} actions={actions} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyRow({ status, actions }: { status: IntegrationKeyStatus; actions: SnapshotActions }) {
|
||||
const copy = KEY_COPY[status.name];
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const perform = async (operation: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await operation();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : String(failure));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = (event: FormEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<form onSubmit={save} style={{ marginBottom: 24 }}>
|
||||
<div className="row" style={{ gap: 8, marginBottom: 6 }}>
|
||||
<span className="section-label">{copy.label}</span>
|
||||
{status.configured ? <Pill tone="green" dot>{t("Saved")}</Pill> : <Pill>{t("Not set")}</Pill>}
|
||||
</div>
|
||||
<p style={{ margin: "0 0 8px", color: "var(--text-2)" }}>{copy.hint}</p>
|
||||
<div className="row" style={{ gap: 8, alignItems: "flex-end" }}>
|
||||
<div style={{ flex: 1, maxWidth: 420 }}>
|
||||
<Field label={status.configured ? t("Replace key") : t("Key")}>
|
||||
<input className="input" name="value" type="password" autoComplete="off" placeholder={copy.placeholder} required />
|
||||
</Field>
|
||||
</div>
|
||||
<Button type="submit" tone="primary" disabled={busy}>{t("Save")}</Button>
|
||||
{status.configured && (
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void perform(() => actions.run(() => window.agentCompany.clearIntegrationKey({ name: status.name })))}
|
||||
>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export * from "./governance";
|
||||
export * from "./collaboration";
|
||||
export * from "./onboarding";
|
||||
export * from "./composio";
|
||||
export * from "./integration-keys";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<DesktopResult<AppSnapshot>>;
|
||||
updateEmployeeProfile(input: UpdateEmployeeProfileInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setAutonomousPaused(input: SetAutonomousPausedInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setIntegrationKey(input: SetIntegrationKeyInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
clearIntegrationKey(input: ClearIntegrationKeyInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createTask(input: CreateTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
updateTask(input: UpdateTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
deleteTask(input: DeleteTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AgentTurnResult & { requestId: string }> => {
|
||||
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();
|
||||
|
||||
@@ -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***");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user