From 110aee57f52f661911af014af7629fc2207114f6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 2 Oct 2026 17:47:17 -0500 Subject: [PATCH] Initialize saved Google connections for local Gemini Allow Google account defaults in the database and seed the Gemini API-key auth choice in isolated runtime homes. Cover connection persistence, probing, execution, and migration replay. Co-Authored-By: Paperclip --- doc/connections/AI-CONNECTIONS.md | 4 + .../src/migrations/0295_absurd_starhawk.sql | 4 + ...{0290_snapshot.json => 0295_snapshot.json} | 403 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + .../db/src/schema/ai_connection_defaults.ts | 2 +- .../db/src/schema/ai_provider_defaults.ts | 2 +- server/src/__tests__/ai-connections.test.ts | 71 ++- server/src/services/ai-connection-runtime.ts | 10 + 8 files changed, 496 insertions(+), 7 deletions(-) create mode 100644 packages/db/src/migrations/0295_absurd_starhawk.sql rename packages/db/src/migrations/meta/{0290_snapshot.json => 0295_snapshot.json} (99%) diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index f621eca48ce..e86eeb75091 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -540,6 +540,10 @@ credentials belong to the connection; the model belongs to the agent. | Hermes local | OpenRouter; custom/local Chat Completions | | Gemini CLI, Grok | Their native API connections; custom routes are not advertised | +Migration `0295` adds Google to both account-default provider constraints. Local +Gemini connections seed the API-key auth choice in their disposable home before +environment probes and task execution. The settings file contains no credential. + OpenClaw Gateway, Hermes Gateway, Claude Managed, AWS AgentCore, Process, HTTP, and legacy `acpx_local` are excluded: external agents retain their own model configuration, and `acpx_local` is retired. Cursor/Pi/Copilot custom routing, diff --git a/packages/db/src/migrations/0295_absurd_starhawk.sql b/packages/db/src/migrations/0295_absurd_starhawk.sql new file mode 100644 index 00000000000..6375d384de7 --- /dev/null +++ b/packages/db/src/migrations/0295_absurd_starhawk.sql @@ -0,0 +1,4 @@ +ALTER TABLE "ai_connection_defaults" DROP CONSTRAINT IF EXISTS "ai_connection_defaults_provider_check";--> statement-breakpoint +ALTER TABLE "ai_provider_defaults" DROP CONSTRAINT IF EXISTS "ai_provider_defaults_provider_check";--> statement-breakpoint +ALTER TABLE "ai_connection_defaults" ADD CONSTRAINT "ai_connection_defaults_provider_check" CHECK ("ai_connection_defaults"."provider" in ('anthropic','openai','openrouter','xai','google'));--> statement-breakpoint +ALTER TABLE "ai_provider_defaults" ADD CONSTRAINT "ai_provider_defaults_provider_check" CHECK ("ai_provider_defaults"."provider" in ('anthropic','openai','openrouter','xai','google')); diff --git a/packages/db/src/migrations/meta/0290_snapshot.json b/packages/db/src/migrations/meta/0295_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0290_snapshot.json rename to packages/db/src/migrations/meta/0295_snapshot.json index 63ef4c6fddf..1b31c354e51 100644 --- a/packages/db/src/migrations/meta/0290_snapshot.json +++ b/packages/db/src/migrations/meta/0295_snapshot.json @@ -1,6 +1,6 @@ { - "id": "26315a87-4675-4dea-9634-efe5e878cc7d", - "prevId": "3960ae6b-5bf1-4fd7-91db-6a18fc7eaa19", + "id": "6f6adf6c-c061-4640-8309-6a53de8dd224", + "prevId": "66a0b3c9-91bb-46b8-a868-a6facedbc14b", "version": "7", "dialect": "postgresql", "tables": { @@ -2894,7 +2894,7 @@ "checkConstraints": { "ai_connection_defaults_provider_check": { "name": "ai_connection_defaults_provider_check", - "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai')" + "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" }, "ai_connection_defaults_method_check": { "name": "ai_connection_defaults_method_check", @@ -3011,7 +3011,7 @@ "checkConstraints": { "ai_provider_defaults_provider_check": { "name": "ai_provider_defaults_provider_check", - "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai')" + "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" } }, "isRLSEnabled": false @@ -12737,6 +12737,373 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_skill_source_entries": { + "name": "company_skill_source_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inspection": { + "name": "inspection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "selection": { + "name": "selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "present": { + "name": "present", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_skill_source_entries_source_path_idx": { + "name": "company_skill_source_entries_source_path_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_source_entries_company_skill_idx": { + "name": "company_skill_source_entries_company_skill_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_source_entries_company_id_companies_id_fk": { + "name": "company_skill_source_entries_company_id_companies_id_fk", + "tableFrom": "company_skill_source_entries", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_source_entries_source_id_company_skill_sources_id_fk": { + "name": "company_skill_source_entries_source_id_company_skill_sources_id_fk", + "tableFrom": "company_skill_source_entries", + "tableTo": "company_skill_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_source_entries_skill_id_company_skills_id_fk": { + "name": "company_skill_source_entries_skill_id_company_skills_id_fk", + "tableFrom": "company_skill_source_entries", + "tableTo": "company_skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skill_sources": { + "name": "company_skill_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_ref": { + "name": "tracking_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "excluded_folders": { + "name": "excluded_folders", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_scan_commit": { + "name": "last_scan_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skill_sources_repository_ref_idx": { + "name": "company_skill_sources_repository_ref_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_sources_identity_ref_idx": { + "name": "company_skill_sources_identity_ref_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_sources_company_id_companies_id_fk": { + "name": "company_skill_sources_company_id_companies_id_fk", + "tableFrom": "company_skill_sources", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_sources_connection_id_tool_connections_id_fk": { + "name": "company_skill_sources_connection_id_tool_connections_id_fk", + "tableFrom": "company_skill_sources", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.company_skill_comments": { "name": "company_skill_comments", "schema": "", @@ -31098,6 +31465,13 @@ "primaryKey": false, "notNull": true }, + "title_needs_generation": { + "name": "title_needs_generation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "description": { "name": "description", "type": "text", @@ -45835,6 +46209,27 @@ "method": "btree", "with": {} }, + "tool_mcp_gateway_tokens_expiry_idx": { + "name": "tool_mcp_gateway_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "tool_mcp_gateway_tokens_gateway_idx": { "name": "tool_mcp_gateway_tokens_gateway_idx", "columns": [ diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 15430d35565..006c132f78e 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2052,6 +2052,13 @@ "when": 1790891286527, "tag": "0294_chilly_marvel_apes", "breakpoints": true + }, + { + "idx": 295, + "version": "7", + "when": 1790981052989, + "tag": "0295_absurd_starhawk", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/ai_connection_defaults.ts b/packages/db/src/schema/ai_connection_defaults.ts index 3dd1a7463bb..4b2c5c7b2cb 100644 --- a/packages/db/src/schema/ai_connection_defaults.ts +++ b/packages/db/src/schema/ai_connection_defaults.ts @@ -42,7 +42,7 @@ export const aiConnectionDefaults = pgTable( }), check( "ai_connection_defaults_provider_check", - sql`${t.provider} in ('anthropic','openai','openrouter','xai')`, + sql`${t.provider} in ('anthropic','openai','openrouter','xai','google')`, ), check( "ai_connection_defaults_method_check", diff --git a/packages/db/src/schema/ai_provider_defaults.ts b/packages/db/src/schema/ai_provider_defaults.ts index 148e2e2dc82..b73dbff1ccc 100644 --- a/packages/db/src/schema/ai_provider_defaults.ts +++ b/packages/db/src/schema/ai_provider_defaults.ts @@ -16,5 +16,5 @@ export const aiProviderDefaults = pgTable("ai_provider_defaults", { }, (t) => [ uniqueIndex("ai_provider_defaults_owner_provider_uq").on(t.companyId, t.userId, t.provider), foreignKey({ columns: [t.companyId, t.grantId], foreignColumns: [connectionGrants.companyId, connectionGrants.id], name: "ai_provider_defaults_company_grant_fk" }), - check("ai_provider_defaults_provider_check", sql`${t.provider} in ('anthropic','openai','openrouter','xai')`), + check("ai_provider_defaults_provider_check", sql`${t.provider} in ('anthropic','openai','openrouter','xai','google')`), ]); diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 4cda599af6e..320d28d5829 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -4,7 +4,7 @@ import { issueRecoveryActionService } from "../services/issue-recovery-actions.j import * as localCredentials from "../services/local-ai-credentials.js"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { randomUUID } from "node:crypto"; -import { mkdtemp, rm, access, readFile, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, access, readFile, writeFile, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { and, eq, sql } from "drizzle-orm"; @@ -13,6 +13,7 @@ import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded import { aiConnectionService } from "../services/ai-connections.js"; import * as executionTarget from "@paperclipai/adapter-utils/execution-target"; import { prepareManagedAiRuntime, assertManagedAiProjectAuth } from "../services/ai-connection-runtime.js"; +import { execute as executeGemini, testEnvironment as testGeminiEnvironment } from "@paperclipai/adapter-gemini-local/server"; import { toolAccessService } from "../services/tool-access.js"; import { secretService } from "../services/secrets.js"; import { aiConnectionBindingSchema, connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared"; @@ -46,6 +47,74 @@ beforeAll(async () => { afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); }); describe("managed AI connections", () => { + it("authenticates local Gemini probes and runs with the saved key in an isolated home", async () => { + const root = await mkdtemp(path.join(home, "gemini-auth-")); + const command = path.join(root, "gemini"); + await writeFile(command, `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const settings = JSON.parse(fs.readFileSync(path.join(process.env.HOME, ".gemini/settings.json"), "utf8")); +if (settings.selectedAuthType !== "gemini-api-key" || settings.security?.auth?.selectedType !== "gemini-api-key" || process.env.GEMINI_API_KEY !== "saved-google-key") { + console.error("Invalid auth method selected"); + process.exit(1); +} +console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-managed" })); +console.log(JSON.stringify({ type: "assistant", message: { content: [{ type: "output_text", text: "hello" }] } })); +console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello", session_id: "gemini-managed" })); +`, { mode: 0o700 }); + const saved = await service.save(companyId, "alice", { + provider: "google", method: "api_key", ownership: "personal", name: "Saved Google", + apiKey: "fixture", allAgents: true, agentIds: [], + }, "saved-google-key"); + const runtime = await prepareManagedAiRuntime(db, { + companyId, agentId, responsibleUserId: "alice", adapterType: "gemini_local", + binding: { provider: "google", method: "api_key", mode: "responsible_user" }, + config: { engine: "cli", command, cwd: root, promptTemplate: "Say hello.", paperclipRuntimeSkills: [], env: { GEMINI_API_KEY: "ambient-google-key" } }, + }); + try { + expect(runtime.attribution.grantId).toBe(saved.grantId); + expect(runtime.home).not.toBe(os.homedir()); + const settingsFile = path.join(runtime.home!, ".gemini/settings.json"); + expect(await readFile(settingsFile, "utf8")).not.toContain("saved-google-key"); + expect((await stat(settingsFile)).mode & 0o777).toBe(0o600); + const probe = await testGeminiEnvironment({ companyId, adapterType: "gemini_local", config: runtime.config }); + expect(probe.status).toBe("pass"); + expect(probe.checks).toContainEqual(expect.objectContaining({ code: "gemini_hello_probe_passed" })); + const result = await executeGemini({ + runId: randomUUID(), + agent: { id: agentId, companyId, name: "Gemini", adapterType: "gemini_local", adapterConfig: { engine: "cli" } }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: runtime.config, context: {}, onLog: async () => {}, + }); + expect(result.exitCode).toBe(0); + expect(result.sessionId).toBe("gemini-managed"); + } finally { + await runtime.cleanup(); + await rm(root, { recursive: true, force: true }); + } + await expect(access(runtime.home!)).rejects.toThrow(); + }); + + it("preserves Google account defaults when the provider constraint migration is reapplied", async () => { + const saved = await service.save(companyId, "bob", { + provider: "google", method: "api_key", ownership: "personal", name: "Google migration", + apiKey: "fixture", allAgents: true, agentIds: [], + }, "google-migration-key"); + const migration = await readFile(new URL("../../../packages/db/src/migrations/0295_absurd_starhawk.sql", import.meta.url), "utf8"); + for (let attempt = 0; attempt < 2; attempt += 1) { + await db.transaction(async (tx) => { + for (const statement of migration.split("--> statement-breakpoint")) { + if (statement.trim()) await tx.execute(sql.raw(statement)); + } + }); + } + const selected = await service.select({ + companyId, agentId, userId: "bob", adapterType: "gemini_local", + binding: { provider: "google", method: "api_key", mode: "responsible_user" }, + }); + expect(selected.grant.id).toBe(saved.grantId); + }); + it.each([false, true])("reports the authoritative connection-manager capability for custom grants (manager: %s)", async (manager) => { const userId = `custom-manager-${manager}`; await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" }); diff --git a/server/src/services/ai-connection-runtime.ts b/server/src/services/ai-connection-runtime.ts index 7059db4e9e1..92383aaa59c 100644 --- a/server/src/services/ai-connection-runtime.ts +++ b/server/src/services/ai-connection-runtime.ts @@ -304,6 +304,16 @@ export async function prepareManagedAiRuntime( }); env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; } + if (!routing && input.binding.provider === "google") { + // Gemini headless CLI requires an explicit auth choice even with an API key. + // Seed only the disposable connection home, never the operator's settings. + const geminiHome = path.join(home, ".gemini"); + await mkdir(geminiHome, { mode: 0o700 }); + await writeFile(path.join(geminiHome, "settings.json"), JSON.stringify({ + selectedAuthType: "gemini-api-key", + security: { auth: { selectedType: "gemini-api-key" } }, + }), { mode: 0o600 }); + } const projected = routing ? managedProviderRouting(routing, harness, value, typeof input.config.model === "string" ? input.config.model : "") : undefined; if (projected) { Object.assign(env, projected.env);