diff --git a/src/commands/manifest/bazel/bazel-repo-discovery.test.mts b/src/commands/manifest/bazel/bazel-repo-discovery.test.mts index 0be94f9c2..c5fd90746 100644 --- a/src/commands/manifest/bazel/bazel-repo-discovery.test.mts +++ b/src/commands/manifest/bazel/bazel-repo-discovery.test.mts @@ -254,7 +254,7 @@ describe('bazel-repo-discovery', () => { probeResult({ code: 2, stderr: - 'ERROR: In extension argument @rules_jvm_external//:extensions.bzl%maven: No module with the apparent repo name @rules_jvm_external exists in the dependency graph. Type \'bazel help mod\' for syntax and help.\n', + "ERROR: In extension argument @rules_jvm_external//:extensions.bzl%maven: No module with the apparent repo name @rules_jvm_external exists in the dependency graph. Type 'bazel help mod' for syntax and help.\n", }), 0, ), diff --git a/src/commands/manifest/bazel/cmd-manifest-bazel.test.mts b/src/commands/manifest/bazel/cmd-manifest-bazel.test.mts index 8cc8ea254..4936f484f 100644 --- a/src/commands/manifest/bazel/cmd-manifest-bazel.test.mts +++ b/src/commands/manifest/bazel/cmd-manifest-bazel.test.mts @@ -243,18 +243,18 @@ describe('evaluateEcosystemOutcomes (explicit mode)', () => { }) describe('perRepoTimeout flag wiring', () => { - const importMeta = { url: 'file:///cmd-manifest-bazel.test.mts' } as ImportMeta + const importMeta = { + url: 'file:///cmd-manifest-bazel.test.mts', + } as ImportMeta beforeEach(() => { vi.mocked(extractBazelToMaven).mockClear() }) it('defaults the explicit command to a 120s per-repo timeout', async () => { - await cmdManifestBazel.run( - [FLAG_CONFIG, '{}', '.'], - importMeta, - { parentName: 'manifest' } as CliCommandContext, - ) + await cmdManifestBazel.run([FLAG_CONFIG, '{}', '.'], importMeta, { + parentName: 'manifest', + } as CliCommandContext) expect(extractBazelToMaven).toHaveBeenCalledTimes(1) expect(extractBazelToMaven).toHaveBeenCalledWith( expect.objectContaining({ perRepoTimeoutMs: 120_000 }), diff --git a/src/commands/manifest/bazel/extract_bazel_to_maven.test.mts b/src/commands/manifest/bazel/extract_bazel_to_maven.test.mts index 6b50ac7df..393804098 100644 --- a/src/commands/manifest/bazel/extract_bazel_to_maven.test.mts +++ b/src/commands/manifest/bazel/extract_bazel_to_maven.test.mts @@ -1192,9 +1192,7 @@ Fetched repositories: expect(result.status).toBe('partial') expect(result.complete).toBe(false) expect(result.manifestPaths).toHaveLength(1) - const loadFailed = result.workspaceOutcomes.filter( - w => w.load === 'failed', - ) + const loadFailed = result.workspaceOutcomes.filter(w => w.load === 'failed') expect(loadFailed).toHaveLength(1) }) diff --git a/src/utils/api.mts b/src/utils/api.mts index 99ff1f247..564eed931 100644 --- a/src/utils/api.mts +++ b/src/utils/api.mts @@ -40,7 +40,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' @@ -455,6 +455,7 @@ async function queryApi(path: string, apiToken: string) { method: 'GET', headers: { Authorization: `Basic ${btoa(`${apiToken}:`)}`, + 'User-Agent': getCliUserAgent(), }, }) return result @@ -640,6 +641,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/api.test.mts b/src/utils/api.test.mts index 2533d0d75..2c6dd3741 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.mts b/src/utils/dlx.mts index f5fb6c20a..639bad848 100644 --- a/src/utils/dlx.mts +++ b/src/utils/dlx.mts @@ -37,7 +37,11 @@ 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' @@ -437,9 +441,8 @@ export async function spawnCoanaDlx( const mixinsEnv: Record = { SOCKET_CLI_VERSION: constants.ENV.INLINED_SOCKET_CLI_VERSION, // Forwarded to the Coana CLI so it can append our product token to its - // outbound axios User-Agent header. Format mirrors Coana's base UA: - // `socket/ node/ /`. - SOCKET_CALLER_USER_AGENT: `socket/${constants.ENV.INLINED_SOCKET_CLI_VERSION} node/${process.version} ${process.platform}/${process.arch}`, + // outbound axios User-Agent header. + SOCKET_CALLER_USER_AGENT: getCliUserAgent(), } const defaultApiToken = getDefaultApiToken() if (defaultApiToken) { diff --git a/src/utils/dlx.test.mts b/src/utils/dlx.test.mts index 888b62874..bdcaf8546 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/glob.test.mts b/src/utils/glob.test.mts index 9cbd3c836..8967093e5 100644 --- a/src/utils/glob.test.mts +++ b/src/utils/glob.test.mts @@ -327,7 +327,8 @@ describe('glob utilities', () => { // pyvenv.cfg marker at its root. Manifests inside it must not surface. mockTestFs({ [`${mockFixturePath}/requirements.txt`]: '', - [`${mockFixturePath}/myenv/pyvenv.cfg`]: 'home = /usr/bin\nversion = 3.11.0\n', + [`${mockFixturePath}/myenv/pyvenv.cfg`]: + 'home = /usr/bin\nversion = 3.11.0\n', [`${mockFixturePath}/myenv/requirements.txt`]: '', [`${mockFixturePath}/myenv/lib/python3.11/site-packages/foo/setup.py`]: '', diff --git a/src/utils/sdk.mts b/src/utils/sdk.mts index 9d551e669..00ec18097 100644 --- a/src/utils/sdk.mts +++ b/src/utils/sdk.mts @@ -51,6 +51,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 diff --git a/src/utils/sdk.test.mts b/src/utils/sdk.test.mts index 644e79497..d299a90e8 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 }