From fcbc28540f094ee8a7dd80877313613769e5a43b Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 24 Aug 2026 02:19:34 -0700 Subject: [PATCH] fix(sync): never put a filesystem path on the wire as ai.project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several providers key a project by its sanitized absolute path ("-Users-me-Projects-app"), and sync shipped that verbatim as ai.project on usage and attribution spans — contradicting docs/sync/README.md's promise that file paths stay local. Confirmed on real exports. One choke point after parseAllSessions maps project to the leaf directory name before either wire path reads it; codeburn yield's local output is untouched. Never-lose: usage span ids and commit attribution keys carry no project (unchanged, no re-send); session attribution keys hash the record, so each already-synced session re-emits once and the receiver upserts by (org, trace) — replaced, not duplicated. Two directories with the same leaf name now collapse to one wire project identity; that is the documented contract, and git.repo still disambiguates. --- src/sync/cli.ts | 5 +++-- src/sync/otlp.ts | 10 ++++++++++ tests/sync-attribution-cli.test.ts | 22 +++++++++++++++++++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/sync/cli.ts b/src/sync/cli.ts index 1c40ec1d..509a112f 100644 --- a/src/sync/cli.ts +++ b/src/sync/cli.ts @@ -23,7 +23,7 @@ import { import { createCredentialStore } from './credentials.js' import { readSyncConfig, writeSyncConfig, deleteSyncConfig, updateLastSync } from './config.js' import { collectUnsentCalls, collectUnsentAttribution, sendBatches, sendAttributionBatches, batchCalls, MAX_PER_PUSH, MAX_ATTRIBUTION_PER_PUSH, type PushResult } from './push.js' -import { batchAttributionItems } from './otlp.js' +import { batchAttributionItems, wireProjectName } from './otlp.js' export function registerSyncCommands(program: Command): void { const sync = program @@ -274,7 +274,8 @@ export function registerSyncCommands(program: Command): void { process.exit(1) } const { range } = getDateRange(period) - const projects = await parseAllSessions(range) + const projects = (await parseAllSessions(range)) + .map(p => ({ ...p, project: wireProjectName(p.projectPath, p.project) })) // Flatten + filter against sent-ledger const { allCalls, unsent, held, frozen } = collectUnsentCalls(projects) diff --git a/src/sync/otlp.ts b/src/sync/otlp.ts index 03c7f11c..2395a2ea 100644 --- a/src/sync/otlp.ts +++ b/src/sync/otlp.ts @@ -65,6 +65,16 @@ export function deriveTraceId(sessionId: string): string { return createHash('sha256').update(sessionId).digest('hex').slice(0, 32) } +/** + * Wire-safe project identity: the leaf directory name. Several providers key a + * project by its sanitized absolute path ("-Users-me-Projects-app"), and + * docs/sync/README.md promises file paths stay local. + */ +export function wireProjectName(projectPath: string, fallback: string): string { + const normalized = projectPath.trim().replace(/\\/g, '/').replace(/\/+$/, '') + return normalized.split('/').filter(Boolean).pop() ?? fallback +} + // --- Timestamp conversion --- function toUnixNano(isoTimestamp: string): string { diff --git a/tests/sync-attribution-cli.test.ts b/tests/sync-attribution-cli.test.ts index 295350fc..65048453 100644 --- a/tests/sync-attribution-cli.test.ts +++ b/tests/sync-attribution-cli.test.ts @@ -10,7 +10,7 @@ import { execFileSync } from 'node:child_process' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest' import { Command } from 'commander' @@ -154,4 +154,24 @@ describe('sync push --attribution (CLI level)', () => { const wire = JSON.stringify(idp.tracesRequests) expect(wire).toContain('github.com/acme/cli-widget') }) + + it('never puts a filesystem path on the wire as ai.project', async () => { + const now = Date.now() + const first = new Date(now - 90 * 60 * 1000).toISOString() + const last = new Date(now - 30 * 60 * 1000).toISOString() + const session = makeSession('cli-sess-1', first, last, [makeCall(`call-${now}`, first)]) + parseAllSessionsMock.mockResolvedValue([ + { project: `-${repoDir.replace(/^\//, '').replace(/\//g, '-')}`, projectPath: repoDir, sessions: [session] } as ProjectSummary, + ]) + await runPush(['--attribution']) + const wire = JSON.stringify(idp.tracesRequests) + expect(wire).not.toContain(repoDir) // no absolute path + expect(wire).not.toContain(repoDir.replace(/\//g, '-')) // no slugified path + const projects = idp.tracesRequests.flatMap(r => (r.body as any).resourceSpans + .flatMap((rs: any) => rs.scopeSpans.flatMap((ss: any) => ss.spans)) + .flatMap((s: any) => s.attributes.filter((a: any) => a.key === 'ai.project') + .map((a: any) => a.value.stringValue))) + expect(projects.length).toBeGreaterThanOrEqual(2) // usage + attribution spans + expect(new Set(projects)).toEqual(new Set([basename(repoDir)])) + }) })