diff --git a/electron-builder.yml b/electron-builder.yml index e6f2c2ef..f0336c3a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -8,9 +8,6 @@ files: - out/**/* - package.json - resources/icon.png -extraResources: - - from: cred.txt - to: cred.txt asar: true mac: category: public.app-category.business diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d90ddbd8..f1021869 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,3 +1,4 @@ +import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { defineConfig, externalizeDepsPlugin } from "electron-vite"; @@ -5,9 +6,26 @@ import react from "@vitejs/plugin-react"; const __dirname = dirname(fileURLToPath(import.meta.url)); +/** + * The Composio integration key is compiled into the main-process bundle so + * no credential file ever ships with the app. It lives in the gitignored + * cred.txt at the repository root (first line "Composio apikey: ak_..."); + * a checkout without the file builds an app whose connector features report + * the integration as unconfigured. Rotation = update cred.txt, rebuild. + */ +function composioApiKeyForBuild(): string { + const credPath = resolve(__dirname, "cred.txt"); + if (!existsSync(credPath)) return ""; + const firstLine = readFileSync(credPath, "utf8").split("\n")[0] ?? ""; + return firstLine.split(":").slice(1).join(":").trim(); +} + export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], + define: { + __COMPOSIO_API_KEY__: JSON.stringify(composioApiKeyForBuild()), + }, resolve: { alias: { "@shared": resolve(__dirname, "src/shared"), diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 7e63ae2a..c72af8d5 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -329,9 +329,9 @@ export function registerIpcHandlers(deps: IpcDeps): void { const composioConnections = new ComposioConnectionService(deps.connection.db); const connectorGrants = new ConnectorGrantsService(deps.connection.db); - ipcMain.handle(IPC_CHANNELS.listComposioToolkits, async (_event, input) => { + ipcMain.handle(IPC_CHANNELS.listComposioToolkits, async () => { try { - return ok(await composioConnections.listToolkits(input.orgId)); + return ok(await composioConnections.listToolkits()); } catch (error) { return fail("composio_toolkits_failed", error instanceof Error ? error.message : String(error)); } diff --git a/src/main/app/startup-recovery.ts b/src/main/app/startup-recovery.ts index fa263acd..230232d1 100644 --- a/src/main/app/startup-recovery.ts +++ b/src/main/app/startup-recovery.ts @@ -2,8 +2,6 @@ import type { DatabaseSync } from "node:sqlite"; import { AuditService } from "../services/audit-service"; const INTERRUPTED_MESSAGE = "Interrupted by application shutdown before completion."; -const COMPOSIO_API_KEY = "***REMOVED***"; -const DEFAULT_COMPOSIO_TOOLKITS = ["github", "gmail", "slack", "notion", "linear", "google_calendar"]; /** * A single app instance executes runs in-process, so any task_run still @@ -38,23 +36,4 @@ export function recoverInterruptedWork(db: DatabaseSync): void { where state in ('pending', 'streaming') and (execution_mode = 'foreground' or external_response_id is null) `).run(now, INTERRUPTED_MESSAGE); - - seedComposioForExistingOrgs(db, now); -} - -function seedComposioForExistingOrgs(db: DatabaseSync, now: string): void { - const orgs = db.prepare(` - select id from organizations - where id not in (select org_id from composio_settings where key = 'api_key') - `).all() as Array<{ id: string }>; - if (orgs.length === 0) return; - const upsert = db.prepare(` - insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?) - on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at - `); - for (const org of orgs) { - upsert.run(org.id, "api_key", COMPOSIO_API_KEY, now); - upsert.run(org.id, "toolkits", JSON.stringify(DEFAULT_COMPOSIO_TOOLKITS), now); - upsert.run(org.id, "entity_id", org.id, now); - } } diff --git a/src/main/database/migrations/046-composio-key-purge.ts b/src/main/database/migrations/046-composio-key-purge.ts new file mode 100644 index 00000000..75a4d4e4 --- /dev/null +++ b/src/main/database/migrations/046-composio-key-purge.ts @@ -0,0 +1,10 @@ +/** + * The Composio API key used to be seeded into composio_settings per + * organization — including a revoked hardcoded key that kept resurfacing. + * The key now ships embedded in the main-process bundle and is never + * persisted, so stored copies (and the never-read toolkits rows) are purged. + * entity_id and auth_config:* rows stay: connections still use them. + */ +export const composioKeyPurgeSchema = ` + delete from composio_settings where key in ('api_key', 'toolkits'); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index ff507503..933e0d35 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -44,6 +44,7 @@ import { approvalNotesMeetingsSprintsSchema } from "./migrations/042-approval-no import { composioIntegrationSchema } from "./migrations/043-composio-integration"; import { mentionWakeStateSchema } from "./migrations/044-mention-wake-state"; import { connectorGrantsSchema } from "./migrations/045-connector-grants"; +import { composioKeyPurgeSchema } from "./migrations/046-composio-key-purge"; interface Migration { version: number; @@ -97,6 +98,7 @@ const MIGRATIONS: Migration[] = [ { version: 43, name: "composio_integration", sql: composioIntegrationSchema }, { version: 44, name: "mention_wake_state", sql: mentionWakeStateSchema }, { version: 45, name: "connector_grants", sql: connectorGrantsSchema }, + { version: 46, name: "composio_key_purge", sql: composioKeyPurgeSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/env.d.ts b/src/main/env.d.ts new file mode 100644 index 00000000..f4ff5f5e --- /dev/null +++ b/src/main/env.d.ts @@ -0,0 +1,6 @@ +/** + * Build-time constants injected by electron.vite.config.ts (`define`). + * Vitest runs without the bundler, so the identifier may not exist at + * runtime — always read it through a `typeof` guard. + */ +declare const __COMPOSIO_API_KEY__: string | undefined; diff --git a/src/main/services/composio/composio-connection-service.ts b/src/main/services/composio/composio-connection-service.ts index 6eaeac4f..bc8265d0 100644 --- a/src/main/services/composio/composio-connection-service.ts +++ b/src/main/services/composio/composio-connection-service.ts @@ -1,7 +1,7 @@ import type { DatabaseSync } from "node:sqlite"; import type { ComposioToolkitSummaryDto, ConnectComposioToolkitResult } from "../../../shared/commands"; import { ComposioClient, type ComposioToolkitSummary } from "./composio-client"; -import { resolveComposioApiKey } from "./composio-credentials"; +import { composioApiKey } from "./composio-credentials"; import { getComposioUserId } from "./composio-tool-bridge"; import { normalizeSlug } from "./connector-grants"; @@ -18,8 +18,8 @@ let catalogCache: { fetchedAt: number; toolkits: ComposioToolkitSummary[] } | nu export class ComposioConnectionService { constructor(private readonly db: DatabaseSync) {} - listToolkits(orgId: string): Promise { - const client = this.requireClient(orgId); + listToolkits(): Promise { + const client = this.requireClient(); return this.catalog(client); } @@ -31,7 +31,7 @@ export class ComposioConnectionService { */ async connectToolkit(orgId: string, toolkitSlug: string, toolkitName: string): Promise { const slug = normalizeSlug(toolkitSlug); - const client = this.requireClient(orgId); + const client = this.requireClient(); const authConfigId = await this.ensureAuthConfig(client, orgId, slug); const account = await client.createConnectedAccount({ authConfigId, @@ -54,7 +54,7 @@ export class ComposioConnectionService { /** Pulls account statuses from Composio and updates the local mirror. */ async refreshAccounts(orgId: string): Promise { - const client = this.requireClient(orgId); + const client = this.requireClient(); const accounts = await client.listConnectedAccounts(getComposioUserId(this.db, orgId)); const now = new Date().toISOString(); for (const account of accounts) { @@ -80,9 +80,9 @@ export class ComposioConnectionService { } } - private requireClient(orgId: string): ComposioClient { - const apiKey = resolveComposioApiKey(this.db, orgId); - if (!apiKey) throw new Error("Composio API key is not configured for this company."); + private requireClient(): ComposioClient { + const apiKey = composioApiKey(); + if (!apiKey) throw new Error("The Composio integration is not configured in this build."); return new ComposioClient(apiKey); } diff --git a/src/main/services/composio/composio-credentials.ts b/src/main/services/composio/composio-credentials.ts index 78464ece..b16f28f3 100644 --- a/src/main/services/composio/composio-credentials.ts +++ b/src/main/services/composio/composio-credentials.ts @@ -1,48 +1,17 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import type { DatabaseSync } from "node:sqlite"; - /** - * The Composio integration key ships with the product: cred.txt (first line - * "Composio apikey: ak_...", gitignored) sits at the project root in - * development and inside the packaged app's resources directory in - * production. Wherever the file exists it is the source of truth — rotating - * the key in the file replaces whatever the database held on the next - * resolution. It is not user-configurable. + * The Composio integration key ships inside the compiled main-process + * bundle: electron.vite.config.ts reads the gitignored cred.txt at the + * repository root during build and injects the key as __COMPOSIO_API_KEY__. + * No credential file is packaged, nothing is persisted to the database, and + * the key is never user-visible or configurable. Rotating the key means + * updating cred.txt and rebuilding. */ -export function resolveComposioApiKey(db: DatabaseSync, orgId: string, credentialFileDirs: string[] = defaultCredentialDirs()): string | null { - const stored = db.prepare( - "select value from composio_settings where org_id = ? and key = 'api_key' limit 1", - ).get(orgId) as { value: string } | undefined; - - const fileKey = readCredentialFile(credentialFileDirs); - if (!fileKey) return stored?.value ?? null; - if (stored?.value === fileKey) return fileKey; - - const now = new Date().toISOString(); - const upsert = db.prepare(` - insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?) - on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at - `); - upsert.run(orgId, "api_key", fileKey, now); - upsert.run(orgId, "entity_id", orgId, now); - return fileKey; +export function composioApiKey(): string | null { + return normalizeComposioApiKey(typeof __COMPOSIO_API_KEY__ === "string" ? __COMPOSIO_API_KEY__ : ""); } -/** Project root in dev; the app's resources directory when packaged. */ -export function defaultCredentialDirs(): string[] { - const dirs = [process.cwd()]; - if (process.resourcesPath) dirs.push(process.resourcesPath); - return dirs; -} - -function readCredentialFile(dirs: string[]): string | null { - for (const dir of dirs) { - const path = join(dir, "cred.txt"); - if (!existsSync(path)) continue; - const firstLine = readFileSync(path, "utf8").split("\n")[0] ?? ""; - const key = firstLine.split(":").slice(1).join(":").trim(); - if (key) return key; - } - return null; +/** 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 ae8487c8..113d4a42 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 { resolveComposioApiKey } from "./composio-credentials"; +import { composioApiKey } from "./composio-credentials"; import { ConnectorGrantsService } from "./connector-grants"; const COMPOSIO_TOOL_PREFIX = "composio_"; @@ -53,7 +53,7 @@ export function composioSchemaToToolDefinition(schema: ComposioToolSchema): Tool * granted this agent. */ export async function loadComposioTools(db: DatabaseSync, orgId: string, agentId: string): Promise { - const apiKey = getComposioApiKey(db, orgId); + const apiKey = composioApiKey(); if (!apiKey) return []; const grants = new ConnectorGrantsService(db); const toolkits = grants.toolkitsForAgent(orgId, agentId); @@ -84,8 +84,8 @@ export async function executeComposioTool( agentId: string, call: ToolCall, ): Promise { - const apiKey = getComposioApiKey(db, orgId); - if (!apiKey) return "ERROR: Composio API key is not configured for this company."; + const apiKey = composioApiKey(); + if (!apiKey) return "ERROR: The Composio integration is not configured in this build."; const toolSlug = originalToolName(call.name); const toolkit = toolkitForTool(toolSlug); const grants = new ConnectorGrantsService(db); @@ -114,10 +114,6 @@ export async function executeComposioTool( } } -function getComposioApiKey(db: DatabaseSync, orgId: string): string | null { - return resolveComposioApiKey(db, orgId); -} - export function getComposioUserId(db: DatabaseSync, orgId: string): string { const row = db.prepare( "select value from composio_settings where org_id = ? and key = 'entity_id' limit 1", diff --git a/src/main/services/onboarding-service.ts b/src/main/services/onboarding-service.ts index 0c701cb0..19151153 100644 --- a/src/main/services/onboarding-service.ts +++ b/src/main/services/onboarding-service.ts @@ -86,7 +86,6 @@ export class OnboardingService { }); const runId = this.createTaskRun(orgId, taskId, ceoAgentId, providerId, workspaceId, now); - this.seedComposioSettings(orgId, now); this.audit.record({ orgId, @@ -235,24 +234,8 @@ export class OnboardingService { return row.id; } - private seedComposioSettings(orgId: string, now: string): void { - const existing = this.deps.db.prepare( - "select value from composio_settings where org_id = ? and key = 'api_key'", - ).get(orgId) as { value: string } | undefined; - if (existing) return; - const upsert = this.deps.db.prepare(` - insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?) - on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at - `); - upsert.run(orgId, "api_key", COMPOSIO_API_KEY, now); - upsert.run(orgId, "toolkits", JSON.stringify(DEFAULT_COMPOSIO_TOOLKITS), now); - upsert.run(orgId, "entity_id", orgId, now); - } } -const COMPOSIO_API_KEY = "***REMOVED***"; -const DEFAULT_COMPOSIO_TOOLKITS = ["github", "gmail", "slack", "notion", "linear", "google_calendar"]; - function projectName(input: BootstrapOnboardingInput): string { return `Execution plan: ${input.goalTitle.trim()}`; } diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index e0536e7e..d475dc2d 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -166,7 +166,6 @@ export class SnapshotService { .map((rule) => ({ ...rule, enabled: toBool(rule.enabled) })), meetings: list(this.db, "meetings", "scheduled_at desc"), sprints: list(this.db, "sprints", "start_date desc"), - composioSettings: list(this.db, "composio_settings", "org_id asc, key asc"), connectorAccounts: list(this.db, "connector_accounts", "toolkit_slug asc"), connectorGrants: list(this.db, "agent_connector_grants", "toolkit_slug asc, created_at asc"), }; diff --git a/src/shared/snapshot-domain.ts b/src/shared/snapshot-domain.ts index 3ae32093..a93c1f11 100644 --- a/src/shared/snapshot-domain.ts +++ b/src/shared/snapshot-domain.ts @@ -140,14 +140,6 @@ export interface AppSnapshot { automationRules: AutomationRuleRecord[]; meetings: MeetingRecord[]; sprints: SprintRecord[]; - composioSettings: ComposioSettingRecord[]; connectorAccounts: ConnectorAccountRecord[]; connectorGrants: ConnectorGrantRecord[]; } - -export interface ComposioSettingRecord { - orgId: string; - key: string; - value: string; - updatedAt: string; -} diff --git a/tests/unit/composio-credentials.test.ts b/tests/unit/composio-credentials.test.ts index 4ab67350..143011d7 100644 --- a/tests/unit/composio-credentials.test.ts +++ b/tests/unit/composio-credentials.test.ts @@ -1,78 +1,19 @@ -import { randomUUID } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; -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 { resolveComposioApiKey } from "../../src/main/services/composio/composio-credentials"; - -interface Harness { - connection: DatabaseConnection; - orgId: string; - credDir: string; -} - -function harness(): Harness { - const connection = new DatabaseConnection(join(tmpdir(), `ac-cred-${randomUUID()}.db`)); - const organization = new OrganizationService(connection.db); - let orgId = ""; - connection.transaction(() => { - orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" }); - }); - const credDir = join(tmpdir(), `ac-cred-dir-${randomUUID()}`); - mkdirSync(credDir, { recursive: true }); - return { connection, orgId, credDir }; -} - -function storedKey(h: Harness): string | null { - const row = h.connection.db.prepare( - "select value from composio_settings where org_id = ? and key = 'api_key'", - ).get(h.orgId) as { value: string } | undefined; - return row?.value ?? null; -} - -function writeCred(h: Harness, key: string): void { - writeFileSync(join(h.credDir, "cred.txt"), `Composio apikey: ${key}\nProject id: pr_x\n`, "utf8"); -} +import { composioApiKey, normalizeComposioApiKey } from "../../src/main/services/composio/composio-credentials"; describe("composio credential resolution", () => { - test("seeds the database from the credential file", () => { - const h = harness(); - writeCred(h, "ak_first"); - expect(resolveComposioApiKey(h.connection.db, h.orgId, [h.credDir])).toBe("ak_first"); - expect(storedKey(h)).toBe("ak_first"); + test("trims the embedded key", () => { + expect(normalizeComposioApiKey(" ak_live ")).toBe("ak_live"); }); - test("a rotated credential file replaces the stale stored key", () => { - const h = harness(); - writeCred(h, "ak_old"); - resolveComposioApiKey(h.connection.db, h.orgId, [h.credDir]); - writeCred(h, "ak_rotated"); - expect(resolveComposioApiKey(h.connection.db, h.orgId, [h.credDir])).toBe("ak_rotated"); - expect(storedKey(h)).toBe("ak_rotated"); + test("a blank key resolves to null so callers report the integration as unconfigured", () => { + expect(normalizeComposioApiKey("")).toBeNull(); + expect(normalizeComposioApiKey(" ")).toBeNull(); }); - test("finds the credential file in a later directory when the first has none", () => { - const h = harness(); - writeCred(h, "ak_resources"); - const emptyDir = join(tmpdir(), `ac-cred-empty-${randomUUID()}`); - mkdirSync(emptyDir, { recursive: true }); - expect(resolveComposioApiKey(h.connection.db, h.orgId, [emptyDir, h.credDir])).toBe("ak_resources"); - expect(storedKey(h)).toBe("ak_resources"); - }); - - test("without a credential file the stored key stands", () => { - const h = harness(); - const now = new Date().toISOString(); - h.connection.db.prepare( - "insert into composio_settings(org_id, key, value, updated_at) values (?, 'api_key', 'ak_settings', ?)", - ).run(h.orgId, now); - expect(resolveComposioApiKey(h.connection.db, h.orgId, [h.credDir])).toBe("ak_settings"); - }); - - test("no file and no stored key yields null", () => { - const h = harness(); - expect(resolveComposioApiKey(h.connection.db, h.orgId, [h.credDir])).toBeNull(); + test("without the build-time injection the key is null", () => { + // Vitest runs without the electron-vite define — the same shape as a + // build from a checkout that has no cred.txt. + expect(composioApiKey()).toBeNull(); }); }); diff --git a/tests/unit/composio-key-purge-migration.test.ts b/tests/unit/composio-key-purge-migration.test.ts new file mode 100644 index 00000000..ecb58aa7 --- /dev/null +++ b/tests/unit/composio-key-purge-migration.test.ts @@ -0,0 +1,32 @@ +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 { composioKeyPurgeSchema } from "../../src/main/database/migrations/046-composio-key-purge"; +import { OrganizationService } from "../../src/main/services/organization-service"; + +describe("composio key purge migration", () => { + test("removes stored api_key and toolkits rows but keeps connection state", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-purge-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" }); + }); + const now = new Date().toISOString(); + const insert = connection.db.prepare( + "insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)", + ); + insert.run(orgId, "api_key", "ak_revoked", now); + insert.run(orgId, "toolkits", '["github"]', now); + insert.run(orgId, "entity_id", orgId, now); + insert.run(orgId, "auth_config:github", "ac_123", now); + + connection.db.exec(composioKeyPurgeSchema); + + const keys = (connection.db.prepare("select key from composio_settings where org_id = ? order by key asc").all(orgId) as Array<{ key: string }>) + .map((row) => row.key); + expect(keys).toEqual(["auth_config:github", "entity_id"]); + }); +}); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 83dbfd0c..f3a42097 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]); + 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]); connection.close(); });