From 37a5b46f85e43678341dcbb3268013b0494ac1f9 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:55:37 +0300 Subject: [PATCH 1/2] fix(sync): key the device, span and trace digests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync path derived three identifiers with bare SHA-256 and sent them to a configured endpoint. `deriveDeviceId` hashed `hostname:username` and truncated to 64 bits, commented "pseudonymous, stable". An unkeyed digest of a host and username pair is not pseudonymous against anyone who can guess plausible values: hash the guess, compare, done. `deriveSpanId` hashed the dedup key — and for pi, zerostack, lingtai-tui and codebuff that key embeds the raw absolute source path, home directory included, because the bridge passes `source.path` straight through. Guess a plausible home and project name and the same confirmation works. This is the project's own standard, not an outside opinion. Decision D1 requires a caller-supplied HMAC key for fingerprints precisely so digests of paths cannot be dictionary-attacked, and core's fingerprint module throws on an empty key to enforce it. The sync path bypassed the primitive entirely. It also contradicted the project's own user-facing guarantee: docs/sync/README.md promises that code, file contents, diffs and PATHS stay local, and the unkeyed span id shipped absolute paths (for the four providers above) in a form confirmable by anyone with a plausible guess. All three ids are now HMAC-SHA256 under the per-install privacy key — the same key core's fingerprints use — with domain prefixes so one value in two positions never yields the same digest, and composite inputs joined with the same ASCII Unit Separator (0x1f) core/fingerprint.ts uses so a value containing ':' cannot forge a field boundary. The derive functions throw on an empty key rather than degrading. The payload builder obtains the key itself, so the decode path, which runs with an empty key by design, never reaches it. Sync now REQUIRES the persisted key: privacy-key.ts exposes a strict variant that aborts the push instead of falling back to per-process randomness when the config dir is unwritable, and refuses to silently regenerate a key file that fails validation (truncated by a full disk, a partial write). Cross-process id stability is load-bearing — partially rejected batches are not ledgered precisely because deterministic span ids make full-batch retry safe — so a per-process fallback key would emit fresh ids on every retry and let the backend double-count accepted spans, and a silent re-key would orphan everything already pushed. The fingerprint consumers keep the tolerant fallback: they only need per-process stability. The refusal is now complete, and enforced for every corrupt shape: "no file at all" is the only state a first use may create. A file that exists but is unreadable, zero-byte or whitespace-only (a partial write), or fails hex validation aborts the push and is left untouched — treating those as MISSING would silently regenerate the file and re-key every id, which is exactly the case the strict path exists to refuse. First creation is also exclusive (O_CREAT|O_EXCL): when two processes race the first use, the loser re-reads and adopts the winner's key, so concurrent pushes can never mint different keys and mix cached device ids with spans derived from the other. Scope, stated honestly: sync is opt-in and needs an endpoint plus credentials, the digests are of identifiers rather than prompts or file contents, and this predates the extraction. It is not an active leak of user content. It is a weak construction the project already knows how to do properly. This change narrows the exposure rather than closing it: ai.project still ships a project name in the clear, and in one Claude fallback path that name is a dash-encoded absolute path. Blast radius: every id is re-keyed once at upgrade, so anything already pushed stops correlating with new sends and the backend sees a fresh device identity. Ids stay stable afterwards unless the key file is lost. The host-side sent ledger keys off the raw dedup key and is unaffected, so re-push filtering keeps working. --- docs/sync/DEVELOPER.md | 27 +- docs/sync/README.md | 2 +- packages/cli/src/privacy-key.ts | 184 +++++++++- packages/cli/src/sync/index.ts | 2 +- packages/cli/src/sync/otlp.ts | 60 ++- packages/cli/src/sync/push.ts | 4 +- .../fixtures/privacy-key-first-use-worker.ts | 21 ++ packages/cli/tests/sync-infra-e2e.test.ts | 5 +- packages/cli/tests/sync-ledger-otlp.test.ts | 98 +++-- packages/cli/tests/sync-privacy-key.test.ts | 344 ++++++++++++++++++ 10 files changed, 686 insertions(+), 61 deletions(-) create mode 100644 packages/cli/tests/fixtures/privacy-key-first-use-worker.ts create mode 100644 packages/cli/tests/sync-privacy-key.test.ts diff --git a/docs/sync/DEVELOPER.md b/docs/sync/DEVELOPER.md index 130edf11..eb797f1d 100644 --- a/docs/sync/DEVELOPER.md +++ b/docs/sync/DEVELOPER.md @@ -105,12 +105,31 @@ Strict protobuf-JSON mapping of `ExportTraceServiceRequest`. lowerCamelCase fiel ### Span identity (deterministic) +All ids are HMAC-SHA256 keyed by the per-install host privacy key (decision D1, +see `packages/cli/src/privacy-key.ts`) with a role prefix — never a bare +SHA-256, which would be confirmable by dictionary attack: + ``` -span_id = first 8 bytes of SHA-256(deduplicationKey) → hex (16 chars) -trace_id = first 16 bytes of SHA-256(sessionId) → hex (32 chars) +span_id = first 8 bytes of HMAC-SHA256(privacyKey, "sync-span:" + deduplicationKey) → hex (16 chars) +trace_id = first 16 bytes of HMAC-SHA256(privacyKey, "sync-trace:" + sessionId) → hex (32 chars) ``` -Re-sends are byte-identical. Server-side dedup is defense-in-depth. +The key is generated once per install, persisted in the codeburn config dir, +and never leaves the host, so re-sends are byte-identical on the same machine. +Sync REQUIRES that persisted key: push aborts with an error if the config dir +is unwritable (no per-process fallback key) or a key file exists but does not +hold a valid key — corrupt content, a zero-byte file (a partial write), or an +unreadable file, with no silent regeneration in any of those cases. Only "no +file at all" may be created, and that first create is exclusive +(O_CREAT|O_EXCL): concurrent first pushes collide, the loser re-reads and +adopts the winner's key, so two processes can never mint different keys and +mix ids derived under each. Either degradation — a per-process fallback key, +or a silent re-key — would re-key every id between processes and break the +partial-rejection retry guarantee below. +Deliberately deleting the key file (or changing the derivation) re-keys every +id: spans already sent under the old construction no longer correlate with new +ones. A corrupt key file is the one case that never re-keys silently — the +push stops and the operator must fix the disk or delete the file on purpose. ### Resource attributes @@ -118,7 +137,7 @@ Re-sends are byte-identical. Server-side dedup is defense-in-depth. { "resource": { "attributes": [ - { "key": "codeburn.device_id", "value": { "stringValue": "" } } + { "key": "codeburn.device_id", "value": { "stringValue": " (\\x1f = ASCII Unit Separator)" } } ] } } diff --git a/docs/sync/README.md b/docs/sync/README.md index 5f1343ed..47557929 100644 --- a/docs/sync/README.md +++ b/docs/sync/README.md @@ -93,7 +93,7 @@ Each AI interaction becomes one OTLP span with these attributes: | `ai.project` | `my-app` | Project name | | `ai.tools` | `["Edit", "Bash"]` | Tools invoked | -A pseudonymous `device_id` distinguishes your machines without revealing hostnames. +A keyed `device_id` (HMAC of hostname and username under a per-install key that never leaves the machine) distinguishes your machines without revealing hostnames. ### What is NOT sent diff --git a/packages/cli/src/privacy-key.ts b/packages/cli/src/privacy-key.ts index 44580ffa..205324bf 100644 --- a/packages/cli/src/privacy-key.ts +++ b/packages/cli/src/privacy-key.ts @@ -24,35 +24,185 @@ function keyPath(): string { return join(getConfigDir(), KEY_FILE) } +/** + * State of the key file, without creating anything. The distinction that + * matters: 'missing' (no file at all — a first use, which may create one) + * versus 'unreadable'/'invalid' (a file that EXISTS but does not contain a + * usable key — a corrupt file, which must never be silently replaced). A + * zero-byte file or a file that fails to read is a partial write / disk + * failure, not an absent key. + */ +type KeyFileState = + | { kind: 'missing' } + | { kind: 'valid'; key: string } + | { kind: 'unreadable' } + | { kind: 'invalid' } + +function readKeyFileState(path: string): KeyFileState { + if (!existsSync(path)) return { kind: 'missing' } + + let raw = '' + try { + raw = readFileSync(path, 'utf-8').trim() + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing' } + // Exists but unreadable (EACCES, EIO, ...). Corrupt for our purposes. + return { kind: 'unreadable' } + } + if (KEY_HEX.test(raw)) return { kind: 'valid', key: raw } + // Exists but empty, whitespace-only, or not 64 hex chars. A zero-byte file + // is a partial write — corrupt, not missing. + return { kind: 'invalid' } +} + +/** Synchronous sleep for the bounded EEXIST re-read retry below. */ +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +} + +/** + * Outcome of an exclusive first-use create. + */ +type FirstUseOutcome = + | { kind: 'key'; key: string } // created by us, or adopted from a concurrent winner + | { kind: 'invalid-existing' } // a concurrent create won but left no valid key (crashed mid-write) + | { kind: 'write-failed' } // mkdir or write failed for another reason (unwritable dir) + +/** + * Create the key file with O_CREAT|O_EXCL so exactly one concurrent first use + * wins. A non-exclusive write would let two processes mint different keys and + * each cache its own — then device ids derived under one key mix with spans + * derived under the other. Losers re-read and adopt the winner's key. The + * winner's write lands immediately after its create, so on EEXIST we retry a + * bounded number of times before concluding the file was left by a crash. + */ +function createKeyFileExclusive(path: string): FirstUseOutcome { + const key = randomBytes(32).toString('hex') + try { + mkdirSync(getConfigDir(), { recursive: true }) + writeFileSync(path, key + '\n', { mode: 0o600, flag: 'wx' }) + return { kind: 'key', key } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + return { kind: 'write-failed' } + } + for (let attempt = 0; attempt < 5; attempt++) { + const state = readKeyFileState(path) + if (state.kind === 'valid') return { kind: 'key', key: state.key } + sleepSync(10) + } + return { kind: 'invalid-existing' } + } +} + /** * Return the host privacy key, generating and persisting one on first use. * Falls back to an in-memory ephemeral key if the config dir is unwritable, so * a read-only environment still gets stable (per-process) fingerprints rather * than throwing. + * + * An existing key file that is unreadable, empty, or fails hex validation is + * likewise NEVER overwritten: the caller gets an ephemeral key and the file is + * left alone, so {@link getPersistedHostPrivacyKey} can still detect the + * corruption and fail loudly instead of finding a freshly regenerated key. + * 'No file at all' is the ONLY state that may create one. + * + * First creation is exclusive, so concurrent first uses converge on one key + * instead of each minting (and overwriting) its own. + * + * This tolerance is CORRECT only for consumers whose fingerprints need + * per-process stability (the optimize detectors). Sync ids need CROSS-PROCESS + * stability — use {@link getPersistedHostPrivacyKey} there instead. */ export function getHostPrivacyKey(): string { if (cached) return cached const path = keyPath() - if (existsSync(path)) { - try { - const raw = readFileSync(path, 'utf-8').trim() - if (KEY_HEX.test(raw)) { - cached = raw - return cached - } - } catch { - // fall through to regenerate - } + const state = readKeyFileState(path) + if (state.kind === 'valid') { + cached = state.key + return cached + } + if (state.kind !== 'missing') { + // The file exists but is not a usable key (corrupt content, empty partial + // write, or unreadable). Never overwrite it: that would silently re-key + // every fingerprint id derived from the old key. Fall back to an ephemeral + // key so optimize detectors keep per-process stability, and leave the file + // untouched so getPersistedHostPrivacyKey still detects the corruption and + // fails loudly. + cached = randomBytes(32).toString('hex') + return cached } - const key = randomBytes(32).toString('hex') - try { - mkdirSync(getConfigDir(), { recursive: true }) - writeFileSync(path, key + '\n', { mode: 0o600 }) - } catch { - // Config dir unwritable — keep the key in memory for this process only. + const outcome = createKeyFileExclusive(path) + if (outcome.kind === 'key') { + cached = outcome.key + return cached } - cached = key + // Unwritable dir, or a concurrent first use crashed before writing a key. + // Keep the key in memory for this process only; never clobber the file. + cached = randomBytes(32).toString('hex') return cached } + +/** + * Like {@link getHostPrivacyKey}, but REQUIRES a persisted key and fails + * loudly instead of degrading to per-process randomness. Sync uses this: its + * device/span/trace ids must be byte-identical across processes — the + * partial-rejection retry guarantee in sync/push.ts depends on it — so an + * ephemeral key (which would emit fresh ids on every push) is worse than no + * push at all. + * + * It also refuses to silently regenerate a key file that exists but does not + * hold a valid key — corrupt content, a zero-byte partial write, or an + * unreadable file. Overwriting any of those would re-key every id with no + * notice, silently orphaning whatever was already pushed to the backend. The + * operator must see the corruption and decide — fix the disk, or delete the + * file deliberately. Only 'no file at all' may be created, and that first + * create is exclusive so concurrent first pushes converge on one key. + */ +export function getPersistedHostPrivacyKey(): string { + const path = keyPath() + + const state = readKeyFileState(path) + if (state.kind === 'valid') { + cached = state.key + return cached + } + if (state.kind === 'unreadable') { + throw new Error( + `Host privacy key at ${path} exists but could not be read. ` + + 'Refusing to overwrite it: that would silently re-key every id and orphan ' + + 'already-synced data. Fix the disk or file permissions, or delete the file ' + + 'deliberately, then retry.' + ) + } + if (state.kind === 'invalid') { + throw new Error( + `Host privacy key at ${path} is corrupted (expected 64 hex chars). ` + + 'Refusing to overwrite it: that would silently re-key every id and orphan ' + + 'already-synced data. Fix the disk or delete the file deliberately, then retry.' + ) + } + + const outcome = createKeyFileExclusive(path) + if (outcome.kind === 'key') { + cached = outcome.key + return cached + } + if (outcome.kind === 'invalid-existing') { + // A concurrent first use created the file but crashed before writing a + // valid key. The file exists and holds no key — same refusal as above. + throw new Error( + `Host privacy key at ${path} exists but does not contain a valid key ` + + '(a concurrent first use left it empty). Refusing to overwrite it: that would ' + + 'silently re-key every id and orphan already-synced data. Delete the file ' + + 'deliberately, then retry.' + ) + } + throw new Error( + `Cannot persist a host privacy key at ${path} (config dir not writable). ` + + 'Sync requires a stable on-disk key so ids are identical across pushes; ' + + 'an in-memory key would change every id on the next run. Fix permissions and retry.' + ) +} diff --git a/packages/cli/src/sync/index.ts b/packages/cli/src/sync/index.ts index 347d03e4..4d5a836b 100644 --- a/packages/cli/src/sync/index.ts +++ b/packages/cli/src/sync/index.ts @@ -4,5 +4,5 @@ export { fetchOidcConfig, generatePkce, buildAuthUrl, resolveScopes, exchangeCod export { createCredentialStore, type CredentialStore, type StorageMethod } from './credentials.js' export { readSyncConfig, writeSyncConfig, deleteSyncConfig, type SyncConfig } from './config.js' export { readLedger, appendToLedger, ledgerKeySet, clearLedger } from './ledger.js' -export { buildOtlpPayload, batchCalls, deriveSpanId, deriveTraceId, getDeviceId } from './otlp.js' +export { buildOtlpPayload, batchCalls, deriveSpanId, deriveTraceId, deriveDeviceId, getDeviceId } from './otlp.js' export { collectUnsentCalls, sendBatches, parseRetryAfterMs, MAX_PER_PUSH, type PushResult, type PushOutcome } from './push.js' diff --git a/packages/cli/src/sync/otlp.ts b/packages/cli/src/sync/otlp.ts index 8a12c7e6..f04d4e75 100644 --- a/packages/cli/src/sync/otlp.ts +++ b/packages/cli/src/sync/otlp.ts @@ -2,11 +2,20 @@ * codeburn sync — OTLP payload builder. * * Converts ParsedApiCall[] into an ExportTraceServiceRequest (OTLP/HTTP JSON). - * Span and trace IDs are derived deterministically from deduplicationKey/sessionId. + * Span and trace IDs are derived deterministically from deduplicationKey/sessionId + * under the persisted host privacy key. + * + * NOTE: not a pure converter. The first call reads — and, if absent, creates — + * the persisted privacy key via privacy-key.ts, so it does synchronous + * filesystem I/O and can THROW. Sync aborts the push when the key cannot be + * persisted or an existing key file is corrupt, because cross-process id + * stability (and with it the partial-rejection retry safety in push.ts) + * depends on a real on-disk key rather than per-process randomness. */ -import { createHash } from 'crypto' +import { createHmac } from 'crypto' import { hostname, userInfo } from 'os' +import { getPersistedHostPrivacyKey } from '../privacy-key.js' import type { ParsedApiCall } from '../types.js' export interface OtlpSpan { @@ -39,29 +48,55 @@ export interface OtlpPayload { }> } -// --- Device ID (pseudonymous, stable) --- +// --- Keyed ID derivation (decision D1) --- +// +// Every identifier this payload emits is an HMAC-SHA256 digest keyed by the +// persisted host privacy key (privacy-key.ts) — never a bare SHA-256. An +// unkeyed digest of an identifier is confirmable by dictionary attack (guess +// the input, hash it, compare); keying makes that infeasible without the key. +// This mirrors core/fingerprint.ts, which requires a caller-supplied key for +// exactly this reason. Ids stay deterministic for a given host because the +// key is stable per install. The domain prefix separates roles so the same +// value in two positions can never produce the same digest; composite inputs +// are joined with the same ASCII Unit Separator (0x1f) core/fingerprint.ts +// uses, so a value containing ':' cannot forge a field boundary (host 'a' + +// user 'b:c' must not collide with host 'a:b' + user 'c'). + +// Field separator for composite HMAC inputs (ASCII Unit Separator), matching +// core/fingerprint.ts. +const SEP = String.fromCharCode(0x1f) + +function keyedId(privacyKey: string, domain: string, parts: string[], len: number): string { + if (!privacyKey) throw new Error('privacyKey is required') + return createHmac('sha256', privacyKey) + .update(`${domain}:${parts.join(SEP)}`) + .digest('hex') + .slice(0, len) +} + +// --- Device ID (HMAC-keyed, stable) --- let cachedDeviceId: string | null = null /** Pure derivation — exposed so the encoding can be golden-pinned in tests. */ -export function deriveDeviceId(host: string, username: string): string { - return createHash('sha256').update(`${host}:${username}`).digest('hex').slice(0, 16) +export function deriveDeviceId(privacyKey: string, host: string, username: string): string { + return keyedId(privacyKey, 'sync-device', [host, username], 16) } export function getDeviceId(): string { if (cachedDeviceId) return cachedDeviceId - cachedDeviceId = deriveDeviceId(hostname(), userInfo().username) + cachedDeviceId = deriveDeviceId(getPersistedHostPrivacyKey(), hostname(), userInfo().username) return cachedDeviceId } // --- Span/Trace ID derivation (deterministic) --- -export function deriveSpanId(deduplicationKey: string): string { - return createHash('sha256').update(deduplicationKey).digest('hex').slice(0, 16) +export function deriveSpanId(privacyKey: string, deduplicationKey: string): string { + return keyedId(privacyKey, 'sync-span', [deduplicationKey], 16) } -export function deriveTraceId(sessionId: string): string { - return createHash('sha256').update(sessionId).digest('hex').slice(0, 32) +export function deriveTraceId(privacyKey: string, sessionId: string): string { + return keyedId(privacyKey, 'sync-trace', [sessionId], 32) } // --- Timestamp conversion --- @@ -82,6 +117,7 @@ export interface CallWithSession { export function buildOtlpPayload(calls: CallWithSession[]): OtlpPayload { const deviceId = getDeviceId() + const privacyKey = getPersistedHostPrivacyKey() const spans: OtlpSpan[] = calls.map(({ call, sessionId, project }) => { const startNano = toUnixNano(call.timestamp) @@ -110,8 +146,8 @@ export function buildOtlpPayload(calls: CallWithSession[]): OtlpPayload { attributes.push({ key: 'ai.cost_estimated', value: { boolValue: isEstimated } }) return { - traceId: deriveTraceId(sessionId), - spanId: deriveSpanId(call.deduplicationKey), + traceId: deriveTraceId(privacyKey, sessionId), + spanId: deriveSpanId(privacyKey, call.deduplicationKey), name: `${call.provider}/${call.model}`, startTimeUnixNano: startNano, endTimeUnixNano: endNano, diff --git a/packages/cli/src/sync/push.ts b/packages/cli/src/sync/push.ts index 0444c718..ce3c8f19 100644 --- a/packages/cli/src/sync/push.ts +++ b/packages/cli/src/sync/push.ts @@ -83,7 +83,9 @@ export function parseRetryAfterMs(value: string | null): number | null { /** * Send batches sequentially until all are sent. Ledgers each fully-accepted * batch. Partially-rejected batches are NOT ledgered (OTLP doesn't identify - * which spans were rejected; deterministic span IDs make full-batch retry safe). + * which spans were rejected; deterministic span IDs make full-batch retry safe + * — buildOtlpPayload enforces that determinism by aborting the push when the + * privacy key cannot be persisted, so a retry can never carry fresh span IDs). * * 429 responses are honored: waits Retry-After (capped at maxWaitMs, default * backoff 5s when absent) and retries the same batch, up to max429Retries diff --git a/packages/cli/tests/fixtures/privacy-key-first-use-worker.ts b/packages/cli/tests/fixtures/privacy-key-first-use-worker.ts new file mode 100644 index 00000000..8c8130d0 --- /dev/null +++ b/packages/cli/tests/fixtures/privacy-key-first-use-worker.ts @@ -0,0 +1,21 @@ +import { existsSync } from 'fs' +import { writeFile } from 'fs/promises' + +import { getPersistedHostPrivacyKey } from '../../src/privacy-key.js' +import { deriveDeviceId } from '../../src/sync/otlp.js' + +const [goFile, readyFile] = process.argv.slice(2) +if (!goFile || !readyFile) throw new Error('missing GO_FILE/READY_FILE argument') + +// Signal the parent that we are booted and spinning on the start barrier, so +// it only releases the race once BOTH processes are waiting. +await writeFile(readyFile, '') +// Busy spin (no sleep): a sleep would let one child complete its whole +// create+write before the other wakes, serializing the race instead of firing +// it. Both children observe the go file within microseconds of each other and +// attempt their first use in lockstep. +while (!existsSync(goFile)) {} + +const key = getPersistedHostPrivacyKey() +const deviceId = deriveDeviceId(key, 'race-host', 'race-user') +process.stdout.write(JSON.stringify({ key, deviceId }) + '\n') diff --git a/packages/cli/tests/sync-infra-e2e.test.ts b/packages/cli/tests/sync-infra-e2e.test.ts index 71265e7c..f0264f8d 100644 --- a/packages/cli/tests/sync-infra-e2e.test.ts +++ b/packages/cli/tests/sync-infra-e2e.test.ts @@ -15,11 +15,11 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { randomBytes, createHash } from 'crypto' +import { randomBytes } from 'crypto' import { fetchDiscoveryDoc } from '../src/sync/discovery.js' import { fetchOidcConfig, refreshToken } from '../src/sync/auth.js' -import { buildOtlpPayload, deriveSpanId, type CallWithSession } from '../src/sync/otlp.js' +import { buildOtlpPayload, type CallWithSession } from '../src/sync/otlp.js' import type { ParsedApiCall, TokenUsage } from '../src/types.js' const BASE_URL = process.env.CODEBURN_SYNC_URL @@ -185,7 +185,6 @@ describe.skipIf(SKIP)('sync infra e2e — push + verify', () => { it('deterministic span IDs allow safe re-push', async () => { const dedup = `test:infra-e2e:${testRunId}:idempotent` - const spanId = deriveSpanId(dedup) // Push same data twice const call: CallWithSession = { diff --git a/packages/cli/tests/sync-ledger-otlp.test.ts b/packages/cli/tests/sync-ledger-otlp.test.ts index 0b32f80d..4f03f385 100644 --- a/packages/cli/tests/sync-ledger-otlp.test.ts +++ b/packages/cli/tests/sync-ledger-otlp.test.ts @@ -60,49 +60,75 @@ function makeCallWithSession(overrides?: Partial & { deduplicatio // ── OTLP Span/Trace ID Derivation ──────────────────────────────────── +// Fixed key for the golden pins: 64 hex chars, the same shape as the real +// per-install key from privacy-key.ts. A golden pins the FULL encoding +// (domain prefix + HMAC-SHA256 + truncation), so an accidental construction +// change fails loudly instead of silently re-keying every emitted id. +const TEST_KEY = 'c0deb00c'.repeat(8) + describe('deriveSpanId', () => { it('returns 16 hex chars', () => { - const id = deriveSpanId('cursor:bubble:abc123') + const id = deriveSpanId(TEST_KEY, 'cursor:bubble:abc123') expect(id).toMatch(/^[0-9a-f]{16}$/) }) it('is deterministic (same input = same output)', () => { - const a = deriveSpanId('my:dedup:key') - const b = deriveSpanId('my:dedup:key') + const a = deriveSpanId(TEST_KEY, 'my:dedup:key') + const b = deriveSpanId(TEST_KEY, 'my:dedup:key') expect(a).toBe(b) }) it('different inputs produce different IDs', () => { - const a = deriveSpanId('key-1') - const b = deriveSpanId('key-2') + const a = deriveSpanId(TEST_KEY, 'key-1') + const b = deriveSpanId(TEST_KEY, 'key-2') + expect(a).not.toBe(b) + }) + + it('same input under different keys produces different IDs', () => { + const a = deriveSpanId(TEST_KEY, 'same:key') + const b = deriveSpanId('f'.repeat(64), 'same:key') expect(a).not.toBe(b) }) - // GOLDEN PIN — do not update this value. The idempotency contract depends - // on span IDs being stable across releases: if the hash input or encoding - // ever changes, every re-sent span gets a new identity and backends that - // key on span ID double-count history. If this test fails, revert the - // encoding change (or design an explicit migration). - it('golden: SHA-256(deduplicationKey) first 8 bytes as hex', () => { - expect(deriveSpanId('golden-dedup-key')).toBe('ec3ca28cceacf381') + it('throws on an empty key (decision D1)', () => { + expect(() => deriveSpanId('', 'x')).toThrow(/privacyKey is required/) + }) + + // GOLDEN — deliberately updated in the keyed-encoding change. The old pin + // (ec3ca28cceacf381, plain SHA-256 of the dedup key) is retired because the + // construction it pinned was the dictionary-attackable one. Previously-sent + // span ids no longer correlate with new ones for the same dedup key — the + // host-side ledger is keyed by the raw deduplicationKey, not the span id, so + // re-push filtering is unaffected. If this test fails, revert the encoding + // change (or design an explicit migration). + it('golden: HMAC-SHA256(privacyKey, "sync-span:golden-dedup-key") first 8 bytes as hex', () => { + expect(deriveSpanId(TEST_KEY, 'golden-dedup-key')).toBe('517d8367a13d6124') }) }) describe('deriveTraceId', () => { it('returns 32 hex chars', () => { - const id = deriveTraceId('session-xyz') + const id = deriveTraceId(TEST_KEY, 'session-xyz') expect(id).toMatch(/^[0-9a-f]{32}$/) }) it('is deterministic', () => { - const a = deriveTraceId('session-1') - const b = deriveTraceId('session-1') + const a = deriveTraceId(TEST_KEY, 'session-1') + const b = deriveTraceId(TEST_KEY, 'session-1') expect(a).toBe(b) }) - // GOLDEN PIN — see deriveSpanId golden test for why this must not change. - it('golden: SHA-256(sessionId) first 16 bytes as hex', () => { - expect(deriveTraceId('golden-session-id')).toBe('ff1b1358ef64c52f80e50e7ae47ca176') + it('same input under different keys produces different IDs', () => { + const a = deriveTraceId(TEST_KEY, 'session-1') + const b = deriveTraceId('f'.repeat(64), 'session-1') + expect(a).not.toBe(b) + }) + + // GOLDEN — deliberately updated with the same keyed-encoding change as the + // span-id pin above; session ids can embed path-derived material for some + // providers, so they get the same construction. + it('golden: HMAC-SHA256(privacyKey, "sync-trace:golden-session-id") first 16 bytes as hex', () => { + expect(deriveTraceId(TEST_KEY, 'golden-session-id')).toBe('7a3483584b8b6bd1b07d8a347549b1b7') }) }) @@ -115,11 +141,39 @@ describe('getDeviceId', () => { it('is stable across calls', () => { expect(getDeviceId()).toBe(getDeviceId()) }) +}) + +describe('deriveDeviceId', () => { + it('returns 16 hex chars', () => { + const id = deriveDeviceId(TEST_KEY, 'host.example', 'alice') + expect(id).toMatch(/^[0-9a-f]{16}$/) + }) + + it('is deterministic across calls', () => { + const a = deriveDeviceId(TEST_KEY, 'host.example', 'alice') + const b = deriveDeviceId(TEST_KEY, 'host.example', 'alice') + expect(a).toBe(b) + }) + + it('same host/user under different keys produces different IDs', () => { + const a = deriveDeviceId(TEST_KEY, 'host.example', 'alice') + const b = deriveDeviceId('f'.repeat(64), 'host.example', 'alice') + expect(a).not.toBe(b) + }) + + // GOLDEN — deliberately updated with the same keyed-encoding change as the + // span/trace pins: the old pin (10f57c433adc234f) pinned the colon-joined + // construction `sync-device:host.example:alice`. The host/username join now + // uses the same ASCII Unit Separator (0x1f) as core/fingerprint.ts so a + // username containing ':' cannot forge a host/user boundary. + it('golden: HMAC-SHA256(privacyKey, "sync-device:" + host + US + username) first 8 bytes as hex', () => { + expect(deriveDeviceId(TEST_KEY, 'host.example', 'alice')).toBe('f829295f3e76896f') + }) - // GOLDEN PIN — device ID must be stable across releases so a developer's - // machine keeps one identity in the backend. - it('golden: SHA-256(hostname:username) first 8 bytes as hex', () => { - expect(deriveDeviceId('host.example', 'alice')).toBe('004d1a2fc048f575') + // F3: field-boundary collision — ':' is not a valid separator for composite + // inputs. host 'a' + user 'b:c' must differ from host 'a:b' + user 'c'. + it('separates host and username with US, not ":" (no boundary collision)', () => { + expect(deriveDeviceId(TEST_KEY, 'a', 'b:c')).not.toBe(deriveDeviceId(TEST_KEY, 'a:b', 'c')) }) }) diff --git a/packages/cli/tests/sync-privacy-key.test.ts b/packages/cli/tests/sync-privacy-key.test.ts new file mode 100644 index 00000000..4b659dde --- /dev/null +++ b/packages/cli/tests/sync-privacy-key.test.ts @@ -0,0 +1,344 @@ +/** + * Tests for the sync privacy-key contract (src/privacy-key.ts). + * + * Sync ids (device/span/trace) must be stable ACROSS processes: the + * partial-rejection retry in push.ts only works if a retry carries the same + * span ids. getPersistedHostPrivacyKey enforces that by refusing to degrade — + * it throws when the key cannot be persisted, and refuses to silently + * regenerate a key file that exists but does not hold a valid key. That + * includes a zero-byte or whitespace-only file (a partial write) and a file + * that cannot be read: 'no file at all' is the only state that may be + * created. The tolerant getHostPrivacyKey (used by the optimize detectors, + * which only need per-process stability) also refuses to overwrite a corrupt + * file — it degrades to an ephemeral in-memory key and leaves the file alone, + * so the strict path still sees the corruption. + * + * First creation is exclusive (O_CREAT|O_EXCL): concurrent first uses converge + * on one key — the loser re-reads and adopts the winner's — so two processes + * can never mint different keys and mix cached device ids with spans derived + * from the other. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtemp, mkdir, rm, chmod, readFile, writeFile, stat } from 'fs/promises' +import { spawn, type ChildProcess } from 'child_process' +import { existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +import { getHostPrivacyKey, getPersistedHostPrivacyKey } from '../src/privacy-key.js' +import { buildOtlpPayload, deriveDeviceId, type CallWithSession } from '../src/sync/otlp.js' +import type { ParsedApiCall, TokenUsage } from '../src/types.js' + +// ── Test env: isolated HOME per test ───────────────────────────────── + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-privkey-')) + process.env.HOME = tmpDir +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function keyFilePath(): string { + return join(tmpDir, '.config', 'codeburn', 'privacy-key') +} + +// ── getPersistedHostPrivacyKey: happy path ─────────────────────────── + +describe('getPersistedHostPrivacyKey', () => { + it('generates and persists a valid key on first use', async () => { + const key = getPersistedHostPrivacyKey() + expect(key).toMatch(/^[0-9a-f]{64}$/) + + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw.trim()).toBe(key) + }) + + it('persists with owner-only permissions', async () => { + getPersistedHostPrivacyKey() + const s = await stat(keyFilePath()) + // mode 0600: no group or other bits, whatever the umask clears + expect(s.mode & 0o077).toBe(0) + }) + + it('is stable across calls in one process', () => { + expect(getPersistedHostPrivacyKey()).toBe(getPersistedHostPrivacyKey()) + }) + + it('returns the same key in a fresh process (cross-process stability)', async () => { + const first = getPersistedHostPrivacyKey() + + // Simulate a second process: wipe the module registry so the cached key + // is gone and the file must be re-read. + vi.resetModules() + const { getPersistedHostPrivacyKey: again } = await import('../src/privacy-key.js') + expect(again()).toBe(first) + }) +}) + +// ── F1 failure modes ───────────────────────────────────────────────── + +describe('getPersistedHostPrivacyKey — F1 failure modes', () => { + it('throws on an unwritable config dir instead of degrading to a per-process key', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + await chmod(configDir, 0o555) // read-only: writes fail + + try { + expect(() => getPersistedHostPrivacyKey()).toThrow(/Cannot persist a host privacy key/) + // No ephemeral fallback file appears + await expect(readFile(keyFilePath(), 'utf-8')).rejects.toThrow() + } finally { + await chmod(configDir, 0o755) // let afterEach rm it + } + }) + + it('throws on a corrupt key file and does NOT overwrite it', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + // Truncated / partial write: non-empty but not 64 hex chars + await writeFile(keyFilePath(), 'deadbeef-truncated') + + expect(() => getPersistedHostPrivacyKey()).toThrow(/corrupted/) + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw).toBe('deadbeef-truncated') // untouched — no silent re-key + }) + + it('throws on a zero-byte key file (partial write) and does NOT replace it', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + // A crashed writer: file created, nothing written. A zero-byte file is a + // partial write — corrupt, NOT an absent key. It must not be treated as + // missing and silently regenerated. + await writeFile(keyFilePath(), '') + + expect(() => getPersistedHostPrivacyKey()).toThrow(/corrupted/) + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw).toBe('') // untouched — no silent re-key + }) + + it('throws on a whitespace-only key file and does NOT replace it', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + await writeFile(keyFilePath(), '\n\n') + + expect(() => getPersistedHostPrivacyKey()).toThrow(/corrupted/) + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw).toBe('\n\n') // untouched — no silent re-key + }) + + it('throws when the key file exists but cannot be read, and does NOT replace it', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + // A valid key made unreadable: the file EXISTS, so it must never be + // overwritten — the failure must surface to the operator instead. + const original = 'c0deb00c'.repeat(8) + await writeFile(keyFilePath(), original + '\n') + await chmod(keyFilePath(), 0o000) + + try { + expect(() => getPersistedHostPrivacyKey()).toThrow(/could not be read/) + } finally { + await chmod(keyFilePath(), 0o600) // restore so we can inspect it + } + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw).toBe(original + '\n') // untouched — no silent re-key + }) +}) + +// ── Tolerant path: corrupt file is never silently replaced ───────────── + +describe('getHostPrivacyKey — corrupt file is never silently replaced', () => { + it('falls back to an ephemeral key, leaves the file alone, and the strict path still fails', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + await writeFile(keyFilePath(), 'deadbeef-truncated') + + // Fresh module instance: don't let a cached key from earlier tests mask + // the corrupt-file path. + vi.resetModules() + const { getHostPrivacyKey, getPersistedHostPrivacyKey } = await import('../src/privacy-key.js') + + const key = getHostPrivacyKey() + expect(key).toMatch(/^[0-9a-f]{64}$/) + expect(getHostPrivacyKey()).toBe(key) // per-process stability kept + + // The tolerant path must NOT have overwritten the corrupt file. + expect(await readFile(keyFilePath(), 'utf-8')).toBe('deadbeef-truncated') + + // The strict path still sees the corruption and fails loudly — it is not + // masked by a freshly regenerated file. + expect(() => getPersistedHostPrivacyKey()).toThrow(/corrupted/) + expect(await readFile(keyFilePath(), 'utf-8')).toBe('deadbeef-truncated') + }) +}) + +// ── F1 through the sync-facing surface (buildOtlpPayload) ──────────── + +function makeCall(): ParsedApiCall { + const usage: TokenUsage = { + inputTokens: 10, + outputTokens: 5, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + } + return { + provider: 'kiro', + model: 'claude-sonnet-4-6', + usage, + costUSD: 0.01, + tools: [], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-07-10T10:00:00.000Z', + bashCommands: [], + deduplicationKey: 'test:key:1', + } +} + +function makeCws(): CallWithSession { + return { call: makeCall(), sessionId: 'session-abc', project: 'my-project' } +} + +describe('buildOtlpPayload — F1 failure mode', () => { + it('aborts (throws) before any payload is built when the key cannot be persisted', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + await chmod(configDir, 0o555) + + try { + expect(() => buildOtlpPayload([makeCws()])).toThrow(/Cannot persist a host privacy key/) + } finally { + await chmod(configDir, 0o755) + } + }) + + it('aborts (throws) on a corrupt key file', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + await writeFile(keyFilePath(), 'deadbeef-truncated') + + expect(() => buildOtlpPayload([makeCws()])).toThrow(/corrupted/) + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw).toBe('deadbeef-truncated') + }) + + it('aborts (throws) on a zero-byte key file and does NOT replace it', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + await writeFile(keyFilePath(), '') + + expect(() => buildOtlpPayload([makeCws()])).toThrow(/corrupted/) + const raw = await readFile(keyFilePath(), 'utf-8') + expect(raw).toBe('') + }) +}) + +// ── Concurrent first use: exclusive create + convergence ───────────── + +async function waitFor(path: string, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + while (!existsSync(path)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`) + await new Promise(resolve => { setTimeout(resolve, 5) }) + } +} + +function firstUseWorker(goFile: string, readyFile: string, homeDir: string): ChildProcess { + return spawn( + process.execPath, + ['--import', 'tsx', join(process.cwd(), 'tests/fixtures/privacy-key-first-use-worker.ts'), goFile, readyFile], + { cwd: process.cwd(), env: { ...process.env, HOME: homeDir }, stdio: ['ignore', 'pipe', 'pipe'] } + ) +} + +function waitForExit(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + child.stdout?.on('data', chunk => { stdout += String(chunk) }) + child.stderr?.on('data', chunk => { stderr += String(chunk) }) + child.once('error', reject) + child.once('exit', code => { + if (code !== 0) reject(new Error(`worker exited ${code}: ${stderr}`)) + else resolve(stdout) + }) + }) +} + +describe('concurrent first use', () => { + it('on a create collision (EEXIST), re-reads and adopts the concurrent winner key', async () => { + const configDir = join(tmpDir, '.config', 'codeburn') + await mkdir(configDir, { recursive: true }) + const winnerKey = 'e'.repeat(64) + + // Simulate another process winning the create race: our exclusive write + // collides, and the winner's key is already on disk when we re-read. + vi.doMock('fs', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + writeFileSync: (p: string, _data: unknown, _opts?: unknown) => { + actual.writeFileSync(p, winnerKey + '\n', { mode: 0o600, flag: 'wx' }) + const err = new Error(`EEXIST: file already exists, open '${p}'`) as NodeJS.ErrnoException + err.code = 'EEXIST' + throw err + }, + } + }) + vi.resetModules() + try { + const { getPersistedHostPrivacyKey } = await import('../src/privacy-key.js') + expect(getPersistedHostPrivacyKey()).toBe(winnerKey) + expect(await readFile(keyFilePath(), 'utf-8')).toBe(winnerKey + '\n') + } finally { + vi.doUnmock('fs') + vi.resetModules() + } + }) + + it('two processes racing the first use end with ONE key and consistent ids', async () => { + // Three rounds, each on a fresh HOME: a round where the key file already + // exists cannot race, so every round must start from an empty config dir. + // On a non-exclusive first write a racing pair mints two keys and the + // assertion below fails; with the exclusive create they always converge. + for (let round = 0; round < 3; round++) { + const home = join(tmpDir, `round-${round}`) + const goFile = join(home, 'go') + const readyA = join(home, 'a.ready') + const readyB = join(home, 'b.ready') + await mkdir(home, { recursive: true }) + + const a = firstUseWorker(goFile, readyA, home) + const b = firstUseWorker(goFile, readyB, home) + // Only release the race once BOTH processes are booted and spinning. + await Promise.all([waitFor(readyA), waitFor(readyB)]) + await writeFile(goFile, 'go') + + const [outA, outB] = await Promise.all([waitForExit(a), waitForExit(b)]) + const resA = JSON.parse(outA) as { key: string; deviceId: string } + const resB = JSON.parse(outB) as { key: string; deviceId: string } + + // The loser must adopt the winner's key, not mint and overwrite its own — + // otherwise cached device ids from one key mix with spans derived from the + // other. + expect(resA.key).toBe(resB.key) + // ... and the converged key is the one persisted on disk. + expect((await readFile(join(home, '.config', 'codeburn', 'privacy-key'), 'utf-8')).trim()).toBe(resA.key) + // Ids derived under the converged key agree across both processes. + expect(resA.deviceId).toBe(resB.deviceId) + expect(resA.deviceId).toBe(deriveDeviceId(resA.key, 'race-host', 'race-user')) + } + }) +}) From f29d27e39d4d9aa207804ac2eced9e8ae1a3a8ed Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:56:26 +0300 Subject: [PATCH 2/2] feat(sync): push git attribution spans, with the hardening that followed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports three upstream commits this branch never received: the attribution feature (1bf7206), the review hardening on top of it (ccee28a), and the security follow-up that closed credential-leak paths and added session retraction (50c8251). They are ported as an end state rather than in sequence. Two and three revise one, so replaying them in order would have introduced the very issues they fix and then removed them again — and anything missed in the third pass would have shipped a feature with a reopened hole, which is the specific way this port could have gone wrong. The credential-leak paths that commit closes are enumerated and checked off individually against the result. One correction to an earlier draft of this message, which claimed no new unkeyed digest is introduced. That was wrong: stateHash in sync/otlp.ts is a new unkeyed sha256, and it feeds deriveSpanId, so it is an input to a value that goes on the wire. It is not a D1 violation — D1 governs core's fingerprint module and its caller-supplied key, while stateHash is a local ledger discriminator computed over data that is itself sent in cleartext, so it hides nothing and leaks nothing. But the sentence was false and is worth correcting rather than quietly dropping. This branch now carries #931's commit (c467548, "fix(sync): key the device, span and trace digests") beneath this one — cherry-picked onto the shared base so the history stays two clean commits. That ordering is load-bearing: reconciliation is mandatory in every merge order, not optional. git merge-tree reports no conflict against #931 in either direction, yet the merged file does not compile: #931 drops the createHash import and gives the derive functions a privacyKey first argument, so an unreconciled attribution section leaves stateHash with an undefined symbol and two one-argument call sites. Rebasing replays the same breakage, which is why the earlier "land #931 first, OR reconcile" framing was wrong. The two call sites are reconciled INTO #931's keyed signatures, in the direction #931 demands: buildAttributionOtlpPayload obtains the persisted host privacy key exactly as buildOtlpPayload does — one getPersistedHostPrivacyKey call per builder, no second source of the key — and threads it into deriveTraceId and deriveSpanId. This is the security point of the reconciliation: loosening the signatures back to one argument would reintroduce exactly the unkeyed span and trace ids #931 exists to remove, in new code. stateHash stays unkeyed, deliberately: it is a local ledger discriminator over One more merge-compat fix, in #931's own test file (sync-privacy-key.test.ts): the concurrency fixture path was built from process.cwd(), which is the repo root under `--root packages/cli` — the worker then exited on a nonexistent file before writing its ready file and the race test timed out. The path is now anchored to the test file's own location (fileURLToPath(import.meta.url)). This is the only line of #931's tree this branch touches; 37a5b46 remains a verbatim copy of c467548. Second fix in #931's tree, same motivation: the concurrency race test adopted with only a 50ms budget. createKeyFileExclusive polled the winner's file 5x10ms after EEXIST, and the strict entry check refused an 'invalid' file INSTANTLY — but the winner's create (open) and write are separate syscalls, and under load the loser can read the still-empty file either at entry or inside the poll. Both windows now share one bounded awaitValidKey (500ms) that ADOPTS the winner's key when it lands and otherwise throws the same refusal. Nothing is ever overwritten; a file left invalid by a crash or truncated write still fails loudly. This is the second #931 file this branch touches; 37a5b46 remains a verbatim copy of c467548. --- CHANGELOG.md | 3 + docs/sync/README.md | 35 +- packages/cli/src/privacy-key.ts | 40 +- packages/cli/src/sync/cli.ts | 87 +- packages/cli/src/sync/otlp.ts | 184 ++++- packages/cli/src/sync/push.ts | 90 ++- packages/cli/src/yield.ts | 317 +++++++- packages/cli/tests/fixtures/mock-idp.ts | 17 + .../cli/tests/sync-attribution-cli.test.ts | 157 ++++ packages/cli/tests/sync-attribution.test.ts | 756 ++++++++++++++++++ packages/cli/tests/sync-privacy-key.test.ts | 9 +- 11 files changed, 1647 insertions(+), 48 deletions(-) create mode 100644 packages/cli/tests/sync-attribution-cli.test.ts create mode 100644 packages/cli/tests/sync-attribution.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98c38076..c4ec2e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added (CLI) +- `codeburn sync push --attribution` (opt-in): sends git attribution spans — the session→commit correlation from `codeburn yield` (`codeburn.session.attribution` and `codeburn.commit` span types with normalized repo remote, commit SHAs, merged/reverted state, and PR links). Nothing new is sent without the flag; local-only repos and Windows filesystem paths are never emitted as repo identities, and sessions whose project path no longer resolves never inherit the push-time working directory's repo. See docs/sync/README.md "Git attribution". + ### Fixed - Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611) diff --git a/docs/sync/README.md b/docs/sync/README.md index 47557929..27250189 100644 --- a/docs/sync/README.md +++ b/docs/sync/README.md @@ -48,6 +48,9 @@ codeburn sync push --since 30d # Preview what would be sent codeburn sync push --dry-run + +# Also push git attribution (opt-in — see "Git attribution" below) +codeburn sync push --attribution ``` ### `codeburn sync status` @@ -95,6 +98,36 @@ Each AI interaction becomes one OTLP span with these attributes: A keyed `device_id` (HMAC of hostname and username under a per-install key that never leaves the machine) distinguishes your machines without revealing hostnames. +### Git attribution (opt-in: `--attribution`) + +`codeburn sync push --attribution` additionally sends the session→commit correlation that `codeburn yield` computes locally, so the backend can join AI usage to git activity without git hooks. Two extra span types are emitted: + +**`codeburn.session.attribution`** — one per session with joinable evidence: + +| Field | Example | Description | +|---|---|---| +| `ai.session_id` | `abc123…` | Session (shares the usage spans' traceId) | +| `ai.project` | `my-app` | Project name | +| `git.repo` | `github.com/acme/widget` | Normalized `origin` remote (credentials and ports stripped) | +| `git.pr_links` | `["…/pull/12"]` | PR URLs captured for the session | +| `git.commit_count` | `2` | Number of attributed commits | + +**`codeburn.commit`** — one per commit attributed to a session: + +| Field | Example | Description | +|---|---|---| +| `git.sha` | `4f2a…` | Commit SHA | +| `git.in_main` | `true` | Whether the commit landed in the main branch | +| `git.was_reverted` | `false` | Whether a later commit reverted it | + +Attribution is **inferred** (timestamp-window correlation, the same heuristic as `codeburn yield`); the resource attribute `codeburn.attribution_methodology: timestamp-window` marks it as such. State transitions (a commit merging to main, or being reverted) are re-sent automatically on later pushes — receivers should upsert commits by `(git.repo, git.sha)` and session spans by `ai.session_id` (latest state wins). When a commit migrates to a later-parsed session with a tighter window, the losing session re-emits with `git.commit_count: 0` (a retraction), so summing `git.commit_count` across upserted session rows never double-counts. Retractions fire only when the commit was won by another session — commits that merely age out of the `--since` window are not retracted, so a previously-synced count stays correct. Session spans also re-emit when an ongoing session's window grows, keeping the span end time current. + +With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamps (span start times), PR URLs, and the merged/reverted booleans leave your machine — plus the same pseudonymous `codeburn.device_id` resource attribute the usage spans carry. PR links are rebuilt client-side from scheme + host + path only (userinfo, query strings, and fragments are dropped; https, `/org/repo/pull/N` path, bounded length, max 20 per session), and the repo identity itself passes a strict hostname/path allow-list before sending — malformed or transport-helper remotes (`ext::…`, `codecommit::…`) are rejected outright rather than parsed. Precisely what is and is not sent: + +- **Commits**: only from repos with a network `origin` remote, and only for sessions whose own project path resolved to that repo. Local-only repos, `file://` remotes, and Windows filesystem paths are never emitted as repo identities. A session whose project path no longer resolves never inherits the repo of the directory you happen to push from. +- **PR links**: sent whenever a session captured them, even when the session's repo could not be identified — the PR URL itself names the repo, so this adds no information beyond the link the session already recorded. +- Without the flag, none of this is sent. + ### What is NOT sent - **Prompts** — your actual messages to AI are never included @@ -102,7 +135,7 @@ A keyed `device_id` (HMAC of hostname and username under a per-install key that - **Bash commands** — may contain secrets, never sent - **Your name/email** — identity is derived server-side from your login token -There is no flag to override this. Privacy is structural, not configurable. +There is no flag to override this. Privacy is structural, not configurable. The only additive opt-in is `--attribution` (repo remotes, commit SHAs, and PR URLs — never code or prompts), described above. ## Authentication diff --git a/packages/cli/src/privacy-key.ts b/packages/cli/src/privacy-key.ts index 205324bf..10e11fc1 100644 --- a/packages/cli/src/privacy-key.ts +++ b/packages/cli/src/privacy-key.ts @@ -60,6 +60,27 @@ function sleepSync(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } +/** + * Bounded wait for a concurrent first-use winner's key to land. The winner's + * create (open) and write are separate syscalls — between them the file exists + * but is empty — and under scheduler pressure that gap can exceed the 50ms the + * original EEXIST loop allowed. 500ms keeps the wait bounded (a crashed winner + * leaves an empty file forever, so the corruption refusal stays reachable) + * while making adoption robust on a loaded machine. + */ +const ADOPTION_WAIT_MS = 500 +const ADOPTION_POLL_MS = 50 + +function awaitValidKey(path: string): string | null { + const deadline = Date.now() + ADOPTION_WAIT_MS + while (Date.now() < deadline) { + const state = readKeyFileState(path) + if (state.kind === 'valid') return state.key + sleepSync(ADOPTION_POLL_MS) + } + return null +} + /** * Outcome of an exclusive first-use create. */ @@ -86,11 +107,10 @@ function createKeyFileExclusive(path: string): FirstUseOutcome { if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { return { kind: 'write-failed' } } - for (let attempt = 0; attempt < 5; attempt++) { - const state = readKeyFileState(path) - if (state.kind === 'valid') return { kind: 'key', key: state.key } - sleepSync(10) - } + // Loser of the create race: adopt the winner's key once its write lands + // (the file exists but may still be empty; see awaitValidKey). + const adopted = awaitValidKey(path) + if (adopted) return { kind: 'key', key: adopted } return { kind: 'invalid-existing' } } } @@ -178,6 +198,16 @@ export function getPersistedHostPrivacyKey(): string { ) } if (state.kind === 'invalid') { + // A concurrent first use may be mid-write (file created, key not yet + // written): wait a bounded window and ADOPT the winner's key when it + // lands — the loser never mints its own. A file that stays invalid (a + // crash, a truncated write) still gets the refusal below; nothing is + // ever overwritten. + const adopted = awaitValidKey(path) + if (adopted) { + cached = adopted + return cached + } throw new Error( `Host privacy key at ${path} is corrupted (expected 64 hex chars). ` + 'Refusing to overwrite it: that would silently re-key every id and orphan ' + diff --git a/packages/cli/src/sync/cli.ts b/packages/cli/src/sync/cli.ts index 174b616c..f6f28db6 100644 --- a/packages/cli/src/sync/cli.ts +++ b/packages/cli/src/sync/cli.ts @@ -22,7 +22,8 @@ import { } from './auth.js' import { createCredentialStore } from './credentials.js' import { readSyncConfig, writeSyncConfig, deleteSyncConfig, updateLastSync } from './config.js' -import { collectUnsentCalls, sendBatches, batchCalls, MAX_PER_PUSH } from './push.js' +import { collectUnsentCalls, collectUnsentAttribution, sendBatches, sendAttributionBatches, batchCalls, MAX_PER_PUSH, MAX_ATTRIBUTION_PER_PUSH, type PushResult } from './push.js' +import { batchAttributionItems } from './otlp.js' export function registerSyncCommands(program: Command): void { const sync = program @@ -226,7 +227,8 @@ export function registerSyncCommands(program: Command): void { .description('Push unsent telemetry data to the configured endpoint') .option('--since ', 'Time window: today, 7d, 30d, month, all (max 6 months)', '7d') .option('--dry-run', 'Show what would be sent without sending') - .action(async (opts: { since: string; dryRun?: boolean }) => { + .option('--attribution', 'Also push git attribution spans (session→commit correlation from `codeburn yield`, plus PR links). Sends normalized repo remotes and commit SHAs to the endpoint.') + .action(async (opts: { since: string; dryRun?: boolean; attribution?: boolean }) => { const config = readSyncConfig() if (!config) { process.stderr.write('Sync not configured. Run `codeburn sync setup ` first.\n') @@ -277,6 +279,18 @@ export function registerSyncCommands(program: Command): void { // Flatten + filter against sent-ledger const { allCalls, unsent } = collectUnsentCalls(projects) + // Attribution records (opt-in): session→commit correlation computed + // locally from the same parsed projects. Reuses the yield engine. + let attributionUnsent: Awaited>['unsent'] = [] + let attributionTotal = 0 + if (opts.attribution) { + const { computeAttributionRecords } = await import('../yield.js') + const records = computeAttributionRecords(projects, range, process.cwd()) + const collected = collectUnsentAttribution(records) + attributionUnsent = collected.unsent + attributionTotal = collected.allItems.length + } + if (opts.dryRun) { const toPushCount = Math.min(unsent.length, MAX_PER_PUSH) const cost = unsent.slice(0, MAX_PER_PUSH).reduce((s, c) => s + c.call.costUSD, 0) @@ -285,10 +299,19 @@ export function registerSyncCommands(program: Command): void { if (unsent.length > MAX_PER_PUSH) { process.stderr.write(`[dry-run] ${unsent.length - MAX_PER_PUSH} more calls exceed the ${MAX_PER_PUSH} safety limit — a second push would be needed\n`) } + if (opts.attribution) { + const toPushAttr = attributionUnsent.slice(0, MAX_ATTRIBUTION_PER_PUSH) + const commits = toPushAttr.filter(i => i.kind === 'commit').length + const sessions = toPushAttr.filter(i => i.kind === 'session').length + process.stderr.write(`[dry-run] Attribution: ${attributionTotal} facts total, would push ${toPushAttr.length} (${sessions} sessions, ${commits} commits)\n`) + if (attributionUnsent.length > MAX_ATTRIBUTION_PER_PUSH) { + process.stderr.write(`[dry-run] ${attributionUnsent.length - MAX_ATTRIBUTION_PER_PUSH} more attribution facts exceed the ${MAX_ATTRIBUTION_PER_PUSH} safety limit — a second push would be needed\n`) + } + } return } - if (unsent.length === 0) { + if (unsent.length === 0 && attributionUnsent.length === 0) { process.stderr.write(`Nothing to push (${allCalls.length} calls already synced).\n`) updateLastSync() return @@ -302,15 +325,18 @@ export function registerSyncCommands(program: Command): void { // Batch and send (loops until done; waits out 429 rate limits) const discoveryDoc = await fetchDiscoveryDoc(config.baseUrl) - const batches = batchCalls(toPush, discoveryDoc.max_batch_size) const endpoint = `${config.baseUrl}${config.tracesPath}` - const result = await sendBatches({ - endpoint, - accessToken: tokens.access_token, - batches, - log: msg => process.stderr.write(`${msg}\n`), - }) + let result: PushResult = { outcome: 'complete', totalSent: 0, totalRejected: 0, totalCostSent: 0 } + if (toPush.length > 0) { + const batches = batchCalls(toPush, discoveryDoc.max_batch_size) + result = await sendBatches({ + endpoint, + accessToken: tokens.access_token, + batches, + log: msg => process.stderr.write(`${msg}\n`), + }) + } if (result.outcome === 'auth-rejected') { process.stderr.write('Auth rejected by server. Run `codeburn sync setup` to re-authenticate.\n') @@ -323,11 +349,50 @@ export function registerSyncCommands(program: Command): void { process.stderr.write(`Server error (HTTP ${result.httpStatus}). Remaining calls will be sent on the next push.\n`) } + // Attribution spans ride the same endpoint after the usage push + // completes. Skipped when the usage push hit rate limits or server + // errors — the endpoint is already unhappy; both retry on next push. + let attrResult: PushResult | null = null + if (opts.attribution && attributionUnsent.length > 0) { + if (result.outcome === 'complete') { + // Safety valve, mirroring the usage-call cap + const attrToPush = attributionUnsent.slice(0, MAX_ATTRIBUTION_PER_PUSH) + if (attributionUnsent.length > MAX_ATTRIBUTION_PER_PUSH) { + process.stderr.write(`${attributionUnsent.length} attribution facts exceed the ${MAX_ATTRIBUTION_PER_PUSH} safety limit. Pushing first ${MAX_ATTRIBUTION_PER_PUSH}; run again to continue.\n`) + } + const attrBatches = batchAttributionItems(attrToPush, discoveryDoc.max_batch_size) + attrResult = await sendAttributionBatches({ + endpoint, + accessToken: tokens.access_token, + batches: attrBatches, + log: msg => process.stderr.write(`${msg}\n`), + }) + if (attrResult.outcome === 'auth-rejected') { + process.stderr.write('Auth rejected by server during attribution push. Run `codeburn sync setup` to re-authenticate.\n') + process.exit(1) + } + if (attrResult.outcome === 'rate-limited') { + process.stderr.write(`Rate limited during attribution push — gave up after repeated retries. Remaining facts will be sent on the next push.\n`) + } + if (attrResult.outcome === 'server-error') { + process.stderr.write(`Server error (HTTP ${attrResult.httpStatus}) during attribution push. Remaining facts will be sent on the next push.\n`) + } + } else { + process.stderr.write(`Skipping attribution push (${attributionUnsent.length} facts) — will retry on next push.\n`) + } + } + // Update lastSync updateLastSync() // Summary process.stderr.write(`\nSynced ${result.totalSent} calls ($${result.totalCostSent.toFixed(2)}) to ${config.baseUrl}\n`) + if (attrResult) { + const attrSuffix = attrResult.outcome !== 'complete' + ? ` (push incomplete — remainder retries next push)` + : attrResult.totalRejected > 0 ? `, ${attrResult.totalRejected} rejected (will retry)` : '' + process.stderr.write(` Attribution: ${attrResult.totalSent} facts synced${attrSuffix}\n`) + } if (result.totalRejected > 0) { process.stderr.write(` ${result.totalRejected} spans rejected (will retry on next push)\n`) } @@ -337,7 +402,7 @@ export function registerSyncCommands(program: Command): void { // Non-zero exit when the push did not complete, so cron/scripts can // detect it. Ledgered progress is kept; next push resumes. - if (result.outcome !== 'complete') { + if (result.outcome !== 'complete' || (attrResult !== null && attrResult.outcome !== 'complete')) { process.exitCode = 1 } } catch (err) { diff --git a/packages/cli/src/sync/otlp.ts b/packages/cli/src/sync/otlp.ts index f04d4e75..37b8286e 100644 --- a/packages/cli/src/sync/otlp.ts +++ b/packages/cli/src/sync/otlp.ts @@ -13,10 +13,11 @@ * depends on a real on-disk key rather than per-process randomness. */ -import { createHmac } from 'crypto' +import { createHmac, createHash } from 'crypto' import { hostname, userInfo } from 'os' import { getPersistedHostPrivacyKey } from '../privacy-key.js' import type { ParsedApiCall } from '../types.js' +import type { SessionAttributionRecord } from '../yield.js' export interface OtlpSpan { traceId: string @@ -177,3 +178,184 @@ export function batchCalls(calls: CallWithSession[], maxBatchSize: number): Call } return batches } + +// --- Attribution spans (sync push --attribution) --- + +export const SESSION_ATTRIBUTION_SPAN_NAME = 'codeburn.session.attribution' +export const COMMIT_ATTRIBUTION_SPAN_NAME = 'codeburn.commit' + +/** + * A single ledger-able attribution unit: either one session-level record + * (repo + PR links + commit count) or one attributed commit. The dedup key + * encodes the mutable state (inMain/wasReverted for commits; repo, PR links, + * and commit set for sessions), so a state TRANSITION mints a new key and the + * updated fact is re-sent on the next push — the receiver upserts by + * (repo, sha) / (session). Identical states dedupe via the sent-ledger. + */ +export type AttributionItem = { + kind: 'session' | 'commit' + dedupKey: string + /** Span start: commit author time for commits, session start for sessions. ISO 8601. */ + timestamp: string + /** Span end for session items (session lastTimestamp). Absent for commits. */ + endTimestamp?: string + sessionId: string + project: string + repo: string | null + // session kind + prLinks?: string[] + commitCount?: number + // commit kind + sha?: string + inMain?: boolean + wasReverted?: boolean +} + +/** + * Local ledger discriminator over the session's public state (repo, project, + * window timestamps, PR links, commit states). Deliberately NOT keyed: every + * value it compresses rides the same payload in cleartext, and the dedup key + * it feeds never leaves the machine — the wire id is the HMAC of that key + * under the privacy key (keyedId above), so keying stateHash too would add + * nothing. It exists only to mint a fresh dedup key when the state changes. + */ +function stateHash(parts: string[]): string { + return createHash('sha256').update(parts.join('\u001e')).digest('hex').slice(0, 16) +} + +/** Deterministic dedup key for a commit attribution fact (state included). */ +export function commitAttributionKey(sessionId: string, sha: string, inMain: boolean, wasReverted: boolean): string { + return `attr:c:${sessionId}:${sha}:${inMain ? 1 : 0}${wasReverted ? 1 : 0}` +} + +/** Deterministic dedup key for a session attribution fact (state included). */ +export function sessionAttributionKey(record: SessionAttributionRecord): string { + const commitStates = record.commits + .map(c => `${c.sha}:${c.inMain ? 1 : 0}${c.wasReverted ? 1 : 0}`) + .sort() + // Project and both window timestamps are part of the state: an ongoing + // session whose window grew (or whose project resolution changed) re-emits + // with the corrected span times instead of freezing at first send. + return `attr:s:${record.sessionId}:${stateHash([ + record.repo ?? '', + record.project, + record.firstTimestamp, + record.lastTimestamp, + ...record.prLinks, + ...commitStates, + ])}` +} + +/** Ledger-key prefix for a session's attribution facts (any state). */ +export function sessionAttributionKeyPrefix(sessionId: string): string { + return `attr:s:${sessionId}:` +} + +/** Flatten attribution records into ledger-able items (one session item + one per commit). */ +export function flattenAttributionRecords(records: SessionAttributionRecord[]): AttributionItem[] { + const items: AttributionItem[] = [] + for (const record of records) { + items.push({ + kind: 'session', + dedupKey: sessionAttributionKey(record), + timestamp: record.firstTimestamp, + endTimestamp: record.lastTimestamp, + sessionId: record.sessionId, + project: record.project, + repo: record.repo, + prLinks: record.prLinks, + commitCount: record.commits.length, + }) + for (const commit of record.commits) { + items.push({ + kind: 'commit', + dedupKey: commitAttributionKey(record.sessionId, commit.sha, commit.inMain, commit.wasReverted), + timestamp: commit.timestamp, + sessionId: record.sessionId, + project: record.project, + repo: record.repo, + sha: commit.sha, + inMain: commit.inMain, + wasReverted: commit.wasReverted, + }) + } + } + return items +} + +/** + * Build an OTLP payload from attribution items. Spans share the session's + * traceId with the usage spans (`deriveTraceId(privacyKey, sessionId)`), so a + * receiver can correlate cost and attribution without any extra key. + */ +export function buildAttributionOtlpPayload(items: AttributionItem[]): OtlpPayload { + const deviceId = getDeviceId() + const privacyKey = getPersistedHostPrivacyKey() + + const spans: OtlpSpan[] = items.map(item => { + const startNano = toUnixNano(item.timestamp) + // Clamp like the usage builder: end is never 0 (malformed timestamp) and + // never earlier than start + 1ms (out-of-order session timestamps). + const minEndNano = BigInt(startNano) + 1_000_000n + const rawEndNano = item.endTimestamp ? BigInt(toUnixNano(item.endTimestamp)) : 0n + const endNano = (rawEndNano > minEndNano ? rawEndNano : minEndNano).toString() + + const attributes: OtlpAttribute[] = [ + { key: 'ai.session_id', value: { stringValue: item.sessionId } }, + { key: 'ai.project', value: { stringValue: item.project } }, + ] + if (item.repo) { + attributes.push({ key: 'git.repo', value: { stringValue: item.repo } }) + } + + if (item.kind === 'commit') { + attributes.push( + { key: 'git.sha', value: { stringValue: item.sha ?? '' } }, + { key: 'git.in_main', value: { boolValue: item.inMain ?? false } }, + { key: 'git.was_reverted', value: { boolValue: item.wasReverted ?? false } }, + ) + } else { + attributes.push({ key: 'git.commit_count', value: { intValue: String(item.commitCount ?? 0) } }) + if (item.prLinks && item.prLinks.length > 0) { + attributes.push({ + key: 'git.pr_links', + value: { arrayValue: { values: item.prLinks.map(u => ({ stringValue: u })) } }, + }) + } + } + + return { + traceId: deriveTraceId(privacyKey, item.sessionId), + spanId: deriveSpanId(privacyKey, item.dedupKey), + name: item.kind === 'commit' ? COMMIT_ATTRIBUTION_SPAN_NAME : SESSION_ATTRIBUTION_SPAN_NAME, + startTimeUnixNano: startNano, + endTimeUnixNano: endNano, + attributes, + } + }) + + return { + resourceSpans: [{ + resource: { + attributes: [ + { key: 'codeburn.device_id', value: { stringValue: deviceId } }, + // Honesty marker: this attribution is inferred (timestamp-window + // correlation), not declared. Receivers should label it as such. + { key: 'codeburn.attribution_methodology', value: { stringValue: 'timestamp-window' } }, + ], + }, + scopeSpans: [{ + spans, + }], + }], + } +} + +/** Split attribution items into batches of maxBatchSize. */ +export function batchAttributionItems(items: AttributionItem[], maxBatchSize: number): AttributionItem[][] { + const batches: AttributionItem[][] = [] + for (let i = 0; i < items.length; i += maxBatchSize) { + batches.push(items.slice(i, i + maxBatchSize)) + } + return batches +} diff --git a/packages/cli/src/sync/push.ts b/packages/cli/src/sync/push.ts index ce3c8f19..d2942c6f 100644 --- a/packages/cli/src/sync/push.ts +++ b/packages/cli/src/sync/push.ts @@ -8,7 +8,16 @@ import type { ProjectSummary } from '../types.js' import { assertHttps } from './discovery.js' import { ledgerKeySet, appendToLedger, type LedgerEntry } from './ledger.js' -import { buildOtlpPayload, batchCalls, type CallWithSession } from './otlp.js' +import { + buildOtlpPayload, + batchCalls, + buildAttributionOtlpPayload, + flattenAttributionRecords, + type CallWithSession, + type AttributionItem, + type OtlpPayload, +} from './otlp.js' +import type { SessionAttributionRecord } from '../yield.js' /** * Safety valve, not a routine cap — pushes now loop until all batches are @@ -93,6 +102,23 @@ export function parseRetryAfterMs(value: string | null): number | null { * retry on the next push. */ export async function sendBatches(opts: SendBatchesOptions): Promise { + return sendBatchesCore({ + ...opts, + buildPayload: buildOtlpPayload, + toOutbound: c => ({ key: c.call.deduplicationKey, ts: c.call.timestamp, costUSD: c.call.costUSD }), + }) +} + +/** How sendBatchesCore ledgers and prices a batch item. */ +type OutboundItem = { key: string; ts: string; costUSD: number } + +type SendBatchesCoreOptions = Omit & { + batches: T[][] + buildPayload: (batch: T[]) => OtlpPayload + toOutbound: (item: T) => OutboundItem +} + +async function sendBatchesCore(opts: SendBatchesCoreOptions): Promise { assertHttps(opts.endpoint, 'Traces endpoint') const log = opts.log ?? (() => {}) const sleep = opts.sleep ?? ((ms: number) => new Promise(r => setTimeout(r, ms))) @@ -109,7 +135,7 @@ export async function sendBatches(opts: SendBatchesOptions): Promise // Retry loop for the current batch (429 only) for (;;) { - const payload = buildOtlpPayload(batch) + const payload = opts.buildPayload(batch) const response = await fetch(opts.endpoint, { method: 'POST', @@ -160,13 +186,11 @@ export async function sendBatches(opts: SendBatchesOptions): Promise totalRejected += rejected log(` Batch: ${rejected}/${batch.length} spans rejected — whole batch will retry on next push`) } else { - const entries: LedgerEntry[] = batch.map(c => ({ - key: c.call.deduplicationKey, - ts: c.call.timestamp, - })) + const outbound = batch.map(opts.toOutbound) + const entries: LedgerEntry[] = outbound.map(o => ({ key: o.key, ts: o.ts })) appendToLedger(entries) totalSent += batch.length - totalCostSent += batch.reduce((s, c) => s + c.call.costUSD, 0) + totalCostSent += outbound.reduce((s, o) => s + o.costUSD, 0) } break // batch done (success or partial) — move to next batch } @@ -175,4 +199,56 @@ export async function sendBatches(opts: SendBatchesOptions): Promise return { outcome: 'complete', totalSent, totalRejected, totalCostSent, totalWaitMs } } +/** + * Safety valve for attribution items, mirroring MAX_PER_PUSH: bounds a first + * `--since all --attribution` push over a long history. Remaining facts are + * sent on the next push (the ledger tracks progress). + */ +export const MAX_ATTRIBUTION_PER_PUSH = 10_000 + +/** Flatten attribution records into items and filter out already-sent ones. */ +export function collectUnsentAttribution(records: SessionAttributionRecord[]): { + allItems: AttributionItem[] + unsent: AttributionItem[] +} { + const sent = ledgerKeySet() + + // Empty records (no commits, no PR links) exist only to RETRACT a session + // span whose commits migrated to another session. Send one only when a + // PRIOR state for that session was already ledgered — a session that was + // never sent has nothing to retract. + const sessionsWithPriorState = new Set() + for (const key of sent) { + if (key.startsWith('attr:s:')) { + const sessionId = key.slice('attr:s:'.length, key.lastIndexOf(':')) + sessionsWithPriorState.add(sessionId) + } + } + const sendable = records.filter(r => + r.commits.length > 0 || r.prLinks.length > 0 || sessionsWithPriorState.has(r.sessionId), + ) + + const allItems = flattenAttributionRecords(sendable) + const unsent = allItems.filter(i => !sent.has(i.dedupKey)) + return { allItems, unsent } +} + +export interface SendAttributionBatchesOptions extends Omit { + batches: AttributionItem[][] +} + +/** + * Send attribution batches through the same retry/ledger pipeline as usage + * batches. Items are ledgered by their state-encoding dedup keys, so an + * identical attribution fact is sent once and a state transition (commit + * merged to main, commit reverted) re-sends the updated fact. + */ +export async function sendAttributionBatches(opts: SendAttributionBatchesOptions): Promise { + return sendBatchesCore({ + ...opts, + buildPayload: buildAttributionOtlpPayload, + toOutbound: item => ({ key: item.dedupKey, ts: item.timestamp, costUSD: 0 }), + }) +} + export { batchCalls } diff --git a/packages/cli/src/yield.ts b/packages/cli/src/yield.ts index ce3cbd05..79239b99 100644 --- a/packages/cli/src/yield.ts +++ b/packages/cli/src/yield.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'child_process' import { realpathSync } from 'fs' import { resolve } from 'path' import { parseAllSessions } from './parser.js' -import type { DateRange, SessionSummary } from './types.js' +import type { DateRange, ProjectSummary, SessionSummary } from './types.js' export type YieldCategory = 'productive' | 'reverted' | 'abandoned' | 'ambiguous' @@ -133,7 +133,98 @@ function getMainBranch(cwd: string): string { return 'main' } -type CommitInfo = { +/** + * Normalize a git remote URL to a host-scoped repo identity (`host/org/repo`) + * usable as a server-side join key. Handles the three common transports: + * + * git@github.com:org/repo.git -> github.com/org/repo + * ssh://git@github.com:22/org/repo.git -> github.com/org/repo + * https://user:tok@github.com/org/repo.git -> github.com/org/repo + * + * Credentials and ports are stripped (a token embedded in an https remote must + * never leave the machine), the host is lowercased (path case is preserved), + * and a trailing `.git` / `/` is removed. Local paths and `file://` remotes + * return null — a repo with no network remote has no server-side identity. + */ +/** Max length of an emitted repo identity (`host/org/repo`). */ +const MAX_REPO_IDENTITY_LENGTH = 200 + +/** + * Positive validation (allow-list) of a composed repo identity — the final + * gate EVERY branch passes through before anything is returned. The host must + * look like a hostname and every path segment like a repo path segment, so no + * upstream parsing quirk (transport-helper remotes like `ext::…` or + * `codecommit::…`, credentials that survived a malformed URL, oversized + * strings) can reach the wire. Rejecting is always safe: an unrecognizable + * remote simply has no server-side identity. + */ +function isValidRepoIdentity(host: string, segments: string[]): boolean { + if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(host)) return false + if (segments.length === 0) return false + return segments.every(s => /^[A-Za-z0-9._~-]+$/.test(s)) +} + +export function normalizeRemoteUrl(url: string): string | null { + const trimmed = url.trim() + if (!trimmed) return null + + // Windows drive-letter paths (`C:\Users\...`, `C:/Users/...`) are local + // filesystem paths, not scp-like remotes — without this check the scp-like + // branch would parse `C:` as a host and emit the user's local path as a + // repo identity. + if (/^[a-zA-Z]:[\\/]/.test(trimmed)) return null + + let host: string + let path: string + + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) { + let parsed: URL + try { + parsed = new URL(trimmed) + } catch { + return null + } + if (parsed.protocol === 'file:') return null + if (!parsed.hostname) return null + host = parsed.hostname + path = parsed.pathname + } else { + // scp-like syntax: [user@]host:path. Credentials (userinfo) are split off + // at the FIRST `@` BEFORE any host matching — expressing userinfo as an + // optional regex group lets backtracking abandon the group and re-parse a + // credential prefix as `host:path`, dumping the token into the path + // (e.g. `x-access-token:ghp_…@github.com/org/repo`). Host must be at + // least 2 chars: a single-character "host" is a Windows drive-relative + // path (`C:repo`), never a real remote host. + const at = trimmed.indexOf('@') + const rest = at >= 0 ? trimmed.slice(at + 1) : trimmed + const scpLike = /^([^:/\\]{2,}):(?!\/\/)(.+)$/.exec(rest) + if (!scpLike) return null + host = scpLike[1] + path = scpLike[2] + } + + const cleanPath = path + .replace(/\/+/g, '/') // collapse doubled slashes → one join key, not two + .replace(/^\/+/, '') + .replace(/\/+$/, '') + .replace(/\.git$/i, '') // case-insensitive: Repo.GIT joins with repo.git + if (!cleanPath) return null + + const identity = `${host.toLowerCase()}/${cleanPath}` + if (identity.length > MAX_REPO_IDENTITY_LENGTH) return null + if (!isValidRepoIdentity(host.toLowerCase(), cleanPath.split('/'))) return null + + return identity +} + +/** `git remote get-url origin`, normalized. Null when absent or local-only. */ +function getRepoRemote(gitDir: string): string | null { + const url = runGit(['remote', 'get-url', 'origin'], gitDir) + return url ? normalizeRemoteUrl(url) : null +} + +export type CommitInfo = { sha: string timestamp: Date inMain: boolean @@ -296,34 +387,39 @@ function categorizeSession( return { category: 'abandoned', commitCount: commits.length } } -export async function computeYield(range: DateRange, cwd: string, provider: string = 'all'): Promise { - const projects = await parseAllSessions(range, provider) - - const summary: YieldSummary = { - productive: { cost: 0, sessions: 0 }, - reverted: { cost: 0, sessions: 0 }, - abandoned: { cost: 0, sessions: 0 }, - ambiguous: { cost: 0, sessions: 0 }, - total: { cost: 0, sessions: 0 }, - details: [], - } +type RepoGroup = { + commits: CommitInfo[] + sessions: SessionSummary[] + projectNames: string[] + /** Parallel to `sessions`: true when the session's identity came from its + * OWN project path; false when it inherited the cwd-fallback identity. + * The attribution (sync) path must never egress fallback-derived repos. */ + ownIdentity: boolean[] + /** A directory to run further git queries in (remote lookup); null when the group has no git identity. */ + gitDir: string | null +} +/** + * Group sessions by canonical repository identity and load each group's + * commits for the range. Shared by `computeYield` (categorization) and + * `computeAttributionRecords` (sync). Grouping semantics are unchanged from + * the original computeYield implementation: each commit is awarded at most + * once across the whole repo; monorepo subdirectories and worktrees collapse + * to one group; a project whose path is missing or not a git repo falls back + * to the cwd repo (or an empty commit list when cwd is not a repo either). + */ +function buildRepoGroups( + projects: ProjectSummary[], + range: DateRange, + cwd: string, +): Map { const repoIdentityCache = new Map() - // Get all commits in the date range for correlation const cwdIdentity = resolveRepoIdentity(cwd, repoIdentityCache) const cwdCommits = cwdIdentity ? getCommitsInRange(cwd, range.start, range.end, getMainBranch(cwd)) : [] - // Group sessions by canonical repository identity before attributing so that - // each commit is awarded at most once across the whole repo. Two monorepo - // subdirectory sessions, or two worktrees of one repo, resolve to the same - // git-common-dir and share ONE group; keying on the raw path would double - // count. A project whose path is missing or not a git repo falls back to the - // cwd repo (or, when cwd is not a repo either, an empty commit list) exactly - // as before. - type RepoGroup = { commits: CommitInfo[]; sessions: SessionSummary[]; projectNames: string[] } const repoGroups = new Map() for (const project of projects) { const projectIdentity = project.projectPath @@ -342,15 +438,35 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri : getCommitsInRange(identity.gitDir, range.start, range.end, getMainBranch(identity.gitDir)), sessions: [], projectNames: [], + ownIdentity: [], + gitDir: identity?.gitDir ?? null, } repoGroups.set(groupKey, group) } for (const session of project.sessions) { group.sessions.push(session) group.projectNames.push(project.project) + group.ownIdentity.push(projectIdentity !== null) } } + return repoGroups +} + +export async function computeYield(range: DateRange, cwd: string, provider: string = 'all'): Promise { + const projects = await parseAllSessions(range, provider) + + const summary: YieldSummary = { + productive: { cost: 0, sessions: 0 }, + reverted: { cost: 0, sessions: 0 }, + abandoned: { cost: 0, sessions: 0 }, + ambiguous: { cost: 0, sessions: 0 }, + total: { cost: 0, sessions: 0 }, + details: [], + } + + const repoGroups = buildRepoGroups(projects, range, cwd) + for (const group of repoGroups.values()) { const attributions = attributeCommits(group.sessions, group.commits) for (const [index, session] of group.sessions.entries()) { @@ -446,3 +562,160 @@ export function buildYieldJsonReport( })), } } + +// --- Sync attribution (codeburn sync push --attribution) --- + +export type CommitAttribution = { + sha: string + /** Commit author time, ISO 8601. */ + timestamp: string + inMain: boolean + wasReverted: boolean +} + +/** + * Per-session git attribution record — the sync-facing projection of the + * yield timestamp-window correlation. One record per session that produced + * server-joinable evidence: attributed commits (requires a normalized remote) + * and/or PR links. + */ +export type SessionAttributionRecord = { + sessionId: string + project: string + /** Normalized origin remote (`host/org/repo`), the server-side join key. Null when only prLinks are available. */ + repo: string | null + /** GitHub PR URLs captured for the session (already normalized upstream). */ + prLinks: string[] + /** Commits attributed to this session. Empty when repo is null (SHAs without a repo identity cannot be joined). */ + commits: CommitAttribution[] + /** Session window, ISO 8601 — lets the receiver reason about attribution recency. */ + firstTimestamp: string + lastTimestamp: string +} + +/** Max PR links retained per session attribution record. */ +export const MAX_PR_LINKS_PER_SESSION = 20 + +/** + * Shape-check PR links before they leave the machine. Upstream parsers only + * verify truthiness, so arbitrary strings can land in `session.prLinks`. + * Keep only https URLs shaped like a PR (`/org/repo/pull/N` — GitHub and + * GitHub Enterprise), bounded in length, capped per session, sorted. + * + * Links are REBUILT from `origin + pathname`, never passed through verbatim: + * userinfo (`https://alice:token@…`), query strings (copy-pasted GitHub + * links routinely carry `?notification_referrer_id=…`), and fragments are + * all dropped. Rebuilt links that collapse to the same URL dedupe. + */ +export function sanitizePrLinks(links: string[]): string[] { + const valid = new Set() + for (const link of links) { + if (typeof link !== 'string' || link.length === 0 || link.length > 512) continue + let url: URL + try { + url = new URL(link) + } catch { + continue + } + if (url.protocol !== 'https:') continue + if (!/^\/[^/]+\/[^/]+\/pull\/\d+$/.test(url.pathname)) continue + const rebuilt = `${url.origin}${url.pathname}` + if (rebuilt.length > 256) continue + valid.add(rebuilt) + } + return [...valid].sort().slice(0, MAX_PR_LINKS_PER_SESSION) +} + +/** + * Compute per-session attribution records for sync. Reuses the exact yield + * repo grouping + tightest-window commit attribution (`methodology: + * timestamp-window`), then joins in each repo group's normalized origin + * remote and the session's sanitized PR links. + * + * Inclusion rules: + * - Only sessions whose OWN project path resolved to a repo participate in + * commit attribution; cwd-fallback sessions never carry a repo or commits + * (privacy gate — see below) but still emit a record when they have PR links. + * - Commits require a normalized remote; a SHA without a repo identity has + * no server-side meaning. + * - A session with no commits and no PR links is emitted ONLY when it lost a + * commit to a tighter-window session in THIS computation (`lostCandidacy`) + * — a retraction candidate. A session that merely aged its commits out of + * the range lost them to nobody, and emitting an empty record for it would + * permanently retract a still-correct server-side count. + * + * Takes already-parsed projects (sync push has them in hand) instead of + * re-parsing like computeYield does. + */ +export function computeAttributionRecords( + projects: ProjectSummary[], + range: DateRange, + cwd: string, +): SessionAttributionRecord[] { + const repoGroups = buildRepoGroups(projects, range, cwd) + const records: SessionAttributionRecord[] = [] + + for (const group of repoGroups.values()) { + const remote = group.gitDir ? getRepoRemote(group.gitDir) : null + + // Privacy gate: only sessions whose identity came from their OWN project + // path participate in commit attribution. A session whose project path no + // longer resolves (deleted/renamed dir, non-repo session) inherits the + // cwd-fallback identity in buildRepoGroups — attributing it here would + // egress whatever repo the user happens to be pushing from, with commits + // that session never touched. Fallback sessions get no repo and no + // commits; they still emit a record when they carry PR links (which are + // session-native and safe). Excluding them from the competition also + // prevents a fallback window from stealing a commit that belongs to a + // genuine session. + const ownSessions = group.sessions.filter((_, i) => group.ownIdentity[i]) + const attributions = attributeCommits(ownSessions, group.commits) + // Keyed by object reference: session objects are unique per group entry, + // whereas sessionId strings could collide across projects. + const attributionBySession = new Map() + const lostCandidacyBySession = new Map() + for (const [i, session] of ownSessions.entries()) { + attributionBySession.set(session, attributions[i]?.commits ?? []) + lostCandidacyBySession.set(session, attributions[i]?.lostCandidacy ?? false) + } + + for (const [index, session] of group.sessions.entries()) { + if (!session.firstTimestamp) continue + + const isOwn = group.ownIdentity[index] === true + const sessionRemote = isOwn ? remote : null + const attributedCommits = sessionRemote + ? (attributionBySession.get(session) ?? []) + : [] + const prLinks = sanitizePrLinks(session.prLinks ?? []) + // Empty sessions are retraction candidates ONLY when they lost a commit + // to a tighter-window session in THIS run: that commit's server-side + // attribution is migrating, so the loser must re-emit commit_count=0. + // An empty session whose commits merely aged out of the --since range + // (rolling window, or a narrower window than a previous push) lost them + // to NOBODY — emitting a retraction for it would permanently zero a + // still-correct server-side count, because the original state key stays + // ledgered and is never re-sent. + const lostToTighterSession = sessionRemote !== null && + (lostCandidacyBySession.get(session) ?? false) + if (attributedCommits.length === 0 && prLinks.length === 0 && !lostToTighterSession) continue + + records.push({ + sessionId: session.sessionId, + project: group.projectNames[index] ?? session.project, + repo: sessionRemote, + prLinks, + commits: attributedCommits.map(c => ({ + sha: c.sha, + timestamp: c.timestamp.toISOString(), + inMain: c.inMain, + wasReverted: c.wasReverted, + })), + firstTimestamp: session.firstTimestamp, + lastTimestamp: session.lastTimestamp ?? session.firstTimestamp, + }) + } + } + + return records +} diff --git a/packages/cli/tests/fixtures/mock-idp.ts b/packages/cli/tests/fixtures/mock-idp.ts index e873b6b6..d70b9f57 100644 --- a/packages/cli/tests/fixtures/mock-idp.ts +++ b/packages/cli/tests/fixtures/mock-idp.ts @@ -34,6 +34,8 @@ export interface MockIdp { revokedTokens: string[] /** Authorization codes that have been exchanged */ exchangedCodes: string[] + /** OTLP trace batches received at POST /v1/traces */ + tracesRequests: Array<{ auth: string | undefined; body: unknown }> } export async function startMockIdp(opts: MockIdpOptions = {}): Promise { @@ -52,6 +54,7 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise issuedTokens: { access: [], refresh: [] }, revokedTokens: [], exchangedCodes: [], + tracesRequests: [], close: async () => {}, } @@ -59,6 +62,20 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise const url = new URL(req.url ?? '/', `http://127.0.0.1:${state.port}`) const path = url.pathname + // --- OTLP traces collector (records batches for push tests) --- + if (path === '/v1/traces' && req.method === 'POST') { + let body = '' + req.on('data', chunk => { body += chunk }) + req.on('end', () => { + let parsed: unknown = null + try { parsed = JSON.parse(body || '{}') } catch { /* keep null */ } + state.tracesRequests.push({ auth: req.headers.authorization, body: parsed }) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end('{}') + }) + return + } + // --- Discovery doc --- if (path === '/.well-known/codeburn-export.json') { res.writeHead(200, { 'Content-Type': 'application/json' }) diff --git a/packages/cli/tests/sync-attribution-cli.test.ts b/packages/cli/tests/sync-attribution-cli.test.ts new file mode 100644 index 00000000..295350fc --- /dev/null +++ b/packages/cli/tests/sync-attribution-cli.test.ts @@ -0,0 +1,157 @@ +/** + * CLI-level tests for `codeburn sync push --attribution`. + * + * Drives the real commander action against a mock IdP + collector: + * - --dry-run --attribution: NO telemetry reaches the traces endpoint + * - push WITHOUT the flag: no attribution span names on the wire + * - push WITH the flag: attribution spans arrive alongside usage spans + */ + +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 { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest' +import { Command } from 'commander' + +import { startMockIdp, type MockIdp } from './fixtures/mock-idp.js' +import type { ProjectSummary, SessionSummary, ParsedApiCall, TokenUsage } from '../src/types.js' + +const { parseAllSessionsMock } = vi.hoisted(() => ({ parseAllSessionsMock: vi.fn() })) +vi.mock('../src/parser.js', () => ({ parseAllSessions: parseAllSessionsMock })) + +function git(cwd: string, args: string[], env: Record = {}): string { + return execFileSync('git', args, { cwd, encoding: 'utf-8', env: { ...process.env, ...env } }).trim() +} + +function makeUsage(): TokenUsage { + return { inputTokens: 10, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 } +} + +function makeCall(key: string, ts: string): ParsedApiCall { + return { + provider: 'test', model: 'test-model', usage: makeUsage(), costUSD: 0.01, + tools: [], mcpTools: [], skills: [], subagentTypes: [], hasAgentSpawn: false, + hasPlanMode: false, speed: 'standard', timestamp: ts, bashCommands: [], + deduplicationKey: key, + } +} + +function makeSession(id: string, first: string, last: string, calls: ParsedApiCall[]): SessionSummary { + return { + sessionId: id, project: 'app', firstTimestamp: first, lastTimestamp: last, + totalCostUSD: 0.01, totalSavingsUSD: 0, totalInputTokens: 10, totalOutputTokens: 5, + totalReasoningTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, + apiCalls: calls.length, + turns: [{ userMessage: 'x', assistantCalls: calls, timestamp: first, sessionId: id, category: 'coding', retries: 0, hasEdits: false }], + modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], skillBreakdown: {}, subagentBreakdown: {}, + } +} + +let idp: MockIdp +let tmpHome: string +let repoDir: string +const originalHome = process.env.HOME +const originalXdg = process.env.XDG_CACHE_HOME +const originalStore = process.env.CODEBURN_SYNC_TOKEN_STORE + +async function runPush(args: string[]): Promise { + const { registerSyncCommands } = await import('../src/sync/cli.js') + const program = new Command() + program.exitOverride() // throw instead of process.exit on commander errors + registerSyncCommands(program) + await program.parseAsync(['node', 'codeburn', 'sync', 'push', ...args]) +} + +beforeAll(async () => { + idp = await startMockIdp({ rotateTokens: false }) + process.env.CODEBURN_SYNC_TOKEN_STORE = 'file' + + // Real git repo with a remote — a recent commit inside the session window + repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-cli-repo-')) + git(repoDir, ['init', '-b', 'main']) + git(repoDir, ['config', 'user.email', 't@e.com']) + git(repoDir, ['config', 'user.name', 'T']) + git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/cli-widget.git']) + await writeFile(join(repoDir, 'f.txt'), 'x\n') + const commitIso = new Date(Date.now() - 60 * 60 * 1000).toISOString() + git(repoDir, ['add', '.']) + git(repoDir, ['commit', '-m', 'feat: recent'], { GIT_AUTHOR_DATE: commitIso, GIT_COMMITTER_DATE: commitIso }) +}) + +afterAll(async () => { + await idp.close() + await rm(repoDir, { recursive: true, force: true }) + if (originalStore === undefined) delete process.env.CODEBURN_SYNC_TOKEN_STORE + else process.env.CODEBURN_SYNC_TOKEN_STORE = originalStore +}) + +beforeEach(async () => { + tmpHome = await mkdtemp(join(tmpdir(), 'codeburn-attr-cli-')) + process.env.HOME = tmpHome + process.env.XDG_CACHE_HOME = join(tmpHome, '.cache') + idp.tracesRequests.length = 0 + + // Configure sync against the mock IdP + store the refresh token + const { writeSyncConfig } = await import('../src/sync/config.js') + const { createCredentialStore } = await import('../src/sync/credentials.js') + writeSyncConfig({ baseUrl: idp.baseUrl, clientId: 'mock-client-id', tracesPath: '/v1/traces', issuer: idp.baseUrl }) + createCredentialStore().store('mock-refresh-token-v1') + + // One session in the last hour, working in the real repo + 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: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary, + ]) + + // The push action reads process.cwd() for the attribution cwd — run from + // a neutral non-repo dir so nothing can come from a cwd fallback. + vi.spyOn(process, 'cwd').mockReturnValue(tmpHome) +}) + +afterEach(async () => { + vi.restoreAllMocks() + process.exitCode = 0 + process.env.HOME = originalHome + if (originalXdg === undefined) delete process.env.XDG_CACHE_HOME + else process.env.XDG_CACHE_HOME = originalXdg + await rm(tmpHome, { recursive: true, force: true }) +}) + +const spanNames = (): string[] => + idp.tracesRequests.flatMap(r => { + const body = r.body as { resourceSpans?: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }> } + return (body.resourceSpans ?? []).flatMap(rs => rs.scopeSpans.flatMap(ss => ss.spans.map(s => s.name))) + }) + +describe('sync push --attribution (CLI level)', () => { + it('--dry-run --attribution sends nothing to the traces endpoint', async () => { + await runPush(['--dry-run', '--attribution']) + expect(idp.tracesRequests).toHaveLength(0) + }) + + it('push WITHOUT --attribution never emits attribution span names', async () => { + await runPush([]) + expect(idp.tracesRequests.length).toBeGreaterThan(0) + const names = spanNames() + expect(names.length).toBeGreaterThan(0) + expect(names).not.toContain('codeburn.session.attribution') + expect(names).not.toContain('codeburn.commit') + expect(JSON.stringify(idp.tracesRequests)).not.toContain('git.sha') + }) + + it('push WITH --attribution emits usage + attribution spans', async () => { + await runPush(['--attribution']) + const names = spanNames() + expect(names).toContain('test/test-model') // usage span + expect(names).toContain('codeburn.session.attribution') // session span + expect(names).toContain('codeburn.commit') // commit span + const wire = JSON.stringify(idp.tracesRequests) + expect(wire).toContain('github.com/acme/cli-widget') + }) +}) diff --git a/packages/cli/tests/sync-attribution.test.ts b/packages/cli/tests/sync-attribution.test.ts new file mode 100644 index 00000000..d3775588 --- /dev/null +++ b/packages/cli/tests/sync-attribution.test.ts @@ -0,0 +1,756 @@ +/** + * Tests for sync git attribution (sync push --attribution). + * + * Covers: remote URL normalization, session→commit attribution record + * computation (reusing the yield engine), state-encoding dedup keys, + * attribution OTLP span construction, and the send/ledger pipeline. + */ + +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 { createServer, type Server } from 'node:http' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import type { ProjectSummary, SessionSummary } from '../src/types.js' +import { + normalizeRemoteUrl, + computeAttributionRecords, + sanitizePrLinks, + MAX_PR_LINKS_PER_SESSION, + type SessionAttributionRecord, +} from '../src/yield.js' +import { + flattenAttributionRecords, + commitAttributionKey, + sessionAttributionKey, + buildAttributionOtlpPayload, + batchAttributionItems, + deriveTraceId, + SESSION_ATTRIBUTION_SPAN_NAME, + COMMIT_ATTRIBUTION_SPAN_NAME, + type OtlpAttribute, +} from '../src/sync/otlp.js' + +// ── Git fixtures (mirrors yield-repo-grouping.test.ts) ──────────────── + +function git(cwd: string, args: string[], env: Record = {}): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf-8', + env: { ...process.env, ...env }, + }).trim() +} + +function initRepo(dir: string): void { + git(dir, ['init', '-b', 'main']) + git(dir, ['config', 'user.email', 'test@example.com']) + git(dir, ['config', 'user.name', 'Test']) +} + +function commitAt(dir: string, message: string, iso: string): void { + git(dir, ['add', '.']) + git(dir, ['commit', '-m', message], { + GIT_AUTHOR_DATE: iso, + GIT_COMMITTER_DATE: iso, + }) +} + +function makeSession(overrides: Partial): SessionSummary { + return { + sessionId: 'session', + project: 'app', + firstTimestamp: '2026-01-01T10:00:00.000Z', + lastTimestamp: '2026-01-01T11:00:00.000Z', + totalCostUSD: 1, + totalSavingsUSD: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {}, + subagentBreakdown: {}, + ...overrides, + } +} + +const range = { + start: new Date('2026-01-01T00:00:00.000Z'), + end: new Date('2026-01-02T00:00:00.000Z'), +} + +// ── normalizeRemoteUrl ──────────────────────────────────────────────── + +describe('normalizeRemoteUrl', () => { + it('normalizes scp-like ssh remotes', () => { + expect(normalizeRemoteUrl('git@github.com:acme/widget.git')).toBe('github.com/acme/widget') + expect(normalizeRemoteUrl('git@GitHub.com:Acme/Widget')).toBe('github.com/Acme/Widget') + }) + + it('normalizes ssh:// remotes, dropping user and port', () => { + expect(normalizeRemoteUrl('ssh://git@github.com/acme/widget.git')).toBe('github.com/acme/widget') + expect(normalizeRemoteUrl('ssh://git@gitlab.example.com:2222/group/sub/repo.git')).toBe('gitlab.example.com/group/sub/repo') + }) + + it('normalizes https remotes and strips embedded credentials', () => { + expect(normalizeRemoteUrl('https://github.com/acme/widget.git')).toBe('github.com/acme/widget') + expect(normalizeRemoteUrl('https://user:s3cret-token@github.com/acme/widget.git')).toBe('github.com/acme/widget') + expect(normalizeRemoteUrl('https://github.com/acme/widget/')).toBe('github.com/acme/widget') + }) + + it('returns null for local paths and file:// remotes', () => { + expect(normalizeRemoteUrl('/home/dev/repos/widget')).toBeNull() + expect(normalizeRemoteUrl('file:///home/dev/repos/widget')).toBeNull() + expect(normalizeRemoteUrl('../relative/repo')).toBeNull() + expect(normalizeRemoteUrl('')).toBeNull() + }) + + it('rejects Windows drive-letter paths (never a remote identity)', () => { + expect(normalizeRemoteUrl('C:/Users/alice/private/repo')).toBeNull() + expect(normalizeRemoteUrl('C:\\Users\\alice\\private\\repo')).toBeNull() + expect(normalizeRemoteUrl('c:/repo')).toBeNull() + expect(normalizeRemoteUrl('Z:\\work\\nda-client-repo')).toBeNull() + // Drive-relative (no slash after colon) — single-char host rejection + expect(normalizeRemoteUrl('C:repo')).toBeNull() + expect(normalizeRemoteUrl('c:relative\\path')).toBeNull() + }) + + it('rejects other adversarial forms without over-rejecting real remotes', () => { + // Single-character "host" is never a real remote host + expect(normalizeRemoteUrl('a:path/to/repo')).toBeNull() + expect(normalizeRemoteUrl('git@C:/foo')).toBeNull() + // Dotless intranet hosts remain valid (2+ chars) + expect(normalizeRemoteUrl('gitserver:team/repo.git')).toBe('gitserver/team/repo') + expect(normalizeRemoteUrl('git@gitbox:org/repo.git')).toBe('gitbox/org/repo') + }) + + it('never leaks credentials via scp-branch backtracking on malformed remotes', () => { + // Credential-prefixed remotes: the userinfo split happens BEFORE host + // matching, so a token can never be re-parsed as host:path. + expect(normalizeRemoteUrl('x-access-token:ghp_LIVETOKEN_abcdefghijklmnop@github.com/acme/private-repo.git')).toBeNull() + expect(normalizeRemoteUrl('oauth2:glpat-TOKEN@gitlab.com/org/repo.git')).toBeNull() + // One dropped slash: not a URL, must not fall through as host "https" + expect(normalizeRemoteUrl('https:/user:ghp_TOKEN@github.com/org/repo.git')).toBeNull() + // Multiple @: split at the first, residual @ fails the allow-list + expect(normalizeRemoteUrl('a@b@github.com:org/repo.git')).toBeNull() + expect(normalizeRemoteUrl('user@host:path@with-at')).toBeNull() + }) + + it('rejects transport-helper remotes and enforces shape + length on the identity', () => { + // git-remote-ext: embeds a local SSH key path + expect(normalizeRemoteUrl('ext::ssh -i /Users/me/.ssh/id_ed25519_work git@github.com %S /acme/private.git')).toBeNull() + expect(normalizeRemoteUrl('ext::sh -c whatever')).toBeNull() + // git-remote-codecommit: embeds an AWS profile name + expect(normalizeRemoteUrl('codecommit::us-east-1://MyAwsProfile@MyRepo')).toBeNull() + expect(normalizeRemoteUrl('codecommit::us-east-1://MyRepo')).toBeNull() + // Length bound + expect(normalizeRemoteUrl(`git@github.com:org/${'a'.repeat(300)}.git`)).toBeNull() + // Path segments must be repo-shaped (no spaces, colons, @) + expect(normalizeRemoteUrl('gitserver:has space/repo.git')).toBeNull() + // Legit multi-segment (GitLab subgroup) paths survive the allow-list + expect(normalizeRemoteUrl('https://gitlab.example.com/group/sub/repo.git')).toBe('gitlab.example.com/group/sub/repo') + }) + + it('normalizes .GIT case-insensitively and collapses doubled slashes to one join key', () => { + expect(normalizeRemoteUrl('git@github.com:acme/Repo.GIT')).toBe('github.com/acme/Repo') + expect(normalizeRemoteUrl('https://github.com/acme/Repo.Git')).toBe('github.com/acme/Repo') + expect(normalizeRemoteUrl('https://github.com/acme//repo.git')).toBe('github.com/acme/repo') + expect(normalizeRemoteUrl('git@github.com:acme//repo.git')).toBe('github.com/acme/repo') + }) +}) + +// ── computeAttributionRecords ───────────────────────────────────────── + +describe('computeAttributionRecords', () => { + it('attributes commits with normalized remote, inMain, and timestamps', async () => { + const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-repo-')) + try { + initRepo(repoDir) + git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git']) + await writeFile(join(repoDir, 'file.txt'), 'hello\n') + commitAt(repoDir, 'feat: shipped', '2026-01-01T10:30:00Z') + const sha = git(repoDir, ['rev-parse', 'HEAD']) + + const session = makeSession({ + sessionId: 'sess-a', + prLinks: ['https://github.com/acme/widget/pull/12'], + }) + const projects = [ + { project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary, + ] + + const records = computeAttributionRecords(projects, range, repoDir) + + expect(records).toHaveLength(1) + const record = records[0]! + expect(record.sessionId).toBe('sess-a') + expect(record.repo).toBe('github.com/acme/widget') + expect(record.prLinks).toEqual(['https://github.com/acme/widget/pull/12']) + expect(record.commits).toHaveLength(1) + expect(record.commits[0]).toMatchObject({ sha, inMain: true, wasReverted: false }) + expect(new Date(record.commits[0]!.timestamp).toISOString()).toBe('2026-01-01T10:30:00.000Z') + expect(record.firstTimestamp).toBe('2026-01-01T10:00:00.000Z') + expect(record.lastTimestamp).toBe('2026-01-01T11:00:00.000Z') + } finally { + await rm(repoDir, { recursive: true, force: true }) + } + }) + + it('omits empty sessions that lost nothing — commits aged out of range are NOT retracted', async () => { + const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-empty-')) + try { + initRepo(repoDir) + git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git']) + await writeFile(join(repoDir, 'file.txt'), 'hello\n') + // Commit outside every session window: no session competes for it, so + // nobody "lost" it. Even if this session previously synced a commit + // (now outside the --since range), emitting an empty record here would + // permanently zero a still-correct server-side count. + commitAt(repoDir, 'feat: unrelated', '2026-01-01T20:00:00Z') + + const session = makeSession({ sessionId: 'sess-idle' }) + const projects = [ + { project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary, + ] + + expect(computeAttributionRecords(projects, range, repoDir)).toEqual([]) + + // …and therefore nothing can be sent, even with prior ledger state for + // this session (simulating an earlier wider---since push). + const { writeLedger } = await import('../src/sync/ledger.js') + writeLedger([{ key: 'attr:s:sess-idle:0123456789abcdef', ts: '2026-01-01T10:00:00.000Z' }]) + const { collectUnsentAttribution } = await import('../src/sync/push.js') + expect(collectUnsentAttribution(computeAttributionRecords(projects, range, repoDir)).unsent).toEqual([]) + } finally { + await rm(repoDir, { recursive: true, force: true }) + } + }) + + it('emits a retraction candidate for a session that lost its commit to a tighter window', async () => { + const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-lost-')) + try { + initRepo(repoDir) + git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git']) + await writeFile(join(repoDir, 'file.txt'), 'hello\n') + commitAt(repoDir, 'feat: contested', '2026-01-01T10:30:00Z') + + const tight = makeSession({ + sessionId: 'sess-tight', + firstTimestamp: '2026-01-01T10:15:00.000Z', + lastTimestamp: '2026-01-01T10:45:00.000Z', + }) + const broadLoser = makeSession({ sessionId: 'sess-broad-loser' }) + const projects = [ + { project: 'app', projectPath: repoDir, sessions: [tight, broadLoser] } as ProjectSummary, + ] + + const records = computeAttributionRecords(projects, range, repoDir) + const loser = records.find(r => r.sessionId === 'sess-broad-loser')! + // The loser IS emitted (retraction candidate: lostCandidacy) with zero + // commits — the sync layer decides whether a prior state warrants + // actually sending it. + expect(loser).toBeDefined() + expect(loser.commits).toEqual([]) + expect(loser.repo).toBe('github.com/acme/widget') + } finally { + await rm(repoDir, { recursive: true, force: true }) + } + }) + + it('drops commits when the repo has no remote, but keeps PR-linked sessions', async () => { + const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-noremote-')) + try { + initRepo(repoDir) // no origin remote + await writeFile(join(repoDir, 'file.txt'), 'hello\n') + commitAt(repoDir, 'feat: local only', '2026-01-01T10:30:00Z') + + const withPr = makeSession({ + sessionId: 'sess-pr', + prLinks: ['https://github.com/acme/widget/pull/7'], + firstTimestamp: '2026-01-01T10:15:00.000Z', + lastTimestamp: '2026-01-01T10:45:00.000Z', + }) + const withoutPr = makeSession({ sessionId: 'sess-nopr' }) + const projects = [ + { project: 'app', projectPath: repoDir, sessions: [withPr, withoutPr] } as ProjectSummary, + ] + + const records = computeAttributionRecords(projects, range, repoDir) + + // sess-pr wins the commit window but has no repo identity, so commits + // are dropped; the PR link alone justifies the record. sess-nopr has + // nothing joinable and is omitted. + expect(records).toHaveLength(1) + expect(records[0]!.sessionId).toBe('sess-pr') + expect(records[0]!.repo).toBeNull() + expect(records[0]!.commits).toEqual([]) + expect(records[0]!.prLinks).toEqual(['https://github.com/acme/widget/pull/7']) + } finally { + await rm(repoDir, { recursive: true, force: true }) + } + }) + + it('never egresses the cwd repo for sessions whose project path did not resolve (fallback)', async () => { + // The reviewer's repro: push from inside a private repo while a session's + // project path no longer resolves. The fallback identity must NOT leak + // the cwd repo's remote or commits into that session's attribution. + const cwdRepo = await mkdtemp(join(tmpdir(), 'codeburn-attr-privatecwd-')) + try { + initRepo(cwdRepo) + git(cwdRepo, ['remote', 'add', 'origin', 'git@github.com:secret-org/nda-client-repo.git']) + await writeFile(join(cwdRepo, 'file.txt'), 'confidential\n') + commitAt(cwdRepo, 'feat: private work', '2026-01-01T10:30:00Z') + + // Session A: project path is gone (deleted dir) — falls back to cwd + const orphanNoPr = makeSession({ sessionId: 'orphan-nopr', ...{ firstTimestamp: '2026-01-01T10:15:00.000Z', lastTimestamp: '2026-01-01T10:45:00.000Z' } }) + // Session B: also fallback, but carries a PR link (session-native, safe) + const orphanWithPr = makeSession({ + sessionId: 'orphan-pr', + prLinks: ['https://github.com/acme/widget/pull/9'], + firstTimestamp: '2026-01-01T12:00:00.000Z', + lastTimestamp: '2026-01-01T12:30:00.000Z', + }) + // Session C: genuinely belongs to the cwd repo (own path resolves) + const genuine = makeSession({ sessionId: 'genuine-cwd', firstTimestamp: '2026-01-01T10:00:00.000Z', lastTimestamp: '2026-01-01T11:00:00.000Z' }) + + const projects = [ + { project: 'ghost', projectPath: join(cwdRepo, 'no-such-dir-anymore-xyz'), sessions: [orphanNoPr] }, + { project: 'ghost2', projectPath: '', sessions: [orphanWithPr] }, + { project: 'real', projectPath: cwdRepo, sessions: [genuine] }, + ] as ProjectSummary[] + + const records = computeAttributionRecords(projects, range, cwdRepo) + + // orphan-nopr: nothing joinable -> no record at all + expect(records.find(r => r.sessionId === 'orphan-nopr')).toBeUndefined() + // orphan-pr: PR link only — no repo, no commits + const pr = records.find(r => r.sessionId === 'orphan-pr')! + expect(pr.repo).toBeNull() + expect(pr.commits).toEqual([]) + // genuine cwd session keeps full attribution + const own = records.find(r => r.sessionId === 'genuine-cwd')! + expect(own.repo).toBe('github.com/secret-org/nda-client-repo') + expect(own.commits).toHaveLength(1) + // The private repo identity appears ONLY on the genuine record + const leaked = records.filter(r => r.sessionId !== 'genuine-cwd' && JSON.stringify(r).includes('secret-org')) + expect(leaked).toEqual([]) + } finally { + await rm(cwdRepo, { recursive: true, force: true }) + } + }) + + it('fallback sessions cannot steal a commit from a genuine session', async () => { + const cwdRepo = await mkdtemp(join(tmpdir(), 'codeburn-attr-steal-')) + try { + initRepo(cwdRepo) + git(cwdRepo, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git']) + await writeFile(join(cwdRepo, 'file.txt'), 'x\n') + commitAt(cwdRepo, 'feat: mine', '2026-01-01T10:30:00Z') + + // Fallback session has the TIGHTER window (would win under old logic); + // genuine session has the broader window. + const fallbackTight = makeSession({ + sessionId: 'fallback-tight', + prLinks: ['https://github.com/acme/widget/pull/2'], + firstTimestamp: '2026-01-01T10:25:00.000Z', + lastTimestamp: '2026-01-01T10:35:00.000Z', + }) + const genuineBroad = makeSession({ sessionId: 'genuine-broad' }) + + const projects = [ + { project: 'ghost', projectPath: '', sessions: [fallbackTight] }, + { project: 'real', projectPath: cwdRepo, sessions: [genuineBroad] }, + ] as ProjectSummary[] + + const records = computeAttributionRecords(projects, range, cwdRepo) + + expect(records.find(r => r.sessionId === 'fallback-tight')!.commits).toEqual([]) + expect(records.find(r => r.sessionId === 'genuine-broad')!.commits).toHaveLength(1) + } finally { + await rm(cwdRepo, { recursive: true, force: true }) + } + }) + + it('awards each commit to a single session (tightest window)', async () => { + const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-overlap-')) + try { + initRepo(repoDir) + git(repoDir, ['remote', 'add', 'origin', 'https://github.com/acme/widget.git']) + await writeFile(join(repoDir, 'file.txt'), 'hello\n') + commitAt(repoDir, 'feat: shared window', '2026-01-01T10:30:00Z') + + const tight = makeSession({ + sessionId: 'sess-tight', + firstTimestamp: '2026-01-01T10:15:00.000Z', + lastTimestamp: '2026-01-01T10:45:00.000Z', + }) + const broad = makeSession({ sessionId: 'sess-broad' }) + const projects = [ + { project: 'app', projectPath: repoDir, sessions: [tight, broad] } as ProjectSummary, + ] + + const records = computeAttributionRecords(projects, range, repoDir) + + // Both sessions get records (the loser is a retraction candidate), + // but the commit is awarded exactly once — to the tighter window. + expect(records).toHaveLength(2) + const tightRecord = records.find(r => r.sessionId === 'sess-tight')! + const broadRecord = records.find(r => r.sessionId === 'sess-broad')! + expect(tightRecord.commits).toHaveLength(1) + expect(broadRecord.commits).toEqual([]) + } finally { + await rm(repoDir, { recursive: true, force: true }) + } + }) +}) + +// ── Dedup keys and flattening ───────────────────────────────────────── + +function makeRecord(overrides: Partial = {}): SessionAttributionRecord { + return { + sessionId: 'sess-1', + project: 'app', + repo: 'github.com/acme/widget', + prLinks: ['https://github.com/acme/widget/pull/3'], + commits: [ + { sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: false }, + ], + firstTimestamp: '2026-01-01T10:00:00.000Z', + lastTimestamp: '2026-01-01T11:00:00.000Z', + ...overrides, + } +} + +describe('attribution dedup keys', () => { + it('encodes commit state so a state transition mints a new key', () => { + const before = commitAttributionKey('sess-1', 'abc123', false, false) + const merged = commitAttributionKey('sess-1', 'abc123', true, false) + const reverted = commitAttributionKey('sess-1', 'abc123', true, true) + expect(new Set([before, merged, reverted]).size).toBe(3) + // Same state = same key (ledger dedupes repeats) + expect(commitAttributionKey('sess-1', 'abc123', true, false)).toBe(merged) + }) + + it('session key is stable for identical state and changes with commit state', () => { + const record = makeRecord() + expect(sessionAttributionKey(record)).toBe(sessionAttributionKey(makeRecord())) + + const mutated = makeRecord({ + commits: [{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: true }], + }) + expect(sessionAttributionKey(mutated)).not.toBe(sessionAttributionKey(record)) + }) + + it('session key changes when the window or project changes (ongoing sessions re-emit)', () => { + const base = makeRecord() + const grown = makeRecord({ lastTimestamp: '2026-01-01T12:00:00.000Z' }) + const renamed = makeRecord({ project: 'app-renamed' }) + expect(sessionAttributionKey(grown)).not.toBe(sessionAttributionKey(base)) + expect(sessionAttributionKey(renamed)).not.toBe(sessionAttributionKey(base)) + }) + + it('flattens one session item plus one item per commit', () => { + const items = flattenAttributionRecords([makeRecord()]) + expect(items).toHaveLength(2) + expect(items[0]).toMatchObject({ kind: 'session', commitCount: 1, endTimestamp: '2026-01-01T11:00:00.000Z' }) + expect(items[1]).toMatchObject({ kind: 'commit', sha: 'a'.repeat(40), inMain: true, wasReverted: false }) + expect(items.map(i => i.dedupKey)).toEqual([ + sessionAttributionKey(makeRecord()), + commitAttributionKey('sess-1', 'a'.repeat(40), true, false), + ]) + }) +}) + +// ── PR link sanitization ────────────────────────────────────────────── + +describe('sanitizePrLinks', () => { + it('keeps only https URLs shaped like org/repo/pull/N', () => { + expect(sanitizePrLinks([ + 'https://github.com/acme/widget/pull/12', + 'https://ghe.corp.example.com/team/svc/pull/3', // GHE hosts allowed + 'http://github.com/acme/widget/pull/12', // not https + 'javascript:alert(1)', // not a URL shape we accept + 'https://github.com/acme/widget/issues/12', // not a PR path + 'https://github.com/acme/widget/pull/12/files', // extra path segment + 'not a url at all', + '', + 'https://github.com/acme/widget/pull/notanumber', + ])).toEqual([ + 'https://ghe.corp.example.com/team/svc/pull/3', + 'https://github.com/acme/widget/pull/12', + ]) + }) + + it('rebuilds links from origin + pathname: userinfo, query, and fragment never survive', () => { + expect(sanitizePrLinks([ + 'https://alice:ghp_TOKEN@github.com/acme/widget/pull/5', + 'https://github.com/acme/widget/pull/7?notification_referrer_id=xyz', + 'https://github.com/acme/widget/pull/6#pullrequestreview-123', + ])).toEqual([ + 'https://github.com/acme/widget/pull/5', + 'https://github.com/acme/widget/pull/6', + 'https://github.com/acme/widget/pull/7', + ]) + }) + + it('dedupes links that collapse to the same rebuilt URL', () => { + expect(sanitizePrLinks([ + 'https://github.com/acme/widget/pull/9', + 'https://github.com/acme/widget/pull/9?ref=a', + 'https://github.com/acme/widget/pull/9#comment', + ])).toEqual(['https://github.com/acme/widget/pull/9']) + }) + + it('drops oversized inputs and caps the count per session', () => { + // A long referrer query is stripped, so the link survives... + const longQuery = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(300)}` + expect(sanitizePrLinks([longQuery])).toEqual(['https://github.com/acme/widget/pull/1']) + // ...but pathological inputs beyond the input bound are dropped outright + const huge = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(600)}` + expect(sanitizePrLinks([huge])).toEqual([]) + // And a rebuilt link that is itself oversized is dropped + const longPath = `https://github.com/${'o'.repeat(150)}/${'r'.repeat(80)}/pull/1` + expect(sanitizePrLinks([longPath])).toEqual([]) + + const many = Array.from({ length: 30 }, (_, i) => `https://github.com/acme/widget/pull/${i + 1}`) + expect(sanitizePrLinks(many)).toHaveLength(MAX_PR_LINKS_PER_SESSION) + }) +}) + +// ── OTLP payload ────────────────────────────────────────────────────── + +function attrMap(attributes: OtlpAttribute[]): Record { + return Object.fromEntries(attributes.map(a => [a.key, a.value])) +} + +describe('buildAttributionOtlpPayload', () => { + it('builds session and commit spans sharing the session traceId', async () => { + const items = flattenAttributionRecords([makeRecord()]) + const payload = buildAttributionOtlpPayload(items) + + const resource = payload.resourceSpans[0]! + const resourceAttrs = attrMap(resource.resource.attributes) + expect(resourceAttrs['codeburn.attribution_methodology']).toEqual({ stringValue: 'timestamp-window' }) + expect(resourceAttrs['codeburn.device_id']).toBeDefined() + + const spans = resource.scopeSpans[0]!.spans + expect(spans).toHaveLength(2) + + const sessionSpan = spans.find(s => s.name === SESSION_ATTRIBUTION_SPAN_NAME)! + const commitSpan = spans.find(s => s.name === COMMIT_ATTRIBUTION_SPAN_NAME)! + // Expected traceId computed under the same persisted key the payload + // builder obtains (getPersistedHostPrivacyKey), never a second key source. + const { getPersistedHostPrivacyKey } = await import('../src/privacy-key.js') + expect(sessionSpan.traceId).toBe(deriveTraceId(getPersistedHostPrivacyKey(), 'sess-1')) + expect(commitSpan.traceId).toBe(sessionSpan.traceId) + expect(sessionSpan.spanId).not.toBe(commitSpan.spanId) + + const sessionAttrs = attrMap(sessionSpan.attributes) + expect(sessionAttrs['ai.session_id']).toEqual({ stringValue: 'sess-1' }) + expect(sessionAttrs['ai.project']).toEqual({ stringValue: 'app' }) + expect(sessionAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' }) + expect(sessionAttrs['git.commit_count']).toEqual({ intValue: '1' }) + expect(sessionAttrs['git.pr_links']).toEqual({ + arrayValue: { values: [{ stringValue: 'https://github.com/acme/widget/pull/3' }] }, + }) + // Session span carries the real window as its duration + expect(sessionSpan.startTimeUnixNano).toBe((BigInt(new Date('2026-01-01T10:00:00.000Z').getTime()) * 1_000_000n).toString()) + expect(sessionSpan.endTimeUnixNano).toBe((BigInt(new Date('2026-01-01T11:00:00.000Z').getTime()) * 1_000_000n).toString()) + + const commitAttrs = attrMap(commitSpan.attributes) + expect(commitAttrs['git.sha']).toEqual({ stringValue: 'a'.repeat(40) }) + expect(commitAttrs['git.in_main']).toEqual({ boolValue: true }) + expect(commitAttrs['git.was_reverted']).toEqual({ boolValue: false }) + expect(commitAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' }) + }) + + it('omits git.repo when null and pr_links when empty', () => { + const items = flattenAttributionRecords([makeRecord({ repo: null, prLinks: [], commits: [] })]) + const payload = buildAttributionOtlpPayload(items) + const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans + expect(spans).toHaveLength(1) + const attrs = attrMap(spans[0]!.attributes) + expect(attrs['git.repo']).toBeUndefined() + expect(attrs['git.pr_links']).toBeUndefined() + expect(attrs['git.commit_count']).toEqual({ intValue: '0' }) + }) + + it('batches items by maxBatchSize', () => { + const items = flattenAttributionRecords([makeRecord(), makeRecord({ sessionId: 'sess-2' })]) + expect(batchAttributionItems(items, 3).map(b => b.length)).toEqual([3, 1]) + }) + + it('clamps span end time: never 0, never earlier than start + 1ms', () => { + // Session window ends BEFORE it starts (out-of-order provider timestamps) + const outOfOrder = flattenAttributionRecords([makeRecord({ + firstTimestamp: '2026-01-01T11:00:00.000Z', + lastTimestamp: '2026-01-01T10:00:00.000Z', + })]) + const span1 = buildAttributionOtlpPayload(outOfOrder).resourceSpans[0]!.scopeSpans[0]!.spans[0]! + expect(BigInt(span1.endTimeUnixNano)).toBe(BigInt(span1.startTimeUnixNano) + 1_000_000n) + + // Malformed end timestamp (toUnixNano -> 0) + const malformed = flattenAttributionRecords([makeRecord({ lastTimestamp: 'not-a-date' })]) + const span2 = buildAttributionOtlpPayload(malformed).resourceSpans[0]!.scopeSpans[0]!.spans[0]! + expect(span2.endTimeUnixNano).not.toBe('0') + expect(BigInt(span2.endTimeUnixNano)).toBe(BigInt(span2.startTimeUnixNano) + 1_000_000n) + }) +}) + +// ── Send + ledger pipeline ──────────────────────────────────────────── + +type MockResponse = { status: number; body?: unknown; headers?: Record } + +function startMockOtlp(responses: MockResponse[]): Promise<{ + url: string + server: Server + requests: Array<{ auth: string | undefined; body: unknown }> +}> { + const requests: Array<{ auth: string | undefined; body: unknown }> = [] + let idx = 0 + + return new Promise(resolve => { + const server = createServer((req, res) => { + let raw = '' + req.on('data', c => { raw += c }) + req.on('end', () => { + requests.push({ auth: req.headers.authorization, body: JSON.parse(raw || '{}') }) + const r = responses[Math.min(idx, responses.length - 1)]! + idx++ + res.writeHead(r.status, { 'Content-Type': 'application/json', ...r.headers }) + res.end(r.body !== undefined ? JSON.stringify(r.body) : '{}') + }) + }) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as { port: number } + resolve({ url: `http://127.0.0.1:${addr.port}/v1/traces`, server, requests }) + }) + }) +} + +let tmpDir: string +const originalHome = process.env.HOME +const originalXdgCache = process.env.XDG_CACHE_HOME + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-push-')) + process.env.HOME = tmpDir + process.env.XDG_CACHE_HOME = join(tmpDir, '.cache') +}) + +afterEach(async () => { + process.env.HOME = originalHome + if (originalXdgCache === undefined) delete process.env.XDG_CACHE_HOME + else process.env.XDG_CACHE_HOME = originalXdgCache + await rm(tmpDir, { recursive: true, force: true }) +}) + +describe('sendAttributionBatches + collectUnsentAttribution', () => { + it('sends attribution spans, ledgers dedup keys, and filters them on the next collect', async () => { + const { sendAttributionBatches, collectUnsentAttribution } = await import('../src/sync/push.js') + const { readLedger } = await import('../src/sync/ledger.js') + + const record = makeRecord() + const first = collectUnsentAttribution([record]) + expect(first.unsent).toHaveLength(2) + + const mock = await startMockOtlp([{ status: 200 }]) + try { + const result = await sendAttributionBatches({ + endpoint: mock.url, + accessToken: 'token-1', + batches: [first.unsent], + }) + + expect(result.outcome).toBe('complete') + expect(result.totalSent).toBe(2) + expect(result.totalCostSent).toBe(0) + expect(mock.requests).toHaveLength(1) + expect(mock.requests[0]!.auth).toBe('Bearer token-1') + + const body = mock.requests[0]!.body as { resourceSpans: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }> } + const names = body.resourceSpans[0]!.scopeSpans[0]!.spans.map(s => s.name).sort() + expect(names).toEqual([COMMIT_ATTRIBUTION_SPAN_NAME, SESSION_ATTRIBUTION_SPAN_NAME]) + + const ledgered = readLedger().map(e => e.key).sort() + expect(ledgered).toEqual(first.unsent.map(i => i.dedupKey).sort()) + + // Identical state on the next push: nothing unsent + expect(collectUnsentAttribution([record]).unsent).toEqual([]) + + // State transition (commit reverted): the changed facts re-send + const mutated = makeRecord({ + commits: [{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: true }], + }) + const after = collectUnsentAttribution([mutated]) + expect(after.unsent.map(i => i.kind).sort()).toEqual(['commit', 'session']) + } finally { + mock.server.close() + } + }) + + it('retracts a session span when its commit migrates to a tighter-window session', async () => { + const { sendAttributionBatches, collectUnsentAttribution } = await import('../src/sync/push.js') + + const sha = 'b'.repeat(40) + const commit = { sha, timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: false } + + // Push 1: session A (broad window) owns the commit + const push1 = collectUnsentAttribution([makeRecord({ sessionId: 'sess-A', prLinks: [], commits: [commit] })]) + expect(push1.unsent).toHaveLength(2) + const mock = await startMockOtlp([{ status: 200 }]) + try { + await sendAttributionBatches({ endpoint: mock.url, accessToken: 't', batches: [push1.unsent] }) + + // Push 2: a later-parsed tighter session B now wins the commit; A is empty + const push2 = collectUnsentAttribution([ + makeRecord({ sessionId: 'sess-A', prLinks: [], commits: [] }), // loser: retraction candidate + makeRecord({ sessionId: 'sess-B', prLinks: [], commits: [commit] }), // winner + ]) + + // A re-emits with commit_count 0 (retraction), B emits session + commit + const kinds = push2.unsent.map(i => `${i.sessionId}:${i.kind}`).sort() + expect(kinds).toEqual(['sess-A:session', 'sess-B:commit', 'sess-B:session']) + const retraction = push2.unsent.find(i => i.sessionId === 'sess-A')! + expect(retraction.commitCount).toBe(0) + expect(retraction.dedupKey).not.toBe(push1.unsent.find(i => i.kind === 'session')!.dedupKey) + + // A session that was NEVER sent stays excluded when empty + const neverSent = collectUnsentAttribution([ + makeRecord({ sessionId: 'sess-never', prLinks: [], commits: [] }), + ]) + expect(neverSent.unsent).toEqual([]) + } finally { + mock.server.close() + } + }) + + it('does not ledger on server error', async () => { + const { sendAttributionBatches } = await import('../src/sync/push.js') + const { readLedger } = await import('../src/sync/ledger.js') + + const items = flattenAttributionRecords([makeRecord()]) + const mock = await startMockOtlp([{ status: 500 }]) + try { + const result = await sendAttributionBatches({ + endpoint: mock.url, + accessToken: 'token-1', + batches: [items], + }) + expect(result.outcome).toBe('server-error') + expect(readLedger()).toEqual([]) + } finally { + mock.server.close() + } + }) +}) diff --git a/packages/cli/tests/sync-privacy-key.test.ts b/packages/cli/tests/sync-privacy-key.test.ts index 4b659dde..e9c1fe82 100644 --- a/packages/cli/tests/sync-privacy-key.test.ts +++ b/packages/cli/tests/sync-privacy-key.test.ts @@ -25,6 +25,7 @@ import { spawn, type ChildProcess } from 'child_process' import { existsSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' +import { fileURLToPath } from 'url' import { getHostPrivacyKey, getPersistedHostPrivacyKey } from '../src/privacy-key.js' import { buildOtlpPayload, deriveDeviceId, type CallWithSession } from '../src/sync/otlp.js' @@ -255,10 +256,16 @@ async function waitFor(path: string, timeoutMs = 5_000): Promise { } } +// Fixture path anchored to THIS file, not process.cwd(): vitest workers run +// with the invocation cwd (repo root under `--root packages/cli`), so a +// cwd-relative path silently points at a nonexistent file and the worker +// exits before ever writing its ready file. +const FIRST_USE_WORKER = fileURLToPath(new URL('./fixtures/privacy-key-first-use-worker.ts', import.meta.url)) + function firstUseWorker(goFile: string, readyFile: string, homeDir: string): ChildProcess { return spawn( process.execPath, - ['--import', 'tsx', join(process.cwd(), 'tests/fixtures/privacy-key-first-use-worker.ts'), goFile, readyFile], + ['--import', 'tsx', FIRST_USE_WORKER, goFile, readyFile], { cwd: process.cwd(), env: { ...process.env, HOME: homeDir }, stdio: ['ignore', 'pipe', 'pipe'] } ) }