From 0bae7c2f3703967428f490861660e42ea4f6d46a Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 7 Jul 2026 22:41:02 +0800 Subject: [PATCH] Capture repo attribution metadata --- .../src/agents/codex/event-processor.test.ts | 46 +++++++++++- .../src/agents/codex/event-processor.ts | 3 + plugins/trace-codex/src/git-metadata.test.ts | 73 +++++++++++++++++++ plugins/trace-codex/src/git-metadata.ts | 53 ++++++++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 plugins/trace-codex/src/git-metadata.test.ts create mode 100644 plugins/trace-codex/src/git-metadata.ts diff --git a/plugins/trace-codex/src/agents/codex/event-processor.test.ts b/plugins/trace-codex/src/agents/codex/event-processor.test.ts index 1d58162..1e55bad 100644 --- a/plugins/trace-codex/src/agents/codex/event-processor.test.ts +++ b/plugins/trace-codex/src/agents/codex/event-processor.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { ReportingConfig, SpanFactory } from "../../braintrust/logger.ts"; import type { EnqueueEvent } from "../../server/routes.ts"; import { createTestLogger, spansToTree, withCapturedTrace } from "../../test-helpers.ts"; @@ -41,6 +45,32 @@ import type { WaitForOptions, } from "./transcript-reader.ts"; +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeGitRepo(): { dir: string; commit: string } { + const dir = mkdtempSync(join(tmpdir(), "codex-git-metadata-")); + tempDirs.push(dir); + execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: dir }); + writeFileSync(join(dir, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: dir }); + execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: dir }); + execFileSync("git", ["remote", "add", "origin", "https://token@github.com/acme/app.git"], { + cwd: dir, + }); + const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" }).trim(); + return { dir, commit }; +} + // Helpers below build a session as an ordered list of transcript writes and // hooks. The final `stop()` hook triggers the catch-up that turns the buffered // transcript records into spans (waiting for the task_complete sentinel). @@ -107,6 +137,20 @@ describe("CodexEventProcessor: root span", () => { ); }); + test("root span metadata includes minimal git attribution fields", async () => { + const repo = makeGitRepo(); + + await assertProducesTrace([sessionStart(), sessionMeta({ cwd: repo.dir }), stop({})], { + span_attributes: { type: "task" }, + metadata: { + git_origin_url: "https://github.com/acme/app.git", + git_branch: "main", + git_commit_sha: repo.commit, + }, + children: [], + }); + }); + test("duplicate session_meta still yields a single root span", async () => { await assertProducesTrace( [sessionStart(), sessionMeta({ cwd: "/work" }), sessionMeta({ cwd: "/work" }), stop({})], diff --git a/plugins/trace-codex/src/agents/codex/event-processor.ts b/plugins/trace-codex/src/agents/codex/event-processor.ts index 0d11a79..9894ead 100644 --- a/plugins/trace-codex/src/agents/codex/event-processor.ts +++ b/plugins/trace-codex/src/agents/codex/event-processor.ts @@ -35,6 +35,7 @@ import { type SpanRef, spanRef, } from "../../braintrust/logger.ts"; +import { gitMetadataForCwd } from "../../git-metadata.ts"; import type { Logger } from "../../log.ts"; import type { EventProcessor } from "../../processor/event-processor.ts"; import type { EnqueueEvent } from "../../server/routes.ts"; @@ -1154,6 +1155,7 @@ export class CodexEventProcessor implements EventProcessor { const cwd = typeof payload.cwd === "string" ? payload.cwd : undefined; const sessionId = typeof payload.id === "string" ? payload.id : this.queueId; const startTime = isoToUnixSeconds(record.timestamp); + const gitMetadata = gitMetadataForCwd(cwd); const projectDir = projectDirName(cwd); const spanName = projectDir ? `codex: ${projectDir}` : "codex session"; @@ -1172,6 +1174,7 @@ export class CodexEventProcessor implements EventProcessor { session_id: sessionId, model: this.mainScope?.model, cwd, + ...gitMetadata, source: this.rootEnrichment.source, permission_mode: this.rootEnrichment.permissionMode, cli_version: diff --git a/plugins/trace-codex/src/git-metadata.test.ts b/plugins/trace-codex/src/git-metadata.test.ts new file mode 100644 index 0000000..89f74a4 --- /dev/null +++ b/plugins/trace-codex/src/git-metadata.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gitMetadataForCwd, redactGitRemoteUrl } from "./git-metadata.ts"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function makeGitRepo(): { dir: string; commit: string } { + const dir = makeTempDir("codex-git-metadata-"); + execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: dir }); + writeFileSync(join(dir, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: dir }); + execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["branch", "-M", "main"], { cwd: dir }); + execFileSync("git", ["remote", "add", "origin", "https://token@github.com/acme/app.git"], { + cwd: dir, + }); + const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" }).trim(); + return { dir, commit }; +} + +describe("git metadata", () => { + test("redacts credentials from URL-style remotes", () => { + expect(redactGitRemoteUrl("https://token:secret@github.com/acme/app.git")).toBe( + "https://github.com/acme/app.git", + ); + }); + + test("keeps scp-like SSH remotes intact", () => { + expect(redactGitRemoteUrl("git@github.com:acme/app.git")).toBe("git@github.com:acme/app.git"); + }); + + test("captures origin, branch, and commit", () => { + const repo = makeGitRepo(); + + expect(gitMetadataForCwd(repo.dir)).toEqual({ + git_origin_url: "https://github.com/acme/app.git", + git_branch: "main", + git_commit_sha: repo.commit, + }); + }); + + test("omits branch for detached HEAD", () => { + const repo = makeGitRepo(); + execFileSync("git", ["checkout", "--detach", "HEAD"], { cwd: repo.dir, stdio: "ignore" }); + + expect(gitMetadataForCwd(repo.dir)).toEqual({ + git_origin_url: "https://github.com/acme/app.git", + git_commit_sha: repo.commit, + }); + }); + + test("omits all fields outside a git repo", () => { + expect(gitMetadataForCwd(makeTempDir("codex-not-git-"))).toEqual({}); + }); +}); diff --git a/plugins/trace-codex/src/git-metadata.ts b/plugins/trace-codex/src/git-metadata.ts new file mode 100644 index 0000000..4123277 --- /dev/null +++ b/plugins/trace-codex/src/git-metadata.ts @@ -0,0 +1,53 @@ +import { spawnSync } from "node:child_process"; + +export interface GitMetadata { + git_origin_url?: string; + git_branch?: string; + git_commit_sha?: string; +} + +function runGit(cwd: string, args: string[]): string | undefined { + try { + const result = spawnSync("git", ["-C", cwd, ...args], { + encoding: "utf8", + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + timeout: 500, + windowsHide: true, + }); + if (result.status !== 0 || typeof result.stdout !== "string") return undefined; + const value = result.stdout.trim(); + return value.length > 0 ? value : undefined; + } catch { + return undefined; + } +} + +export function redactGitRemoteUrl(remoteUrl: string): string { + const trimmed = remoteUrl.trim(); + if (!trimmed) return trimmed; + + try { + const parsed = new URL(trimmed); + if (parsed.username || parsed.password) { + parsed.username = ""; + parsed.password = ""; + } + return parsed.toString(); + } catch { + return trimmed; + } +} + +export function gitMetadataForCwd(cwd: string | undefined): GitMetadata { + if (!cwd) return {}; + + const origin = runGit(cwd, ["remote", "get-url", "origin"]); + const branch = runGit(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]); + const commit = runGit(cwd, ["rev-parse", "HEAD"]); + + return { + ...(origin ? { git_origin_url: redactGitRemoteUrl(origin) } : {}), + ...(branch ? { git_branch: branch } : {}), + ...(commit ? { git_commit_sha: commit } : {}), + }; +}