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
@@ -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