Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
27 changes: 23 additions & 4 deletions docs/sync/DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,20 +105,39 @@ 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

```json
{
"resource": {
"attributes": [
{ "key": "codeburn.device_id", "value": { "stringValue": "<SHA-256(hostname+username)[:16]>" } }
{ "key": "codeburn.device_id", "value": { "stringValue": "<HMAC-SHA256(privacyKey, \"sync-device:\" + hostname + \"\\x1f\" + username)[:16]> (\\x1f = ASCII Unit Separator)" } }
]
}
}
Expand Down
37 changes: 35 additions & 2 deletions docs/sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -93,7 +96,37 @@ 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.

### 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

Expand All @@ -102,7 +135,7 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam
- **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

Expand Down
214 changes: 197 additions & 17 deletions packages/cli/src/privacy-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,35 +24,215 @@ 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)
}

/**
* 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.
*/
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' }
}
// 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' }
}
}

/**
* 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') {
// 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 ' +
'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.'
)
}
Loading
Loading