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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-02 17:47:32 -05:00
1 parent 83b3861599
commit 110aee57f5
8 files changed
+496 -7

No files matched your search

+4
View File
@@ -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,
@@ -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'));
@@ -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": [
+7
View File
@@ -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
}
]
}
@@ -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",
@@ -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')`),
]);
+70 -1
View File
@@ -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" });
@@ -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);