fix(sync): never put a filesystem path on the wire as ai.project

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.
This commit is contained in:
iamtoruk 2026-08-24 02:19:34 -07:00
parent e5f02933c4
commit fcbc28540f
3 changed files with 34 additions and 3 deletions

View file

@ -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)

View file

@ -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 {

View file

@ -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)]))
})
})