From 653604b5823a0ab0dfe56f6db8047979f8d18542 Mon Sep 17 00:00:00 2001 From: Weishi Zeng Date: Thu, 29 Jan 2026 15:58:09 -0800 Subject: [PATCH 1/2] Add Node.js version requirements and build preparation - Document minimum Node.js (22.9.0) and npm (11.6.0) versions - Add note for nvm users about global package reinstallation - Add prepare script to run build before npm install - Clean up package-lock.json peer dependency markers Co-Authored-By: Claude Opus 4.5 --- README.md | 4 +++- package-lock.json | 7 ------- package.json | 1 + 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9a89087..02b9366 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,17 @@ for details. ### Prerequisites - `curl` and `npm` need to be present on your system. +- Node.js >= 22.9.0 and npm >= 11.6.0 (upgrade with `npm install -g npm@latest`) - The browser requires a graphical environment. - ### Steps 1. Clone this repository to your local machine. 2. Enter the repository's directory. 3. `npm install -g .` +**nvm users**: Global packages are per node version. If you switch versions, reinstall with `npm install -g .` + ## Integrations Warning: giving AI agents access to your API credentials is potentially diff --git a/package-lock.json b/package-lock.json index c6c86ef..4d04fc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1215,7 +1215,6 @@ "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1265,7 +1264,6 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -1596,7 +1594,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1897,7 +1894,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2530,7 +2526,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2874,7 +2869,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2930,7 +2924,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package.json b/package.json index af591e7..e28745c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "latchkey": "./dist/src/cli.js" }, "scripts": { + "prepare": "npm run build", "postinstall": "npx playwright install chromium", "build": "tsc", "dev": "tsc --watch", From b7ce6f134d5dfe074e08031fee13e9d4b33f2458 Mon Sep 17 00:00:00 2001 From: Weishi Zeng Date: Thu, 29 Jan 2026 16:45:13 -0800 Subject: [PATCH 2/2] Add Databricks service support Implement cookie-based authentication for Databricks workspaces using browser session cookies (DBAUTH) and CSRF tokens. Unlike other services with fixed URLs, Databricks uses dynamic workspace URLs (*.cloud.databricks.com) so it's handled via dynamic service creation rather than static registry. - Add DatabricksApiCredentials class for cookie + CSRF token storage - Add Databricks service with BrowserFollowupServiceSession for login flow - Update registry to detect Databricks URLs and create service dynamically - Update services command to include databricks in the list - Add unit tests for credentials serialization and URL matching - Document Databricks usage in README Co-Authored-By: Claude Opus 4.5 --- README.md | 8 +- src/apiCredentials.ts | 49 +++++++++ src/cliCommands.ts | 6 +- src/registry.ts | 18 ++- src/services/databricks.ts | 206 +++++++++++++++++++++++++++++++++++ src/services/index.ts | 1 + tests/apiCredentials.test.ts | 91 ++++++++++++++++ tests/registry.test.ts | 39 ++++++- 8 files changed, 413 insertions(+), 5 deletions(-) create mode 100644 src/services/databricks.ts diff --git a/README.md b/README.md index 02b9366..ed23d70 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ latchkey curl -X POST 'https://slack.com/api/conversations.create' \ Latchkey is a command-line tool that injects credentials to curl requests to known public APIs. - `latchkey services` - - Get a list of known and supported third-party services (Slack, Discord, Linear, GitHub, etc.). + - Get a list of known and supported third-party services (Slack, Discord, Linear, GitHub, Dropbox, Databricks). - `latchkey curl ` - Automatically inject credentials to your otherwise standard curl calls to public APIs. - (The first time you access a service, a browser pop-up with a login screen appears.) @@ -145,6 +145,12 @@ state file), run: latchkey clear ``` +### Databricks + +``` +latchkey curl 'https://dbc-b28fe787-b68d.cloud.databricks.com/ajax-api/2.0/mlflow/experiments/list' +``` + ### Advanced configuration You can set these environment variables to override certain diff --git a/src/apiCredentials.ts b/src/apiCredentials.ts index 1a6bd4b..808007d 100644 --- a/src/apiCredentials.ts +++ b/src/apiCredentials.ts @@ -125,6 +125,49 @@ export class SlackApiCredentials implements ApiCredentials { } } +/** + * Databricks-specific credentials (all cookies + CSRF token). + */ +export const DatabricksApiCredentialsSchema = z.object({ + objectType: z.literal('databricks'), + cookies: z.string(), // All cookies as a single string for curl -b + csrfToken: z.string(), + workspaceUrl: z.string(), +}); + +export type DatabricksApiCredentialsData = z.infer; + +export class DatabricksApiCredentials implements ApiCredentials { + readonly objectType = 'databricks' as const; + + constructor( + readonly cookies: string, + readonly csrfToken: string, + readonly workspaceUrl: string + ) {} + + asCurlArguments(): readonly string[] { + const args: string[] = ['-b', this.cookies]; + if (this.csrfToken) { + args.push('-H', `x-csrf-token: ${this.csrfToken}`); + } + return args; + } + + toJSON(): DatabricksApiCredentialsData { + return { + objectType: this.objectType, + cookies: this.cookies, + csrfToken: this.csrfToken, + workspaceUrl: this.workspaceUrl, + }; + } + + static fromJSON(data: DatabricksApiCredentialsData): DatabricksApiCredentials { + return new DatabricksApiCredentials(data.cookies, data.csrfToken, data.workspaceUrl); + } +} + /** * Union schema for all credential types. */ @@ -132,6 +175,7 @@ export const ApiCredentialsSchema = z.discriminatedUnion('objectType', [ AuthorizationBearerSchema, AuthorizationBareSchema, SlackApiCredentialsSchema, + DatabricksApiCredentialsSchema, ]); export type ApiCredentialsData = z.infer; @@ -147,6 +191,8 @@ export function deserializeCredentials(data: ApiCredentialsData): ApiCredentials return AuthorizationBare.fromJSON(data); case 'slack': return SlackApiCredentials.fromJSON(data); + case 'databricks': + return DatabricksApiCredentials.fromJSON(data); default: { const exhaustiveCheck: never = data; throw new ApiCredentialsSerializationError( @@ -169,6 +215,9 @@ export function serializeCredentials(credentials: ApiCredentials): ApiCredential if (credentials instanceof SlackApiCredentials) { return credentials.toJSON(); } + if (credentials instanceof DatabricksApiCredentials) { + return credentials.toJSON(); + } throw new ApiCredentialsSerializationError(`Unknown credential type: ${credentials.objectType}`); } diff --git a/src/cliCommands.ts b/src/cliCommands.ts index 9999174..a7d3c04 100644 --- a/src/cliCommands.ts +++ b/src/cliCommands.ts @@ -172,8 +172,10 @@ export function registerCommands(program: Command, deps: CliDependencies): void .command('services') .description('List known and supported third-party services.') .action(() => { - const serviceNames = deps.registry.services.map((service) => service.name); - deps.log(serviceNames.join(' ')); + const staticServices = deps.registry.services.map((service) => service.name); + // Add dynamic services that aren't in the static registry + const allServices = [...staticServices, 'databricks']; + deps.log(allServices.join(' ')); }); program diff --git a/src/registry.ts b/src/registry.ts index b1e0b70..dcaaed9 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -2,7 +2,16 @@ * Service registry for looking up services by name or URL. */ -import { Service, SLACK, DISCORD, DROPBOX, GITHUB, LINEAR } from './services/index.js'; +import { + Service, + SLACK, + DISCORD, + DROPBOX, + GITHUB, + LINEAR, + isDatabricksUrl, + createDatabricksService, +} from './services/index.js'; export class Registry { readonly services: readonly Service[]; @@ -21,6 +30,7 @@ export class Registry { } getByUrl(url: string): Service | null { + // Check static services first for (const service of this.services) { for (const baseApiUrl of service.baseApiUrls) { if (url.startsWith(baseApiUrl)) { @@ -28,6 +38,12 @@ export class Registry { } } } + + // Check dynamic services (Databricks) + if (isDatabricksUrl(url)) { + return createDatabricksService(url); + } + return null; } } diff --git a/src/services/databricks.ts b/src/services/databricks.ts new file mode 100644 index 0000000..ab851f0 --- /dev/null +++ b/src/services/databricks.ts @@ -0,0 +1,206 @@ +/** + * Databricks service implementation. + * + * Databricks uses cookie-based authentication with DBAUTH cookie and CSRF token. + * + * Note: The public API (`/api/2.0/`) requires Personal Access Tokens (PATs). + * The internal API (`/ajax-api/2.0/`) works with browser session cookies, + * which is what this implementation captures. Users should use ajax-api endpoints. + */ + +import type { Response, BrowserContext, Page } from 'playwright'; +import { ApiCredentialStatus, ApiCredentials, DatabricksApiCredentials } from '../apiCredentials.js'; +import { runCaptured } from '../curl.js'; +import { Service, BrowserFollowupServiceSession, LoginFailedError } from './base.js'; + +const LOGIN_DETECTION_TIMEOUT_MS = 120000; + +class DatabricksServiceSession extends BrowserFollowupServiceSession { + private csrfToken: string | null = null; + + constructor( + service: Service, + private readonly workspaceUrl: string + ) { + super(service); + } + + onResponse(response: Response): void { + const request = response.request(); + const url = request.url(); + + // Try to capture CSRF token from API requests + if (url.includes('.cloud.databricks.com')) { + request + .allHeaders() + .then((headers) => { + const csrf = headers['x-csrf-token']; + if (csrf) { + this.csrfToken = csrf; + } + }) + .catch(() => { + // Ignore errors + }); + } + } + + protected isHeadfulLoginComplete(): boolean { + // We use URL-based detection in waitForHeadfulLoginComplete + return false; + } + + /** + * Wait for login to complete by checking the page URL. + * Login is complete when the URL is on the workspace (not login/SSO pages). + */ + protected override async waitForHeadfulLoginComplete(page: Page): Promise { + const workspaceHost = new URL(this.workspaceUrl).host; + + // Poll until we're on the workspace and not on a login page + const startTime = Date.now(); + while (Date.now() - startTime < LOGIN_DETECTION_TIMEOUT_MS) { + const url = page.url(); + const isOnWorkspace = url.includes(workspaceHost); + const isOnLogin = + url.includes('/login') || + url.includes('/oidc') || + url.includes('/saml') || + url.includes('accounts.cloud.databricks.com'); + + if (isOnWorkspace && !isOnLogin) { + // Give a moment for the page to load + await page.waitForTimeout(2000); + return; + } + + await page.waitForTimeout(500); + } + + throw new LoginFailedError('Login timed out waiting for workspace page.'); + } + + protected async performBrowserFollowup(context: BrowserContext): Promise { + const page = context.pages()[0]; + if (!page) { + throw new LoginFailedError('No page available in browser context.'); + } + + // Navigate to trigger API calls and capture CSRF token + const responseHandler = async (response: Response): Promise => { + const request = response.request(); + try { + const headers = await request.allHeaders(); + const csrf = headers['x-csrf-token']; + if (csrf) { + this.csrfToken = csrf; + } + } catch { + // Ignore errors + } + }; + + page.on('response', responseHandler); + + try { + // Navigate to compute page to trigger API calls + await page.goto(`${this.workspaceUrl}/compute`, { + timeout: 30000, + waitUntil: 'networkidle', + }); + } catch { + // Ignore navigation errors, we might still have captured what we need + } + + page.off('response', responseHandler); + + // Extract all Databricks cookies from browser context + const cookies = await context.cookies(); + const databricksCookies = cookies.filter( + (c) => c.domain.includes('.cloud.databricks.com') || c.domain.includes('databricks.com') + ); + + if (databricksCookies.length === 0) { + throw new LoginFailedError('Failed to find Databricks cookies. Login may have failed.'); + } + + // Check that we have DBAUTH specifically + const hasDbAuth = databricksCookies.some((c) => c.name === 'DBAUTH'); + if (!hasDbAuth) { + throw new LoginFailedError('Failed to find DBAUTH cookie. Login may have failed.'); + } + + // Format cookies as "name1=value1; name2=value2; ..." for curl -b + const cookieString = databricksCookies.map((c) => `${c.name}=${c.value}`).join('; '); + + // CSRF token might not be required for all API endpoints + const csrfToken = this.csrfToken ?? ''; + + return new DatabricksApiCredentials(cookieString, csrfToken, this.workspaceUrl); + } +} + +export class Databricks implements Service { + readonly name = 'databricks'; + // Databricks workspace URLs vary, so we use a pattern-based approach + readonly baseApiUrls = [] as const; + readonly loginUrl: string; + readonly workspaceUrl: string; + + readonly credentialCheckCurlArguments: readonly string[]; + + constructor(workspaceUrl: string) { + // Normalize workspace URL (remove trailing slash and path) + const url = new URL(workspaceUrl); + this.workspaceUrl = `${url.protocol}//${url.host}`; + this.loginUrl = this.workspaceUrl; + this.credentialCheckCurlArguments = [`${this.workspaceUrl}/api/2.0/clusters/list`]; + } + + getSession(): DatabricksServiceSession { + return new DatabricksServiceSession(this, this.workspaceUrl); + } + + checkApiCredentials(apiCredentials: ApiCredentials): ApiCredentialStatus { + if (!(apiCredentials instanceof DatabricksApiCredentials)) { + return ApiCredentialStatus.Invalid; + } + + const result = runCaptured( + [ + '-s', + '-o', + '/dev/null', + '-w', + '%{http_code}', + ...apiCredentials.asCurlArguments(), + ...this.credentialCheckCurlArguments, + ], + 10 + ); + + if (result.stdout === '200') { + return ApiCredentialStatus.Valid; + } + return ApiCredentialStatus.Invalid; + } +} + +/** + * Check if a URL matches a Databricks workspace. + */ +export function isDatabricksUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.host.endsWith('.cloud.databricks.com'); + } catch { + return false; + } +} + +/** + * Create a Databricks service instance for the given URL. + */ +export function createDatabricksService(url: string): Databricks { + return new Databricks(url); +} diff --git a/src/services/index.ts b/src/services/index.ts index eebe243..9574f68 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -11,3 +11,4 @@ export { Discord, DISCORD } from './discord.js'; export { Github, GITHUB } from './github.js'; export { Dropbox, DROPBOX } from './dropbox.js'; export { Linear, LINEAR } from './linear.js'; +export { Databricks, isDatabricksUrl, createDatabricksService } from './databricks.js'; diff --git a/tests/apiCredentials.test.ts b/tests/apiCredentials.test.ts index 3f95ff2..3552c71 100644 --- a/tests/apiCredentials.test.ts +++ b/tests/apiCredentials.test.ts @@ -3,6 +3,7 @@ import { AuthorizationBearer, AuthorizationBare, SlackApiCredentials, + DatabricksApiCredentials, deserializeCredentials, serializeCredentials, ApiCredentialsSchema, @@ -88,6 +89,58 @@ describe('SlackApiCredentials', () => { }); }); +describe('DatabricksApiCredentials', () => { + it('should generate correct curl arguments with cookies and csrf token', () => { + const credentials = new DatabricksApiCredentials( + 'DBAUTH=token123; lfid=abc', + 'csrf-token-456', + 'https://workspace.cloud.databricks.com' + ); + expect(credentials.asCurlArguments()).toEqual([ + '-b', + 'DBAUTH=token123; lfid=abc', + '-H', + 'x-csrf-token: csrf-token-456', + ]); + }); + + it('should generate curl arguments without csrf token when empty', () => { + const credentials = new DatabricksApiCredentials( + 'DBAUTH=token123', + '', + 'https://workspace.cloud.databricks.com' + ); + expect(credentials.asCurlArguments()).toEqual(['-b', 'DBAUTH=token123']); + }); + + it('should serialize to JSON', () => { + const credentials = new DatabricksApiCredentials( + 'DBAUTH=token123', + 'csrf-token', + 'https://workspace.cloud.databricks.com' + ); + expect(credentials.toJSON()).toEqual({ + objectType: 'databricks', + cookies: 'DBAUTH=token123', + csrfToken: 'csrf-token', + workspaceUrl: 'https://workspace.cloud.databricks.com', + }); + }); + + it('should deserialize from JSON', () => { + const data = { + objectType: 'databricks' as const, + cookies: 'DBAUTH=token123; lfid=abc', + csrfToken: 'csrf-token-456', + workspaceUrl: 'https://workspace.cloud.databricks.com', + }; + const credentials = DatabricksApiCredentials.fromJSON(data); + expect(credentials.cookies).toBe('DBAUTH=token123; lfid=abc'); + expect(credentials.csrfToken).toBe('csrf-token-456'); + expect(credentials.workspaceUrl).toBe('https://workspace.cloud.databricks.com'); + }); +}); + describe('deserializeCredentials', () => { it('should deserialize AuthorizationBearer', () => { const data = { @@ -120,6 +173,19 @@ describe('deserializeCredentials', () => { expect((credentials as SlackApiCredentials).token).toBe('slack-token'); expect((credentials as SlackApiCredentials).dCookie).toBe('slack-cookie'); }); + + it('should deserialize DatabricksApiCredentials', () => { + const data = { + objectType: 'databricks' as const, + cookies: 'DBAUTH=token123', + csrfToken: 'csrf-token', + workspaceUrl: 'https://workspace.cloud.databricks.com', + }; + const credentials = deserializeCredentials(data); + expect(credentials).toBeInstanceOf(DatabricksApiCredentials); + expect((credentials as DatabricksApiCredentials).cookies).toBe('DBAUTH=token123'); + expect((credentials as DatabricksApiCredentials).csrfToken).toBe('csrf-token'); + }); }); describe('serializeCredentials', () => { @@ -150,6 +216,21 @@ describe('serializeCredentials', () => { dCookie: 'cookie', }); }); + + it('should serialize DatabricksApiCredentials', () => { + const credentials = new DatabricksApiCredentials( + 'DBAUTH=token123', + 'csrf-token', + 'https://workspace.cloud.databricks.com' + ); + const data = serializeCredentials(credentials); + expect(data).toEqual({ + objectType: 'databricks', + cookies: 'DBAUTH=token123', + csrfToken: 'csrf-token', + workspaceUrl: 'https://workspace.cloud.databricks.com', + }); + }); }); describe('ApiCredentialsSchema', () => { @@ -178,6 +259,16 @@ describe('ApiCredentialsSchema', () => { expect(result.success).toBe(true); }); + it('should validate DatabricksApiCredentials', () => { + const result = ApiCredentialsSchema.safeParse({ + objectType: 'databricks', + cookies: 'DBAUTH=token', + csrfToken: 'csrf', + workspaceUrl: 'https://workspace.cloud.databricks.com', + }); + expect(result.success).toBe(true); + }); + it('should reject invalid object type', () => { const result = ApiCredentialsSchema.safeParse({ objectType: 'invalid', diff --git a/tests/registry.test.ts b/tests/registry.test.ts index 4bcd116..27c94e1 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { Registry, REGISTRY } from '../src/registry.js'; -import { SLACK, DISCORD, GITHUB, DROPBOX, LINEAR } from '../src/services/index.js'; +import { SLACK, DISCORD, GITHUB, DROPBOX, LINEAR, isDatabricksUrl } from '../src/services/index.js'; describe('Registry', () => { describe('getByName', () => { @@ -71,6 +71,43 @@ describe('Registry', () => { expect(REGISTRY.getByUrl('https://slack.com/')).toBeNull(); expect(REGISTRY.getByUrl('https://slack.com')).toBeNull(); }); + + it('should find Databricks by workspace URL', () => { + const service = REGISTRY.getByUrl( + 'https://dbc-12345678-abcd.cloud.databricks.com/ajax-api/2.0/clusters/list' + ); + expect(service).not.toBeNull(); + expect(service?.name).toBe('databricks'); + }); + + it('should find Databricks for different workspace URLs', () => { + const service1 = REGISTRY.getByUrl( + 'https://adb-123456789.cloud.databricks.com/api/2.0/jobs/list' + ); + const service2 = REGISTRY.getByUrl( + 'https://dbc-b28fe787-b68d.cloud.databricks.com/ajax-api/2.0/mlflow/experiments/list' + ); + expect(service1?.name).toBe('databricks'); + expect(service2?.name).toBe('databricks'); + }); + }); + + describe('isDatabricksUrl', () => { + it('should match Databricks workspace URLs', () => { + expect(isDatabricksUrl('https://dbc-12345678-abcd.cloud.databricks.com/api/2.0/clusters/list')).toBe(true); + expect(isDatabricksUrl('https://adb-123456789.cloud.databricks.com/ajax-api/2.0/jobs/list')).toBe(true); + }); + + it('should not match non-Databricks URLs', () => { + expect(isDatabricksUrl('https://example.com')).toBe(false); + expect(isDatabricksUrl('https://databricks.com')).toBe(false); + expect(isDatabricksUrl('https://cloud.databricks.com')).toBe(false); + }); + + it('should handle invalid URLs gracefully', () => { + expect(isDatabricksUrl('not-a-url')).toBe(false); + expect(isDatabricksUrl('')).toBe(false); + }); }); describe('services', () => {