From 49eeea3786941a29c2260a8920f69d084461b8c1 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Tue, 19 May 2026 18:00:00 -0700 Subject: [PATCH 1/3] feat(api): add User-Agent header to raw apiFetch calls Add getCliUserAgent() to sdk.mts that builds the CLI's full user agent string for direct (non-SDK) API calls, including the CLI product token, Node.js version, and OS platform/arch: socket/1.1.96 node/v22.0.0 linux/arm64 Add User-Agent header to queryApi() and sendApiRequest() which both go through apiFetch() bypassing the SDK. For SDK-routed calls the userAgent option already passes just the CLI product token (socket/1.1.96 ...) and the SDK constructs the full UA by prepending its own base including node/OS. --- src/utils/api.mts | 4 +++- src/utils/sdk.mts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/utils/api.mts b/src/utils/api.mts index 26f1085ca2..26915afea5 100644 --- a/src/utils/api.mts +++ b/src/utils/api.mts @@ -39,7 +39,7 @@ import constants, { HTTP_STATUS_UNAUTHORIZED, } from '../constants.mts' import { getRequirements, getRequirementsKey } from './requirements.mts' -import { getDefaultApiToken, getExtraCaCerts } from './sdk.mts' +import { getCliUserAgent, getDefaultApiToken, getExtraCaCerts } from './sdk.mts' import type { CResult } from '../types.mts' import type { Spinner } from '@socketsecurity/registry/lib/spinner' @@ -437,6 +437,7 @@ async function queryApi(path: string, apiToken: string) { method: 'GET', headers: { Authorization: `Basic ${btoa(`${apiToken}:`)}`, + 'User-Agent': getCliUserAgent(), }, }) return result @@ -622,6 +623,7 @@ export async function sendApiRequest( headers: { Authorization: `Basic ${btoa(`${apiToken}:`)}`, 'Content-Type': 'application/json', + 'User-Agent': getCliUserAgent(), }, ...(body ? { body: JSON.stringify(body) } : {}), } diff --git a/src/utils/sdk.mts b/src/utils/sdk.mts index ac72b0dc29..dae24773de 100644 --- a/src/utils/sdk.mts +++ b/src/utils/sdk.mts @@ -50,6 +50,26 @@ import { trackCliEvent } from './telemetry/integration.mts' import type { CResult } from '../types.mts' import type { RequestInfo, ResponseInfo } from '@socketsecurity/sdk' +// Lazy-evaluated full CLI user agent for direct (non-SDK) API calls. +// Includes the CLI product token, Node.js version, and OS platform/arch. +// e.g. "socket/1.1.96 node/v22.0.0 linux/arm64" +let _cliUserAgent: string | undefined +export function getCliUserAgent(): string { + if (!_cliUserAgent) { + const name = constants.ENV.INLINED_SOCKET_CLI_NAME.replace('@', '').replace( + '/', + '-', + ) + const version = constants.ENV.INLINED_SOCKET_CLI_VERSION + _cliUserAgent = [ + `${name}/${version}`, + `node/${process.version}`, + `${process.platform}/${process.arch}`, + ].join(' ') + } + return _cliUserAgent +} + const TOKEN_PREFIX = 'sktsec_' const TOKEN_PREFIX_LENGTH = TOKEN_PREFIX.length const TOKEN_VISIBLE_LENGTH = 5 From 3778a647c87e384cc27b974cf754cbf797ca0324 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Tue, 19 May 2026 18:05:35 -0700 Subject: [PATCH 2/3] feat(dlx): pass SOCKET_CALLER_USER_AGENT when spawning coana Adds the CLI's full user agent string to the env vars forwarded to the coana subprocess. Coana reads SOCKET_CALLER_USER_AGENT and appends it to its own base UA, producing a chain like: coana-tech-cli/15.3.0 node/v22.0.0 linux/arm64 socket/1.1.96 node/v22.0.0 linux/arm64 --- src/utils/dlx.mts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/dlx.mts b/src/utils/dlx.mts index acb167af63..4c1fe6b6d1 100644 --- a/src/utils/dlx.mts +++ b/src/utils/dlx.mts @@ -33,7 +33,7 @@ import constants, { } from '../constants.mts' import { getErrorCause } from './errors.mts' import { findUp } from './fs.mts' -import { getDefaultApiToken, getDefaultProxyUrl } from './sdk.mts' +import { getCliUserAgent, getDefaultApiToken, getDefaultProxyUrl } from './sdk.mts' import { isYarnBerry } from './yarn-version.mts' import type { ShadowBinOptions, ShadowBinResult } from '../shadow/npm-base.mts' @@ -207,6 +207,8 @@ export async function spawnCoanaDlx( const mixinsEnv: Record = { SOCKET_CLI_VERSION: constants.ENV.INLINED_SOCKET_CLI_VERSION, + // Pass the CLI's full user agent so coana can append it to its own UA. + SOCKET_CALLER_USER_AGENT: getCliUserAgent(), } const defaultApiToken = getDefaultApiToken() if (defaultApiToken) { From 3d865a28d29368f6d5dadd9befb6e7d44a730e78 Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 7 Jul 2026 13:24:37 -0400 Subject: [PATCH 3/3] test(api): cover cli user-agent header + coana env injection api.test.mts and dlx.test.mts mocked ./sdk.mts without getCliUserAgent, so any test exercising queryApi/sendApiRequest or spawnCoanaDlx threw at the User-Agent call site instead of asserting real behavior. Add the missing mock export, assert the outgoing User-Agent header in api.mts requests, cover getCliUserAgent's format and caching in sdk.mts, and cover SOCKET_CALLER_USER_AGENT injection (and caller-env precedence) in spawnCoanaDlx. --- src/utils/api.test.mts | 6 ++++++ src/utils/dlx.test.mts | 34 ++++++++++++++++++++++++++++++++++ src/utils/sdk.test.mts | 19 ++++++++++++++++++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/utils/api.test.mts b/src/utils/api.test.mts index 2533d0d750..2c6dd37413 100644 --- a/src/utils/api.test.mts +++ b/src/utils/api.test.mts @@ -29,7 +29,11 @@ import type { EventEmitter } from 'node:events' // Mock getExtraCaCerts from sdk.mts. const mockGetExtraCaCerts = vi.hoisted(() => vi.fn(() => undefined)) const mockGetDefaultApiToken = vi.hoisted(() => vi.fn(() => 'test-api-token')) +const mockGetCliUserAgent = vi.hoisted(() => + vi.fn(() => 'socket-cli/1.0.0 node/v20.0.0 test/x64'), +) vi.mock('./sdk.mts', () => ({ + getCliUserAgent: mockGetCliUserAgent, getDefaultApiToken: mockGetDefaultApiToken, getDefaultApiBaseUrl: vi.fn(() => undefined), getDefaultProxyUrl: vi.fn(() => undefined), @@ -157,6 +161,8 @@ describe('apiFetch with extra CA certificates', () => { // The request is made with that explicit agent. const callArgs = mockHttpsRequest.mock.calls[0] expect(callArgs[1].agent).toMatchObject({ _isHttpsAgent: true }) + // The outgoing request carries the CLI's User-Agent header. + expect(callArgs[1].headers['User-Agent']).toBe(mockGetCliUserAgent()) expect(result.ok).toBe(true) }) diff --git a/src/utils/dlx.test.mts b/src/utils/dlx.test.mts index 888b62874b..bdcaf85463 100644 --- a/src/utils/dlx.test.mts +++ b/src/utils/dlx.test.mts @@ -19,6 +19,7 @@ vi.mock('@socketsecurity/registry/lib/spawn', () => ({ })) vi.mock('./sdk.mts', () => ({ + getCliUserAgent: () => 'socket-cli/1.0.0 node/v20.0.0 test/x64', getDefaultApiToken: () => undefined, getDefaultProxyUrl: () => undefined, })) @@ -678,6 +679,39 @@ describe('utils/dlx', () => { delete process.env['npm_config_registry'] } }) + + it('injects SOCKET_CALLER_USER_AGENT from getCliUserAgent into the spawn env', async () => { + const result = await spawnCoanaDlx(['run', '.'], 'acme', { + coanaVersion: nextVersion(), + }) + expect(result.ok).toBe(true) + + const nodeCall = mockSpawn.mock.calls.find(([cmd]) => cmd === 'node')! + const nodeEnv = (nodeCall[2] as { env: NodeJS.ProcessEnv }).env + + expect(nodeEnv['SOCKET_CALLER_USER_AGENT']).toBe( + 'socket-cli/1.0.0 node/v20.0.0 test/x64', + ) + }) + + it('lets a caller-supplied env.SOCKET_CALLER_USER_AGENT override the injected default', async () => { + // spawnCoanaDlx builds finalEnv as + // { ...process.env, ...constants.processEnv, ...mixinsEnv, ...spawnEnv } + // — spawnEnv (the caller's options.env) is spread last, so it wins. + const result = await spawnCoanaDlx(['run', '.'], 'acme', { + coanaVersion: nextVersion(), + env: { SOCKET_CALLER_USER_AGENT: 'custom-caller-agent/1.0.0' }, + }) + expect(result.ok).toBe(true) + + const nodeCall = mockSpawn.mock.calls.find(([cmd]) => cmd === 'node')! + const nodeEnv = (nodeCall[2] as { env: NodeJS.ProcessEnv }).env + + // caller-supplied env in options.env takes precedence over the injected default. + expect(nodeEnv['SOCKET_CALLER_USER_AGENT']).toBe( + 'custom-caller-agent/1.0.0', + ) + }) }) describe('spawnCoanaDlx stdio + error surfacing', () => { diff --git a/src/utils/sdk.test.mts b/src/utils/sdk.test.mts index 644e79497c..d299a90e8f 100644 --- a/src/utils/sdk.test.mts +++ b/src/utils/sdk.test.mts @@ -25,7 +25,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import constants from '../constants.mts' -import { getExtraCaCerts, setupSdk } from './sdk.mts' +import { getCliUserAgent, getExtraCaCerts, setupSdk } from './sdk.mts' import type { RequestInfo, ResponseInfo } from '@socketsecurity/sdk' @@ -575,6 +575,23 @@ describe('SDK setup with telemetry hooks', () => { }) }) +describe('getCliUserAgent', () => { + it('formats name/version, node version, and platform/arch', () => { + const userAgent = getCliUserAgent() + + expect(userAgent).toBe( + `socket-cli/1.1.34 node/${process.version} ${process.platform}/${process.arch}`, + ) + }) + + it('returns a stable value across repeated calls', () => { + const first = getCliUserAgent() + const second = getCliUserAgent() + + expect(second).toBe(first) + }) +}) + describe('getExtraCaCerts', () => { const savedEnv = { ...process.env }