From 68db309f2db5c47a7e4b38a02443fd53f34d6452 Mon Sep 17 00:00:00 2001 From: Juniper Bevensee Date: Thu, 30 Jul 2026 12:04:33 +1200 Subject: [PATCH 1/2] fix(deploy): make Google MCP actually work for newly created agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A newly created agent's Google MCP could not start, for three independent reasons. All three had to be wrong for the outage to be invisible, and all three were. 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 an operator who ticked "Google" in the wizard silently got no Google at all — no mount, no mcp_servers entry, no error. Now probes both names, and probes dist/index.js rather than the directory so an unbuilt checkout is caught too. When Google is enabled and nothing is found, the deploy now FAILS with the paths it looked at instead of shipping an agent whose integration does not exist. 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. Now points at /opt/data/google-permissions.yaml, inside the agent data dir mount. 3. Nothing wrote a Google permission config anywhere. HSM now generates one. On (3), a security constraint shapes the default: 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 an entire Google account. The wizard collects no folder IDs, so every service is generated at access: none, with a header explaining that granting access requires listing folder IDs and how to run the one-time OAuth flow. An optional googleIdentity in the request body lets an API caller match an existing token file; it defaults to the agent slug. Also creates the google-tokens dir at deploy time so it is owned by the invoking user — letting Docker create the missing bind-mount source can leave it root-owned, and the OAuth flow needs to write token JSON into it. Separately, config.yaml now pins web.search_backend: brave-free. Leaving it unset makes the runtime auto-detect from whichever API key happens to be present, so the provider an agent searches with was a side effect of .env contents rather than a decision — and it changed silently when a key was added. Both creation paths share generateDefaultConfig, so the wizard and the harness service both get it. Tests: 12 new route-level integration tests running the real deploy logic and its real fs writes against temp dirs — including that Google-off writes no Google artifacts, and that every generated service defaults to none. Verified non-vacuous: 9 of 12 fail against origin/main. 905 tests pass, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) --- app/api/setup/deploy/route.ts | 105 ++++++- .../deploy-route-google.integration.test.ts | 267 ++++++++++++++++++ lib/templates/config-yaml.ts | 8 +- 3 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 lib/services/__tests__/deploy-route-google.integration.test.ts diff --git a/app/api/setup/deploy/route.ts b/app/api/setup/deploy/route.ts index 4d422ea..3d25925 100644 --- a/app/api/setup/deploy/route.ts +++ b/app/api/setup/deploy/route.ts @@ -256,10 +256,37 @@ 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) + // Resolve Google MCP dir early (needed for both config.yaml and compose). + // Probe both checkout names: the repo is NimbleCoOrg/google-multiplayer-mcp + // but it is commonly cloned as `google-mcp`, and only checking the long + // name meant googleMcpDir silently became undefined — the operator ticked + // Google in the wizard and got no Google, with nothing reported. 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 + const googleMcpCandidateDirs = process.env.GOOGLE_MCP_DIR + ? [expandPath(process.env.GOOGLE_MCP_DIR)] + : [ + expandPath('~/Documents/GitHub/google-multiplayer-mcp'), + expandPath('~/Documents/GitHub/google-mcp'), + ] + const googleMcpFoundDir = googleMcpCandidateDirs.find((d) => + fs.existsSync(path.join(d, 'dist', 'index.js')), + ) + if (googleEnabled && !googleMcpFoundDir) { + // Fail loudly rather than deploying an agent whose Google integration + // silently does not exist. Checking for dist/index.js (not just the + // directory) also catches an un-built checkout, which would mount fine + // and then exit 1 at runtime. + return NextResponse.json( + { + error: + 'Google was enabled but no built google-multiplayer-mcp checkout was found. ' + + `Looked for dist/index.js in: ${googleMcpCandidateDirs.join(', ')}. ` + + 'Clone and `npm run build` it, or set GOOGLE_MCP_DIR to its path.', + }, + { status: 400 }, + ) + } + const googleMcpDir = googleEnabled ? googleMcpFoundDir : undefined // Build MCP servers config based on enabled integrations const githubMcpEnabled = body.githubMcpEnabled === true && !!githubToken @@ -288,7 +315,15 @@ 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', + ], } } @@ -306,6 +341,68 @@ 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: +# docker exec -it 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..2a2fd20 --- /dev/null +++ b/lib/services/__tests__/deploy-route-google.integration.test.ts @@ -0,0 +1,267 @@ +// @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 *built* google-mcp checkout under the mocked home. */ +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') + 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('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') + 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('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/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 --- From 04cb2dc6416e981b6b1f0344ae92440c9b6a181b Mon Sep 17 00:00:00 2001 From: Juniper Bevensee Date: Thu, 30 Jul 2026 12:21:57 +1200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20independent=20audit=20?= =?UTF-8?q?=E2=80=94=20no=20side=20effects=20on=20reject,=20and=20the=20cr?= =?UTF-8?q?edentials/paths=20the=20server=20actually=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCKING (B1): the Google preflight ran AFTER mkdirSync(agentDataDir) and the .env write, and returned from inside the try — so the catch's cleanup was unreachable. Rejecting a deploy left a half-created agent dir containing the resolved PLAINTEXT LLM key, and then 409'd every retry of that name forever; recovery needed `rm -rf ~/.hermes-` from a shell with nothing in the UI saying so. On the letta runtime it also orphaned an already-created brain agent, which then 409'd by name too. Moved the whole preflight above every side effect — above the LLM key resolution and the letta branch, not merely above mkdir — which is the rule the letta branch itself states ("before a single door byte is written"). A new test asserts no dir, no docker start, no key-registry writes, and that the same name is still deployable afterwards. Three more from the audit, each the same class of bug this PR set out to fix — a path or name that nothing on the other side reads: - S4: GOOGLE_TOKEN_DIR was set nowhere, so the server fell back to $HOME/.nimbleco-google/tokens. With HOME=/opt/data the tokens landed somewhere the compose file never mounts deliberately, making the /opt/google/tokens mount dead weight. Now set explicitly. - S2: GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET were never provisioned, and HSM never writes ~/.nimbleco-google/config.json, so loadGlobalConfig() always threw and the OAuth flow was impossible regardless of scoping. Now written to the agent .env (from the request, else inherited from HSM's own env) and referenced from the mcp_servers env block. - S5: googleIdentity was interpolated into YAML unvalidated. "a: b" broke the parse (MCP exits 1 — the exact regression this fixes) and a newline could inject permission entries past the deny-by-default. Now `^[A-Za-z0-9._-]+$`. Found while fixing the above, not in the audit: generateEnvContent wrote a wizard-supplied Brave key as BRAVE_API_KEY only. The runtime reads exclusively BRAVE_SEARCH_API_KEY (plugins/web/brave_free/provider.py, and the auto-detect table in tools/web_tools.py); nothing reads BRAVE_API_KEY. So the key was invisible to the agent, brave-free reported itself unavailable, and the search_backend pin added in this PR would have fallen straight through to auto-detect — inert. Both names are now written. Also: N1 the probe now requires node_modules as well as dist/index.js (the bundle imports bare googleapis/js-yaml, so a pruned checkout passed and exited 1 at runtime); N2 non-absolute GOOGLE_MCP_DIR is rejected (Docker reads a relative volume source as a named volume, mounting an empty dir over the bundle); N4 the 400 now carries ok: false like its neighbours; S3 the generated OAuth runbook now passes GOOGLE_TOKEN_DIR and the client creds, without which it hit EROFS on the read_only container. Tests: 18 (was 12). Verified by mutation — reverting each of the six fixes individually fails a test. 911 pass on the real tree, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) --- app/api/setup/deploy/route.ts | 131 +++++++++++++----- .../deploy-route-google.integration.test.ts | 95 ++++++++++++- lib/services/agent-deploy-templates.ts | 25 +++- 3 files changed, 215 insertions(+), 36 deletions(-) diff --git a/app/api/setup/deploy/route.ts b/app/api/setup/deploy/route.ts index 3d25925..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,37 +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). - // Probe both checkout names: the repo is NimbleCoOrg/google-multiplayer-mcp - // but it is commonly cloned as `google-mcp`, and only checking the long - // name meant googleMcpDir silently became undefined — the operator ticked - // Google in the wizard and got no Google, with nothing reported. - const googleEnabled = body.googleEnabled === true - const googleMcpCandidateDirs = process.env.GOOGLE_MCP_DIR - ? [expandPath(process.env.GOOGLE_MCP_DIR)] - : [ - expandPath('~/Documents/GitHub/google-multiplayer-mcp'), - expandPath('~/Documents/GitHub/google-mcp'), - ] - const googleMcpFoundDir = googleMcpCandidateDirs.find((d) => - fs.existsSync(path.join(d, 'dist', 'index.js')), - ) - if (googleEnabled && !googleMcpFoundDir) { - // Fail loudly rather than deploying an agent whose Google integration - // silently does not exist. Checking for dist/index.js (not just the - // directory) also catches an un-built checkout, which would mount fine - // and then exit 1 at runtime. - return NextResponse.json( - { - error: - 'Google was enabled but no built google-multiplayer-mcp checkout was found. ' + - `Looked for dist/index.js in: ${googleMcpCandidateDirs.join(', ')}. ` + - 'Clone and `npm run build` it, or set GOOGLE_MCP_DIR to its path.', - }, - { status: 400 }, - ) - } - const googleMcpDir = googleEnabled ? googleMcpFoundDir : undefined // Build MCP servers config based on enabled integrations const githubMcpEnabled = body.githubMcpEnabled === true && !!githubToken @@ -324,6 +368,21 @@ export async function POST(request: Request) { '--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}', + }, } } @@ -370,8 +429,14 @@ export async function POST(request: Request) { # folders: # - # -# Then restart the agent. Run the OAuth flow once before first use: -# docker exec -it hermes-${slug} node \\ +# 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} diff --git a/lib/services/__tests__/deploy-route-google.integration.test.ts b/lib/services/__tests__/deploy-route-google.integration.test.ts index 2a2fd20..74741e3 100644 --- a/lib/services/__tests__/deploy-route-google.integration.test.ts +++ b/lib/services/__tests__/deploy-route-google.integration.test.ts @@ -98,11 +98,16 @@ function readPermissions(file: string): { identity: string; access: Record { 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 }) @@ -254,6 +337,16 @@ describe('POST /api/setup/deploy — Google MCP wiring', () => { // --- 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' }) 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