From d085de87f162958c1d4a875d189bb30a8c42ec77 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Wed, 18 Mar 2026 12:00:39 -0700 Subject: [PATCH 1/3] fix: harden auth field handling for all warehouse drivers (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add config field name normalization so LLM-generated, dbt, and SDK field names all resolve to canonical snake_case before reaching drivers. - Add `normalizeConfig()` in `@altimateai/drivers` with per-driver alias maps (Snowflake, BigQuery, Databricks, Redshift, PostgreSQL, MySQL, SQL Server, Oracle) and common aliases (`username` → `user`, `dbname` → `database`) - Call `normalizeConfig()` before both `resolveConfig()` and `saveConnection()` in registry so credential store uses canonical names - Add Snowflake inline PEM support via `config.private_key` - Add BigQuery `credentials_json` (inline JSON) support - Add MySQL SSL construction from top-level `ssl_ca`/`ssl_cert`/`ssl_key` fields at connection time (not during normalization, to preserve credential store detection) - Expand `SENSITIVE_FIELDS` with `private_key`, `token`, `credentials_json`, `keyfile_json`, `ssl_key`, `ssl_cert`, `ssl_ca` - Update `sensitiveKeys` in `project-scan.ts` to match - Fix `detectAuthMethod()` to recognize inline `private_key` - Update `warehouse_add` tool description with per-warehouse field docs - Add 72 unit tests for normalization logic Closes #267 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/drivers/src/bigquery.ts | 12 +- packages/drivers/src/index.ts | 3 + packages/drivers/src/mysql.ts | 6 + packages/drivers/src/normalize.ts | 166 +++++ packages/drivers/src/snowflake.ts | 21 +- .../native/connections/credential-store.ts | 7 + .../altimate/native/connections/registry.ts | 15 +- .../src/altimate/tools/project-scan.ts | 6 +- .../src/altimate/tools/warehouse-add.ts | 13 +- .../test/altimate/driver-normalize.test.ts | 659 ++++++++++++++++++ 10 files changed, 895 insertions(+), 13 deletions(-) create mode 100644 packages/drivers/src/normalize.ts create mode 100644 packages/opencode/test/altimate/driver-normalize.test.ts diff --git a/packages/drivers/src/bigquery.ts b/packages/drivers/src/bigquery.ts index e3931e9143..99e6f9f02e 100644 --- a/packages/drivers/src/bigquery.ts +++ b/packages/drivers/src/bigquery.ts @@ -21,7 +21,17 @@ export async function connect(config: ConnectionConfig): Promise { async connect() { const options: Record = {} if (config.project) options.projectId = config.project - if (config.credentials_path) options.keyFilename = config.credentials_path + if (config.credentials_json) { + try { + options.credentials = typeof config.credentials_json === "string" + ? JSON.parse(config.credentials_json as string) + : config.credentials_json + } catch (e) { + throw new Error(`Failed to parse credentials_json: ${e}`) + } + } else if (config.credentials_path) { + options.keyFilename = config.credentials_path + } if (config.location) options.location = config.location client = new BigQuery(options) diff --git a/packages/drivers/src/index.ts b/packages/drivers/src/index.ts index 871a9a7db1..c19a25f64a 100644 --- a/packages/drivers/src/index.ts +++ b/packages/drivers/src/index.ts @@ -4,6 +4,9 @@ export type { Connector, ConnectorResult, SchemaColumn, ConnectionConfig } from // Re-export escape utilities export { escapeSqlString, escapeSqlIdentifier } from "./sql-escape" +// Re-export config normalization +export { normalizeConfig } from "./normalize" + // Re-export driver connect functions export { connect as connectPostgres } from "./postgres" export { connect as connectSnowflake } from "./snowflake" diff --git a/packages/drivers/src/mysql.ts b/packages/drivers/src/mysql.ts index 9852c7ed5d..9738d25751 100644 --- a/packages/drivers/src/mysql.ts +++ b/packages/drivers/src/mysql.ts @@ -30,6 +30,12 @@ export async function connect(config: ConnectionConfig): Promise { if (config.ssl !== undefined) { poolConfig.ssl = config.ssl + } else if (config.ssl_ca || config.ssl_cert || config.ssl_key) { + const sslObj: Record = {} + if (config.ssl_ca) sslObj.ca = config.ssl_ca + if (config.ssl_cert) sslObj.cert = config.ssl_cert + if (config.ssl_key) sslObj.key = config.ssl_key + poolConfig.ssl = sslObj } pool = mysql.createPool(poolConfig) diff --git a/packages/drivers/src/normalize.ts b/packages/drivers/src/normalize.ts new file mode 100644 index 0000000000..7282ff8ae3 --- /dev/null +++ b/packages/drivers/src/normalize.ts @@ -0,0 +1,166 @@ +/** + * Config field name normalization for warehouse drivers. + * + * The warehouse_add tool takes a generic Record config. + * LLMs, dbt profiles, and SDK conventions use different field names for the + * same config values. This module normalizes them to canonical snake_case + * names before the config reaches the driver. + */ + +import type { ConnectionConfig } from "./types" + +// --------------------------------------------------------------------------- +// Per-driver alias maps +// --------------------------------------------------------------------------- +// Key = canonical field name, Value = list of aliases (checked in order). +// First-value-wins: if the canonical field is already present, aliases are +// ignored. + +type AliasMap = Record + +/** Aliases common to most drivers. */ +const COMMON_ALIASES: AliasMap = { + user: ["username"], + database: ["dbname", "db"], +} + +const SNOWFLAKE_ALIASES: AliasMap = { + ...COMMON_ALIASES, + private_key_path: ["privateKeyPath"], + private_key_passphrase: ["privateKeyPassphrase"], + private_key: ["privateKey"], +} + +const BIGQUERY_ALIASES: AliasMap = { + project: ["projectId", "project_id"], + credentials_path: ["keyfile", "keyFilename", "key_file", "keyFile"], + credentials_json: ["keyfile_json", "keyfileJson"], + dataset: ["defaultDataset", "default_dataset"], +} + +const DATABRICKS_ALIASES: AliasMap = { + server_hostname: ["host", "serverHostname"], + http_path: ["httpPath"], + access_token: ["token", "personal_access_token", "personalAccessToken"], +} + +const POSTGRES_ALIASES: AliasMap = { + ...COMMON_ALIASES, + connection_string: ["connectionString"], +} + +const REDSHIFT_ALIASES: AliasMap = { + ...COMMON_ALIASES, + connection_string: ["connectionString"], +} + +const MYSQL_ALIASES: AliasMap = { + ...COMMON_ALIASES, +} + +const SQLSERVER_ALIASES: AliasMap = { + ...COMMON_ALIASES, + host: ["server", "serverName", "server_name"], + trust_server_certificate: ["trustServerCertificate"], +} + +const ORACLE_ALIASES: AliasMap = { + ...COMMON_ALIASES, + connection_string: ["connectString", "connect_string", "connectionString"], + service_name: ["serviceName"], +} + +/** Map of warehouse type to its alias map. */ +const DRIVER_ALIASES: Record = { + snowflake: SNOWFLAKE_ALIASES, + bigquery: BIGQUERY_ALIASES, + databricks: DATABRICKS_ALIASES, + postgres: POSTGRES_ALIASES, + postgresql: POSTGRES_ALIASES, + redshift: REDSHIFT_ALIASES, + mysql: MYSQL_ALIASES, + mariadb: MYSQL_ALIASES, + sqlserver: SQLSERVER_ALIASES, + mssql: SQLSERVER_ALIASES, + oracle: ORACLE_ALIASES, + // duckdb and sqlite have simple configs — no aliases needed +} + +// --------------------------------------------------------------------------- +// Core logic +// --------------------------------------------------------------------------- + +/** + * Apply alias mappings to a config object. + * For each canonical field: if it is not already set, check each alias in + * order and use the first non-undefined value. Consumed aliases are removed + * from the output to avoid passing unexpected fields to the driver. + */ +function applyAliases(config: ConnectionConfig, aliases: AliasMap): ConnectionConfig { + const result = { ...config } + + for (const [canonical, aliasList] of Object.entries(aliases)) { + let resolved = result[canonical] !== undefined + for (const alias of aliasList) { + if (result[alias] !== undefined) { + if (!resolved) { + result[canonical] = result[alias] + resolved = true + } + delete result[alias] + } + } + } + + return result +} + +/** + * Handle Snowflake private_key: if the value doesn't look like a file path, + * keep it as private_key (inline PEM). If it looks like a path, move it to + * private_key_path. + */ +function normalizeSnowflakePrivateKey(config: ConnectionConfig): ConnectionConfig { + const pk = config.private_key + if (typeof pk !== "string" || !pk) return config + if (config.private_key_path) return config // already have a path + + // Inline PEM starts with "-----BEGIN" or contains newlines + if (pk.includes("-----BEGIN") || pk.includes("\n")) { + return config // keep as private_key (inline PEM) + } + + // Looks like a file path + if (pk.includes("/") || pk.includes("\\") || pk.endsWith(".pem") || pk.endsWith(".p8")) { + const result = { ...config } + result.private_key_path = pk + delete result.private_key + return result + } + + return config +} + +/** + * Normalize a connection config by resolving field name aliases. + * Returns a new config object; does not mutate the input. + * Unknown types pass through unchanged. + */ +export function normalizeConfig(config: ConnectionConfig): ConnectionConfig { + const type = config.type?.toLowerCase() + if (!type) return config + + const aliases = DRIVER_ALIASES[type] + let result = aliases ? applyAliases(config, aliases) : { ...config } + + // Type-specific post-processing + // Note: MySQL SSL fields (ssl_ca, ssl_cert, ssl_key) are NOT constructed + // into an ssl object here. They stay as top-level fields so the credential + // store can detect and strip them. The MySQL driver constructs the ssl + // object at connection time. + if (type === "snowflake") { + result = normalizeSnowflakePrivateKey(result) + } + + return result +} diff --git a/packages/drivers/src/snowflake.ts b/packages/drivers/src/snowflake.ts index aa9b381bff..0def501b3e 100644 --- a/packages/drivers/src/snowflake.ts +++ b/packages/drivers/src/snowflake.ts @@ -41,20 +41,27 @@ export async function connect(config: ConnectionConfig): Promise { async connect() { const options: Record = { account: config.account, - username: config.user ?? config.username, + username: config.user, database: config.database, schema: config.schema, warehouse: config.warehouse, role: config.role, } - // Key-pair auth - if (config.private_key_path) { - const keyPath = config.private_key_path as string - if (!fs.existsSync(keyPath)) { - throw new Error(`Snowflake private key file not found: ${keyPath}`) + // Key-pair auth (file path or inline PEM) + const keyPath = config.private_key_path as string | undefined + const inlinePem = config.private_key as string | undefined + + if (keyPath || inlinePem) { + let keyContent: string + if (keyPath) { + if (!fs.existsSync(keyPath)) { + throw new Error(`Snowflake private key file not found: ${keyPath}`) + } + keyContent = fs.readFileSync(keyPath, "utf-8") + } else { + keyContent = inlinePem! } - const keyContent = fs.readFileSync(keyPath, "utf-8") // If key is encrypted (has ENCRYPTED in header or passphrase provided), // decrypt it using Node crypto — snowflake-sdk expects unencrypted PEM. diff --git a/packages/opencode/src/altimate/native/connections/credential-store.ts b/packages/opencode/src/altimate/native/connections/credential-store.ts index 724455860e..896f6ae868 100644 --- a/packages/opencode/src/altimate/native/connections/credential-store.ts +++ b/packages/opencode/src/altimate/native/connections/credential-store.ts @@ -15,9 +15,16 @@ const SERVICE_NAME = "altimate-code" const SENSITIVE_FIELDS = new Set([ "password", "private_key_passphrase", + "private_key", "access_token", + "token", "ssh_password", "connection_string", + "credentials_json", + "keyfile_json", + "ssl_key", + "ssl_cert", + "ssl_ca", ]) /** Cached keytar module (or null if unavailable). */ diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index c0fbf6b522..8f8d148cf9 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -14,6 +14,7 @@ import * as path from "path" import * as os from "os" import { Log } from "../../../util/log" import type { ConnectionConfig, Connector } from "@altimateai/drivers" +import { normalizeConfig } from "@altimateai/drivers" import { resolveConfig, saveConnection } from "./credential-store" import { startTunnel, extractSshConfig, closeTunnel } from "./ssh-tunnel" import type { WarehouseInfo } from "../types" @@ -138,8 +139,12 @@ async function createConnector( ) } + // Normalize field names first (camelCase → snake_case, dbt → canonical) + // so credential resolution uses canonical names for keychain lookups + let resolvedConfig = normalizeConfig(config) + // Resolve credentials from keychain - let resolvedConfig = await resolveConfig(name, config) + resolvedConfig = await resolveConfig(name, resolvedConfig) // Handle SSH tunnel const sshConfig = extractSshConfig(resolvedConfig) @@ -209,7 +214,7 @@ async function createConnector( export function detectAuthMethod(config: ConnectionConfig | null | undefined): string { if (!config || typeof config !== "object") return "unknown" if (config.connection_string) return "connection_string" - if (config.private_key_path) return "key_pair" + if (config.private_key_path || config.private_key) return "key_pair" if (config.access_token || config.token) return "token" if (config.password) return "password" const t = typeof config.type === "string" ? config.type.toLowerCase() : "" @@ -360,8 +365,12 @@ export async function add( try { ensureLoaded() + // Normalize field names before saving so sensitive fields under alias + // names (e.g., keyfileJson → credentials_json) are properly detected + const normalized = normalizeConfig(config) + // Store credentials in keychain, get sanitized config - const { sanitized, warnings } = await saveConnection(name, config) + const { sanitized, warnings } = await saveConnection(name, normalized) // Save to global config file const globalPath = globalConfigPath() diff --git a/packages/opencode/src/altimate/tools/project-scan.ts b/packages/opencode/src/altimate/tools/project-scan.ts index 48898bb921..63a2dd3558 100644 --- a/packages/opencode/src/altimate/tools/project-scan.ts +++ b/packages/opencode/src/altimate/tools/project-scan.ts @@ -227,7 +227,11 @@ export async function detectEnvVars(): Promise { const matchedSignal = wh.signals.find((s) => process.env[s]) if (!matchedSignal) continue - const sensitiveKeys = new Set(["password", "access_token", "connection_string", "private_key_path"]) + const sensitiveKeys = new Set([ + "password", "access_token", "token", "connection_string", + "private_key_path", "private_key", "private_key_passphrase", + "credentials_json", "keyfile_json", "ssl_key", "ssl_cert", "ssl_ca", + ]) const config: Record = {} for (const [key, envNames] of Object.entries(wh.configMap)) { const names = Array.isArray(envNames) ? envNames : [envNames] diff --git a/packages/opencode/src/altimate/tools/warehouse-add.ts b/packages/opencode/src/altimate/tools/warehouse-add.ts index d396715387..d4f8a6569e 100644 --- a/packages/opencode/src/altimate/tools/warehouse-add.ts +++ b/packages/opencode/src/altimate/tools/warehouse-add.ts @@ -10,7 +10,18 @@ export const WarehouseAddTool = Tool.define("warehouse_add", { config: z .record(z.string(), z.unknown()) .describe( - 'Connection configuration. Must include "type" (postgres, snowflake, duckdb, etc). Example: {"type": "postgres", "host": "localhost", "port": 5432, "database": "mydb", "user": "admin", "password": "secret"}', + `Connection configuration. Must include "type". Field aliases (camelCase, dbt names) are auto-normalized. Canonical fields per type: +- postgres: host, port, database, user, password, ssl, connection_string, statement_timeout +- snowflake: account, user, password, database, schema, warehouse, role, private_key_path, private_key_passphrase, private_key (inline PEM) +- bigquery: project, credentials_path (service account JSON file), credentials_json (inline JSON), location, dataset +- databricks: server_hostname, http_path, access_token, catalog, schema +- redshift: host, port, database, user, password, ssl, connection_string +- mysql: host, port, database, user, password, ssl (or ssl_ca, ssl_cert, ssl_key) +- sqlserver: host, port, database, user, password, encrypt, trust_server_certificate +- oracle: connection_string (or host, port, service_name), user, password +- duckdb: path (file path or ":memory:") +- sqlite: path (file path) +Example: {"type": "snowflake", "account": "xy12345.us-east-1", "user": "admin", "password": "secret", "database": "MYDB", "warehouse": "COMPUTE_WH"}`, ), }), async execute(args, ctx) { diff --git a/packages/opencode/test/altimate/driver-normalize.test.ts b/packages/opencode/test/altimate/driver-normalize.test.ts new file mode 100644 index 0000000000..87a5310fef --- /dev/null +++ b/packages/opencode/test/altimate/driver-normalize.test.ts @@ -0,0 +1,659 @@ +import { describe, expect, test } from "bun:test" +import { normalizeConfig } from "@altimateai/drivers" +import { isSensitiveField } from "../../src/altimate/native/connections/credential-store" + +// --------------------------------------------------------------------------- +// normalizeConfig — identity (canonical fields pass through unchanged) +// --------------------------------------------------------------------------- + +describe("normalizeConfig — identity", () => { + test("canonical postgres config passes through unchanged", () => { + const config = { + type: "postgres", + host: "localhost", + port: 5432, + database: "mydb", + user: "admin", + password: "secret", + } + expect(normalizeConfig(config)).toEqual(config) + }) + + test("canonical snowflake config passes through unchanged", () => { + const config = { + type: "snowflake", + account: "xy12345", + user: "admin", + password: "secret", + database: "MYDB", + warehouse: "COMPUTE_WH", + } + expect(normalizeConfig(config)).toEqual(config) + }) + + test("unknown type passes through unchanged", () => { + const config = { type: "unknown_db", foo: "bar", baz: 42 } + expect(normalizeConfig(config)).toEqual(config) + }) + + test("missing type passes through unchanged", () => { + const config = { type: "", host: "localhost" } + expect(normalizeConfig(config)).toEqual(config) + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — first-value-wins +// --------------------------------------------------------------------------- + +describe("normalizeConfig — first-value-wins", () => { + test("canonical field takes precedence over alias", () => { + const config = { + type: "postgres", + user: "correct", + username: "wrong", + } + const result = normalizeConfig(config) + expect(result.user).toBe("correct") + expect(result.username).toBeUndefined() + }) + + test("canonical database takes precedence over dbname", () => { + const config = { + type: "mysql", + database: "correct_db", + dbname: "wrong_db", + } + const result = normalizeConfig(config) + expect(result.database).toBe("correct_db") + expect(result.dbname).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — common aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — common aliases", () => { + test("username → user for postgres", () => { + const result = normalizeConfig({ + type: "postgres", + username: "admin", + }) + expect(result.user).toBe("admin") + expect(result.username).toBeUndefined() + }) + + test("dbname → database for postgres", () => { + const result = normalizeConfig({ + type: "postgres", + dbname: "mydb", + }) + expect(result.database).toBe("mydb") + expect(result.dbname).toBeUndefined() + }) + + test("db → database for mysql", () => { + const result = normalizeConfig({ + type: "mysql", + db: "mydb", + }) + expect(result.database).toBe("mydb") + expect(result.db).toBeUndefined() + }) + + test("username → user for redshift", () => { + const result = normalizeConfig({ + type: "redshift", + username: "admin", + }) + expect(result.user).toBe("admin") + expect(result.username).toBeUndefined() + }) + + test("username → user for oracle", () => { + const result = normalizeConfig({ + type: "oracle", + username: "admin", + }) + expect(result.user).toBe("admin") + expect(result.username).toBeUndefined() + }) + + test("username → user for sqlserver", () => { + const result = normalizeConfig({ + type: "sqlserver", + username: "sa", + }) + expect(result.user).toBe("sa") + expect(result.username).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — Snowflake aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — Snowflake", () => { + test("privateKeyPath → private_key_path", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + privateKeyPath: "/path/to/key.p8", + }) + expect(result.private_key_path).toBe("/path/to/key.p8") + expect(result.privateKeyPath).toBeUndefined() + }) + + test("privateKeyPassphrase → private_key_passphrase", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + privateKeyPassphrase: "secret", + }) + expect(result.private_key_passphrase).toBe("secret") + expect(result.privateKeyPassphrase).toBeUndefined() + }) + + test("privateKey → private_key", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + privateKey: "-----BEGIN PRIVATE KEY-----\nMIIE...", + }) + expect(result.private_key).toBe("-----BEGIN PRIVATE KEY-----\nMIIE...") + expect(result.privateKey).toBeUndefined() + }) + + test("private_key with file path → private_key_path", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + private_key: "/home/user/.ssh/snowflake_key.p8", + }) + expect(result.private_key_path).toBe("/home/user/.ssh/snowflake_key.p8") + expect(result.private_key).toBeUndefined() + }) + + test("private_key with .pem extension → private_key_path", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + private_key: "rsa_key.pem", + }) + expect(result.private_key_path).toBe("rsa_key.pem") + expect(result.private_key).toBeUndefined() + }) + + test("private_key with inline PEM stays as private_key", () => { + const pem = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg..." + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + private_key: pem, + }) + expect(result.private_key).toBe(pem) + expect(result.private_key_path).toBeUndefined() + }) + + test("private_key_path takes precedence over private_key path detection", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + user: "admin", + private_key_path: "/correct/path.p8", + private_key: "/wrong/path.p8", + }) + expect(result.private_key_path).toBe("/correct/path.p8") + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — BigQuery aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — BigQuery", () => { + test("projectId → project", () => { + const result = normalizeConfig({ + type: "bigquery", + projectId: "my-project", + }) + expect(result.project).toBe("my-project") + expect(result.projectId).toBeUndefined() + }) + + test("project_id → project", () => { + const result = normalizeConfig({ + type: "bigquery", + project_id: "my-project", + }) + expect(result.project).toBe("my-project") + expect(result.project_id).toBeUndefined() + }) + + test("keyFilename → credentials_path", () => { + const result = normalizeConfig({ + type: "bigquery", + keyFilename: "/path/to/key.json", + }) + expect(result.credentials_path).toBe("/path/to/key.json") + expect(result.keyFilename).toBeUndefined() + }) + + test("keyfile → credentials_path", () => { + const result = normalizeConfig({ + type: "bigquery", + keyfile: "/path/to/key.json", + }) + expect(result.credentials_path).toBe("/path/to/key.json") + expect(result.keyfile).toBeUndefined() + }) + + test("key_file → credentials_path", () => { + const result = normalizeConfig({ + type: "bigquery", + key_file: "/path/to/key.json", + }) + expect(result.credentials_path).toBe("/path/to/key.json") + expect(result.key_file).toBeUndefined() + }) + + test("keyFile → credentials_path", () => { + const result = normalizeConfig({ + type: "bigquery", + keyFile: "/path/to/key.json", + }) + expect(result.credentials_path).toBe("/path/to/key.json") + expect(result.keyFile).toBeUndefined() + }) + + test("keyfile_json → credentials_json", () => { + const result = normalizeConfig({ + type: "bigquery", + keyfile_json: '{"key": "value"}', + }) + expect(result.credentials_json).toBe('{"key": "value"}') + expect(result.keyfile_json).toBeUndefined() + }) + + test("keyfileJson → credentials_json", () => { + const result = normalizeConfig({ + type: "bigquery", + keyfileJson: '{"key": "value"}', + }) + expect(result.credentials_json).toBe('{"key": "value"}') + expect(result.keyfileJson).toBeUndefined() + }) + + test("defaultDataset → dataset", () => { + const result = normalizeConfig({ + type: "bigquery", + defaultDataset: "my_dataset", + }) + expect(result.dataset).toBe("my_dataset") + expect(result.defaultDataset).toBeUndefined() + }) + + test("default_dataset → dataset", () => { + const result = normalizeConfig({ + type: "bigquery", + default_dataset: "my_dataset", + }) + expect(result.dataset).toBe("my_dataset") + expect(result.default_dataset).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — Databricks aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — Databricks", () => { + test("host → server_hostname (databricks only)", () => { + const result = normalizeConfig({ + type: "databricks", + host: "my-workspace.cloud.databricks.com", + }) + expect(result.server_hostname).toBe("my-workspace.cloud.databricks.com") + expect(result.host).toBeUndefined() + }) + + test("serverHostname → server_hostname", () => { + const result = normalizeConfig({ + type: "databricks", + serverHostname: "my-workspace.cloud.databricks.com", + }) + expect(result.server_hostname).toBe("my-workspace.cloud.databricks.com") + expect(result.serverHostname).toBeUndefined() + }) + + test("httpPath → http_path", () => { + const result = normalizeConfig({ + type: "databricks", + httpPath: "/sql/1.0/endpoints/abc", + }) + expect(result.http_path).toBe("/sql/1.0/endpoints/abc") + expect(result.httpPath).toBeUndefined() + }) + + test("token → access_token", () => { + const result = normalizeConfig({ + type: "databricks", + token: "dapi1234", + }) + expect(result.access_token).toBe("dapi1234") + expect(result.token).toBeUndefined() + }) + + test("personal_access_token → access_token", () => { + const result = normalizeConfig({ + type: "databricks", + personal_access_token: "dapi1234", + }) + expect(result.access_token).toBe("dapi1234") + expect(result.personal_access_token).toBeUndefined() + }) + + test("personalAccessToken → access_token", () => { + const result = normalizeConfig({ + type: "databricks", + personalAccessToken: "dapi1234", + }) + expect(result.access_token).toBe("dapi1234") + expect(result.personalAccessToken).toBeUndefined() + }) + + test("host alias does NOT apply to postgres", () => { + const result = normalizeConfig({ + type: "postgres", + host: "localhost", + }) + expect(result.host).toBe("localhost") + expect(result.server_hostname).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — PostgreSQL / Redshift aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — PostgreSQL / Redshift", () => { + test("connectionString → connection_string for postgres", () => { + const result = normalizeConfig({ + type: "postgres", + connectionString: "postgresql://user:pass@host/db", + }) + expect(result.connection_string).toBe("postgresql://user:pass@host/db") + expect(result.connectionString).toBeUndefined() + }) + + test("connectionString → connection_string for redshift", () => { + const result = normalizeConfig({ + type: "redshift", + connectionString: "postgresql://user:pass@host/db", + }) + expect(result.connection_string).toBe("postgresql://user:pass@host/db") + expect(result.connectionString).toBeUndefined() + }) + + test("postgresql type alias works", () => { + const result = normalizeConfig({ + type: "postgresql", + username: "admin", + dbname: "mydb", + }) + expect(result.user).toBe("admin") + expect(result.database).toBe("mydb") + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — SQL Server aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — SQL Server", () => { + test("server → host", () => { + const result = normalizeConfig({ + type: "sqlserver", + server: "myserver.database.windows.net", + }) + expect(result.host).toBe("myserver.database.windows.net") + expect(result.server).toBeUndefined() + }) + + test("serverName → host", () => { + const result = normalizeConfig({ + type: "sqlserver", + serverName: "myserver", + }) + expect(result.host).toBe("myserver") + expect(result.serverName).toBeUndefined() + }) + + test("server_name → host", () => { + const result = normalizeConfig({ + type: "sqlserver", + server_name: "myserver", + }) + expect(result.host).toBe("myserver") + expect(result.server_name).toBeUndefined() + }) + + test("trustServerCertificate → trust_server_certificate", () => { + const result = normalizeConfig({ + type: "sqlserver", + trustServerCertificate: true, + }) + expect(result.trust_server_certificate).toBe(true) + expect(result.trustServerCertificate).toBeUndefined() + }) + + test("mssql type alias works", () => { + const result = normalizeConfig({ + type: "mssql", + server: "myserver", + username: "sa", + }) + expect(result.host).toBe("myserver") + expect(result.user).toBe("sa") + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — Oracle aliases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — Oracle", () => { + test("connectString → connection_string", () => { + const result = normalizeConfig({ + type: "oracle", + connectString: "localhost:1521/ORCL", + }) + expect(result.connection_string).toBe("localhost:1521/ORCL") + expect(result.connectString).toBeUndefined() + }) + + test("connect_string → connection_string", () => { + const result = normalizeConfig({ + type: "oracle", + connect_string: "localhost:1521/ORCL", + }) + expect(result.connection_string).toBe("localhost:1521/ORCL") + expect(result.connect_string).toBeUndefined() + }) + + test("connectionString → connection_string", () => { + const result = normalizeConfig({ + type: "oracle", + connectionString: "localhost:1521/ORCL", + }) + expect(result.connection_string).toBe("localhost:1521/ORCL") + expect(result.connectionString).toBeUndefined() + }) + + test("serviceName → service_name", () => { + const result = normalizeConfig({ + type: "oracle", + serviceName: "ORCL", + }) + expect(result.service_name).toBe("ORCL") + expect(result.serviceName).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — MySQL SSL fields stay top-level +// --------------------------------------------------------------------------- +// SSL fields (ssl_ca, ssl_cert, ssl_key) are NOT constructed into an ssl +// object by normalizeConfig. They stay top-level so the credential store can +// detect and strip them. The MySQL driver constructs the ssl object at +// connection time. + +describe("normalizeConfig — MySQL SSL fields", () => { + test("ssl_ca/ssl_cert/ssl_key stay as top-level fields", () => { + const result = normalizeConfig({ + type: "mysql", + host: "localhost", + ssl_ca: "/path/ca.pem", + ssl_cert: "/path/client-cert.pem", + ssl_key: "/path/client-key.pem", + }) + expect(result.ssl_ca).toBe("/path/ca.pem") + expect(result.ssl_cert).toBe("/path/client-cert.pem") + expect(result.ssl_key).toBe("/path/client-key.pem") + expect(result.ssl).toBeUndefined() + }) + + test("existing ssl object is preserved", () => { + const sslObj = { rejectUnauthorized: false } + const result = normalizeConfig({ + type: "mysql", + ssl: sslObj, + }) + expect(result.ssl).toEqual(sslObj) + }) + + test("mariadb ssl fields also stay top-level", () => { + const result = normalizeConfig({ + type: "mariadb", + ssl_ca: "/path/ca.pem", + }) + expect(result.ssl_ca).toBe("/path/ca.pem") + expect(result.ssl).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// isSensitiveField — expanded set +// --------------------------------------------------------------------------- + +describe("isSensitiveField — expanded set", () => { + const expectedSensitive = [ + "password", + "private_key_passphrase", + "private_key", + "access_token", + "token", + "ssh_password", + "connection_string", + "credentials_json", + "keyfile_json", + "ssl_key", + "ssl_cert", + "ssl_ca", + ] + + for (const field of expectedSensitive) { + test(`${field} is sensitive`, () => { + expect(isSensitiveField(field)).toBe(true) + }) + } + + test("host is not sensitive", () => { + expect(isSensitiveField("host")).toBe(false) + }) + + test("user is not sensitive", () => { + expect(isSensitiveField("user")).toBe(false) + }) + + test("database is not sensitive", () => { + expect(isSensitiveField("database")).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — does not mutate input +// --------------------------------------------------------------------------- + +describe("normalizeConfig — immutability", () => { + test("input config is not mutated", () => { + const original = { + type: "postgres", + username: "admin", + dbname: "mydb", + } + const copy = { ...original } + normalizeConfig(original) + expect(original).toEqual(copy) + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — case-insensitive type +// --------------------------------------------------------------------------- + +describe("normalizeConfig — case-insensitive type", () => { + test("uppercase POSTGRES normalizes aliases", () => { + const result = normalizeConfig({ type: "POSTGRES", username: "admin" }) + expect(result.user).toBe("admin") + expect(result.username).toBeUndefined() + }) + + test("mixed case Snowflake normalizes aliases", () => { + const result = normalizeConfig({ + type: "Snowflake", + account: "xy12345", + privateKeyPath: "/path/key.p8", + }) + expect(result.private_key_path).toBe("/path/key.p8") + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — DuckDB/SQLite passthrough +// --------------------------------------------------------------------------- + +describe("normalizeConfig — DuckDB/SQLite passthrough", () => { + test("duckdb config passes through unchanged", () => { + const config = { type: "duckdb", path: ":memory:" } + expect(normalizeConfig(config)).toEqual(config) + }) + + test("sqlite config passes through unchanged", () => { + const config = { type: "sqlite", path: "/tmp/test.db" } + expect(normalizeConfig(config)).toEqual(config) + }) +}) + +// --------------------------------------------------------------------------- +// normalizeConfig — Snowflake private_key edge cases +// --------------------------------------------------------------------------- + +describe("normalizeConfig — Snowflake private_key edge cases", () => { + test("opaque string without path indicators stays as private_key", () => { + const result = normalizeConfig({ + type: "snowflake", + account: "xy12345", + private_key: "some-opaque-token-value", + }) + expect(result.private_key).toBe("some-opaque-token-value") + expect(result.private_key_path).toBeUndefined() + }) +}) From f03ae6435242d33e45e7bcb8b0287e4748caaa3b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Wed, 18 Mar 2026 16:23:40 -0700 Subject: [PATCH 2/3] fix: add missing `passcode` and `oauth_client_secret` to sensitive field lists Add `passcode` and `oauth_client_secret` to `sensitiveKeys` in `project-scan.ts` and expand test coverage to verify all sensitive fields from `credential-store.ts` including alias variants. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/altimate/tools/project-scan.ts | 1 + packages/opencode/test/altimate/driver-normalize.test.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/tools/project-scan.ts b/packages/opencode/src/altimate/tools/project-scan.ts index 63a2dd3558..d2abd4d525 100644 --- a/packages/opencode/src/altimate/tools/project-scan.ts +++ b/packages/opencode/src/altimate/tools/project-scan.ts @@ -231,6 +231,7 @@ export async function detectEnvVars(): Promise { "password", "access_token", "token", "connection_string", "private_key_path", "private_key", "private_key_passphrase", "credentials_json", "keyfile_json", "ssl_key", "ssl_cert", "ssl_ca", + "oauth_client_secret", "passcode", ]) const config: Record = {} for (const [key, envNames] of Object.entries(wh.configMap)) { diff --git a/packages/opencode/test/altimate/driver-normalize.test.ts b/packages/opencode/test/altimate/driver-normalize.test.ts index 87a5310fef..f437e1187c 100644 --- a/packages/opencode/test/altimate/driver-normalize.test.ts +++ b/packages/opencode/test/altimate/driver-normalize.test.ts @@ -556,10 +556,16 @@ describe("normalizeConfig — MySQL SSL fields", () => { describe("isSensitiveField — expanded set", () => { const expectedSensitive = [ "password", - "private_key_passphrase", "private_key", + "privateKey", + "private_key_passphrase", + "privateKeyPassphrase", + "privateKeyPass", "access_token", "token", + "oauth_client_secret", + "oauthClientSecret", + "passcode", "ssh_password", "connection_string", "credentials_json", From 0d7ea8df950420a4cfe463dd7ecbf9cd238c45e9 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Wed, 18 Mar 2026 16:25:18 -0700 Subject: [PATCH 3/3] fix: restore Snowflake `username` fallback and fix `normalizeConfig` immutability - Restore `config.user ?? config.username` in Snowflake driver for backward compatibility with direct driver users who bypass the registry's `normalizeConfig` call - Fix `normalizeConfig` returning original object (breaking immutability contract) when `config.type` is empty/undefined Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/drivers/src/normalize.ts | 2 +- packages/drivers/src/snowflake.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/drivers/src/normalize.ts b/packages/drivers/src/normalize.ts index dd07274be5..edcb85b8c2 100644 --- a/packages/drivers/src/normalize.ts +++ b/packages/drivers/src/normalize.ts @@ -151,7 +151,7 @@ function normalizeSnowflakePrivateKey(config: ConnectionConfig): ConnectionConfi */ export function normalizeConfig(config: ConnectionConfig): ConnectionConfig { const type = config.type?.toLowerCase() - if (!type) return config + if (!type) return { ...config } const aliases = DRIVER_ALIASES[type] let result = aliases ? applyAliases(config, aliases) : { ...config } diff --git a/packages/drivers/src/snowflake.ts b/packages/drivers/src/snowflake.ts index ca72c74e4c..a6299cdcea 100644 --- a/packages/drivers/src/snowflake.ts +++ b/packages/drivers/src/snowflake.ts @@ -51,7 +51,7 @@ export async function connect(config: ConnectionConfig): Promise { async connect() { const options: Record = { account: config.account, - username: config.user, + username: config.user ?? config.username, database: config.database, schema: config.schema, warehouse: config.warehouse,