diff --git a/src/adapters/axiosAdapter.ts b/src/adapters/axiosAdapter.ts index 3392f60..0b0a150 100644 --- a/src/adapters/axiosAdapter.ts +++ b/src/adapters/axiosAdapter.ts @@ -4,6 +4,7 @@ import type { Adapter, AdapterResponse } from "../adapter"; import { AdapterError } from "../adapter"; import { ClientOptions } from "../clientOptions"; import { createRequestHeaders } from "./createRequestHeaders"; +import { createUserAgent } from "./createUserAgent"; export class AxiosAdapter implements Adapter { public async execute(options: ClientOptions): Promise> { @@ -21,11 +22,7 @@ export class AxiosAdapter implements Adapter { }; if (typeof XMLHttpRequest === "undefined") { if (config.headers) { - let userAgent = "ts-octopusdeploy"; - if (options.configuration.userAgentApp) { - userAgent = `${userAgent} ${options.configuration.userAgentApp}`; - } - config.headers["User-Agent"] = userAgent; + config.headers["User-Agent"] = createUserAgent(options.configuration); } } const response = await axios.request(config); diff --git a/src/adapters/createUserAgent.test.ts b/src/adapters/createUserAgent.test.ts new file mode 100644 index 0000000..45c7dfa --- /dev/null +++ b/src/adapters/createUserAgent.test.ts @@ -0,0 +1,25 @@ +import { ClientConfiguration } from "../clientConfiguration"; +import { createUserAgent } from "./createUserAgent"; + +describe("createUserAgent", () => { + const configuration: ClientConfiguration = { + instanceURL: "https://my.octopus.app", + userAgentApp: "GitHubActions (release;deploy;v3)", + }; + + test("is unchanged when no agent is detected", () => { + expect(createUserAgent(configuration, {})).toBe("ts-octopusdeploy GitHubActions (release;deploy;v3)"); + }); + + test("places the agent between the library and the requesting app", () => { + expect(createUserAgent(configuration, { CLAUDECODE: "1" })).toBe("ts-octopusdeploy AIAgent/ClaudeCode GitHubActions (release;deploy;v3)"); + }); + + test("uses the caller-supplied agent", () => { + expect(createUserAgent({ ...configuration, aiAgent: "claude-code" }, {})).toBe("ts-octopusdeploy AIAgent/ClaudeCode GitHubActions (release;deploy;v3)"); + }); + + test("omits an empty requesting app", () => { + expect(createUserAgent({ ...configuration, userAgentApp: "" }, {})).toBe("ts-octopusdeploy"); + }); +}); diff --git a/src/adapters/createUserAgent.ts b/src/adapters/createUserAgent.ts new file mode 100644 index 0000000..56c39ca --- /dev/null +++ b/src/adapters/createUserAgent.ts @@ -0,0 +1,6 @@ +import { getAiAgentUserAgentToken } from "../aiAgent"; +import type { ClientConfiguration } from "../clientConfiguration"; + +export function createUserAgent(configuration: ClientConfiguration, env?: Record): string { + return ["ts-octopusdeploy", getAiAgentUserAgentToken(configuration.aiAgent, env), configuration.userAgentApp].filter((part) => !!part).join(" "); +} diff --git a/src/aiAgent.test.ts b/src/aiAgent.test.ts new file mode 100644 index 0000000..7bfb313 --- /dev/null +++ b/src/aiAgent.test.ts @@ -0,0 +1,103 @@ +import { getAiAgentUserAgentToken } from "./aiAgent"; + +describe("getAiAgentUserAgentToken", () => { + test("returns undefined when no agent is detected", () => { + expect(getAiAgentUserAgentToken(undefined, {})).toBeUndefined(); + }); + + test.each([ + ["CLAUDECODE", "1", "AIAgent/ClaudeCode"], + ["CLAUDE_CODE_ENTRYPOINT", "cli", "AIAgent/ClaudeCode"], + ["CURSOR_AGENT", "1", "AIAgent/Cursor"], + ["CURSOR_EXTENSION_HOST_ROLE", "agent-exec", "AIAgent/Cursor"], + ["CODEX_SANDBOX", "seatbelt", "AIAgent/Codex"], + ["CODEX_THREAD_ID", "abc", "AIAgent/Codex"], + ["GEMINI_CLI", "1", "AIAgent/GeminiCLI"], + ["COPILOT_CLI", "1", "AIAgent/GitHubCopilot"], + ["OPENCODE", "1", "AIAgent/OpenCode"], + ["OPENCODE_CLIENT", "cli", "AIAgent/OpenCode"], + ["ANTIGRAVITY_AGENT", "1", "AIAgent/Antigravity"], + ["GOOSE_TERMINAL", "1", "AIAgent/Goose"], + ["AGENT", "goose", "AIAgent/Goose"], + ["AGENT", "amp", "AIAgent/Amp"], + ["CLINE_ACTIVE", "true", "AIAgent/Cline"], + ["ROO_CODE_TASK_ID", "abc", "AIAgent/RooCode"], + ["AUGMENT_AGENT", "1", "AIAgent/Augment"], + ["QWEN_CODE", "1", "AIAgent/QwenCode"], + ["DROID_CLI", "1", "AIAgent/FactoryDroid"], + ["KIMI_CLI", "1", "AIAgent/KimiCLI"], + ["TRAE_AI_SHELL_ID", "abc", "AIAgent/Trae"], + ["OR_APP_NAME", "aider", "AIAgent/Aider"], + ["OR_APP_NAME", "OpenHands", "AIAgent/OpenHands"], + ])("detects %s=%s as %s", (variable, value, expected) => { + expect(getAiAgentUserAgentToken(undefined, { [variable]: value })).toBe(expected); + }); + + test.each([ + ["CURSOR_EXTENSION_HOST_ROLE", "user"], + ["OPENCODE", "0"], + ["AGENT", "teamcity-agent-01"], + ["OR_APP_NAME", "SomethingElse"], + ])("requires a matching value for %s", (variable, value) => { + expect(getAiAgentUserAgentToken(undefined, { [variable]: value })).toBeUndefined(); + }); + + test.each(["CURSOR_TRACE_ID", "COPILOT_GITHUB_TOKEN", "GOOSE_PROVIDER", "REPL_ID", "ZED_TERM"])("ignores %s, which is set without an agent", (variable) => { + expect(getAiAgentUserAgentToken(undefined, { [variable]: "1" })).toBeUndefined(); + }); + + test("the first known agent wins when agents are nested", () => { + expect(getAiAgentUserAgentToken(undefined, { CURSOR_AGENT: "1", CLAUDECODE: "1" })).toBe("AIAgent/ClaudeCode"); + }); + + test.each([ + ["claude-code_2-1-322_agent", "AIAgent/ClaudeCode"], + ["claude-code", "AIAgent/ClaudeCode"], + ["Claude-Code", "AIAgent/ClaudeCode"], + ["cursor-cli", "AIAgent/Cursor"], + ["github_copilot_vscode_agent", "AIAgent/GitHubCopilot"], + ["github-copilot-cli", "AIAgent/GitHubCopilot"], + ["devin@1", "AIAgent/Devin"], + ["custom-agent@2.0", "AIAgent/Other"], + ])("maps AI_AGENT=%s to %s", (value, expected) => { + expect(getAiAgentUserAgentToken(undefined, { AI_AGENT: value })).toBe(expected); + }); + + test("AI_AGENT takes precedence over vendor variables", () => { + expect(getAiAgentUserAgentToken(undefined, { CURSOR_AGENT: "1", AI_AGENT: "claude-code_2-1-322_agent" })).toBe("AIAgent/ClaudeCode"); + }); + + test("an unrecognised AI_AGENT falls back to the vendor name", () => { + expect(getAiAgentUserAgentToken(undefined, { GEMINI_CLI: "1", AI_AGENT: "gcli" })).toBe("AIAgent/GeminiCLI"); + }); + + test.each([ + [" evil) agent\r\nX-Injected: 1 ", "AIAgent/Other"], + [" \t ", undefined], + ])("never includes the raw AI_AGENT value (%j)", (value, expected) => { + expect(getAiAgentUserAgentToken(undefined, { AI_AGENT: value })).toBe(expected); + }); + + describe("caller-supplied agent", () => { + test("is mapped to a known name", () => { + expect(getAiAgentUserAgentToken("claude-code", {})).toBe("AIAgent/ClaudeCode"); + }); + + test("takes precedence over AI_AGENT and vendor variables", () => { + expect(getAiAgentUserAgentToken("cursor-vscode", { AI_AGENT: "claude-code", CLAUDECODE: "1" })).toBe("AIAgent/Cursor"); + }); + + test("falls back to AI_AGENT, then vendor variables, when not recognised", () => { + expect(getAiAgentUserAgentToken("some-mcp-client", { AI_AGENT: "claude-code" })).toBe("AIAgent/ClaudeCode"); + expect(getAiAgentUserAgentToken("some-mcp-client", { CLAUDECODE: "1" })).toBe("AIAgent/ClaudeCode"); + }); + + test("reports as Other when nothing is recognised", () => { + expect(getAiAgentUserAgentToken("some-mcp-client", {})).toBe("AIAgent/Other"); + }); + + test("matches names containing spaces", () => { + expect(getAiAgentUserAgentToken("Claude Code", {})).toBe("AIAgent/ClaudeCode"); + }); + }); +}); diff --git a/src/aiAgent.ts b/src/aiAgent.ts new file mode 100644 index 0000000..b4ae1ef --- /dev/null +++ b/src/aiAgent.ts @@ -0,0 +1,109 @@ +// The agent list is maintained in go-octopusdeploy and ported from there; keep in sync. +// https://github.com/OctopusDeploy/go-octopusdeploy/blob/main/pkg/services/api/ai_agent_provider.go +// +// Detection relies on self-reported values, so it is suitable for analytics but must not be relied on for +// security decisions. + +type EnvironmentVariables = Record; + +interface AgentRule { + variable: string; + // When set, the variable's value must match (case-insensitive). Otherwise any non-empty value matches. + value?: string; +} + +// The emerging cross-vendor convention for an agent to identify itself. +// https://github.com/vercel/vercel/tree/main/packages/detect-agent +const aiAgentVariable = "AI_AGENT"; + +const otherAgentName = "Other"; + +// Ordered: the first agent with a matching rule wins, so the result is deterministic when several agents' +// variables are present (e.g. one agent running another). +// +// Variables that are commonly present without an agent driving the process are deliberately excluded: +// CURSOR_TRACE_ID (any Cursor terminal), COPILOT_GITHUB_TOKEN and GOOSE_PROVIDER (user configuration), +// REPL_ID (any Replit workspace) and ZED_TERM (any Zed terminal). +const knownAgents: ReadonlyArray<{ name: string; rules: AgentRule[] }> = [ + { name: "ClaudeCode", rules: [{ variable: "CLAUDECODE" }, { variable: "CLAUDE_CODE_ENTRYPOINT" }] }, + { name: "Cursor", rules: [{ variable: "CURSOR_AGENT" }, { variable: "CURSOR_EXTENSION_HOST_ROLE", value: "agent-exec" }] }, + { name: "Codex", rules: [{ variable: "CODEX_SANDBOX" }, { variable: "CODEX_THREAD_ID" }, { variable: "CODEX_CI" }] }, + { name: "GeminiCLI", rules: [{ variable: "GEMINI_CLI" }] }, + { name: "GitHubCopilot", rules: [{ variable: "COPILOT_CLI" }, { variable: "COPILOT_AGENT" }, { variable: "GITHUB_COPILOT_CLI_MODE" }] }, + { name: "OpenCode", rules: [{ variable: "OPENCODE", value: "1" }, { variable: "OPENCODE_CLIENT" }, { variable: "OPENCODE_AI" }] }, + { name: "Antigravity", rules: [{ variable: "ANTIGRAVITY_AGENT" }] }, + { name: "Goose", rules: [{ variable: "GOOSE_TERMINAL" }, { variable: "AGENT", value: "goose" }] }, + { name: "Amp", rules: [{ variable: "AGENT", value: "amp" }] }, + { name: "Cline", rules: [{ variable: "CLINE_ACTIVE" }, { variable: "CLINE_TASK_ID" }] }, + { name: "RooCode", rules: [{ variable: "ROO_CODE_TASK_ID" }] }, + { name: "Augment", rules: [{ variable: "AUGMENT_AGENT" }] }, + { name: "QwenCode", rules: [{ variable: "QWEN_CODE" }] }, + { name: "FactoryDroid", rules: [{ variable: "DROID_CLI" }] }, + { name: "KimiCLI", rules: [{ variable: "KIMI_CLI" }] }, + { name: "Trae", rules: [{ variable: "TRAE_AI_SHELL_ID" }] }, + { name: "Aider", rules: [{ variable: "OR_APP_NAME", value: "Aider" }] }, + { name: "OpenHands", rules: [{ variable: "OR_APP_NAME", value: "OpenHands" }] }, +]; + +// Maps the start of a normalised self-reported agent identifier to a known agent name. Vendors don't agree on a +// format (e.g. "claude-code_2-1-322_agent", "github_copilot_vscode_agent", "devin@1"), so values are lowercased +// and underscores and spaces replaced with hyphens before matching. +const agentNamePrefixes: ReadonlyArray<{ prefix: string; name: string }> = [ + { prefix: "claude-code", name: "ClaudeCode" }, + { prefix: "cursor", name: "Cursor" }, + { prefix: "codex", name: "Codex" }, + { prefix: "gemini", name: "GeminiCLI" }, + { prefix: "github-copilot", name: "GitHubCopilot" }, + { prefix: "opencode", name: "OpenCode" }, + { prefix: "antigravity", name: "Antigravity" }, + { prefix: "goose", name: "Goose" }, + { prefix: "amp", name: "Amp" }, + { prefix: "cline", name: "Cline" }, + { prefix: "augment", name: "Augment" }, + { prefix: "devin", name: "Devin" }, + { prefix: "replit", name: "Replit" }, + { prefix: "v0", name: "V0" }, +]; + +/** + * Returns a User-Agent token naming the AI agent driving this client (e.g. "AIAgent/ClaudeCode"), or undefined + * when none is detected. + * + * Self-reported identifiers are checked first, in order: an identifier supplied by the caller (for example an MCP + * server passing the connected client's `clientInfo.name`), then the `AI_AGENT` environment variable. If neither is + * recognised, vendor-specific environment variables are checked. An identifier that is present but not recognised + * reports as "AIAgent/Other". Only the normalised name is reported: raw identifiers are free-form, so they are used + * for matching and never included in the output. + */ +export function getAiAgentUserAgentToken(callerSuppliedAgent?: string, env: EnvironmentVariables = readProcessEnvironment()): string | undefined { + const selfReported = [callerSuppliedAgent, env[aiAgentVariable]].map((value) => value?.trim()).filter((value): value is string => !!value); + + const name = + selfReported.map(nameFromIdentifier).find((n): n is string => n !== undefined) ?? + nameFromVendorVariables(env) ?? + (selfReported.length > 0 ? otherAgentName : undefined); + + return name ? `AIAgent/${name}` : undefined; +} + +function nameFromIdentifier(identifier: string): string | undefined { + const normalised = identifier.toLowerCase().replace(/[_\s]+/g, "-"); + return agentNamePrefixes.find((p) => normalised.startsWith(p.prefix))?.name; +} + +function nameFromVendorVariables(env: EnvironmentVariables): string | undefined { + return knownAgents.find((agent) => + agent.rules.some((rule) => { + const value = env[rule.variable]; + if (!value) { + return false; + } + return rule.value === undefined || value.toLowerCase() === rule.value.toLowerCase(); + }) + )?.name; +} + +function readProcessEnvironment(): EnvironmentVariables { + // The client also runs in browsers, where there is no process environment to read. + return typeof process !== "undefined" && process.env ? process.env : {}; +} diff --git a/src/clientConfiguration.ts b/src/clientConfiguration.ts index 04e3440..18dbf8a 100644 --- a/src/clientConfiguration.ts +++ b/src/clientConfiguration.ts @@ -3,6 +3,7 @@ import { Logger } from "./logger"; export interface ClientConfiguration { userAgentApp: string; + aiAgent?: string; httpsAgent?: Agent; apiKey?: string; accessToken?: string;