From a4b3234f127721f7c0342b30c4546df93108e034 Mon Sep 17 00:00:00 2001 From: msukkari Date: Fri, 11 Sep 2026 22:31:58 -0700 Subject: [PATCH] fix(setup-sourcebot): mount Vertex credentials in Docker --- CHANGELOG.md | 3 ++ packages/setupWizard/package.json | 1 + packages/setupWizard/src/index.ts | 23 +++++++--- packages/setupWizard/src/models.ts | 45 ++++++++++++++++--- packages/setupWizard/src/utils.ts | 17 +++++++ packages/setupWizard/test/credentials.test.ts | 39 ++++++++++++++++ 6 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 packages/setupWizard/test/credentials.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a14d69cd..19123571e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Fixed setup-sourcebot service-account credentials for Google Vertex providers by generating a read-only container mount and using its container path. + ## [5.1.13] - 2026-09-12 ### Fixed diff --git a/packages/setupWizard/package.json b/packages/setupWizard/package.json index 464d21b53..d6151b1e1 100644 --- a/packages/setupWizard/package.json +++ b/packages/setupWizard/package.json @@ -11,6 +11,7 @@ "bin": "./dist/index.js", "scripts": { "build": "tsc", + "test": "tsx --test test/*.test.ts", "watch": "tsc --watch", "dev": "tsx src/index.ts", "prepublishOnly": "yarn build" diff --git a/packages/setupWizard/src/index.ts b/packages/setupWizard/src/index.ts index 0d592d2aa..e479e14dd 100644 --- a/packages/setupWizard/src/index.ts +++ b/packages/setupWizard/src/index.ts @@ -22,8 +22,10 @@ import { type EnvVars, generateConnectionName, generateSecret, + GOOGLE_APPLICATION_CREDENTIALS_CONTAINER_PATH, INPUT_THEME, note, + readOnlyBindMount, } from './utils.js'; const DOCKER_COMPOSE_BRANCH = 'main'; @@ -511,8 +513,13 @@ async function main() { } } - const { models, env: modelEnv } = await collectModels(); + const { models, env: modelEnv, credentialHostPath } = await collectModels(); Object.assign(allEnv, modelEnv); + if (credentialHostPath) { + // The host path must never be written to .env: Compose runs Sourcebot in + // a container, where only the stable container target is available. + allEnv.GOOGLE_APPLICATION_CREDENTIALS = GOOGLE_APPLICATION_CREDENTIALS_CONTAINER_PATH; + } const authUrl = await input({ message: 'What URL will Sourcebot be hosted at?', @@ -554,7 +561,7 @@ async function main() { } } - if (localRepoIndex.size > 0 && existsSync('docker-compose.override.yml')) { + if ((localRepoIndex.size > 0 || credentialHostPath) && existsSync('docker-compose.override.yml')) { const overwrite = await confirm({ message: 'docker-compose.override.yml already exists. Overwrite?', default: true, @@ -615,10 +622,16 @@ async function main() { const writtenFiles = ['config.json', '.env']; - if (localRepoIndex.size > 0) { + if (localRepoIndex.size > 0 || credentialHostPath) { const mounts = [...localRepoIndex.entries()] .sort((a, b) => a[1] - b[1]) - .map(([p, i]) => ` - ${p}:/repos/${i}:ro`); + .flatMap(([p, i]) => readOnlyBindMount(p, `/repos/${i}`)); + if (credentialHostPath) { + mounts.push(...readOnlyBindMount( + credentialHostPath, + GOOGLE_APPLICATION_CREDENTIALS_CONTAINER_PATH, + )); + } const overrideYaml = [ '# Generated by setup-sourcebot', '# Merged with docker-compose.yml at `docker compose up` time.', @@ -644,7 +657,7 @@ async function main() { docsUrl: 'https://docs.sourcebot.dev/docs/configuration/environment-variables', }, 'docker-compose.override.yml': { - description: 'Mounts your local repositories into the Sourcebot container so they can be indexed. Merged with docker-compose.yml at `docker compose up` time.', + description: 'Mounts your local repositories and, when configured, Google Vertex credentials into the Sourcebot container. All generated bind mounts are read-only and merged with docker-compose.yml at `docker compose up` time.', }, }; diff --git a/packages/setupWizard/src/models.ts b/packages/setupWizard/src/models.ts index 50faea65f..37ba2abde 100644 --- a/packages/setupWizard/src/models.ts +++ b/packages/setupWizard/src/models.ts @@ -1,5 +1,8 @@ import { confirm, input, password, select } from '@inquirer/prompts'; import { select as searchSelect } from 'inquirer-select-pro'; +import { accessSync, constants, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; import type { AmazonBedrockLanguageModel, AzureLanguageModel, @@ -53,6 +56,32 @@ const PROVIDER_ID_OVERRIDES: Record = { 'google-generative-ai': 'google', }; +function expandHostPath(p: string): string { + const trimmed = p.trim(); + if (trimmed.startsWith('~')) { + return resolve(join(homedir(), trimmed.slice(1))); + } + return resolve(trimmed); +} + +function isReadableFile(path: string): boolean { + try { + return statSync(path).isFile() && accessSync(path, constants.R_OK) === undefined; + } catch { + return false; + } +} + +export function validateCredentialPath(value: string): true | string { + if (!value?.trim()) { + return 'Credentials path is required'; + } + const path = expandHostPath(value); + return isReadableFile(path) + ? true + : 'Credentials file must exist and be readable'; +} + let catalogPromise: Promise | null = null; async function loadCatalog(): Promise { @@ -164,6 +193,7 @@ async function collectModelConfig( provider: Provider, model: string, env: EnvVars, + credentialPath: { value?: string }, ): Promise { switch (provider) { case 'anthropic': @@ -284,9 +314,11 @@ async function collectModelConfig( if (!useAppDefault) { if (!env['GOOGLE_APPLICATION_CREDENTIALS']) { env['GOOGLE_APPLICATION_CREDENTIALS'] = await input({ - message: 'Path to service account credentials JSON (stored locally in .env as GOOGLE_APPLICATION_CREDENTIALS)', - validate: (v) => !v?.trim() ? 'Credentials path is required' : true, + message: 'Path to service account credentials JSON (mounted read-only into the Sourcebot container)', + validate: validateCredentialPath, }); + env['GOOGLE_APPLICATION_CREDENTIALS'] = expandHostPath(env['GOOGLE_APPLICATION_CREDENTIALS']); + credentialPath.value = env['GOOGLE_APPLICATION_CREDENTIALS']; } config.credentials = { env: 'GOOGLE_APPLICATION_CREDENTIALS' }; } @@ -295,9 +327,10 @@ async function collectModelConfig( } } -export async function collectModels(): Promise<{ models: LanguageModel[]; env: EnvVars }> { +export async function collectModels(): Promise<{ models: LanguageModel[]; env: EnvVars; credentialHostPath?: string }> { const models: LanguageModel[] = []; const env: EnvVars = {}; + const credentialPath: { value?: string } = {}; note( [ @@ -317,7 +350,7 @@ export async function collectModels(): Promise<{ models: LanguageModel[]; env: E }); if (!wantsAI) { - return { models, env }; + return { models, env, credentialHostPath: credentialPath.value }; } // eslint-disable-next-line no-constant-condition @@ -354,7 +387,7 @@ export async function collectModels(): Promise<{ models: LanguageModel[]; env: E validate: (v) => !v?.trim() ? 'Model name is required' : true, }); - const config = await collectModelConfig(provider, model, env); + const config = await collectModelConfig(provider, model, env, credentialPath); const displayName = (await input({ message: 'Display name (optional, press enter to skip)', @@ -374,5 +407,5 @@ export async function collectModels(): Promise<{ models: LanguageModel[]; env: E } } - return { models, env }; + return { models, env, credentialHostPath: credentialPath.value }; } diff --git a/packages/setupWizard/src/utils.ts b/packages/setupWizard/src/utils.ts index 8a7b08f1f..9b6b02e66 100644 --- a/packages/setupWizard/src/utils.ts +++ b/packages/setupWizard/src/utils.ts @@ -5,6 +5,23 @@ import type { ConnectionConfig } from '@sourcebot/schemas/v3/index.type'; export type { ConnectionConfig }; export type EnvVars = Record; + +// Keep the credential path stable across generated config and Compose files. The +// host path is emitted only as a bind source; Sourcebot always reads this path in +// the container. +export const GOOGLE_APPLICATION_CREDENTIALS_CONTAINER_PATH = '/run/secrets/sourcebot-google-application-credentials.json'; + +// JSON strings are valid YAML double-quoted scalars. Using JSON.stringify here +// keeps bind sources safe when host paths contain spaces, quotes, or YAML syntax. +export function readOnlyBindMount(source: string, target: string): string[] { + return [ + ' - type: bind', + ` source: ${JSON.stringify(source)}`, + ` target: ${JSON.stringify(target)}`, + ' read_only: true', + ]; +} + export type CollectResult = { /** * One or more connections produced by the host's collect function. Single-connection diff --git a/packages/setupWizard/test/credentials.test.ts b/packages/setupWizard/test/credentials.test.ts new file mode 100644 index 000000000..2865af757 --- /dev/null +++ b/packages/setupWizard/test/credentials.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { validateCredentialPath } from '../src/models.js'; +import { + GOOGLE_APPLICATION_CREDENTIALS_CONTAINER_PATH, + readOnlyBindMount, +} from '../src/utils.js'; + +test('credential path validation requires a readable file', () => { + const root = mkdtempSync(join(tmpdir(), 'setup-sourcebot-credentials-')); + const credentialFile = join(root, 'service account.json'); + writeFileSync(credentialFile, '{}'); + + try { + assert.equal(validateCredentialPath(''), 'Credentials path is required'); + assert.equal(validateCredentialPath(join(root, 'missing.json')), 'Credentials file must exist and be readable'); + assert.equal(validateCredentialPath(root), 'Credentials file must exist and be readable'); + assert.equal(validateCredentialPath(credentialFile), true); + + chmodSync(credentialFile, 0o000); + assert.equal(validateCredentialPath(credentialFile), 'Credentials file must exist and be readable'); + } finally { + chmodSync(credentialFile, 0o644); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('read-only bind mounts preserve special host paths and the container target', () => { + const source = '/tmp/repos with spaces/#root/service account "vertex".json'; + assert.deepEqual(readOnlyBindMount(source, GOOGLE_APPLICATION_CREDENTIALS_CONTAINER_PATH), [ + ' - type: bind', + ` source: ${JSON.stringify(source)}`, + ` target: ${JSON.stringify(GOOGLE_APPLICATION_CREDENTIALS_CONTAINER_PATH)}`, + ' read_only: true', + ]); +});