Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions src/adapters/axiosAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TResource> implements Adapter<TResource> {
public async execute(options: ClientOptions): Promise<AdapterResponse<TResource>> {
Expand All @@ -21,11 +22,7 @@ export class AxiosAdapter<TResource> implements Adapter<TResource> {
};
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<TResource>(config);
Expand Down
25 changes: 25 additions & 0 deletions src/adapters/createUserAgent.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
6 changes: 6 additions & 0 deletions src/adapters/createUserAgent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { getAiAgentUserAgentToken } from "../aiAgent";
import type { ClientConfiguration } from "../clientConfiguration";

export function createUserAgent(configuration: ClientConfiguration, env?: Record<string, string | undefined>): string {
return ["ts-octopusdeploy", getAiAgentUserAgentToken(configuration.aiAgent, env), configuration.userAgentApp].filter((part) => !!part).join(" ");
}
103 changes: 103 additions & 0 deletions src/aiAgent.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
109 changes: 109 additions & 0 deletions src/aiAgent.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;

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 : {};
}
1 change: 1 addition & 0 deletions src/clientConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Logger } from "./logger";

export interface ClientConfiguration {
userAgentApp: string;
aiAgent?: string;
httpsAgent?: Agent;
apiKey?: string;
accessToken?: string;
Expand Down
Loading