diff --git a/app/api/setup/deploy/route.ts b/app/api/setup/deploy/route.ts index 4d422ea..9202be8 100644 --- a/app/api/setup/deploy/route.ts +++ b/app/api/setup/deploy/route.ts @@ -88,6 +88,72 @@ export async function POST(request: Request) { return NextResponse.json({ ok: false, error: 'Invalid name — could not slugify' }, { status: 400 }) } + // ── Google MCP preflight ─────────────────────────────────────────────── + // Validated HERE, before any side effect, per the same rule the Letta + // branch below states: return before a single door byte is written. An + // earlier revision of this check sat after mkdirSync + the .env write, so + // rejecting a deploy left a half-created agent dir containing the resolved + // plaintext LLM key AND permanently 409'd every retry of that name. + // + // Probe both checkout names: the repo is NimbleCoOrg/google-multiplayer-mcp + // but it is commonly cloned as `google-mcp`. Only checking the long name + // meant googleMcpDir silently became undefined — the operator ticked Google + // in the wizard and got no Google, with nothing reported anywhere. + const googleEnabled = body.googleEnabled === true + const googleIdentityRaw = + typeof body.googleIdentity === 'string' ? body.googleIdentity.trim() : '' + // Interpolated into YAML below; a colon or newline would either break the + // parse (MCP exits 1 — the very failure this fixes) or inject permission + // entries past the deny-by-default this is built around. + if (googleIdentityRaw && !/^[A-Za-z0-9._-]+$/.test(googleIdentityRaw)) { + return NextResponse.json( + { + ok: false, + error: + 'Invalid googleIdentity — only letters, digits, dot, underscore and hyphen are allowed.', + }, + { status: 400 }, + ) + } + const googleIdentity = googleIdentityRaw || slug + + const googleMcpCandidateDirs = ( + process.env.GOOGLE_MCP_DIR + ? [expandPath(process.env.GOOGLE_MCP_DIR)] + : [ + expandPath('~/Documents/GitHub/google-multiplayer-mcp'), + expandPath('~/Documents/GitHub/google-mcp'), + ] + // A relative path passes existsSync (resolved against the server cwd) but + // Docker reads a non-absolute volume source as a NAMED VOLUME, silently + // mounting an empty dir over the bundle. + ).filter((d) => path.isAbsolute(d)) + const googleMcpFoundDir = googleEnabled + ? googleMcpCandidateDirs.find( + (d) => + fs.existsSync(path.join(d, 'dist', 'index.js')) && + // The bundle is plain ESM importing bare `googleapis` / `js-yaml`, + // which resolve only via the checkout's own node_modules. Without + // this a pruned checkout passes the probe and exits 1 at runtime. + fs.existsSync(path.join(d, 'node_modules')), + ) + : undefined + if (googleEnabled && !googleMcpFoundDir) { + return NextResponse.json( + { + ok: false, + error: + 'Google was enabled but no usable google-multiplayer-mcp checkout was found. ' + + `Needs both dist/index.js and node_modules in one of: ${googleMcpCandidateDirs.join(', ') || '(no absolute path configured)'}. ` + + 'Clone it, run `npm install && npm run build`, or set GOOGLE_MCP_DIR to an absolute path.', + }, + { status: 400 }, + ) + } + // Declared with the preflight so it cannot be referenced above its own + // initialisation — a TDZ throw here would 500 the entire deploy. + const googleMcpDir = googleMcpFoundDir + // Resolve the LLM credential. An `existingKeyId` selects a key already in the // registry — its value is resolved server-side so the secret never crosses the // API boundary. Otherwise the pasted `llmKey` (if any) is used verbatim. @@ -246,6 +312,15 @@ export async function POST(request: Request) { signalPhone: signalEnabled ? signalPhone : undefined, githubToken, braveKey, + // Operator-level OAuth client, shared across agents. Taken from the + // request when supplied, else inherited from HSM's own environment — + // without it google-multiplayer-mcp's auth tools cannot run at all. + googleClientId: googleMcpDir + ? (typeof body.googleClientId === 'string' && body.googleClientId) || process.env.GOOGLE_CLIENT_ID || undefined + : undefined, + googleClientSecret: googleMcpDir + ? (typeof body.googleClientSecret === 'string' && body.googleClientSecret) || process.env.GOOGLE_CLIENT_SECRET || undefined + : undefined, notionKey, browserEnabled: body.browserEnabled === true, lettaBrain, @@ -256,10 +331,6 @@ export async function POST(request: Request) { // cont-init hook reads this .env). HSM no longer writes the credential // files — single source of truth in the runtime. - // Resolve Google MCP dir early (needed for both config.yaml and compose) - const googleEnabled = body.googleEnabled === true - const googleMcpCandidateDir = expandPath(process.env.GOOGLE_MCP_DIR || '~/Documents/GitHub/google-multiplayer-mcp') - const googleMcpDir = googleEnabled && fs.existsSync(googleMcpCandidateDir) ? googleMcpCandidateDir : undefined // Build MCP servers config based on enabled integrations const githubMcpEnabled = body.githubMcpEnabled === true && !!githubToken @@ -288,7 +359,30 @@ export async function POST(request: Request) { if (googleMcpDir) { mcpServers.google = { command: 'node', - args: ['/opt/google-multiplayer-mcp/dist/index.js', '--config', '/opt/google/config.yaml'], + // --config points into /opt/data (the agent data dir bind mount), NOT + // /opt/google — only /opt/google/tokens is mounted, so the previous + // /opt/google/config.yaml did not exist and the server exited 1 on + // readFileSync. HSM writes this file below. + args: [ + '/opt/google-multiplayer-mcp/dist/index.js', + '--config', + '/opt/data/google-permissions.yaml', + ], + env: { + // Without this the server falls back to $HOME/.nimbleco-google/tokens + // (auth.ts), and since the gateway exports HOME=/opt/data the tokens + // land somewhere the compose file never mounts on purpose — making + // the /opt/google/tokens mount dead weight. Pointing the server at + // the mounted path is what makes token persistence intentional + // rather than incidental. + GOOGLE_TOKEN_DIR: '/opt/google/tokens', + // OAuth client credentials. auth.ts reads these from the process env + // before falling back to ~/.nimbleco-google/config.json, which HSM + // never writes — so without them every auth tool fails and the agent + // can never complete the flow, no matter how it is scoped. + GOOGLE_CLIENT_ID: '${GOOGLE_CLIENT_ID}', + GOOGLE_CLIENT_SECRET: '${GOOGLE_CLIENT_SECRET}', + }, } } @@ -306,6 +400,74 @@ export async function POST(request: Request) { const configContent = generateConfigYaml(provider, primaryModel, fallbackModel, body.browserEnabled === true, Object.keys(mcpServers).length > 0 ? mcpServers : undefined, extraEnabledPlugins, enabledPlatforms) fs.writeFileSync(path.join(agentDataDir, 'config.yaml'), configContent, 'utf-8') + // Write the Google permission config the MCP server reads via --config. + // Nothing previously created this file, so google MCP exited 1 on startup + // for every newly created agent. + // + // SECURITY: every service defaults to access: none. In + // google-multiplayer-mcp an EMPTY `folders` list means NO RESTRICTION + // (permissions.ts getAllowedFolders), so emitting e.g. `drive: {access: + // write, folders: []}` would hand a brand-new agent unscoped read/write + // over the whole Google account. The wizard collects no folder IDs, so the + // only safe generated default is to grant nothing and let the operator + // scope it deliberately. + if (googleMcpDir) { + const googleIdentity = + typeof body.googleIdentity === 'string' && body.googleIdentity.trim() + ? body.googleIdentity.trim() + : slug + const googlePermissionsYaml = `# Google permissions for ${slug} — generated by Swarm Map. +# +# Every service starts at "none" on purpose. An EMPTY "folders" list means +# UNRESTRICTED access to that service, so granting access without also listing +# folder IDs gives this agent the whole account. +# +# To grant scoped access, set access (read|write) AND list folder IDs: +# +# drive: +# access: write +# folders: +# - +# +# Then restart the agent. Run the OAuth flow once before first use. Both env +# vars are required: the container is read_only, so GOOGLE_TOKEN_DIR must point +# at the mounted (writable) tokens dir, and the client credentials are not in +# the exec environment by default. +# docker exec -it \\ +# -e GOOGLE_TOKEN_DIR=/opt/google/tokens \\ +# -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET \\ +# hermes-${slug} node \\ +# /opt/google-multiplayer-mcp/dist/index.js auth-headless ${googleIdentity} +google: + identity: ${googleIdentity} + permissions: + drive: + access: none + folders: [] + docs: + access: none + folders: [] + sheets: + access: none + folders: [] + calendar: + access: none + folders: [] + gmail: + access: none +` + fs.writeFileSync( + path.join(agentDataDir, 'google-permissions.yaml'), + googlePermissionsYaml, + 'utf-8', + ) + // The compose file bind-mounts this to /opt/google/tokens. Create it here + // so it exists with the invoking user's ownership; letting Docker create + // the missing source path can leave it root-owned, and the OAuth flow + // needs to write token JSON into it. + fs.mkdirSync(path.join(agentDataDir, 'google-tokens'), { recursive: true }) + } + // Write SOUL.md const personalitySection = persona ? `## Personality\n\n${persona}` diff --git a/lib/services/__tests__/deploy-route-google.integration.test.ts b/lib/services/__tests__/deploy-route-google.integration.test.ts new file mode 100644 index 0000000..74741e3 --- /dev/null +++ b/lib/services/__tests__/deploy-route-google.integration.test.ts @@ -0,0 +1,360 @@ +// @vitest-environment node +// +// Route-level integration test for the Google MCP wiring in POST +// /api/setup/deploy. Same harness as deploy-route.integration.test.ts: Docker, +// the services singleton and os.homedir are mocked; the route's real logic and +// its real fs writes run against temp dirs. +// +// Regression context — a newly created agent's Google MCP could not start, for +// three independent reasons: +// +// 1. GOOGLE_MCP_DIR defaulted only to ~/Documents/GitHub/google-multiplayer-mcp. +// The repo is commonly cloned as `google-mcp`, so the existsSync probe +// failed, googleMcpDir became undefined, and the operator who ticked +// "Google" in the wizard silently got no Google — no mount, no mcp_servers +// entry, no error. +// 2. The generated config pointed --config at /opt/google/config.yaml. Only +// /opt/google/tokens is bind-mounted, so that path never existed and the +// server exited 1 on readFileSync. +// 3. Nothing wrote a Google permission config at all. +// +// And a security constraint that shapes (3): in google-multiplayer-mcp an EMPTY +// `folders` list means NO RESTRICTION (permissions.ts getAllowedFolders), so a +// generated file must default every service to access: none rather than +// granting unscoped access to a whole Google account. +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import fs from 'fs' +import path from 'path' +import os from 'os' + +const h = vi.hoisted(() => ({ + tmpHome: '', + tmpData: '', + settings: { useLocalBuild: false, defaultImage: 'ghcr.io/x:latest' } as Record, + getDecryptedValue: vi.fn(() => 'sk-ant-api-REALVALUE123'), + list: vi.fn(() => [] as unknown[]), + update: vi.fn(), + setAssignment: vi.fn(), + add: vi.fn(), + createOverlay: vi.fn(async () => ({ id: 'h_x' })), + dockerStart: vi.fn(), + healthCheck: vi.fn(() => false), +})) + +vi.mock('child_process', () => ({ execSync: vi.fn(() => Buffer.from('')) })) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + const homedir = () => h.tmpHome + return { ...actual, homedir, default: { ...actual, homedir } } +}) + +vi.mock('@/lib/services/templates', () => ({ installBaselineTemplates: vi.fn(async () => []) })) +vi.mock('@/lib/services/usecase-templates', () => ({ + getUseCaseTemplate: vi.fn(), + installUseCaseTemplate: vi.fn(async () => []), + templateEnabledPlugins: () => [], +})) + +vi.mock('@/lib/services', () => ({ + services: { + docker: { isAvailable: () => true, pullImage: () => ({ ok: true }), healthCheck: h.healthCheck, start: h.dockerStart }, + config: { getSettings: () => ({ ...h.settings, dataDir: h.tmpData }) }, + keys: { getDecryptedValue: h.getDecryptedValue, list: h.list, update: h.update, setAssignment: h.setAssignment, add: h.add }, + harness: { createOverlay: h.createOverlay }, + letta: { listAgents: vi.fn(async () => []), createAgent: vi.fn(), deleteAgent: vi.fn() }, + }, +})) + +import { POST } from '@/app/api/setup/deploy/route' + +function deploy(body: Record) { + return POST(new Request('http://localhost/api/setup/deploy', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + })) +} + +/** Minimal reader for the generated permissions file. + * + * Deliberately not using a YAML library: this repo declares none (js-yaml is + * only transitively present in node_modules), and the file we generate is a + * fixed shape we control. Returns the identity plus every service's access + * level, so a test can assert on ALL of them rather than a chosen few. + */ +function readPermissions(file: string): { identity: string; access: Record } { + const text = fs.readFileSync(file, 'utf-8') + const body = text.split('\n').filter((l) => !l.trimStart().startsWith('#')) + const identity = body.find((l) => /^\s*identity:/.test(l))?.split(':')[1]?.trim() ?? '' + const access: Record = {} + let service = '' + for (const line of body) { + const svc = line.match(/^ {4}(\w+):\s*$/) + if (svc) { service = svc[1]; continue } + const acc = line.match(/^ {6}access:\s*(\S+)\s*$/) + if (acc && service) access[service] = acc[1] + } + return { identity, access } +} + +/** Create a fake *usable* google-mcp checkout: built AND with deps installed. + * + * Both are required by the preflight — the bundle imports bare `googleapis` / + * `js-yaml`, which resolve only via the checkout's own node_modules. + */ +function makeGoogleCheckout(dirName: string) { + const dir = path.join(h.tmpHome, 'Documents', 'GitHub', dirName) + fs.mkdirSync(path.join(dir, 'dist'), { recursive: true }) + fs.writeFileSync(path.join(dir, 'dist', 'index.js'), '// built bundle\n') + fs.mkdirSync(path.join(dir, 'node_modules'), { recursive: true }) + return dir +} + +const BASE = { + provider: 'anthropic', + primaryModel: 'claude-opus-4-6', + llmKey: 'sk-ant-api-X', +} + +function agentDir(slug: string) { + return path.join(h.tmpHome, `.hermes-${slug}`) +} + +describe('POST /api/setup/deploy — Google MCP wiring', () => { + beforeEach(() => { + h.tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gdeploy-home-')) + h.tmpData = fs.mkdtempSync(path.join(os.tmpdir(), 'gdeploy-data-')) + h.settings = { useLocalBuild: false, defaultImage: 'ghcr.io/x:latest' } + delete process.env.GOOGLE_MCP_DIR + vi.mocked(h.dockerStart).mockClear() + }) + + afterEach(() => { + fs.rmSync(h.tmpHome, { recursive: true, force: true }) + fs.rmSync(h.tmpData, { recursive: true, force: true }) + delete process.env.GOOGLE_MCP_DIR + }) + + // --- defect 1: the short checkout name must be found ------------------- + + it.each(['google-multiplayer-mcp', 'google-mcp'])( + 'finds a built checkout named %s', + async (dirName) => { + makeGoogleCheckout(dirName) + + const res = await deploy({ ...BASE, name: 'gm', googleEnabled: true }) + expect((await res.json()).ok).toBe(true) + + const cfg = fs.readFileSync(path.join(agentDir('gm'), 'config.yaml'), 'utf-8') + expect(cfg).toContain('google:') + expect(cfg).toContain('/opt/google-multiplayer-mcp/dist/index.js') + }, + ) + + it('fails loudly when Google is enabled but no built checkout exists', async () => { + // No checkout created at all. + const res = await deploy({ ...BASE, name: 'gmissing', googleEnabled: true }) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/google-multiplayer-mcp/i) + expect(body.error).toMatch(/GOOGLE_MCP_DIR|npm run build/i) + // Silently deploying an agent whose Google integration does not exist is + // the bug — the operator ticked the box. + expect(h.dockerStart).not.toHaveBeenCalled() + }) + + it('treats an unbuilt checkout as missing (dist/index.js absent)', async () => { + fs.mkdirSync(path.join(h.tmpHome, 'Documents', 'GitHub', 'google-mcp'), { recursive: true }) + + const res = await deploy({ ...BASE, name: 'gunbuilt', googleEnabled: true }) + expect(res.status).toBe(400) + }) + + it('treats a dependency-pruned checkout as missing (no node_modules)', async () => { + const dir = path.join(h.tmpHome, 'Documents', 'GitHub', 'google-mcp') + fs.mkdirSync(path.join(dir, 'dist'), { recursive: true }) + fs.writeFileSync(path.join(dir, 'dist', 'index.js'), '//\n') + // No node_modules: the bundle imports bare `googleapis`/`js-yaml`, so it + // would mount fine and then exit 1 at runtime. + const res = await deploy({ ...BASE, name: 'gpruned', googleEnabled: true }) + expect(res.status).toBe(400) + }) + + it('rejects a googleIdentity that would break or inject into the YAML', async () => { + makeGoogleCheckout('google-mcp') + for (const bad of ['a: b', 'x\ny: z', 'has space', '']) { + const res = await deploy({ + ...BASE, name: `gbad${bad.length}`, googleEnabled: true, googleIdentity: bad, + }) + if (bad === '') { + // Empty is not invalid — it just falls back to the slug. + expect((await res.json()).ok).toBe(true) + } else { + expect(res.status, `googleIdentity=${JSON.stringify(bad)}`).toBe(400) + } + } + }) + + // --- the rejection must not leave anything behind ---------------------- + + it('leaves NO side effects when the preflight rejects', async () => { + // This is the regression that matters most: an earlier revision ran this + // check AFTER mkdirSync + the .env write, so a rejected deploy left a + // half-created agent dir containing the resolved plaintext LLM key and + // then 409'd every retry of the same name forever. + const res = await deploy({ ...BASE, name: 'gclean', googleEnabled: true }) + expect(res.status).toBe(400) + + const dir = agentDir('gclean') + expect(fs.existsSync(dir), 'no agent data dir may be created').toBe(false) + expect(h.dockerStart).not.toHaveBeenCalled() + // Nothing may have been written to the key registry either. + expect(h.update).not.toHaveBeenCalled() + expect(h.setAssignment).not.toHaveBeenCalled() + expect(h.add).not.toHaveBeenCalled() + + // And the same name must still be deployable once the checkout is fixed. + makeGoogleCheckout('google-mcp') + const retry = await deploy({ ...BASE, name: 'gclean', googleEnabled: true }) + expect((await retry.json()).ok, 'retry must not 409 on a leftover dir').toBe(true) + }) + + // --- the credentials and token path the server actually reads ---------- + + it('wires GOOGLE_TOKEN_DIR at the mounted path, not the default HOME path', async () => { + makeGoogleCheckout('google-mcp') + await deploy({ ...BASE, name: 'gtokdir', googleEnabled: true }) + + const cfg = fs.readFileSync(path.join(agentDir('gtokdir'), 'config.yaml'), 'utf-8') + // Without this the server falls back to $HOME/.nimbleco-google/tokens and + // the compose mount at /opt/google/tokens is dead weight. + expect(cfg).toContain('GOOGLE_TOKEN_DIR') + expect(cfg).toContain('/opt/google/tokens') + }) + + it('provisions the OAuth client credentials the auth flow needs', async () => { + makeGoogleCheckout('google-mcp') + await deploy({ + ...BASE, name: 'gcreds', googleEnabled: true, + googleClientId: 'cid-123.apps.googleusercontent.com', + googleClientSecret: 'csecret-abc', + }) + + const env = fs.readFileSync(path.join(agentDir('gcreds'), '.env'), 'utf-8') + expect(env).toContain('GOOGLE_CLIENT_ID=cid-123.apps.googleusercontent.com') + expect(env).toContain('GOOGLE_CLIENT_SECRET=csecret-abc') + const cfg = fs.readFileSync(path.join(agentDir('gcreds'), 'config.yaml'), 'utf-8') + expect(cfg).toContain('GOOGLE_CLIENT_ID') + }) + + it('honours an explicit GOOGLE_MCP_DIR override', async () => { + const custom = fs.mkdtempSync(path.join(os.tmpdir(), 'gcustom-')) + fs.mkdirSync(path.join(custom, 'dist'), { recursive: true }) + fs.writeFileSync(path.join(custom, 'dist', 'index.js'), '//\n') + fs.mkdirSync(path.join(custom, 'node_modules'), { recursive: true }) + process.env.GOOGLE_MCP_DIR = custom + + const res = await deploy({ ...BASE, name: 'gover', googleEnabled: true }) + expect((await res.json()).ok).toBe(true) + fs.rmSync(custom, { recursive: true, force: true }) + }) + + // --- defect 2: --config must point at a path that is actually mounted --- + + it('points --config at the mounted data dir, not /opt/google', async () => { + makeGoogleCheckout('google-mcp') + await deploy({ ...BASE, name: 'gcfg', googleEnabled: true }) + + const cfg = fs.readFileSync(path.join(agentDir('gcfg'), 'config.yaml'), 'utf-8') + expect(cfg).toContain('/opt/data/google-permissions.yaml') + expect(cfg).not.toContain('/opt/google/config.yaml') + }) + + // --- defect 3: the permission file must exist, and be safe ------------- + + it('writes a permission config the server can read', async () => { + makeGoogleCheckout('google-mcp') + await deploy({ ...BASE, name: 'gperm', googleEnabled: true }) + + const p = path.join(agentDir('gperm'), 'google-permissions.yaml') + expect(fs.existsSync(p)).toBe(true) + + const { identity, access } = readPermissions(p) + // google-multiplayer-mcp throws without google.identity. + expect(identity).toBe('gperm') + expect(Object.keys(access).length).toBeGreaterThan(0) + }) + + it('grants NOTHING by default — empty folders means unrestricted', async () => { + makeGoogleCheckout('google-mcp') + await deploy({ ...BASE, name: 'gsafe', googleEnabled: true }) + + const { access } = readPermissions( + path.join(agentDir('gsafe'), 'google-permissions.yaml'), + ) + expect(Object.keys(access).length).toBeGreaterThanOrEqual(4) + + for (const [service, level] of Object.entries(access)) { + expect( + level, + `${service} must default to none: an empty folders list means NO ` + + 'restriction, so any other default hands a brand-new agent the ' + + 'whole Google account', + ).toBe('none') + } + }) + + it('creates the google-tokens dir so the OAuth flow can write into it', async () => { + makeGoogleCheckout('google-mcp') + await deploy({ ...BASE, name: 'gtok', googleEnabled: true }) + + const dir = path.join(agentDir('gtok'), 'google-tokens') + expect(fs.existsSync(dir)).toBe(true) + expect(fs.statSync(dir).isDirectory()).toBe(true) + }) + + it('accepts an explicit identity so it can match an existing token file', async () => { + makeGoogleCheckout('google-mcp') + await deploy({ ...BASE, name: 'gid', googleEnabled: true, googleIdentity: 'frontdoor' }) + + expect(readPermissions(path.join(agentDir('gid'), 'google-permissions.yaml')).identity) + .toBe('frontdoor') + }) + + // --- Google off: nothing google-shaped is emitted ---------------------- + + it('writes no Google artifacts when Google is not enabled', async () => { + makeGoogleCheckout('google-mcp') // present but unused + await deploy({ ...BASE, name: 'goff' }) + + const dir = agentDir('goff') + expect(fs.existsSync(path.join(dir, 'google-permissions.yaml'))).toBe(false) + expect(fs.existsSync(path.join(dir, 'google-tokens'))).toBe(false) + const cfg = fs.readFileSync(path.join(dir, 'config.yaml'), 'utf-8') + expect(cfg).not.toContain('google-multiplayer-mcp') + }) + + // --- the web backend the searches actually use -------------------------- + + it('writes the Brave key under the name the runtime actually reads', async () => { + await deploy({ ...BASE, name: 'gbrave', braveKey: 'brave-key-xyz' }) + + const env = fs.readFileSync(path.join(agentDir('gbrave'), '.env'), 'utf-8') + // brave_free/provider.py probes ONLY BRAVE_SEARCH_API_KEY. Writing just + // BRAVE_API_KEY made a wizard-supplied key invisible, so brave-free + // reported itself unavailable and the pinned search_backend fell through. + expect(env).toContain('BRAVE_SEARCH_API_KEY=brave-key-xyz') + }) + + it('pins search_backend so it is not auto-detected from .env ordering', async () => { + await deploy({ ...BASE, name: 'gweb' }) + + const cfg = fs.readFileSync(path.join(agentDir('gweb'), 'config.yaml'), 'utf-8') + expect(cfg).toContain('search_backend: brave-free') + expect(cfg).toContain('extract_backend: firecrawl') + // 'brave' is not a registered provider name; it resolves to nothing and the + // runtime silently falls back to a different provider. + expect(cfg).not.toMatch(/search_backend:\s*brave\s*$/m) + }) +}) diff --git a/lib/services/agent-deploy-templates.ts b/lib/services/agent-deploy-templates.ts index 58804dd..909919c 100644 --- a/lib/services/agent-deploy-templates.ts +++ b/lib/services/agent-deploy-templates.ts @@ -26,6 +26,8 @@ export function generateEnvContent(params: { githubToken?: string braveKey?: string notionKey?: string + googleClientId?: string + googleClientSecret?: string browserEnabled?: boolean /** * Letta-brain "door" wiring (B1 contract): when set, the gateway forwards @@ -35,7 +37,8 @@ export function generateEnvContent(params: { */ lettaBrain?: { url: string; agentId: string; apiKey?: string } }): string { - const { name, port, provider, llmKey, bundledOllama, mattermostUrl, mattermostToken, telegramToken, discordToken, slackBotToken, slackAppToken, signalPhone, githubToken, braveKey, notionKey, browserEnabled, lettaBrain } = params + const { name, port, provider, llmKey, bundledOllama, mattermostUrl, mattermostToken, telegramToken, discordToken, slackBotToken, slackAppToken, signalPhone, githubToken, braveKey, notionKey, + googleClientId, googleClientSecret, browserEnabled, lettaBrain } = params // Every user-supplied string below is spliced onto its own `.env` line. A // newline in any of them would inject extra env lines — including overriding @@ -43,6 +46,7 @@ export function generateEnvContent(params: { for (const [field, value] of Object.entries({ name, provider, llmKey, mattermostUrl, mattermostToken, telegramToken, discordToken, slackBotToken, slackAppToken, signalPhone, githubToken, braveKey, notionKey, + googleClientId, googleClientSecret, })) { if (typeof value === 'string') assertNoNewline(value, field) } @@ -201,6 +205,12 @@ export function generateEnvContent(params: { lines.push(`LETTA_BRAIN_AGENT_ID=${lettaBrain.agentId}`) if (lettaBrain.apiKey) lines.push(`LETTA_BRAIN_API_KEY=${lettaBrain.apiKey}`) } + if (googleClientId || googleClientSecret) { + lines.push('') + lines.push('# Google OAuth client (read by google-multiplayer-mcp via the mcp_servers env block)') + if (googleClientId) lines.push(`GOOGLE_CLIENT_ID=${googleClientId}`) + if (googleClientSecret) lines.push(`GOOGLE_CLIENT_SECRET=${googleClientSecret}`) + } if (githubToken || braveKey || notionKey) { lines.push('') lines.push('# Optional integrations') @@ -212,7 +222,18 @@ export function generateEnvContent(params: { // higher precedence than env_file, would blank it. Literal mirrors the llm key. lines.push(`GITHUB_PERSONAL_ACCESS_TOKEN=${githubToken}`) } - if (braveKey) lines.push(`BRAVE_API_KEY=${braveKey}`) + if (braveKey) { + // BRAVE_SEARCH_API_KEY is the var the runtime actually reads — + // plugins/web/brave_free/provider.py probes only that name, and so does + // the auto-detect table in tools/web_tools.py. Writing BRAVE_API_KEY + // alone (the historic name) meant a wizard-supplied Brave key was + // invisible to the agent: brave-free reported itself unavailable, and a + // config pinned to it fell through to whatever else happened to have a + // key. Both names are written — the canonical one for the runtime, the + // legacy one for anything outside it that may still read it. + lines.push(`BRAVE_SEARCH_API_KEY=${braveKey}`) + lines.push(`BRAVE_API_KEY=${braveKey}`) + } if (notionKey) { // Write ONLY the canonical NOTION_API_KEY (the var the keys registry // manages for rotation/revocation). The notion MCP server reads diff --git a/lib/templates/config-yaml.ts b/lib/templates/config-yaml.ts index b9310c7..d82cd37 100644 --- a/lib/templates/config-yaml.ts +++ b/lib/templates/config-yaml.ts @@ -146,8 +146,14 @@ tts: edge: voice: "en-US-AriaNeural" -# --- Web tools (Firecrawl for safe content extraction) --- +# --- Web tools --- +# Both keys are set explicitly. Leaving search_backend unset makes the runtime +# auto-detect from whichever API key happens to be present, so the provider an +# agent actually searches with becomes a side effect of .env ordering rather +# than a decision - and it changes silently when a key is added. Names must +# match the runtime's provider registry exactly: brave-free, NOT brave. web: + search_backend: brave-free extract_backend: firecrawl # --- Terminal access ---