diff --git a/src/commands/mcp.test.ts b/src/commands/mcp.test.ts index 288388db..76cdb4f8 100644 --- a/src/commands/mcp.test.ts +++ b/src/commands/mcp.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; import type { Dependencies } from "../container.js"; import { createMockAuthService, @@ -9,6 +9,10 @@ import { createMockGitHitsService, createMockPackageIntelligenceService, } from "../services/test-helpers.js"; +import { + buildClientHeaders, + resetRequestHeadersState, +} from "../shared/request-headers.js"; import { createMcpServer, getMcpToolDefinitions, @@ -232,6 +236,14 @@ describe("createMcpServer", () => { }); describe("startMcpServer", () => { + // `startMcpServer` mutates module-level state in `request-headers.ts` + // (sets `clientName = "githits-cli/mcp"` and registers the lazy MCP + // client-version provider). Reset after each test so later test + // files inherit the default state. + afterEach(() => { + resetRequestHeadersState(); + }); + it("starts successfully without a valid token", async () => { const deps = createTestDeps({ hasValidToken: false }); @@ -239,4 +251,43 @@ describe("startMcpServer", () => { // Auth errors are deferred to individual tool calls. await expect(startMcpServer(deps)).resolves.toBeUndefined(); }); + + it("sets clientMode to githits-cli/mcp", async () => { + // After startMcpServer runs, subsequent buildClientHeaders calls + // tag the client as MCP-mode. Pins the telemetry contract. + const deps = createTestDeps({ hasValidToken: false }); + await startMcpServer(deps); + const headers = buildClientHeaders({}); + expect(headers["x-githits-client-name"]).toBe("githits-cli/mcp"); + }); + + it("registers a lazy MCP clientInfo provider (read at request time, not via race-prone notification)", async () => { + // The MCP SDK dispatches `oninitialized` via an async notification + // that can race the first tool call. The provider pattern reads + // clientInfo synchronously on every buildClientHeaders call, + // eliminating the race. This test pins the provider-based flow. + const deps = createTestDeps({ hasValidToken: false }); + await startMcpServer(deps); + + // Before the initialize handshake lands, the provider returns + // undefined — `buildClientHeaders` falls back to env detection + // (none in the test env), so no x-githits-agent header. + const headersBefore = buildClientHeaders({}); + expect(headersBefore["x-githits-agent"]).toBeUndefined(); + + // Simulate the SDK's _oninitialize landing: `_clientVersion` is + // set synchronously inside the SDK before the initialize response + // is returned. A tool call reaching buildClientHeaders after that + // point should see the agent header populated. We can't easily + // reach into the real SDK's private state from here, so this + // test pins the *mechanism* (provider registration) rather than + // the end-to-end race; the provider's correctness is exercised + // in request-headers.test.ts. + // At this point the provider is registered but returns undefined — + // confirm the fallback path to env detection also works. + const headersWithAgent = buildClientHeaders({ + GITHITS_AGENT: "test-harness/1.0.0", + }); + expect(headersWithAgent["x-githits-agent"]).toBe("test-harness/1.0.0"); + }); }); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 842cc3f1..b603e5dc 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -4,6 +4,10 @@ import type { Command } from "commander"; import { version } from "../../package.json"; import { createContainer, type Dependencies } from "../container.js"; import { dim, highlight, shouldUseColors } from "../shared/colors.js"; +import { + setClientMode, + setMcpClientVersionProvider, +} from "../shared/request-headers.js"; import { createFeedbackTool, createGrepFileTool, @@ -89,10 +93,50 @@ export function createMcpServer(deps: Dependencies): McpServer { /** * Start the MCP server. Exported for testability. + * + * Telemetry wiring: + * - `setClientMode("mcp")` tags every subsequent API request with + * `x-githits-client-name: githits-cli/mcp` so backend telemetry + * can distinguish MCP-driven traffic from direct-CLI traffic. + * - `setMcpClientVersionProvider` registers a lazy reader that + * pulls the connecting client's `clientInfo` (cursor, + * claude-code, etc.) on every request. The MCP SDK sets + * `_clientVersion` synchronously inside `_oninitialize` before + * the initialize response is sent back, so every tool call that + * arrives after the handshake sees a populated value — + * eliminating the race the older `oninitialized` callback + * pattern had where the first tool call could slip through + * before the notification dispatched. */ export async function startMcpServer(deps: Dependencies): Promise { + setClientMode("mcp"); + const server = createMcpServer(deps); const transport = new StdioServerTransport(); + + setMcpClientVersionProvider(() => { + try { + const clientVersion = server.server.getClientVersion(); + if ( + !clientVersion?.name || + typeof clientVersion.name !== "string" || + clientVersion.name.trim().length === 0 + ) { + return undefined; + } + const name = clientVersion.name.trim(); + const rawVersion = clientVersion.version; + const versionOut = + typeof rawVersion === "string" && rawVersion.trim().length > 0 + ? rawVersion.trim() + : undefined; + return { name, version: versionOut }; + } catch { + // Agent header is optional — never block the request. + return undefined; + } + }); + await server.connect(transport); } diff --git a/src/services/githits-service.test.ts b/src/services/githits-service.test.ts index 13208935..748e6b33 100644 --- a/src/services/githits-service.test.ts +++ b/src/services/githits-service.test.ts @@ -51,6 +51,20 @@ describe("GitHitsServiceImpl", () => { expect(headers.Authorization).toBe("Bearer test-token"); }); + it("sends x-githits-* telemetry headers on REST requests", async () => { + // Pins the contract that `GitHitsServiceImpl.headers()` spreads + // the telemetry headers onto every REST call, not just that the + // shared builder emits them. + const fn = mockFetch(() => Promise.resolve(new Response("result"))); + await service.search({ query: "probe", language: "javascript" }); + + const call = fn.mock.calls[0] as unknown as [string, RequestInit]; + const headers = call[1].headers as Record; + expect(headers["x-githits-client-name"]).toBe("githits-cli"); + expect(headers["x-githits-client-version"]).toMatch(/^\S+$/); + expect(headers["x-githits-session-id"]).toMatch(/^[0-9a-f]{16}$/); + }); + it("passes custom license_mode", async () => { const fn = mockFetch(() => Promise.resolve(new Response("result"))); diff --git a/src/services/githits-service.ts b/src/services/githits-service.ts index d3148d94..f7af9d5e 100644 --- a/src/services/githits-service.ts +++ b/src/services/githits-service.ts @@ -1,4 +1,5 @@ import { version } from "../../package.json"; +import { buildClientHeaders } from "../shared/request-headers.js"; /** * Error thrown when the API returns 401 Unauthorized. @@ -138,6 +139,7 @@ export class GitHitsServiceImpl implements GitHitsService { private headers(): Record { return { + ...buildClientHeaders(), Authorization: `Bearer ${this.token}`, "Content-Type": "application/json", "User-Agent": `githits-cli/${version}`, diff --git a/src/shared/pkgseer-graphql.test.ts b/src/shared/pkgseer-graphql.test.ts index 97df9f2b..65c49d6b 100644 --- a/src/shared/pkgseer-graphql.test.ts +++ b/src/shared/pkgseer-graphql.test.ts @@ -148,6 +148,33 @@ describe("postPkgseerGraphql", () => { expect(capturedHeaders?.["User-Agent"]).toMatch(/^githits-cli\/\S+$/); }); + it("sends x-githits-* telemetry headers from buildClientHeaders", async () => { + // Pins the contract that the transport layer spreads the + // telemetry headers onto every request — not just that the + // module under `src/shared/request-headers.ts` builds them. + let capturedHeaders: Record | undefined; + const fetchFn = mock((_url: string, init?: RequestInit) => { + capturedHeaders = init?.headers as Record; + return Promise.resolve(makeResponse(VALID_JSON)); + }); + + await postPkgseerGraphql({ + endpointUrl: ENDPOINT, + token: TOKEN, + query: "query { x }", + variables: {}, + fetchFn: asFetchFn(fetchFn), + }); + + expect(capturedHeaders?.["x-githits-client-name"]).toBe("githits-cli"); + expect(capturedHeaders?.["x-githits-client-version"]).toMatch(/^\S+$/); + expect(capturedHeaders?.["x-githits-session-id"]).toMatch(/^[0-9a-f]{16}$/); + // Authorization still wins over any hypothetical x-githits-* + // collision — spread order (headers first, hardcoded second) + // guarantees this, but pin it. + expect(capturedHeaders?.Authorization).toBe(`Bearer ${TOKEN}`); + }); + it("normalises trailing slashes on endpointUrl", async () => { let capturedUrl: string | undefined; const fetchFn = mock((url: string) => { diff --git a/src/shared/pkgseer-graphql.ts b/src/shared/pkgseer-graphql.ts index 8f8beec6..cf96751a 100644 --- a/src/shared/pkgseer-graphql.ts +++ b/src/shared/pkgseer-graphql.ts @@ -25,6 +25,7 @@ import { version } from "../../package.json"; import { debugLog } from "./debug-log.js"; +import { buildClientHeaders } from "./request-headers.js"; export interface PkgseerGraphqlRequest { /** Full endpoint URL, e.g. `https://pkgseer.dev`. Trailing slashes tolerated. */ @@ -90,6 +91,7 @@ export async function postPkgseerGraphql( response = await fetchFn(`${baseUrl(request.endpointUrl)}/api/graphql`, { method: "POST", headers: { + ...buildClientHeaders(), Authorization: `Bearer ${request.token}`, "Content-Type": "application/json", "User-Agent": userAgent, diff --git a/src/shared/request-headers.test.ts b/src/shared/request-headers.test.ts new file mode 100644 index 00000000..69de7168 --- /dev/null +++ b/src/shared/request-headers.test.ts @@ -0,0 +1,487 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { + buildClientHeaders, + getSessionId, + parseAgentString, + resetRequestHeadersState, + resolveAgentInfo, + resolveRawSessionId, + sanitizeHeaderValue, + setAgentInfo, + setClientMode, + setMcpClientVersionProvider, +} from "./request-headers.js"; + +/** Compute the same hash the module uses internally. */ +function sha256Prefix(input: string): string { + return createHash("sha256").update(input).digest("hex").slice(0, 16); +} + +afterEach(() => { + resetRequestHeadersState(); +}); + +// --------------------------------------------------------------------------- +// resolveRawSessionId +// --------------------------------------------------------------------------- + +describe("resolveRawSessionId", () => { + it("returns TERM_SESSION_ID when set", () => { + const env = { TERM_SESSION_ID: "abc-123" }; + expect(resolveRawSessionId(env, 999)).toBe("abc-123"); + }); + + it("returns ITERM_SESSION_ID when TERM_SESSION_ID is absent", () => { + const env = { ITERM_SESSION_ID: "iterm-456" }; + expect(resolveRawSessionId(env, 999)).toBe("iterm-456"); + }); + + it("returns WEZTERM_PANE when higher-priority vars are absent", () => { + const env = { WEZTERM_PANE: "0" }; + expect(resolveRawSessionId(env, 999)).toBe("0"); + }); + + it("returns WT_SESSION for Windows Terminal", () => { + const env = { WT_SESSION: "win-sess-789" }; + expect(resolveRawSessionId(env, 999)).toBe("win-sess-789"); + }); + + it("returns SSH_CONNECTION for SSH sessions", () => { + const env = { SSH_CONNECTION: "192.168.1.1 12345 10.0.0.1 22" }; + expect(resolveRawSessionId(env, 999)).toBe("192.168.1.1 12345 10.0.0.1 22"); + }); + + it("prefers higher-priority var when multiple are set", () => { + const env = { + TERM_SESSION_ID: "first", + ITERM_SESSION_ID: "second", + WT_SESSION: "third", + }; + expect(resolveRawSessionId(env, 999)).toBe("first"); + }); + + it("skips blank values", () => { + const env = { TERM_SESSION_ID: " ", ITERM_SESSION_ID: "real" }; + expect(resolveRawSessionId(env, 999)).toBe("real"); + }); + + it("skips empty string values", () => { + const env = { TERM_SESSION_ID: "" }; + expect(resolveRawSessionId(env, 999)).toBe("999"); + }); + + it("falls back to PPID when no env vars are set", () => { + expect(resolveRawSessionId({}, 42)).toBe("42"); + }); + + it("falls back to PPID when env is empty", () => { + expect(resolveRawSessionId({}, 1234)).toBe("1234"); + }); + + it("generates a random UUID when PPID is NaN", () => { + const result = resolveRawSessionId({}, NaN); + // UUID v4 format: 8-4-4-4-12 hex chars + expect(result).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it("generates unique values on each call when PPID is NaN", () => { + const a = resolveRawSessionId({}, NaN); + const b = resolveRawSessionId({}, NaN); + expect(a).not.toBe(b); + }); + + it("generates a random UUID when PPID is zero", () => { + const result = resolveRawSessionId({}, 0); + expect(result).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + + it("returns SUPERSET_PANE_ID for Superset/OpenCode", () => { + const env = { SUPERSET_PANE_ID: "pane-abc-123" }; + expect(resolveRawSessionId(env, 999)).toBe("pane-abc-123"); + }); + + it("returns STARSHIP_SESSION_KEY for Starship prompt", () => { + const env = { STARSHIP_SESSION_KEY: "1283792602727211" }; + expect(resolveRawSessionId(env, 999)).toBe("1283792602727211"); + }); +}); + +// --------------------------------------------------------------------------- +// getSessionId +// --------------------------------------------------------------------------- + +describe("getSessionId", () => { + it("returns a 16-char hex string", () => { + const id = getSessionId({}, 42); + expect(id).toMatch(/^[0-9a-f]{16}$/); + }); + + it("hashes the raw session value", () => { + const env = { TERM_SESSION_ID: "my-session" }; + const id = getSessionId(env, 999); + expect(id).toBe(sha256Prefix("my-session")); + }); + + it("hashes PPID fallback", () => { + const id = getSessionId({}, 9999); + expect(id).toBe(sha256Prefix("9999")); + }); + + it("produces stable output for the same input", () => { + const env = { TERM_SESSION_ID: "stable" }; + const a = getSessionId(env, 1); + resetRequestHeadersState(); + const b = getSessionId(env, 1); + expect(a).toBe(b); + }); + + it("produces different output for different inputs", () => { + const a = getSessionId({ TERM_SESSION_ID: "session-a" }, 1); + resetRequestHeadersState(); + const b = getSessionId({ TERM_SESSION_ID: "session-b" }, 1); + expect(a).not.toBe(b); + }); + + it("does not expose the raw session value", () => { + const raw = "secret-terminal-id-12345"; + const env = { TERM_SESSION_ID: raw }; + const id = getSessionId(env, 1); + expect(id).not.toContain(raw); + expect(id).not.toContain("secret"); + }); +}); + +// --------------------------------------------------------------------------- +// parseAgentString +// --------------------------------------------------------------------------- + +describe("parseAgentString", () => { + it("parses name/version format", () => { + expect(parseAgentString("claude-code/1.0.0")).toEqual({ + name: "claude-code", + version: "1.0.0", + }); + }); + + it("parses name only (no slash)", () => { + expect(parseAgentString("cursor")).toEqual({ name: "cursor" }); + }); + + it("handles trailing slash with no version", () => { + expect(parseAgentString("agent/")).toEqual({ name: "agent" }); + }); + + it("returns undefined for empty string", () => { + expect(parseAgentString("")).toBeUndefined(); + }); + + it("returns undefined for whitespace-only string", () => { + expect(parseAgentString(" ")).toBeUndefined(); + }); + + it("returns undefined when name is empty (starts with slash)", () => { + expect(parseAgentString("/1.0.0")).toBeUndefined(); + }); + + it("trims whitespace", () => { + expect(parseAgentString(" agent/1.0 ")).toEqual({ + name: "agent", + version: "1.0", + }); + }); + + it("handles version with multiple slashes", () => { + // Only split on first slash + expect(parseAgentString("agent/1.0/beta")).toEqual({ + name: "agent", + version: "1.0/beta", + }); + }); +}); + +// --------------------------------------------------------------------------- +// resolveAgentInfo +// --------------------------------------------------------------------------- + +describe("resolveAgentInfo", () => { + it("returns undefined when no agent env vars are set", () => { + expect(resolveAgentInfo({})).toBeUndefined(); + }); + + it("uses GITHITS_AGENT explicit override", () => { + const env = { GITHITS_AGENT: "claude-code/1.0.0" }; + expect(resolveAgentInfo(env)).toEqual({ + name: "claude-code", + version: "1.0.0", + }); + }); + + it("GITHITS_AGENT takes precedence over auto-detected agents", () => { + const env = { + GITHITS_AGENT: "custom-agent/2.0", + OPENCODE: "1", + CLAUDECODE: "1", + }; + expect(resolveAgentInfo(env)).toEqual({ + name: "custom-agent", + version: "2.0", + }); + }); + + it("detects OpenCode", () => { + expect(resolveAgentInfo({ OPENCODE: "1" })).toEqual({ name: "opencode" }); + }); + + it("detects Claude Code", () => { + expect(resolveAgentInfo({ CLAUDECODE: "1" })).toEqual({ + name: "claude-code", + }); + }); + + it("detects Cursor", () => { + expect(resolveAgentInfo({ CURSOR_TRACE_ID: "abc-123" })).toEqual({ + name: "cursor", + }); + }); + + it("detects Windsurf", () => { + expect( + resolveAgentInfo({ WINDSURF_CONFIG_DIR: "/home/user/.windsurf" }), + ).toEqual({ name: "windsurf" }); + }); + + it("detects Zed", () => { + expect(resolveAgentInfo({ ZED_TERM: "1" })).toEqual({ name: "zed" }); + }); + + it("detects VS Code", () => { + expect(resolveAgentInfo({ VSCODE_PID: "12345" })).toEqual({ + name: "vscode", + }); + }); + + it("prefers higher-priority agent when multiple are set", () => { + const env = { OPENCODE: "1", VSCODE_PID: "12345" }; + expect(resolveAgentInfo(env)).toEqual({ name: "opencode" }); + }); + + it("skips blank GITHITS_AGENT and falls back to probes", () => { + const env = { GITHITS_AGENT: " ", OPENCODE: "1" }; + expect(resolveAgentInfo(env)).toEqual({ name: "opencode" }); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeHeaderValue +// --------------------------------------------------------------------------- + +describe("sanitizeHeaderValue", () => { + it("returns clean strings unchanged", () => { + expect(sanitizeHeaderValue("hello")).toBe("hello"); + }); + + it("returns undefined for undefined input", () => { + expect(sanitizeHeaderValue(undefined)).toBeUndefined(); + }); + + it("returns undefined for empty string", () => { + expect(sanitizeHeaderValue("")).toBeUndefined(); + }); + + it("returns undefined for whitespace-only string", () => { + expect(sanitizeHeaderValue(" ")).toBeUndefined(); + }); + + it("strips control characters", () => { + expect(sanitizeHeaderValue("hello\x00world")).toBe("helloworld"); + expect(sanitizeHeaderValue("tab\there")).toBe("tabhere"); + expect(sanitizeHeaderValue("new\nline")).toBe("newline"); + expect(sanitizeHeaderValue("cr\rreturn")).toBe("crreturn"); + }); + + it("strips C1 control characters (0x80-0x9F)", () => { + expect(sanitizeHeaderValue("a\x80b\x9fc")).toBe("abc"); + }); + + it("trims whitespace", () => { + expect(sanitizeHeaderValue(" hello ")).toBe("hello"); + }); + + it("returns undefined when only control chars remain", () => { + expect(sanitizeHeaderValue("\x00\x01\x02")).toBeUndefined(); + }); + + it("returns undefined for oversized values", () => { + const longValue = "x".repeat(257); + expect(sanitizeHeaderValue(longValue)).toBeUndefined(); + }); + + it("accepts values at the byte limit", () => { + const exactValue = "x".repeat(256); + expect(sanitizeHeaderValue(exactValue)).toBe(exactValue); + }); + + it("checks byte length not char length for multibyte", () => { + // Each emoji is 4 bytes in UTF-8; 65 of them = 260 bytes > 256 + const multibyteValue = "\u{1F600}".repeat(65); + expect(sanitizeHeaderValue(multibyteValue)).toBeUndefined(); + }); + + it("returns undefined for non-string inputs at runtime", () => { + // These can happen if external data (e.g. MCP clientInfo) has unexpected types + expect(sanitizeHeaderValue(42 as unknown as string)).toBeUndefined(); + expect(sanitizeHeaderValue(true as unknown as string)).toBeUndefined(); + expect(sanitizeHeaderValue({} as unknown as string)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// buildClientHeaders +// --------------------------------------------------------------------------- + +describe("buildClientHeaders", () => { + it("includes base headers without agent when no agent env is set", () => { + const headers = buildClientHeaders({}, 42); + expect(headers["x-githits-client-name"]).toBe("githits-cli"); + expect(headers["x-githits-client-version"]).toMatch(/^\d+\.\d+\.\d+/); + expect(headers["x-githits-session-id"]).toMatch(/^[0-9a-f]{16}$/); + expect(headers["x-githits-agent"]).toBeUndefined(); + }); + + it("includes agent header when agent env is set", () => { + const env = { GITHITS_AGENT: "claude-code/1.0.0" }; + const headers = buildClientHeaders(env, 42); + expect(headers["x-githits-agent"]).toBe("claude-code/1.0.0"); + }); + + it("includes agent header from auto-detected env", () => { + const env = { OPENCODE: "1" }; + const headers = buildClientHeaders(env, 42); + expect(headers["x-githits-agent"]).toBe("opencode"); + }); + + it("includes agent header after setAgentInfo", () => { + setAgentInfo({ name: "claude-code", version: "1.2.3" }); + const headers = buildClientHeaders({}, 42); + expect(headers["x-githits-agent"]).toBe("claude-code/1.2.3"); + }); + + it("setAgentInfo overrides env-detected agent", () => { + // First call detects from env + const env = { OPENCODE: "1" }; + const before = buildClientHeaders(env, 42); + expect(before["x-githits-agent"]).toBe("opencode"); + + // MCP clientInfo overrides + setAgentInfo({ name: "cursor", version: "0.45.0" }); + const after = buildClientHeaders({}, 42); + expect(after["x-githits-agent"]).toBe("cursor/0.45.0"); + }); + + it("only includes x-githits-* headers", () => { + const headers = buildClientHeaders({}, 42); + for (const key of Object.keys(headers)) { + expect(key.startsWith("x-githits-")).toBe(true); + } + }); + + it("returns three headers when no agent detected", () => { + const headers = buildClientHeaders({}, 42); + expect(Object.keys(headers)).toHaveLength(3); + }); + + it("returns four headers when agent is detected", () => { + const headers = buildClientHeaders({ OPENCODE: "1" }, 42); + expect(Object.keys(headers)).toHaveLength(4); + }); + + it("session-id is stable across calls with same env", () => { + const env = { TERM_SESSION_ID: "test-session" }; + const a = buildClientHeaders(env, 42); + const b = buildClientHeaders(env, 42); + expect(a["x-githits-session-id"]).toBe(b["x-githits-session-id"]); + }); + + it("defaults client-name to githits-cli", () => { + const headers = buildClientHeaders({}, 42); + expect(headers["x-githits-client-name"]).toBe("githits-cli"); + }); + + it("changes client-name after setClientMode", () => { + setClientMode("mcp"); + const headers = buildClientHeaders({}, 42); + expect(headers["x-githits-client-name"]).toBe("githits-cli/mcp"); + }); + + it("resets client-name after state reset", () => { + setClientMode("mcp"); + resetRequestHeadersState(); + const headers = buildClientHeaders({}, 42); + expect(headers["x-githits-client-name"]).toBe("githits-cli"); + }); + + it("still produces a session-id when PPID is NaN", () => { + const headers = buildClientHeaders({}, NaN); + expect(headers["x-githits-session-id"]).toMatch(/^[0-9a-f]{16}$/); + }); +}); + +// --------------------------------------------------------------------------- +// setMcpClientVersionProvider — MCP clientInfo lazy reader +// --------------------------------------------------------------------------- + +describe("setMcpClientVersionProvider", () => { + it("uses the provider's return value for x-githits-agent", () => { + setMcpClientVersionProvider(() => ({ + name: "cursor", + version: "0.42.0", + })); + const headers = buildClientHeaders({}); + expect(headers["x-githits-agent"]).toBe("cursor/0.42.0"); + }); + + it("runs the provider on every buildClientHeaders call (no caching)", () => { + let current: { name: string; version?: string } | undefined = { + name: "first", + }; + setMcpClientVersionProvider(() => current); + + expect(buildClientHeaders({})["x-githits-agent"]).toBe("first"); + + // Simulate MCP clientInfo arriving later (post-handshake). + current = { name: "claude-code", version: "1.0.0" }; + expect(buildClientHeaders({})["x-githits-agent"]).toBe("claude-code/1.0.0"); + }); + + it("falls back to env detection when the provider returns undefined", () => { + setMcpClientVersionProvider(() => undefined); + const headers = buildClientHeaders({ OPENCODE: "1" }); + expect(headers["x-githits-agent"]).toBe("opencode"); + }); + + it("falls back to env detection when provider returns a blank name", () => { + setMcpClientVersionProvider(() => ({ name: " " })); + const headers = buildClientHeaders({ CURSOR_TRACE_ID: "abc" }); + expect(headers["x-githits-agent"]).toBe("cursor"); + }); + + it("falls back to env detection when the provider throws", () => { + setMcpClientVersionProvider(() => { + throw new Error("sdk not ready"); + }); + const headers = buildClientHeaders({ CLAUDECODE: "1" }); + expect(headers["x-githits-agent"]).toBe("claude-code"); + }); + + it("is overridden by setAgentInfo (explicit override wins)", () => { + setMcpClientVersionProvider(() => ({ name: "cursor" })); + setAgentInfo({ name: "override", version: "1.0" }); + const headers = buildClientHeaders({}); + expect(headers["x-githits-agent"]).toBe("override/1.0"); + }); +}); diff --git a/src/shared/request-headers.ts b/src/shared/request-headers.ts new file mode 100644 index 00000000..deecfa68 --- /dev/null +++ b/src/shared/request-headers.ts @@ -0,0 +1,394 @@ +import { createHash, randomUUID } from "node:crypto"; +import { version } from "../../package.json"; + +/** + * Maximum byte length for a header value. + * Values exceeding this are dropped (returns undefined). + */ +const MAX_HEADER_BYTES = 256; + +// --------------------------------------------------------------------------- +// Session ID +// --------------------------------------------------------------------------- + +/** + * Environment variables to probe for a terminal session identifier, + * ordered from most-specific to least-specific. + * + * Terminal-specific: + * - TERM_SESSION_ID: macOS Terminal.app + * - ITERM_SESSION_ID: iTerm2 + * - WEZTERM_PANE: WezTerm + * - KITTY_PID: Kitty + * - ALACRITTY_SOCKET: Alacritty + * - WT_SESSION: Windows Terminal + * + * IDE / agent: + * - VSCODE_PID: VS Code (per-window, changes each launch) + * - SUPERSET_PANE_ID: Superset/OpenCode pane + * - SUPERSET_WORKSPACE_ID: Superset workspace session + * + * Shell-level: + * - STARSHIP_SESSION_KEY: Starship prompt (widely used, per-shell session) + * + * Network: + * - SSH_CONNECTION: SSH sessions + */ +const SESSION_ENV_VARS = [ + "TERM_SESSION_ID", + "ITERM_SESSION_ID", + "WEZTERM_PANE", + "KITTY_PID", + "ALACRITTY_SOCKET", + "WT_SESSION", + "VSCODE_PID", + "SUPERSET_PANE_ID", + "SUPERSET_WORKSPACE_ID", + "STARSHIP_SESSION_KEY", + "SSH_CONNECTION", +] as const; + +/** Cached session ID — computed once per process. */ +let cachedSessionId: string | undefined; + +/** + * Resolve a raw terminal session identifier from environment variables. + * Falls back to parent PID (stable across CLI invocations from the same + * shell), then process PID, then a random UUID. + * + * Exposed for testing — callers should use {@link getSessionId} instead. + */ +export function resolveRawSessionId( + env: Record = process.env, + ppid: number = process.ppid, +): string { + for (const key of SESSION_ENV_VARS) { + const value = env[key]; + if (value && value.trim().length > 0) { + return value.trim(); + } + } + // Parent PID is stable across CLI invocations from the same shell/agent, + // making it a better grouping key than process PID. + if (typeof ppid === "number" && !Number.isNaN(ppid) && ppid > 0) { + return String(ppid); + } + return randomUUID(); +} + +/** + * Return a stable, privacy-safe session identifier. + * + * Hashes the raw session value with SHA-256 and truncates to 16 hex chars + * (64 bits — sufficient for grouping, collision-safe at our scale). + * Result is cached for the lifetime of the process. + */ +export function getSessionId( + env?: Record, + ppid?: number, +): string { + if ( + cachedSessionId !== undefined && + env === undefined && + ppid === undefined + ) { + return cachedSessionId; + } + const raw = resolveRawSessionId(env, ppid); + const hashed = hashValue(raw); + // Only cache when called with default (process) values + if (env === undefined && ppid === undefined) { + cachedSessionId = hashed; + } + return hashed; +} + +/** + * SHA-256 hash truncated to 16 hex characters. + */ +function hashValue(input: string): string { + return createHash("sha256").update(input).digest("hex").slice(0, 16); +} + +// --------------------------------------------------------------------------- +// Agent detection +// --------------------------------------------------------------------------- + +/** + * Agent identity: the tool/IDE driving the CLI. + */ +export interface AgentInfo { + name: string; + version?: string; +} + +/** + * Probes for well-known AI agent / IDE env vars. + * Order matters — first match wins. + * + * | Env var | Agent | Notes | + * |----------------------|------------|-----------------------------------| + * | OPENCODE | opencode | OpenCode CLI | + * | CLAUDECODE | claude-code| Claude Code Bash tool shells only | + * | CURSOR_TRACE_ID | cursor | Cursor editor | + * | WINDSURF_CONFIG_DIR | windsurf | Windsurf (Codeium) | + * | ZED_TERM | zed | Zed editor integrated terminal | + * | VSCODE_PID | vscode | VS Code (generic, any extension) | + */ +const AGENT_PROBES: ReadonlyArray<{ + envVar: string; + name: string; +}> = [ + { envVar: "OPENCODE", name: "opencode" }, + { envVar: "CLAUDECODE", name: "claude-code" }, + { envVar: "CURSOR_TRACE_ID", name: "cursor" }, + { envVar: "WINDSURF_CONFIG_DIR", name: "windsurf" }, + { envVar: "ZED_TERM", name: "zed" }, + { envVar: "VSCODE_PID", name: "vscode" }, +]; + +/** + * Parse an agent string in `name/version` format. + * Returns undefined if the input is blank. + */ +export function parseAgentString(raw: string): AgentInfo | undefined { + const trimmed = raw.trim(); + if (trimmed.length === 0) return undefined; + const slashIndex = trimmed.indexOf("/"); + if (slashIndex === -1) return { name: trimmed }; + const name = trimmed.slice(0, slashIndex); + const ver = trimmed.slice(slashIndex + 1); + if (name.length === 0) return undefined; + return { name, version: ver || undefined }; +} + +/** + * Format an AgentInfo as a `name/version` header value. + */ +function formatAgentInfo(info: AgentInfo): string { + return info.version ? `${info.name}/${info.version}` : info.name; +} + +/** + * Detect agent from environment variables. + * + * Priority: + * 1. `GITHITS_AGENT` — explicit override (format: `name/version` or `name`) + * 2. Auto-detect from well-known env vars + */ +export function resolveAgentInfo( + env: Record = process.env, +): AgentInfo | undefined { + // Explicit override — highest priority + const explicit = env.GITHITS_AGENT; + if (explicit && explicit.trim().length > 0) { + return parseAgentString(explicit); + } + + // Probe well-known env vars + for (const probe of AGENT_PROBES) { + const value = env[probe.envVar]; + if (value && value.trim().length > 0) { + return { name: probe.name }; + } + } + + return undefined; +} + +/** Mutable agent info — set from env detection or MCP clientInfo. */ +let currentAgentInfo: AgentInfo | undefined; +let agentInfoInitialized = false; +/** True when setAgentInfo() was called — prevents env re-detection. */ +let agentInfoExplicitlySet = false; + +/** + * Optional lazy provider — called on every `buildClientHeaders()` invocation + * to read the current MCP client identity. Used by the MCP server so we can + * read `clientInfo` directly from the SDK at request time rather than racing + * the `oninitialized` notification callback. + * + * The MCP SDK populates `_clientVersion` **synchronously** inside its + * `_oninitialize` handler (before the initialize response is returned), so + * every tool call that arrives after the initialize handshake will see a + * populated value via this provider. The notification-callback pattern + * (`server.oninitialized = …`) fires asynchronously after the initialize + * response is sent — if the client's first tool call races the notification + * dispatch, a callback-set agent info could be missing. + */ +type McpClientVersionProvider = () => AgentInfo | undefined; +let mcpClientVersionProvider: McpClientVersionProvider | undefined; + +/** + * Register a lazy MCP `clientInfo` provider. The provider runs on every + * `buildClientHeaders()` call; when it returns a value, that takes + * precedence over env-var detection. + */ +export function setMcpClientVersionProvider( + provider: McpClientVersionProvider | undefined, +): void { + mcpClientVersionProvider = provider; +} + +/** + * Get the current agent info. Priority order: + * + * 1. `setAgentInfo()` (explicit override, e.g. from a CLI flag) + * 2. MCP `clientInfo` via the lazy provider (when running as MCP server) + * 3. Env-var auto-detection (OPENCODE, CLAUDECODE, CURSOR_TRACE_ID, …) + * + * When `env` is passed (tests), auto-detection runs against that env. + */ +function getAgentInfo( + env?: Record, +): AgentInfo | undefined { + if (agentInfoExplicitlySet) { + return currentAgentInfo; + } + if (mcpClientVersionProvider) { + try { + const fromProvider = mcpClientVersionProvider(); + if (fromProvider && fromProvider.name.trim().length > 0) { + return fromProvider; + } + } catch { + // Provider failure must never break API requests — fall through to + // env detection. + } + } + if (!agentInfoInitialized || env !== undefined) { + currentAgentInfo = resolveAgentInfo(env); + if (env === undefined) { + agentInfoInitialized = true; + } + } + return currentAgentInfo; +} + +/** + * Override the agent info (e.g., from a CLI flag or test fixture). Takes + * precedence over both the MCP clientInfo provider and env-var detection, + * and cannot be overridden by subsequent env-based resolution. + */ +export function setAgentInfo(info: AgentInfo): void { + currentAgentInfo = info; + agentInfoInitialized = true; + agentInfoExplicitlySet = true; +} + +// --------------------------------------------------------------------------- +// Header sanitization +// --------------------------------------------------------------------------- + +/** + * Control character regex — matches C0 (0x00-0x1F), DEL (0x7F), + * and C1 (0x80-0x9F) control characters. + */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — this regex exists to strip control chars from header values +const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/g; + +/** + * Sanitize a header value: + * 1. Coerce non-string inputs to undefined (runtime safety) + * 2. Strip control characters + * 3. Trim whitespace + * 4. Return undefined if blank or exceeds MAX_HEADER_BYTES + */ +export function sanitizeHeaderValue( + value: string | undefined, +): string | undefined { + if (value === undefined || value === null || typeof value !== "string") { + return undefined; + } + const cleaned = value.replace(CONTROL_CHARS, "").trim(); + if (cleaned.length === 0) return undefined; + // Check byte length (UTF-8) + if (Buffer.byteLength(cleaned, "utf8") > MAX_HEADER_BYTES) return undefined; + return cleaned; +} + +// --------------------------------------------------------------------------- +// Client mode +// --------------------------------------------------------------------------- + +/** Default client name for direct CLI usage. */ +const BASE_CLIENT_NAME = "githits-cli"; + +let clientName = BASE_CLIENT_NAME; + +/** + * Set the client mode suffix (e.g. `"mcp"`). + * Changes the client name from `githits-cli` to `githits-cli/mcp`. + */ +export function setClientMode(mode: string): void { + clientName = `${BASE_CLIENT_NAME}/${mode}`; +} + +// --------------------------------------------------------------------------- +// Header builder +// --------------------------------------------------------------------------- + +/** + * Build the `x-githits-*` client headers for authenticated API requests. + * + * Omits any header whose sanitized value is blank or oversized. + * Returns a plain object suitable for spreading into request headers. + * + * Called per-request so that agent info set after client creation + * (e.g., from MCP clientInfo) is included. + * + * Never throws — returns `{}` on unexpected errors so that API + * requests proceed without client headers rather than failing. + */ +export function buildClientHeaders( + env?: Record, + ppid?: number, +): Record { + try { + const headers: Record = {}; + + const name = sanitizeHeaderValue(clientName); + if (name) { + headers["x-githits-client-name"] = name; + } + + const clientVersion = sanitizeHeaderValue(version); + if (clientVersion) { + headers["x-githits-client-version"] = clientVersion; + } + + const agentInfo = getAgentInfo(env); + if (agentInfo) { + const agentValue = sanitizeHeaderValue(formatAgentInfo(agentInfo)); + if (agentValue) { + headers["x-githits-agent"] = agentValue; + } + } + + const sessionId = sanitizeHeaderValue(getSessionId(env, ppid)); + if (sessionId) { + headers["x-githits-session-id"] = sessionId; + } + + return headers; + } catch { + // Header generation must never break API requests. + return {}; + } +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/** + * Reset all mutable state. For testing only. + */ +export function resetRequestHeadersState(): void { + cachedSessionId = undefined; + currentAgentInfo = undefined; + agentInfoInitialized = false; + agentInfoExplicitlySet = false; + mcpClientVersionProvider = undefined; + clientName = BASE_CLIENT_NAME; +}