diff --git a/packages/cli/src/daily-cache.ts b/packages/cli/src/daily-cache.ts index c5439c34..734b7481 100644 --- a/packages/cli/src/daily-cache.ts +++ b/packages/cli/src/daily-cache.ts @@ -5,7 +5,28 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' -// Bumped to 15: per-project daily rollups. Days and provider slices now carry +// Bumped to 17: dedup-key hygiene (#931, this PR). The codebuff, zerostack, +// pi/omp and grok decoders now thread a FINGERPRINT of the source path into +// their dedup keys (and lingtai-tui normalizes the model component) instead of +// the raw path / raw ledger text, because dedupKey ships on the observation +// envelope. Unchanged files are served from the session cache, whose dedup +// sets are seeded from the CACHED keys — so a warm cache built by the pre-fix +// binary keeps the raw-path keys, the same records re-ingest under the new +// key shape, totals go inconsistent, and the raw path stays on disk forever. +// The per-provider parse versions (session-cache.ts PROVIDER_PARSE_VERSIONS) +// force the session-cache re-parse that drops those keys; this bump forces the +// daily cache to re-derive every day whose sources survive instead of serving +// the pre-fix rollups. Raising MIN_SUPPORTED_VERSION to 17 makes a v16 file +// load as an old-version file rather than the trusted current cache. +// +// v16 is SKIPPED: main already spent it on the codex structural-discovery fix +// (eece4cf, #873/#626). A user who has ever run a main build owns a v16 cache +// containing only the codex fix; claiming 16 here too would load that file as +// CURRENT and COMPLETE, so the invalidation would never fire — the exact +// failure this bump exists to prevent. Claiming 17 instead sends that v16 file +// through the old-version adoption/re-derive path. +// +// v15: per-project daily rollups. Days and provider slices now carry // a `projects` breakdown (cost/calls/savings/sessions per project) so project // history outlives the session files, like models and categories already do. // This bump is the first to ride the v14 carry-forward: the old cache is @@ -57,8 +78,8 @@ import type { DateRange, ProjectSummary } from './types.js' // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 15 -const MIN_SUPPORTED_VERSION = 15 +export const DAILY_CACHE_VERSION = 17 +const MIN_SUPPORTED_VERSION = 17 // Version-suffixed so different binaries each own a distinct file and never // clobber an incompatible schema. Bumping the version mints a fresh filename; // adoptOlderDailyCaches then unions days out of every previous file (including diff --git a/packages/cli/src/providers/bridge.ts b/packages/cli/src/providers/bridge.ts index b03b353f..e17ef2c2 100644 --- a/packages/cli/src/providers/bridge.ts +++ b/packages/cli/src/providers/bridge.ts @@ -1,5 +1,6 @@ import type { DecodeContext } from '@codeburn/core' +import { getHostPrivacyKey } from '../privacy-key.js' import type { Provider, ProbeRoot, SessionSource, SessionParser, ParsedProviderCall } from './types.js' // ── The dual-registry bridge ──────────────────────────────────────────────── @@ -81,10 +82,22 @@ export function createBridgedProvider(spec: BridgedProviderSpec): async *parse(): AsyncGenerator { const records = await spec.readRecords(source) if (records === null) return - // The CLI holds the rich decode only; minimization / fingerprinting - // happens on the sync path, so an empty privacy key is correct here - // (the rich decoder never consumes it), matching claude/codex. - const context: DecodeContext = { privacyKey: '', providerId: spec.name, sourceRef: source.path } + // The host privacy key, threaded into the rich decode (D1). An empty + // key was correct when the bridge was written — the comment then said + // the rich decoder never consumes it, because minimization / + // fingerprinting happened later on the sync path. That intent is + // OVERTAKEN by the sourceRef-fingerprint work (#931): the rich + // decoders now derive their dedup keys from sourceRefFingerprint, + // and dedupKey ships on the observation envelope, so the rich decode + // DOES consume the key. On an empty key core's fingerprint module + // throws (it never degrades to an unkeyed digest), so the bridge has + // to supply the real one. getHostPrivacyKey() is per-install stable + // (persisted, like the optimize detectors use), so dedup keys stay + // stable across runs and the session-cache re-parse / dedup + // semantics are unchanged; it only falls back to a per-process key + // when the config dir is unwritable, in which case the session cache + // cannot persist either. + const context: DecodeContext = { privacyKey: getHostPrivacyKey(), providerId: spec.name, sourceRef: source.path } const { calls } = spec.decode({ records, context, seenKeys }) for (const rich of calls) { yield spec.toProviderCall(rich) diff --git a/packages/cli/src/session-cache.ts b/packages/cli/src/session-cache.ts index 461dd461..f6f9925e 100644 --- a/packages/cli/src/session-cache.ts +++ b/packages/cli/src/session-cache.ts @@ -198,6 +198,13 @@ export const DURABLE_PROVIDER_NAMES: ReadonlySet = new Set(['copilot']) // needs no suffix: the cli-shutdown-cost-v1 bump below already forces its one // re-parse, which lands the flag too, and durable orphans now survive // fingerprint changes (the carry-forward in getOrCreateProviderSection). +// Dedup-key hygiene (#931): codebuff, zerostack, pi/omp and grok now thread a +// FINGERPRINT of the source path into their dedup keys (and lingtai-tui +// normalizes the model component) instead of the raw path / raw ledger text. +// The session cache seeds its dedup sets from the CACHED keys, so a pre-fix +// cache keeps the raw-path keys and the same records re-ingest under the new +// key shape. Each entry/suffix below changes the provider's env fingerprint, +// which forces the one-time re-parse that drops the raw-path keys from disk. export const PROVIDER_PARSE_VERSIONS: Record = { // rich-session-capture-v1: parse-time capture of per-turn gitBranch, per-call // LOC deltas / interruptions / userModified / toolErrors, and session-level @@ -217,9 +224,16 @@ export const PROVIDER_PARSE_VERSIONS: Record = { cursor: 'composer-anchored-crediting-v1-est-cost', 'cursor-agent': 'workspaceless-transcript-v1', copilot: 'cli-shutdown-cost-v1-skills', - grok: 'estimated-cost-v1', + // source-ref-fingerprint-v1: the dedup key now threads a fingerprint of the + // source path (chat dir) instead of the raw path, which ships on the + // envelope. Forces one re-parse so cached raw-path keys are dropped. + codebuff: 'source-ref-fingerprint-v1', + zerostack: 'source-ref-fingerprint-v1', + pi: 'source-ref-fingerprint-v1', + omp: 'source-ref-fingerprint-v1', + grok: 'estimated-cost-v1-source-ref-fingerprint-v1', hermes: 'reasoning-output-accounting-v1-est-cost', - 'lingtai-tui': 'token-ledger-registry-activity-v3', + 'lingtai-tui': 'token-ledger-registry-activity-v3-source-ref-fp-v1-model-normalized-v1', 'ibm-bob': 'worktree-project-grouping-v1', kiro: 'ide-parsing-v1-est-cost', quickdesk: 'emf-sqlite-v2-est-cost', diff --git a/packages/cli/tests/daily-cache-carry-forward.test.ts b/packages/cli/tests/daily-cache-carry-forward.test.ts index 62197fae..1ca75888 100644 --- a/packages/cli/tests/daily-cache-carry-forward.test.ts +++ b/packages/cli/tests/daily-cache-carry-forward.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdir, readFile, rename, rm, writeFile } from 'fs/promises' import { existsSync } from 'fs' import { tmpdir } from 'os' @@ -304,6 +304,48 @@ describe('never-lose invariant: invalidations with vanished sources', () => { expect(out.days[0]).toMatchObject({ date: d.date, cost: d.cost, calls: d.calls, carried: true }) }) + it('a version bump forces a re-derive that drops the raw-path dedup keys', async () => { + // The pre-fix binary shipped daily-cache v15. THIS LITERAL IS THE POINT: + // it must stay pinned to the version the pre-fix binary wrote, so a warm + // complete cache from that binary sits at daily-cache.v15.json with dedup + // keys threaded on RAW source paths (codebuff/zerostack/pi/omp/grok) and + // raw ledger model text (lingtai-tui). Those keys ship on the observation + // envelope, and the session cache seeds its dedup sets from the cached + // keys, so the pre-fix cache re-ingests the same records under the new + // key shapes — the pre-fix day below carries the inflated double count. + // Only the MIN_SUPPORTED_VERSION bump decides whether that file loads as + // the trusted CURRENT cache — freezing the inflated rollup forever — or as + // an old-version file that forces the one-time re-derive, which drops the + // raw-path keys and lands the corrected single count. When the next bump + // lands, move this literal to the version the current binary shipped. + const PRE_FIX_CACHE_VERSION = 15 + const preFixDay = day(daysAgoStr(30), { codebuff: slice(10.0, 2) }) + const preFixCache: DailyCache = { + version: PRE_FIX_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [preFixDay], + complete: true, + } + await writeFile(join(TMP_CACHE_ROOT, `daily-cache.v${PRE_FIX_CACHE_VERSION}.json`), JSON.stringify(preFixCache), 'utf-8') + + // The re-derive under the fingerprint-shaped keys sees ONE record per + // session (the raw-path keys no longer collide with the new keys, so the + // re-ingestion is gone): corrected 5.0 / 1 call. + const aggregate = vi.fn(() => [day(daysAgoStr(30), { codebuff: slice(5.0, 1) })]) + const out = await ensureCacheHydrated(noSessions, aggregate, 'cfg-A') + + // The bump forced a full re-derivation: the fresh parse was consulted. + expect(aggregate).toHaveBeenCalled() + expect(out.version).toBe(DAILY_CACHE_VERSION) + expect(out.complete).toBe(true) + // The corrected single count wins; the inflated pre-fix slice is gone. + expect(out.days[0]!.providers['codebuff']!.cost).toBe(5.0) + expect(out.days[0]!.providers['codebuff']!.calls).toBe(1) + expect(out.days[0]!.cost).toBe(5.0) + }) + it('a same-version file found under an old name is trusted as-is (no spurious rebuild)', async () => { const d = await seed() await rename(dailyCachePath(), join(TMP_CACHE_ROOT, 'daily-cache.json')) diff --git a/packages/cli/tests/providers/codebuff-bridge.test.ts b/packages/cli/tests/providers/codebuff-bridge.test.ts index bdfafa91..9375033a 100644 --- a/packages/cli/tests/providers/codebuff-bridge.test.ts +++ b/packages/cli/tests/providers/codebuff-bridge.test.ts @@ -5,19 +5,29 @@ import { describe, it, expect } from 'vitest' import { createCodebuffProvider } from '../../src/providers/codebuff.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { getHostPrivacyKey } from '../../src/privacy-key.js' +import { sourceRefFingerprint } from '@codeburn/core' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the codebuff bridge migration. The GOLDEN below // was captured from the legacy in-CLI decode before the migration; the bridged // provider (discovery + I/O CLI-side, pure decode in @codeburn/core/providers/codebuff) -// must reproduce it exactly. Dedup keys contain absolute source paths, so they are -// computed from the discovered source at runtime rather than hard-coded. +// must reproduce it exactly. Dedup keys thread a FINGERPRINT of the source path +// (dedupKey ships on the envelope, so the raw path must never appear there), so +// the expected values are DERIVED from the source at runtime via the same +// sourceRefFingerprint the decoder uses — never the raw path, and never a +// hard-coded literal. The bridge threads the HOST privacy key (getHostPrivacyKey, +// per-install stable), so the golden derives with the same key. const here = dirname(fileURLToPath(import.meta.url)) const FIXTURE_DIR = resolve(here, '../fixtures/codebuff-parity/manicode') function expectedGolden(sourcePath: string): ParsedProviderCall[] { const chatDir = sourcePath + // The CLI bridge threads the host privacy key into the rich decode, so the + // decoder keys the source ref under getHostPrivacyKey() — derive the + // expected key the same way instead of pasting what the code emits. + const chatRef = sourceRefFingerprint(getHostPrivacyKey(), chatDir) return [ { provider: 'codebuff', @@ -35,7 +45,7 @@ function expectedGolden(sourcePath: string): ParsedProviderCall[] { bashCommands: ['npm', 'npm'], timestamp: '2026-04-14T10:00:30.000Z', speed: 'standard', - deduplicationKey: `codebuff:${chatDir}:a1`, + deduplicationKey: `codebuff:${chatRef}:a1`, userMessage: 'implement the feature', sessionId: 'manicode/2026-04-14T10-00-00.000Z', }, @@ -55,7 +65,7 @@ function expectedGolden(sourcePath: string): ParsedProviderCall[] { bashCommands: [], timestamp: '2026-04-14T10:01:30.000Z', speed: 'standard', - deduplicationKey: `codebuff:${chatDir}:a2`, + deduplicationKey: `codebuff:${chatRef}:a2`, userMessage: 'fix the bug', sessionId: 'manicode/2026-04-14T10-00-00.000Z', }, @@ -75,7 +85,7 @@ function expectedGolden(sourcePath: string): ParsedProviderCall[] { bashCommands: [], timestamp: '2026-04-14T10:02:00.000Z', speed: 'standard', - deduplicationKey: `codebuff:${chatDir}:a3`, + deduplicationKey: `codebuff:${chatRef}:a3`, userMessage: '', sessionId: 'manicode/2026-04-14T10-00-00.000Z', }, diff --git a/packages/cli/tests/providers/grok-bridge.test.ts b/packages/cli/tests/providers/grok-bridge.test.ts index 65a298fc..4a750982 100644 --- a/packages/cli/tests/providers/grok-bridge.test.ts +++ b/packages/cli/tests/providers/grok-bridge.test.ts @@ -5,6 +5,8 @@ import { describe, it, expect } from 'vitest' import { createGrokProvider } from '../../src/providers/grok.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { getHostPrivacyKey } from '../../src/privacy-key.js' +import { sourceRefFingerprint } from '@codeburn/core' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the grok bridge migration (phase 8). The @@ -12,6 +14,13 @@ import type { ParsedProviderCall, SessionSource } from '../../src/providers/type const here = dirname(fileURLToPath(import.meta.url)) const FIXTURE_DIR = resolve(here, '../fixtures/grok-parity') +// The dedup key threads a FINGERPRINT of the session dir — the raw path is the +// defect and must never appear (dedupKey ships on the envelope), so the +// expected value is DERIVED via the same sourceRefFingerprint the decoder +// uses. The bridge threads the HOST privacy key (getHostPrivacyKey, per-install +// stable), so the golden derives with the same key. +const SESSION_DIR = resolve(FIXTURE_DIR, '%2FUsers%2Ftest/019edf9c-0000-7000-8000-000000000001') +const SESSION_REF = sourceRefFingerprint(getHostPrivacyKey(), SESSION_DIR) const GOLDEN: ParsedProviderCall[] = [ { @@ -33,9 +42,10 @@ const GOLDEN: ParsedProviderCall[] = [ subagentTypes: ['general-purpose'], timestamp: '2026-06-19T11:31:12.282793Z', speed: 'standard', - // The key embeds the session dir's absolute path — compute it from - // FIXTURE_DIR so the golden is portable across checkouts. - deduplicationKey: `grok:${resolve(FIXTURE_DIR, '%2FUsers%2Ftest/019edf9c-0000-7000-8000-000000000001')}:2026-06-19T11:31:12.282793Z:019edf9c-0000-7000-8000-000000000001`, + // The key embeds a fingerprint of the session dir's absolute path — + // derived from FIXTURE_DIR so the golden is portable across checkouts, and + // never the raw path itself. + deduplicationKey: `grok:${SESSION_REF}:2026-06-19T11:31:12.282793Z:019edf9c-0000-7000-8000-000000000001`, userMessage: 'User asks about the repo', sessionId: '019edf9c-0000-7000-8000-000000000001', project: 'myproject', diff --git a/packages/cli/tests/providers/lingtai-tui-bridge.test.ts b/packages/cli/tests/providers/lingtai-tui-bridge.test.ts index 2632ee3f..78ddefd5 100644 --- a/packages/cli/tests/providers/lingtai-tui-bridge.test.ts +++ b/packages/cli/tests/providers/lingtai-tui-bridge.test.ts @@ -4,6 +4,8 @@ import { describe, it, expect } from 'vitest' import { createLingTaiTuiProvider } from '../../src/providers/lingtai-tui.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { getHostPrivacyKey } from '../../src/privacy-key.js' +import { sourceRefFingerprint } from '@codeburn/core' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the lingtai-tui bridge migration (phase 8). @@ -14,8 +16,10 @@ import type { ParsedProviderCall, SessionSource } from '../../src/providers/type // per-source-label activity synthesis (main / tc_wake / daemon => userMessage + // tools + subagentTypes), model/endpoint fallback from the manifest when a // ledger row omits them, run_id vs `${agentId}:${label}` session ids, the -// composite dedup key threaded on the SOURCE PATH (not the agent dir), turnId, -// and the manifest-derived project / projectPath carried onto the call. +// composite dedup key threaded on a FINGERPRINT of the source path (never the +// raw path — dedupKey ships on the envelope; the raw ledger model is normalized +// in the key), turnId, and the manifest-derived project / projectPath carried +// onto the call. const here = dirname(fileURLToPath(import.meta.url)) const FIXTURE_DIR = resolve(here, '../fixtures/lingtai-parity') @@ -33,7 +37,11 @@ function dedup( thinking: number, cached: number, ): string { - return ['lingtai-tui', sourcePath, lineNo, ts, model, endpoint, label, emId, runId, input, output, thinking, cached].join(':') + // The bridge threads the HOST privacy key into the rich decode + // (getHostPrivacyKey, per-install stable), so the decoder keys the source ref + // under that key — derive the expected key the same way instead of pasting + // what the code emits. + return ['lingtai-tui', sourceRefFingerprint(getHostPrivacyKey(), sourcePath), lineNo, ts, model, endpoint, label, emId, runId, input, output, thinking, cached].join(':') } function golden(sourcePath: string, agentDir: string): ParsedProviderCall[] { diff --git a/packages/cli/tests/providers/pi-bridge.test.ts b/packages/cli/tests/providers/pi-bridge.test.ts index af932955..74948a16 100644 --- a/packages/cli/tests/providers/pi-bridge.test.ts +++ b/packages/cli/tests/providers/pi-bridge.test.ts @@ -5,14 +5,18 @@ import { describe, it, expect } from 'vitest' import { createPiProvider, createOmpProvider } from '../../src/providers/pi.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { getHostPrivacyKey } from '../../src/privacy-key.js' +import { sourceRefFingerprint } from '@codeburn/core' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the pi/omp bridge migration (phase 8). One core // decode serves both providers; the GOLDENs were captured from the legacy in-CLI // decode (git show origin/feat/core-extraction:packages/cli/src/providers/pi.ts) -// run over the committed fixtures. Covers: the `::` dedup -// key — anchored to the SESSION FILE PATH, not the sessionId, and computed from -// FIXTURE_DIR so the golden is checkout-portable — plus its +// run over the committed fixtures. Covers: the +// `::` dedup key — the raw session file +// path is the defect and must never appear (dedupKey ships on the envelope), so +// the expected values are DERIVED from the session file path via the same +// sourceRefFingerprint the decoder uses — plus its // responseId||entryId||timestamp||lineIdx fallback chain; sessionId from the // session entry `id` vs the basename-of-path fallback (omp entry omits id -> // 'ofile'); SKILL.md and skill:// reads reclassified as the `Skill` tool with @@ -26,6 +30,12 @@ const OMP_DIR = resolve(here, '../fixtures/pi-parity/omp-sessions') const PI_PATH = resolve(PI_DIR, 'proj1/sess-file.jsonl') const OMP_PATH = resolve(OMP_DIR, 'projO/ofile.jsonl') +// The bridge threads the HOST privacy key into the rich decode +// (getHostPrivacyKey, per-install stable), so the decoder keys the source ref +// under that key — derive the expected keys the same way instead of pasting +// what the code emits. +const PI_REF = sourceRefFingerprint(getHostPrivacyKey(), PI_PATH) +const OMP_REF = sourceRefFingerprint(getHostPrivacyKey(), OMP_PATH) async function collect(provider: { discoverSessions: () => Promise @@ -58,7 +68,7 @@ const PI_GOLDEN: ParsedProviderCall[] = [ skills: ['my-skill', 'web-search'], timestamp: '2026-06-10T10:00:02.000Z', speed: 'standard', - deduplicationKey: `pi:${PI_PATH}:resp-1`, + deduplicationKey: `pi:${PI_REF}:resp-1`, userMessage: 'do stuff', sessionId: 'pi-sess-1', }, @@ -82,8 +92,9 @@ const OMP_GOLDEN: ParsedProviderCall[] = [ timestamp: '2026-06-11T10:00:00.000Z', speed: 'standard', // responseId '' -> entry.id absent -> entry.timestamp; sessionId falls back - // to basename-of-path because the session entry carries no id. - deduplicationKey: `omp:${OMP_PATH}:2026-06-11T10:00:00.000Z`, + // to basename-of-path because the session entry carries no id. The dedup key + // threads a fingerprint of the file path (never the raw path). + deduplicationKey: `omp:${OMP_REF}:2026-06-11T10:00:00.000Z`, userMessage: '', sessionId: 'ofile', }, diff --git a/packages/cli/tests/providers/zerostack-bridge.test.ts b/packages/cli/tests/providers/zerostack-bridge.test.ts index 0c2a9c34..8976bb30 100644 --- a/packages/cli/tests/providers/zerostack-bridge.test.ts +++ b/packages/cli/tests/providers/zerostack-bridge.test.ts @@ -4,25 +4,36 @@ import { describe, it, expect } from 'vitest' import { createZerostackProvider } from '../../src/providers/zerostack.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { getHostPrivacyKey } from '../../src/privacy-key.js' +import { sourceRefFingerprint } from '@codeburn/core' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the zerostack bridge migration (phase 8). // Zerostack is not in the frozen corpus, so a committed fixture golden is THE // parity gate: the bridged provider (discovery + JSON I/O CLI-side, pure decode // delegated to @codeburn/core/providers/zerostack) must reproduce exactly what -// the pre-migration in-CLI decode produced. The dedup key threads the absolute -// source path (`zerostack:::`), so it is built from -// the discovered source rather than hard-coded. Covers: cumulative session -// totals, the zero-token skip (elsewhere), the OpenRouter model passing through -// raw, the empty-model + string-array userMessage + `basename(path)` sessionId -// fallbacks, updated_at-then-created_at timestamp precedence, and the discovered -// project / recorded working_dir carried onto the call. +// the pre-migration in-CLI decode produced. The dedup key threads a FINGERPRINT +// of the source path (`zerostack:::`) — dedupKey +// ships on the envelope, so the raw path is the defect and must never appear; +// the expected value is DERIVED from the discovered source via the same +// sourceRefFingerprint the decoder uses rather than hard-coded. Covers: +// cumulative session totals, the zero-token skip (elsewhere), the OpenRouter +// model passing through raw, the empty-model + string-array userMessage + +// `basename(path)` sessionId fallbacks, updated_at-then-created_at timestamp +// precedence, and the discovered project / recorded working_dir carried onto +// the call. const here = dirname(fileURLToPath(import.meta.url)) const FIXTURE_DIR = resolve(here, '../fixtures/zerostack') function golden(dir: string): ParsedProviderCall[] { const abcPath = join(dir, 'sess-abc.json') const arrayPath = join(dir, 'sess-array.json') + // The bridge threads the HOST privacy key into the rich decode + // (getHostPrivacyKey, per-install stable), so the decoder keys the source ref + // under that key — derive the expected keys the same way instead of pasting + // what the code emits. + const abcRef = sourceRefFingerprint(getHostPrivacyKey(), abcPath) + const arrayRef = sourceRefFingerprint(getHostPrivacyKey(), arrayPath) return [ { provider: 'zerostack', @@ -39,7 +50,7 @@ function golden(dir: string): ParsedProviderCall[] { bashCommands: [], timestamp: '2026-06-19T11:34:14.140631+00:00', speed: 'standard', - deduplicationKey: `zerostack:${abcPath}:2026-06-19T11:34:14.140631+00:00:sess-abc`, + deduplicationKey: `zerostack:${abcRef}:2026-06-19T11:34:14.140631+00:00:sess-abc`, userMessage: 'hello, what is this repo about?', sessionId: 'sess-abc', project: 'myproject', @@ -60,7 +71,7 @@ function golden(dir: string): ParsedProviderCall[] { bashCommands: [], timestamp: '2026-06-20T09:00:00.000000+00:00', speed: 'standard', - deduplicationKey: `zerostack:${arrayPath}:2026-06-20T09:00:00.000000+00:00:sess-array`, + deduplicationKey: `zerostack:${arrayRef}:2026-06-20T09:00:00.000000+00:00:sess-array`, userMessage: 'part one part two', sessionId: 'sess-array', project: 'another', diff --git a/packages/cli/tests/session-cache.test.ts b/packages/cli/tests/session-cache.test.ts index 2c736b22..6a4eedb6 100644 --- a/packages/cli/tests/session-cache.test.ts +++ b/packages/cli/tests/session-cache.test.ts @@ -279,6 +279,21 @@ describe('computeEnvFingerprint', () => { expect(computeEnvFingerprint('kiro')).not.toBe(computeEnvFingerprint('unknown-provider')) expect(computeEnvFingerprint('warp')).not.toBe(computeEnvFingerprint('unknown-provider')) }) + + it('dedup-key-hygiene providers carry a parse version so cached raw-path keys re-derive', () => { + // #931: codebuff, zerostack, pi/omp and grok changed their dedup key shape + // (raw source path -> fingerprint) and lingtai-tui normalized the model + // component. Without an entry here the env fingerprint would not change, a + // warm session cache would keep serving the raw-path keys, and those keys + // seed the dedup sets — re-ingesting the same records under the new shape. + // Each of these providers MUST have a parse version so the one-time + // re-parse that drops the old keys actually fires. The comparison baseline + // is a provider with no entry and no env vars, whose fingerprint omits the + // `parser=` component entirely. + for (const provider of ['codebuff', 'zerostack', 'pi', 'omp', 'grok', 'lingtai-tui']) { + expect(computeEnvFingerprint(provider), provider).not.toBe(computeEnvFingerprint('unknown-provider')) + } + }) }) // ── fingerprintFile ──────────────────────────────────────────────────── diff --git a/packages/core/schemas/observation-0.2.0.json b/packages/core/schemas/observation-0.2.0.json index e8d32875..92c6b640 100644 --- a/packages/core/schemas/observation-0.2.0.json +++ b/packages/core/schemas/observation-0.2.0.json @@ -69,11 +69,15 @@ }, "model": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:/@-]+$" }, "pricingModel": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:/@-]+$" }, "tokens": { "type": "object", diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts index 10a6190e..f2de01bb 100644 --- a/packages/core/src/contracts.ts +++ b/packages/core/src/contracts.ts @@ -20,7 +20,21 @@ export interface DecodeContext { privacyKey: string /** The provider whose records these are. */ providerId: string - /** An opaque fingerprint of the source (file/stream) being decoded. */ + /** + * The host's absolute filesystem path to the source being decoded — NOT an + * opaque fingerprint. Decoders may use it to derive session/chat identity + * (a chat directory name, a session file's basename), but the RAW value must + * never cross into an observation output — and dedupKey is an observation + * output: it is a field on CallObservation that ships on the envelope, so + * folding the raw path into a dedup key is a leak. A decoder that needs an + * opaque form of the source in a dedup key or identity must fingerprint it + * first via fingerprint.ts (`sourceRefFingerprint` — keyed HMAC-SHA256, + * decision D1; the key is required and an empty key throws, so the ref can + * never degrade to an unkeyed digest). Every fingerprint/ref field on the + * envelope (sessionRef, projectRef, gitBranchRef, resource refs, and the + * dedupKey's source component) is HMAC-derived via fingerprint.ts with the + * host privacyKey, which the CLI bridge threads from getHostPrivacyKey(). + */ sourceRef: string } diff --git a/packages/core/src/fingerprint.ts b/packages/core/src/fingerprint.ts index b0343fa5..8270977b 100644 --- a/packages/core/src/fingerprint.ts +++ b/packages/core/src/fingerprint.ts @@ -13,7 +13,7 @@ import { createHmac } from 'node:crypto' const FINGERPRINT_LEN = 16 /** Domain-separation prefixes so the same string in different roles differs. */ -type Domain = 'session' | 'project' | 'branch' | 'resource' +type Domain = 'session' | 'project' | 'branch' | 'resource' | 'source' /** Field separator for composite HMAC inputs (ASCII Unit Separator). */ const SEP = String.fromCharCode(0x1f) @@ -148,6 +148,22 @@ export function branchRef(privacyKey: string, branch: string): string { return hmac(privacyKey, 'branch', branch) } +/** + * Fingerprint the source path for dedup-key / identity derivation. The raw + * absolute path must never cross into an observation output, but a decoder + * that needs a stable opaque form of it (e.g. inside a dedupKey, which ships + * on the envelope) may use this. Keyed HMAC-SHA256 (decision D1) under the + * caller-supplied privacyKey: the key is REQUIRED and an empty key throws + * (like every other fingerprint in this module), so a source ref can never + * silently degrade to an unkeyed, dictionary-attackable digest. With the host + * key the ref is host-scoped — not brute-forceable, and not comparable across + * hosts. The path is normalized first, so a Windows path and its POSIX + * spelling fingerprint identically. + */ +export function sourceRefFingerprint(privacyKey: string, sourceRef: string): string { + return hmac(privacyKey, 'source', normalizePath(sourceRef)) +} + export type CommandFamily = | 'git' | 'test' diff --git a/packages/core/src/observations.ts b/packages/core/src/observations.ts index 7124c044..72102905 100644 --- a/packages/core/src/observations.ts +++ b/packages/core/src/observations.ts @@ -5,6 +5,7 @@ import { CostBasis, FingerprintHex, IsoTimestamp, + ModelIdentifier, NonNegInt, NonNegUSD, OBSERVATION_SCHEMA_VERSION, @@ -26,8 +27,8 @@ import { export const CallObservation = z .object({ provider: z.string().min(1), - model: z.string().min(1), - pricingModel: z.string().min(1).optional(), + model: ModelIdentifier, + pricingModel: ModelIdentifier.optional(), tokens: TokenBuckets, webSearchRequests: NonNegInt, diff --git a/packages/core/src/providers/antigravity/decode.ts b/packages/core/src/providers/antigravity/decode.ts index 3ad3e25d..bfccd4ab 100644 --- a/packages/core/src/providers/antigravity/decode.ts +++ b/packages/core/src/providers/antigravity/decode.ts @@ -5,6 +5,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { normalizeModelIdentifier } from '../../schema.js' import type { AntigravityDecodedCall, AntigravityGeneratorMetadata, @@ -342,7 +343,13 @@ function parseFiniteToken(value: unknown): number { function usageSignature(event: AntigravityStatusLineEvent): string { const u = event.usage return [ - event.model, + // The model component must never be the raw display name: this signature + // feeds the dedup key, which SHIPS on the envelope, and the observation + // boundary normalizes the same value (a display name like "Gemini 3.5 + // Flash (High)" collapses to 'unknown' there). Building the signature + // from the normalized identifier keeps the key and the envelope's model + // field consistent and stops free text from riding the key. + normalizeModelIdentifier(event.model), u.inputTokens, u.outputTokens, u.cacheCreationInputTokens, diff --git a/packages/core/src/providers/antigravity/observations.ts b/packages/core/src/providers/antigravity/observations.ts index 91d9fcb2..9a21eeef 100644 --- a/packages/core/src/providers/antigravity/observations.ts +++ b/packages/core/src/providers/antigravity/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { AntigravityDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Antigravity cascade's rich decode, as the host holds it before minimization. */ export interface RichAntigravitySessionDecode { @@ -27,7 +28,7 @@ export interface AntigravityToObservationsContext { function toCallObservation(call: AntigravityDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/claude/observations.ts b/packages/core/src/providers/claude/observations.ts index 582af21d..49df19f2 100644 --- a/packages/core/src/providers/claude/observations.ts +++ b/packages/core/src/providers/claude/observations.ts @@ -10,6 +10,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { DecodedCall, DecodedTurn } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One session's rich decode, as the host holds it before minimization. */ export interface RichSessionDecode { @@ -38,7 +39,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: DecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.usage.inputTokens, output: call.usage.outputTokens, diff --git a/packages/core/src/providers/codebuff/decode.ts b/packages/core/src/providers/codebuff/decode.ts index 53c6e830..4e09d9b4 100644 --- a/packages/core/src/providers/codebuff/decode.ts +++ b/packages/core/src/providers/codebuff/decode.ts @@ -6,6 +6,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { CodebuffBlock, CodebuffChatMessage, @@ -162,7 +163,8 @@ export type CodebuffDecodeResult = { * Decode a Codebuff chat-messages.json array into rich, cost-free calls. A single * pass: user messages set the pending prompt for the next assistant call; assistant * messages that carry credits or token usage flush into a call. Dedup is keyed on - * `codebuff::` against the live `seenKeys` set (host-owned). + * `codebuff::` against the live `seenKeys` set (host-owned); + * the source path is fingerprinted, never emitted raw. */ export function decodeCodebuff({ records, context, seenKeys: liveSeen }: CodebuffDecodeInput): CodebuffDecodeResult { const seen = liveSeen ?? new Set() @@ -209,7 +211,9 @@ export function decodeCodebuff({ records, context, seenKeys: liveSeen }: Codebuf const timestamp = coerceTimestamp(msg.timestamp ?? msg.metadata?.timestamp) || fallbackTs const dedupId = msg.id ?? String(idx) - const dedupKey = `codebuff:${chatDir}:${dedupId}` + // The dedup key threads a FINGERPRINT of the chat directory (source path), + // never the raw path — dedupKey ships on the envelope. + const dedupKey = `codebuff:${sourceRefFingerprint(context.privacyKey, context.sourceRef)}:${dedupId}` if (seen.has(dedupKey)) continue seen.add(dedupKey) diff --git a/packages/core/src/providers/codebuff/observations.ts b/packages/core/src/providers/codebuff/observations.ts index 71ad60d0..7f03953a 100644 --- a/packages/core/src/providers/codebuff/observations.ts +++ b/packages/core/src/providers/codebuff/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CodebuffDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Codebuff session's rich decode, as the host holds it before minimization. */ export interface RichCodebuffSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CodebuffDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/codewhale/observations.ts b/packages/core/src/providers/codewhale/observations.ts index b726ef38..8d1303d9 100644 --- a/packages/core/src/providers/codewhale/observations.ts +++ b/packages/core/src/providers/codewhale/observations.ts @@ -9,6 +9,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { CodeWhaleDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One CodeWhale session's rich decode, as the host holds it before minimization. */ export interface RichCodeWhaleSessionDecode { @@ -31,7 +32,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CodeWhaleDecodedCall, turnIndex: number, privacyKey: string): CallObservation { const obs: CallObservation = { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/codex/observations.ts b/packages/core/src/providers/codex/observations.ts index 5beacbbb..4ecc4c0b 100644 --- a/packages/core/src/providers/codex/observations.ts +++ b/packages/core/src/providers/codex/observations.ts @@ -10,6 +10,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { CodexDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Codex session's rich decode, as the host holds it before minimization. */ export interface RichCodexSessionDecode { @@ -35,7 +36,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CodexDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/copilot/decode.ts b/packages/core/src/providers/copilot/decode.ts index be889de0..1c28f4e5 100644 --- a/packages/core/src/providers/copilot/decode.ts +++ b/packages/core/src/providers/copilot/decode.ts @@ -1,6 +1,7 @@ import { createHash } from 'crypto' import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { normalizeModelIdentifier } from '../../schema.js' import type { AssistantMessageData, ChatJournalPathSegment, @@ -928,7 +929,11 @@ function decodeJsonl(envelope: Extract // to avoid an empty $0 row (output is intentionally excluded). if (inputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) continue - const dedupKey = `copilot:${sessionId}:shutdown:${model}` + // The model component is normalized exactly as the observation + // boundary normalizes `model`: the key ships on the envelope, so a + // display-name or hostile model string from the JSONL must collapse + // to 'unknown' inside the key, never ride it raw. + const dedupKey = `copilot:${sessionId}:shutdown:${normalizeModelIdentifier(model)}` if (seen.has(dedupKey)) continue seen.add(dedupKey) @@ -1098,6 +1103,11 @@ function decodeJetBrains(envelope: Extract() for (const turn of turns) { // One .db holds many chat tabs; group each turn under its own diff --git a/packages/core/src/providers/copilot/observations.ts b/packages/core/src/providers/copilot/observations.ts index 92ed1a49..7061073c 100644 --- a/packages/core/src/providers/copilot/observations.ts +++ b/packages/core/src/providers/copilot/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CopilotDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Copilot session's rich decode, as the host holds it before minimization. */ export interface RichCopilotSessionDecode { @@ -29,7 +30,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CopilotDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/crush/observations.ts b/packages/core/src/providers/crush/observations.ts index e89342fd..7205660f 100644 --- a/packages/core/src/providers/crush/observations.ts +++ b/packages/core/src/providers/crush/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CrushDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Crush session's rich decode, as the host holds it before minimization. */ export interface RichCrushSessionDecode { @@ -30,7 +31,7 @@ function toCallObservation(call: CrushDecodedCall, turnIndex: number): CallObser const measured = call.measuredCostUSD !== undefined return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/cursor-agent/observations.ts b/packages/core/src/providers/cursor-agent/observations.ts index 06e944e0..5a9d90c1 100644 --- a/packages/core/src/providers/cursor-agent/observations.ts +++ b/packages/core/src/providers/cursor-agent/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CursorAgentDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Cursor Agent session's rich decode, as the host holds it before minimization. */ export interface RichCursorAgentSessionDecode { @@ -32,7 +33,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CursorAgentDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/cursor/observations.ts b/packages/core/src/providers/cursor/observations.ts index ddbda94d..720e00ee 100644 --- a/packages/core/src/providers/cursor/observations.ts +++ b/packages/core/src/providers/cursor/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CursorDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Cursor session's rich decode, as the host holds it before minimization. */ export interface RichCursorSessionDecode { @@ -32,7 +33,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CursorDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/devin/observations.ts b/packages/core/src/providers/devin/observations.ts index 1bb71a64..ad68c66d 100644 --- a/packages/core/src/providers/devin/observations.ts +++ b/packages/core/src/providers/devin/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { DevinDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Devin session's rich decode, as the host holds it before minimization. */ export interface RichDevinSessionDecode { @@ -34,7 +35,7 @@ function toCallObservation(call: DevinDecodedCall, turnIndex: number, privacyKey provider: call.provider, // The raw model id, not the host's display name: the envelope is keyed by // provider ids, and display formatting lives CLI-side. - model: call.generationModel ?? call.modelName, + model: normalizeModelIdentifier(call.generationModel ?? call.modelName), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/droid/observations.ts b/packages/core/src/providers/droid/observations.ts index db24be90..f6784773 100644 --- a/packages/core/src/providers/droid/observations.ts +++ b/packages/core/src/providers/droid/observations.ts @@ -4,6 +4,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { DroidDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export interface RichDroidSessionDecode { sessionId: string @@ -21,7 +22,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: DroidDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/forge/decode.ts b/packages/core/src/providers/forge/decode.ts index a27d29c6..cfffd082 100644 --- a/packages/core/src/providers/forge/decode.ts +++ b/packages/core/src/providers/forge/decode.ts @@ -6,6 +6,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { normalizeModelIdentifier } from '../../schema.js' import type { ForgeConversationRow, ForgeContextMessage, ForgeDecodedCall } from './types.js' function sqliteTimestampToIso(value: string | null | undefined): string { @@ -158,7 +159,11 @@ export function decodeForge({ records, seenKeys: liveSeen }: ForgeDecodeInput): const model = typeof text?.model === 'string' ? text.model : 'unknown' const toolCalls = toolCallsOf(text?.tool_calls) const { tools, rawBashCommands, firstCallId } = extractToolsAndCommands(toolCalls) - const stableId = firstCallId ?? `${model}:${promptTokens}:${outputTokens}:${i}` + // The fallback stableId normalizes the model component exactly as the + // observation boundary does: this key ships on the envelope, so a + // display-name model must collapse to 'unknown' inside it, never ride + // it raw (the primary path uses the tool-call id, which is a machine id). + const stableId = firstCallId ?? `${normalizeModelIdentifier(model)}:${promptTokens}:${outputTokens}:${i}` const deduplicationKey = `forge:${row.conversation_id}:${stableId}` if (seen.has(deduplicationKey)) continue seen.add(deduplicationKey) diff --git a/packages/core/src/providers/forge/observations.ts b/packages/core/src/providers/forge/observations.ts index bb4c9793..1b6472f2 100644 --- a/packages/core/src/providers/forge/observations.ts +++ b/packages/core/src/providers/forge/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { ForgeDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Forge conversation's rich decode, as the host holds it before minimization. */ export interface RichForgeSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: ForgeDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/gemini/observations.ts b/packages/core/src/providers/gemini/observations.ts index 744fa11d..52f056dc 100644 --- a/packages/core/src/providers/gemini/observations.ts +++ b/packages/core/src/providers/gemini/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { GeminiDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Gemini session's rich decode, as the host holds it before minimization. */ export interface RichGeminiSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: GeminiDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/goose/observations.ts b/packages/core/src/providers/goose/observations.ts index ba90005b..c46bdabb 100644 --- a/packages/core/src/providers/goose/observations.ts +++ b/packages/core/src/providers/goose/observations.ts @@ -8,6 +8,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { GooseDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Goose session's rich decode, as the host holds it before minimization. */ export interface RichGooseSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: GooseDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/grok/decode.ts b/packages/core/src/providers/grok/decode.ts index 8ac68c71..fdb11639 100644 --- a/packages/core/src/providers/grok/decode.ts +++ b/packages/core/src/providers/grok/decode.ts @@ -5,6 +5,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { GrokDecodedCall, GrokSessionRecords, GrokSignals, GrokSummary, GrokUpdate } from './types.js' // Grok Build tool ids mapped to the canonical vocabulary. Unknown ids pass @@ -124,7 +125,7 @@ export type GrokDecodeResult = { * The host owns file I/O and the live cross-file dedup set; this function is * pure over the supplied record. */ -export function decodeGrok({ records, seenKeys: liveSeen }: GrokDecodeInput): GrokDecodeResult { +export function decodeGrok({ records, context, seenKeys: liveSeen }: GrokDecodeInput): GrokDecodeResult { const seen = liveSeen ?? new Set() const session = records.find(isGrokSessionRecords) if (!session) return { calls: [], diagnostics: [] } @@ -142,7 +143,11 @@ export function decodeGrok({ records, seenKeys: liveSeen }: GrokDecodeInput): Gr const timestamp = summary.updated_at ?? summary.last_active_at ?? summary.created_at ?? '' const sessionId = summary.info?.id ?? sessionName - const dedupKey = `grok:${sourceDir}:${timestamp}:${sessionId}` + // The dedup key threads a FINGERPRINT of the session directory, never the raw + // path — dedupKey ships on the envelope, so the raw path must not cross into + // an observation output. (sessionName stays the basename-derived session + // identity; it is not a path.) + const dedupKey = `grok:${sourceRefFingerprint(context.privacyKey, sourceDir)}:${timestamp}:${sessionId}` if (seen.has(dedupKey)) return { calls: [], diagnostics: [] } seen.add(dedupKey) diff --git a/packages/core/src/providers/grok/observations.ts b/packages/core/src/providers/grok/observations.ts index 86318aee..a89b25ea 100644 --- a/packages/core/src/providers/grok/observations.ts +++ b/packages/core/src/providers/grok/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { GrokDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Grok session's rich decode, as the host holds it before minimization. */ export interface RichGrokSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: GrokDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/grok/types.ts b/packages/core/src/providers/grok/types.ts index 92d52ea8..5b6c0fbe 100644 --- a/packages/core/src/providers/grok/types.ts +++ b/packages/core/src/providers/grok/types.ts @@ -37,7 +37,8 @@ export type GrokSessionRecords = { summary: GrokSummary signals: GrokSignals | null updatesLines: string[] - /** Absolute session directory, used only for the host-side dedup key. */ + /** Absolute session directory; fingerprinted into the host-side dedup key, + * never emitted raw. */ sourceDir: string /** Basename of the session directory, used as a session id fallback. */ sessionName: string diff --git a/packages/core/src/providers/hermes/observations.ts b/packages/core/src/providers/hermes/observations.ts index 112a11cc..4ccd4ae6 100644 --- a/packages/core/src/providers/hermes/observations.ts +++ b/packages/core/src/providers/hermes/observations.ts @@ -8,6 +8,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { HermesDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Hermes session's rich decode, as the host holds it before minimization. */ export interface RichHermesSessionDecode { @@ -34,7 +35,7 @@ function toCallObservation(call: HermesDecodedCall, turnIndex: number, privacyKe const measured = call.recordedCost !== undefined return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/kimi/observations.ts b/packages/core/src/providers/kimi/observations.ts index 93d26f3a..51f1d6ab 100644 --- a/packages/core/src/providers/kimi/observations.ts +++ b/packages/core/src/providers/kimi/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { KimiDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Kimi session's rich decode, as the host holds it before minimization. */ export interface RichKimiSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: KimiDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/kimicode/observations.ts b/packages/core/src/providers/kimicode/observations.ts index 510c88b9..e6d2aefc 100644 --- a/packages/core/src/providers/kimicode/observations.ts +++ b/packages/core/src/providers/kimicode/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { KimicodeDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Kimicode session's rich decode, as the host holds it before minimization. */ export interface RichKimicodeSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: KimicodeDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/kiro/observations.ts b/packages/core/src/providers/kiro/observations.ts index 08842fec..11206cf2 100644 --- a/packages/core/src/providers/kiro/observations.ts +++ b/packages/core/src/providers/kiro/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { KiroDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Kiro session's rich decode, as the host holds it before minimization. */ export interface RichKiroSessionDecode { @@ -31,7 +32,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: KiroDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/lingtai-tui/decode.ts b/packages/core/src/providers/lingtai-tui/decode.ts index fee074dc..4bbd5723 100644 --- a/packages/core/src/providers/lingtai-tui/decode.ts +++ b/packages/core/src/providers/lingtai-tui/decode.ts @@ -2,6 +2,8 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' +import { normalizeModelIdentifier } from '../../schema.js' import type { LingTaiTuiDecodedCall, LingTaiLedgerEntry, JsonObject } from './types.js' export type LingTaiTuiDecodeInput = { @@ -141,14 +143,20 @@ export function decodeLingTaiTui({ const runId = stringField(obj, 'run_id') ?? '' const sessionId = runId || `${agentId}:${sourceLabel}` const activity = activityForSource(sourceLabel) - // The dedup key threads the source ref (host ledger path) exactly as the - // pre-migration decode did — NOT the agent-dir projectPath. + // The dedup key threads a FINGERPRINT of the source ref (host ledger path), + // never the raw path — dedupKey ships on the envelope, so the raw path must + // not cross into an observation output. (The agent-dir projectPath is never + // used here.) The model component is the NORMALIZED identifier, never the + // raw ledger text: the observation boundary normalizes the same value (a + // display name like "GPT-5.5 Pro (High)" collapses to 'unknown' there), so + // building the key from the normalized form keeps the key and the + // envelope's model field consistent and stops free text from riding the key. const dedupKey = [ 'lingtai-tui', - context.sourceRef, + sourceRefFingerprint(context.privacyKey, context.sourceRef), lineNo, timestamp, - model, + normalizeModelIdentifier(model), endpoint, sourceLabel, emId, diff --git a/packages/core/src/providers/lingtai-tui/observations.ts b/packages/core/src/providers/lingtai-tui/observations.ts index 89364810..e650348e 100644 --- a/packages/core/src/providers/lingtai-tui/observations.ts +++ b/packages/core/src/providers/lingtai-tui/observations.ts @@ -2,6 +2,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { LingTaiTuiDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export interface RichLingTaiTuiSessionDecode { sessionId: string @@ -19,7 +20,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: LingTaiTuiDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/mistral-vibe/observations.ts b/packages/core/src/providers/mistral-vibe/observations.ts index f5e51ae9..e3be2a80 100644 --- a/packages/core/src/providers/mistral-vibe/observations.ts +++ b/packages/core/src/providers/mistral-vibe/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { MistralVibeDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Mistral Vibe session's rich decode, as the host holds it before minimization. */ export interface RichMistralVibeSessionDecode { @@ -33,7 +34,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: MistralVibeDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/mux/observations.ts b/packages/core/src/providers/mux/observations.ts index 584c7330..3e03dfb9 100644 --- a/packages/core/src/providers/mux/observations.ts +++ b/packages/core/src/providers/mux/observations.ts @@ -2,6 +2,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { MuxDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export interface RichMuxSessionDecode { sessionId: string @@ -19,7 +20,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: MuxDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/open-design/observations.ts b/packages/core/src/providers/open-design/observations.ts index ad7f1480..535df0f8 100644 --- a/packages/core/src/providers/open-design/observations.ts +++ b/packages/core/src/providers/open-design/observations.ts @@ -2,6 +2,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { OpenDesignDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export interface RichOpenDesignSessionDecode { sessionId: string @@ -19,7 +20,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: OpenDesignDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/openclaw/observations.ts b/packages/core/src/providers/openclaw/observations.ts index d9e88b7a..fc8e851c 100644 --- a/packages/core/src/providers/openclaw/observations.ts +++ b/packages/core/src/providers/openclaw/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { OpenClawDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One OpenClaw session's rich decode, as the host holds it before minimization. */ export interface RichOpenClawSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: OpenClawDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/opencode-session/observations.ts b/packages/core/src/providers/opencode-session/observations.ts index 60d66291..e026c5d6 100644 --- a/packages/core/src/providers/opencode-session/observations.ts +++ b/packages/core/src/providers/opencode-session/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { OpenCodeSessionDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One OpenCode-session decode, as the host holds it before minimization. */ export interface RichOpenCodeSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: OpenCodeSessionDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/pi/decode.ts b/packages/core/src/providers/pi/decode.ts index a2b488d8..597de5e4 100644 --- a/packages/core/src/providers/pi/decode.ts +++ b/packages/core/src/providers/pi/decode.ts @@ -7,6 +7,7 @@ import { basename } from 'node:path' import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { PiDecodedCall, PiEntry } from './types.js' // Pi/OMP tool ids mapped to the canonical vocabulary. Unknown ids pass through. @@ -83,9 +84,11 @@ function normalizeContentBlocks( /** * Decode Pi/OMP session records into rich, cost-free calls. A single pass over * the entries: user messages set pending prompt; assistant messages with token - * usage flush into calls. Dedup is keyed on `::` - * against live seenKeys. `provider` ('pi' or 'omp') comes from - * `context.providerId`, since Pi and OMP share this exact decode. + * usage flush into calls. Dedup is keyed on + * `::` against live seenKeys — the + * source path is fingerprinted, never emitted raw (dedupKey ships on the + * envelope). `provider` ('pi' or 'omp') comes from `context.providerId`, since + * Pi and OMP share this exact decode. */ export function decodePi({ records, @@ -147,7 +150,11 @@ export function decodePi({ const model = msg.model ?? 'gpt-5' const responseId = msg.responseId ?? '' - const dedupKey = `${provider}:${sourcePath}:${responseId || entry.id || entry.timestamp || String(lineIdx)}` + // The dedup key threads a FINGERPRINT of the session file path, never the + // raw path — dedupKey ships on the envelope, so the raw path must not + // cross into an observation output. (The basename-derived sessionId below + // stays the host's session identity; it is not a path.) + const dedupKey = `${provider}:${sourceRefFingerprint(context.privacyKey, context.sourceRef)}:${responseId || entry.id || entry.timestamp || String(lineIdx)}` if (seen.has(dedupKey)) continue seen.add(dedupKey) diff --git a/packages/core/src/providers/pi/observations.ts b/packages/core/src/providers/pi/observations.ts index 3c47062c..cc3aa22d 100644 --- a/packages/core/src/providers/pi/observations.ts +++ b/packages/core/src/providers/pi/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { PiDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Pi/OMP session's rich decode, as the host holds it before minimization. */ export interface RichPiSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: PiDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/quickdesk/decode.ts b/packages/core/src/providers/quickdesk/decode.ts index e54a995e..1a0a1c90 100644 --- a/packages/core/src/providers/quickdesk/decode.ts +++ b/packages/core/src/providers/quickdesk/decode.ts @@ -6,6 +6,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { normalizeModelIdentifier } from '../../schema.js' import type { QuickdeskDatabaseInput, QuickdeskDecodedCall, @@ -142,7 +143,11 @@ function decodeMetrics(input: QuickdeskMetricsInput, seen: Set): Quickde if (metadata?.deleted) continue const fallbackId = `${project}:${fileId}` - const deduplicationKey = `quickdesk:${linkedSessionId || fallbackId}:${timestamp}:${model}:${inputTokens}:${outputTokens}` + // The model component is normalized exactly as the observation boundary + // normalizes `model`: the key ships on the envelope, so a display name or + // free text in the CSV 'Model' column must collapse to 'unknown' inside + // the key too, never ride it raw. + const deduplicationKey = `quickdesk:${linkedSessionId || fallbackId}:${timestamp}:${normalizeModelIdentifier(model)}:${inputTokens}:${outputTokens}` if (seen.has(deduplicationKey)) continue seen.add(deduplicationKey) diff --git a/packages/core/src/providers/quickdesk/observations.ts b/packages/core/src/providers/quickdesk/observations.ts index 35324147..b256ee6a 100644 --- a/packages/core/src/providers/quickdesk/observations.ts +++ b/packages/core/src/providers/quickdesk/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { QuickdeskDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Quickdesk session's rich decode, as the host holds it before minimization. */ export interface RichQuickdeskSessionDecode { @@ -33,7 +34,7 @@ function toCallObservation(call: QuickdeskDecodedCall, turnIndex: number, privac const measured = call.recordedCost !== undefined return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/qwen/observations.ts b/packages/core/src/providers/qwen/observations.ts index 0eb67394..37257543 100644 --- a/packages/core/src/providers/qwen/observations.ts +++ b/packages/core/src/providers/qwen/observations.ts @@ -10,6 +10,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { QwenDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Qwen session's rich decode, as the host holds it before minimization. */ export interface RichQwenSessionDecode { @@ -35,7 +36,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: QwenDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/vercel-gateway/decode.ts b/packages/core/src/providers/vercel-gateway/decode.ts index 487e9df2..07c3dec1 100644 --- a/packages/core/src/providers/vercel-gateway/decode.ts +++ b/packages/core/src/providers/vercel-gateway/decode.ts @@ -5,6 +5,7 @@ // threads the shared cross-file dedup set. import type { VercelGatewayDecodedCall, VercelGatewayReportRow } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export type VercelGatewayDecodeInput = { records: unknown[] @@ -22,9 +23,15 @@ export type VercelGatewayDecodeResult = { * mapping matches the original host-side parser verbatim: * - day/model/cost defaults * - all-zero rows are skipped BEFORE dedup key burn - * - dedup key `vercel-gateway::` with add-after-skip semantics + * - dedup key `vercel-gateway::` with add-after-skip + * semantics. The model component is run through normalizeModelIdentifier + * (the same function the observation boundary applies to `model`): the + * key SHIPS on the envelope, so a hostile prompt or display name planted + * in the externally-supplied report collapses to 'unknown' inside the key + * too, and a legitimate identifier-shaped slug is unchanged. * - timestamp synthesized as `${day}T12:00:00.000Z` - * - sessionId synthesized as `${day}:${model}` + * - sessionId synthesized as `${day}:${model}` (rich-decode only — never + * shipped raw; the envelope's sessionRef is an HMAC fingerprint of it) */ export function decodeVercelGateway(input: VercelGatewayDecodeInput): VercelGatewayDecodeResult { const { records, seenKeys: liveSeen } = input @@ -44,7 +51,7 @@ export function decodeVercelGateway(input: VercelGatewayDecodeInput): VercelGate // key and block a later non-zero row for the same day×model. if (costUSD === 0 && inputTokens === 0 && outputTokens === 0) continue - const deduplicationKey = `vercel-gateway:${day}:${model}` + const deduplicationKey = `vercel-gateway:${day}:${normalizeModelIdentifier(model)}` if (seen.has(deduplicationKey)) continue seen.add(deduplicationKey) diff --git a/packages/core/src/providers/vercel-gateway/observations.ts b/packages/core/src/providers/vercel-gateway/observations.ts index 9db01053..07150b36 100644 --- a/packages/core/src/providers/vercel-gateway/observations.ts +++ b/packages/core/src/providers/vercel-gateway/observations.ts @@ -3,8 +3,12 @@ // // Vercel Gateway reports contain no free-text user content. The only string // fields that cross into the envelope are machine identifiers: -// - `provider` and `model` are emitted by design under the identifier-exemption +// - `provider` is emitted by design under the identifier-exemption // convention (see architecture-gate.test.ts MACHINE_ID_ALLOWLIST). +// - `model` is externally supplied (the fetched report) and is normalized at +// this boundary: values inside the ModelIdentifier charset cross unchanged, +// anything else (a hostile prompt, a display name) collapses to 'unknown', +// so a bad model can never reject the whole envelope. // - `day` is an API-supplied calendar date. It IS emitted, verbatim, inside the // synthesized timestamp and the dedup key. The envelope's `format: date-time` // constraint on every timestamp is what bounds it: a `day` that is not a real @@ -16,6 +20,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { VercelGatewayDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Vercel Gateway report's rich decode, as the host holds it before minimization. */ export interface RichVercelGatewaySessionDecode { @@ -36,7 +41,7 @@ export interface VercelGatewayToObservationsContext { function toCallObservation(call: VercelGatewayDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/vscode-cline/observations.ts b/packages/core/src/providers/vscode-cline/observations.ts index 66667e2b..6efc88f9 100644 --- a/packages/core/src/providers/vscode-cline/observations.ts +++ b/packages/core/src/providers/vscode-cline/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { VscodeClineDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One vscode-cline session's rich decode, as the host holds it before minimization. */ export interface RichVscodeClineSessionDecode { @@ -30,7 +31,7 @@ function toCallObservation(call: VscodeClineDecodedCall, turnIndex: number): Cal const measured = call.measuredCostUSD !== undefined return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/warp/observations.ts b/packages/core/src/providers/warp/observations.ts index 90606683..ab6a274b 100644 --- a/packages/core/src/providers/warp/observations.ts +++ b/packages/core/src/providers/warp/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { WarpDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Warp session's rich decode, as the host holds it before minimization. */ export interface RichWarpSessionDecode { @@ -32,7 +33,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: WarpDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/zcode/observations.ts b/packages/core/src/providers/zcode/observations.ts index 2f26dd18..5a67f446 100644 --- a/packages/core/src/providers/zcode/observations.ts +++ b/packages/core/src/providers/zcode/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { ZcodeDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One ZCode session's rich decode, as the host holds it before minimization. */ export interface RichZcodeSessionDecode { @@ -29,7 +30,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: ZcodeDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/zed/observations.ts b/packages/core/src/providers/zed/observations.ts index b01ca7b0..43ab3da9 100644 --- a/packages/core/src/providers/zed/observations.ts +++ b/packages/core/src/providers/zed/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { ZedDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Zed thread's rich decode, as the host holds it before minimization. */ export interface RichZedSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: ZedDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/zerostack/decode.ts b/packages/core/src/providers/zerostack/decode.ts index b7f05101..24f6cf3e 100644 --- a/packages/core/src/providers/zerostack/decode.ts +++ b/packages/core/src/providers/zerostack/decode.ts @@ -5,6 +5,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { ZerostackDecodedCall, ZerostackMessage, ZerostackSession } from './types.js' // Zerostack tool ids mapped to the canonical vocabulary. An id with @@ -48,8 +49,9 @@ export type ZerostackDecodeResult = { /** * Decode a Zerostack session file's record into a rich, cost-free call. * Zerostack has one record per session with cumulative token totals. The dedup - * key threads the source ref (host path) exactly as the pre-migration decode did: - * `zerostack:::`. + * key threads a FINGERPRINT of the source ref (host path), never the raw path: + * `zerostack:::`. The raw path + * must not cross into an observation output (dedupKey ships on the envelope). */ export function decodeZerostack({ records, @@ -72,7 +74,7 @@ export function decodeZerostack({ const timestamp = session.updated_at ?? session.created_at ?? '' const sessionId = session.id ?? sessionIdFallback ?? '' - const dedupKey = `zerostack:${context.sourceRef}:${timestamp}:${sessionId}` + const dedupKey = `zerostack:${sourceRefFingerprint(context.privacyKey, context.sourceRef)}:${timestamp}:${sessionId}` if (seen.has(dedupKey)) continue seen.add(dedupKey) diff --git a/packages/core/src/providers/zerostack/observations.ts b/packages/core/src/providers/zerostack/observations.ts index 4955f65c..be3d346b 100644 --- a/packages/core/src/providers/zerostack/observations.ts +++ b/packages/core/src/providers/zerostack/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { ZerostackDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Zerostack session's rich decode, as the host holds it before minimization. */ export interface RichZerostackSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: ZerostackDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 41d2a503..5e01e4fa 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -7,6 +7,20 @@ import { z } from 'zod' * 0.2.0 adds the optional per-call `resourceReads` / `resourceEdits` arrays * (ResourceRef). Strictness rules are unchanged: every added field is either a * fingerprint or a coarse enum, so the anti-smuggling property still holds. + * + * MIGRATION NOTE (in-place hardening, not a version bump): during 0.2.0's + * lifetime the `model` / `pricingModel` validation was tightened in place from + * `minLength: 1` to the ModelIdentifier bound (maxLength 128 + identifier + * charset), and the published schemas/observation-0.2.0.json changed in lock- + * step. The envelope shape is unchanged — producers always normalize through + * `normalizeModelIdentifier` now, so no newly produced envelope can be + * rejected. The one hazard is ARCHIVED envelopes: a pre-hardening 0.2.0 + * envelope whose model held a display name (e.g. "Gemini 3.5 Flash (High)") + * now fails validation against the same version string. Such archives must be + * re-normalized (collapse the model to 'unknown' or an identifier) before + * re-validating. A version bump was considered and rejected: 0.x is already + * breaking-by-default, no field changed shape, and a new version would force + * consumers to carry a second schema for a validation tightening alone. */ export const OBSERVATION_SCHEMA_VERSION = '0.2.0' @@ -42,6 +56,47 @@ export const CanonicalToolName = z .max(64) .regex(/^[A-Za-z0-9_.-]+$/, 'canonical tool names only (no args, paths, or spaces)') +/** + * Model identifier, as reported by the provider. Bounded to the identifier + * charset real model slugs use — letters, digits, and the separators `._:/@-` + * (openai/gpt-4o, anthropic--claude-4.6-opus, us.anthropic.claude-3-5-sonnet- + * 20241022-v2:0, cloudflare/@cf/meta/llama-2-7b-chat-fp16). The bound is + * anti-free-text: whitespace, punctuation outside the separators, and prompt + * text cannot fit, so a planted prompt or command line fails validation. It is + * NOT path-proof — `/`, `.`, `-` and `:` are valid identifier characters, so a + * path-shaped string (e.g. /Users/victim/company/secret-plan.md) can still + * match; the anti-path guarantee lives in the fingerprint fields + * (FingerprintHex), not here. A provider value outside the charset (e.g. a + * display name like "Gemini 3.5 Flash (High)") is normalized to 'unknown' at + * the observation boundary by `normalizeModelIdentifier`, never rejected here. + * The cap is generous (the longest slug in the litellm pricing snapshot is 76 + * chars) but the charset is the binding constraint. + */ +const MODEL_IDENTIFIER_PATTERN = /^[A-Za-z0-9._:/@-]+$/ + +export const ModelIdentifier = z + .string() + .min(1) + .max(128) + .regex(MODEL_IDENTIFIER_PATTERN, 'model identifiers only (letters, digits, and . _ : / @ - separators)') + +/** + * Normalize a provider-supplied model string at the observation boundary (each + * provider's toObservations). Values already inside the ModelIdentifier + * charset pass through unchanged; anything else — provider display names with + * spaces ("Gemini 3.5 Flash (High)", "GPT-5.3 Codex (medium reasoning)"), + * unmapped aliases, empty strings — collapses to 'unknown', the same fallback + * the decoders already use when no model can be resolved. This mirrors how + * non-canonical tool names are dropped rather than failing: a hostile value + * must never be able to reject a whole envelope, because one bad model id in + * one call would otherwise fail an entire multi-session batch. + */ +export function normalizeModelIdentifier(raw: string): string { + const trimmed = raw.trim() + if (trimmed.length === 0 || trimmed.length > 128) return 'unknown' + return MODEL_IDENTIFIER_PATTERN.test(trimmed) ? trimmed : 'unknown' +} + /** Per-call token buckets. All five are required, non-negative integers. */ export const TokenBuckets = z .object({ diff --git a/packages/core/tests/architecture-gate.test.ts b/packages/core/tests/architecture-gate.test.ts index b4016eb3..f49c1a9a 100644 --- a/packages/core/tests/architecture-gate.test.ts +++ b/packages/core/tests/architecture-gate.test.ts @@ -299,11 +299,13 @@ function isBoundedKind(kind: string): boolean { // The only string fields NOT length/charset-capped: machine-generated // identifiers with `minLength:1` and no upper bound. Each is a controlled -// vocabulary emitted by the host (a generator version, a provider/model/pricing -// slug, or a hash-derived dedup key), never user free text — provider and model -// ids have no natural maximum, so no maxLength is asserted. content-smuggling -// tests prove no user text reaches these. Every entry is justified; a NEW -// minLength-only string field NOT listed here fails the gate. +// vocabulary emitted by the host (a generator version, a provider slug, or a +// hash-derived dedup key), never user free text — provider ids have no natural +// maximum, so no maxLength is asserted. In 0.2.0 the model/pricingModel slugs +// ARE capped (ModelIdentifier charset + maxLength); the 0.1.0 entries below +// stay allowlisted only because that schema is frozen as shipped. Every entry +// is justified; a NEW minLength-only string field NOT listed here fails the +// gate. const MACHINE_ID_ALLOWLIST = new Set([ 'observation-0.1.0#ObservationEnvelope/generator/version', 'observation-0.1.0#ObservationEnvelope/sessions/items/providerId', @@ -314,8 +316,6 @@ const MACHINE_ID_ALLOWLIST = new Set([ 'observation-0.2.0#ObservationEnvelope/generator/version', 'observation-0.2.0#ObservationEnvelope/sessions/items/providerId', 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/provider', - 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/model', - 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/pricingModel', 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/dedupKey', ]) @@ -351,8 +351,8 @@ const EXPECTED_STRING_FIELDS: StringField[] = [ { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/endedAt', kind: 'format:date-time' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/gitBranchRef', kind: 'pattern:^[0-9a-f]{16}$' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/provider', kind: 'minLength-only:1' }, - { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/model', kind: 'minLength-only:1' }, - { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/pricingModel', kind: 'minLength-only:1' }, + { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/model', kind: 'pattern:^[A-Za-z0-9._:/@-]+$' }, + { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/pricingModel', kind: 'pattern:^[A-Za-z0-9._:/@-]+$' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/speed', kind: 'enum[2]' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/costBasis', kind: 'enum[2]' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/timestamp', kind: 'format:date-time' }, diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index ebfd145f..83474c27 100644 --- a/packages/core/tests/content-smuggling.test.ts +++ b/packages/core/tests/content-smuggling.test.ts @@ -54,6 +54,9 @@ import { toObservations as toKiroObservations, } from '../src/providers/kiro/index.js' import { decodeVercelGateway, toObservations as toVercelGatewayObservations } from '../src/providers/vercel-gateway/index.js' +import { decodeZerostack, toObservations as toZerostackObservations } from '../src/providers/zerostack/index.js' +import { decodeLingTaiTui, toObservations as toLingTaiTuiObservations } from '../src/providers/lingtai-tui/index.js' +import { decodePi, toObservations as toPiObservations } from '../src/providers/pi/index.js' import type { DecodeContext } from '../src/contracts.js' import type { ZedThreadRow } from '../src/providers/zed/index.js' import type { @@ -2081,11 +2084,13 @@ describe('content-smuggling guardrail: real kiro decode -> toObservations is sec describe('content-smuggling guardrail: real vercel-gateway decode -> toObservations is secret-free', () => { - // A hostile Vercel Gateway report planting every secret in the API fields the - // decode sees. The only free-text-capable API field is `model`; under the - // identifier-exemption convention model is an API identifier emitted by design, - // so the secret planted there is expected to remain. Every other secret must - // be absent from the envelope. + // A hostile Vercel Gateway report planting every secret the API fields can + // carry. `model` is externally supplied (the fetched report), so it is + // normalized at the observation boundary: a hostile prompt collapses to + // 'unknown' and the envelope still parses — a bad model in one call can + // never reject the whole batch. `day` is spliced into the synthesized + // timestamp, whose date-time constraint is the containment: a hostile day + // fails validation, and the error path names the timestamp field. function decodeAndMinimize() { const { calls } = decodeVercelGateway({ records: [ @@ -2109,8 +2114,19 @@ describe('content-smuggling guardrail: real vercel-gateway decode -> toObservati } } - it('produces a schema-valid envelope from the hostile report', () => { - expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + it('normalizes a hostile prompt in model to unknown; the envelope still parses (no whole-batch rejection)', () => { + const env = decodeAndMinimize() + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + const call = env.sessions[0]!.calls[0]! + expect(call.model).toBe('unknown') + }) + + it('the hostile envelope serializes with none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + // The prompt was planted in model, the abs path was passed as projectPath; + // neither may survive the boundary. + expect(serialized).not.toContain(SECRETS.prompt) + expect(serialized).not.toContain(SECRETS.absPath) }) it('is non-vacuous (at least one call)', () => { @@ -2119,20 +2135,40 @@ describe('content-smuggling guardrail: real vercel-gateway decode -> toObservati expect(callCount).toBeGreaterThan(0) }) - it('contains the model secret (identifier-exemption convention) and no other secrets', () => { - const serialized = JSON.stringify(decodeAndMinimize()) - expect(serialized).toContain(SECRETS.prompt) - expect(serialized).not.toContain(SECRETS.absPath) - expect(serialized).not.toContain(SECRETS.apiKey) - expect(serialized).not.toContain(SECRETS.commandLine) - expect(serialized).not.toContain(SECRETS.fileContent) + it('still lets a legitimate identifier-shaped model cross unchanged', () => { + const { calls } = decodeVercelGateway({ + records: [ + { + day: '2026-07-17', + model: 'openai/gpt-4o', + total_cost: 1.23, + input_tokens: 100, + output_tokens: 50, + }, + ], + }) + const { sessions } = toVercelGatewayObservations( + { sessionId: 'report-identifier', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'vercel-gateway' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(envelope.sessions[0]!.calls[0]!.model).toBe('openai/gpt-4o') + const serialized = JSON.stringify(envelope) + expect(serialized).toContain('openai/gpt-4o') }) // `day` is the report's only other string field, and it is NOT sanitized: the // decode splices it verbatim into the synthesized timestamp and the dedup key. // The envelope's date-time constraint is the containment, not the decode — a - // hostile `day` fails validation and therefore never ships. - it('rejects the envelope when a hostile day is spliced into the timestamp', () => { + // hostile `day` fails validation and therefore never ships. The error is + // asserted to name the timestamp field so this test cannot pass because an + // unrelated field broke. + it('rejects the envelope when a hostile day is spliced into the timestamp, and the error names the timestamp field', () => { const { calls } = decodeVercelGateway({ records: [{ day: SECRETS.apiKey, model: 'openai/gpt-4o', total_cost: 1, input_tokens: 1, output_tokens: 1 }], }) @@ -2148,5 +2184,322 @@ describe('content-smuggling guardrail: real vercel-gateway decode -> toObservati sessions, }) expect(parsed.success).toBe(false) + if (!parsed.success) { + const paths = parsed.error.issues.map(i => i.path.join('.')) + expect(paths.some(p => p.includes('timestamp'))).toBe(true) + } + }) +}) + +describe('content-smuggling guardrail: real zerostack decode -> toObservations is secret-free', () => { + // The zerostack dedup key threads the source ref. The raw host path must not + // cross into the envelope — dedupKey ships on the envelope — so the decoder + // fingerprints the source ref instead. A hostile sourceRef (the victim's + // absolute path) planted through the real decoder must appear nowhere in the + // serialized envelope. + const zerostackContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'zerostack', + sourceRef: SECRETS.absPath, + } + + function decodeAndMinimize() { + const records = [ + { + id: 'sess-hostile', + messages: [{ role: 'user', content: SECRETS.prompt }], + total_input_tokens: 100, + total_output_tokens: 50, + model: 'deepseek/deepseek-v4-pro', + created_at: '2026-07-17T10:00:00Z', + updated_at: '2026-07-17T10:01:00Z', + }, + ] + const { calls } = decodeZerostack({ records, context: zerostackContext }) + const { sessions } = toZerostackObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'zerostack' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('fingerprints the source ref into the dedup key; the raw path appears nowhere', () => { + const env = decodeAndMinimize() + const allDedupKeys = env.sessions.flatMap(s => s.calls.map(c => c.dedupKey)) + expect(allDedupKeys.length).toBeGreaterThan(0) + for (const key of allDedupKeys) { + expect(key).not.toContain(SECRETS.absPath) + expect(key).toMatch(/^zerostack:[0-9a-f]{16}:/) + } + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.absPath) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real lingtai-tui decode -> toObservations is secret-free', () => { + // The lingtai-tui dedup key threads the source ref (the ledger path). The raw + // host path must not cross into the envelope — dedupKey ships on the + // envelope — so the decoder fingerprints the source ref instead. + const lingTaiContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'lingtai-tui', + sourceRef: SECRETS.absPath, + } + + function decodeAndMinimize() { + const records = [ + JSON.stringify({ + ts: '2026-07-17T10:00:00.000Z', + input: 100, + output: 50, + model: 'gpt-5.5', + endpoint: 'example-endpoint', + source: 'main', + }), + ] + const { calls } = decodeLingTaiTui({ + records, + context: lingTaiContext, + agentId: 'agent-hostile', + fallbackModel: 'unknown', + fallbackEndpoint: 'unknown', + projectPath: SECRETS.absPath, + }) + const { sessions } = toLingTaiTuiObservations( + { sessionId: 'agent-hostile:main', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'lingtai-tui' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile ledger', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('fingerprints the source ref into the dedup key; the raw path appears nowhere', () => { + const env = decodeAndMinimize() + const allDedupKeys = env.sessions.flatMap(s => s.calls.map(c => c.dedupKey)) + expect(allDedupKeys.length).toBeGreaterThan(0) + for (const key of allDedupKeys) { + expect(key).not.toContain(SECRETS.absPath) + expect(key).toMatch(/^lingtai-tui:[0-9a-f]{16}:/) + } + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.absPath) + }) + + it('normalizes a hostile model out of the dedup key; the raw ledger text appears nowhere', () => { + // The ledger's `model` field is provider free text. The observation boundary + // collapses it to 'unknown', so the KEY (built in the decoder) must use the + // normalized identifier too — otherwise the raw display name / planted + // prompt rides the envelope inside dedupKey. + const records = [ + JSON.stringify({ + ts: '2026-07-17T10:00:00.000Z', + input: 100, + output: 50, + model: SECRETS.prompt, + endpoint: 'example-endpoint', + source: 'main', + }), + ] + const { calls } = decodeLingTaiTui({ + records, + context: lingTaiContext, + agentId: 'agent-hostile', + fallbackModel: 'unknown', + fallbackEndpoint: 'unknown', + projectPath: SECRETS.absPath, + }) + const { sessions } = toLingTaiTuiObservations( + { sessionId: 'agent-hostile:main', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'lingtai-tui' }, + ) + const env = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + expect(env.sessions[0]!.calls[0]!.model).toBe('unknown') + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.prompt) + expect(env.sessions[0]!.calls[0]!.dedupKey).toContain(':unknown:') + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real pi/omp decode -> toObservations is secret-free', () => { + // The pi/omp dedup key threads the session file path. The raw host path must + // not cross into the envelope — dedupKey ships on the envelope — so the + // decoder fingerprints the source ref instead. A hostile sourceRef (the + // victim's absolute path) and a hostile display-name model planted through + // the real decoder must appear nowhere in the serialized envelope. + const piContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'pi', + sourceRef: SECRETS.absPath, + } + + function decodeAndMinimize() { + const records = [ + JSON.stringify({ + type: 'session', + id: 'sess-hostile', + timestamp: '2026-07-17T10:00:00.000Z', + }), + JSON.stringify({ + type: 'message', + id: 'msg-hostile-1', + timestamp: '2026-07-17T10:00:10.000Z', + message: { + role: 'assistant', + model: SECRETS.prompt, + responseId: 'resp-hostile-1', + content: [], + usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 }, + }, + }), + ] + const { calls } = decodePi({ records, context: piContext }) + const { sessions } = toPiObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'pi' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('fingerprints the source ref into the dedup key; the raw path appears nowhere', () => { + const env = decodeAndMinimize() + const allDedupKeys = env.sessions.flatMap(s => s.calls.map(c => c.dedupKey)) + expect(allDedupKeys.length).toBeGreaterThan(0) + for (const key of allDedupKeys) { + expect(key).not.toContain(SECRETS.absPath) + expect(key).toMatch(/^pi:[0-9a-f]{16}:/) + } + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.absPath) + }) + + it('normalizes a hostile display-name model out of the envelope', () => { + const env = decodeAndMinimize() + expect(env.sessions[0]!.calls[0]!.model).toBe('unknown') + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.prompt) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real grok decode -> toObservations is secret-free', () => { + // The grok dedup key threads the session directory. The raw host path must + // not cross into the envelope — dedupKey ships on the envelope — so the + // decoder fingerprints the session dir instead. A hostile sourceDir (the + // victim's absolute path) and a hostile display-name model planted through + // the real decoder must appear nowhere in the serialized envelope. + const grokContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'grok', + sourceRef: SECRETS.absPath, + } + + function decodeAndMinimize() { + const records = [ + { + summary: { + info: { id: 'sess-hostile', cwd: SECRETS.absPath }, + created_at: '2026-07-17T10:00:00.000Z', + updated_at: '2026-07-17T10:01:00.000Z', + current_model_id: SECRETS.prompt, + session_summary: 'hostile', + }, + signals: null, + updatesLines: [ + JSON.stringify({ params: { _meta: { totalTokens: 1000, promptId: 'p1' } } }), + JSON.stringify({ params: { _meta: { totalTokens: 1500, promptId: 'p1' } } }), + ], + sourceDir: SECRETS.absPath, + sessionName: 'sess-hostile', + project: 'hostile', + }, + ] + const { calls } = decodeGrok({ records, context: grokContext }) + const { sessions } = toGrokObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'grok' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('fingerprints the session dir into the dedup key; the raw path appears nowhere', () => { + const env = decodeAndMinimize() + const allDedupKeys = env.sessions.flatMap(s => s.calls.map(c => c.dedupKey)) + expect(allDedupKeys.length).toBeGreaterThan(0) + for (const key of allDedupKeys) { + expect(key).not.toContain(SECRETS.absPath) + expect(key).toMatch(/^grok:[0-9a-f]{16}:/) + } + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.absPath) + }) + + it('normalizes a hostile display-name model out of the envelope', () => { + const env = decodeAndMinimize() + expect(env.sessions[0]!.calls[0]!.model).toBe('unknown') + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.prompt) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } }) }) diff --git a/packages/core/tests/providers/antigravity-decode.test.ts b/packages/core/tests/providers/antigravity-decode.test.ts index ca261c7d..fc794d16 100644 --- a/packages/core/tests/providers/antigravity-decode.test.ts +++ b/packages/core/tests/providers/antigravity-decode.test.ts @@ -346,6 +346,42 @@ describe('antigravity rich decode (moved to @codeburn/core)', () => { expect(calls[1]).toMatchObject({ inputTokens: 100, outputTokens: 10, cacheReadInputTokens: 50 }) }) + it('acceptance: a display-name model read from payload.model.display_name is normalized at the observation boundary', () => { + // The status-line decoder reads payload.model.display_name verbatim when no + // id is present (e.g. "Gemini 3.5 Flash (High)"). The observation boundary + // must normalize it to 'unknown' instead of rejecting the whole envelope. + const payload = { + conversation_id: 'accept-1', + model: { display_name: 'Gemini 3.5 Flash (High)' }, + context_window: { + current_usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + } + const event = parseAntigravityStatusLinePayload(payload, '2026-05-05T05:05:05.005Z') + expect(event).not.toBeNull() + expect(event!.model).toBe('Gemini 3.5 Flash (High)') + + const { calls } = decodeAntigravityStatusLine({ + records: [JSON.stringify(event)], + context, + seenKeys: new Set(), + }) + expect(calls[0]!.model).toBe('Gemini 3.5 Flash (High)') + + const { sessions } = toObservations( + { sessionId: 'accept-1', projectPath: '/Users/t/project', calls }, + { privacyKey: 'test-privacy-key', provider: 'antigravity' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.model).toBe('unknown') + expect(JSON.stringify(envelope)).not.toContain('Gemini 3.5 Flash') + }) + it('parseAntigravityStatusLinePayload uses the injected at value and never captures cwd', () => { const fixedAt = '2026-05-05T05:05:05.005Z' const payload = { diff --git a/packages/core/tests/providers/codebuff-decode.test.ts b/packages/core/tests/providers/codebuff-decode.test.ts index 66082790..a5bb4305 100644 --- a/packages/core/tests/providers/codebuff-decode.test.ts +++ b/packages/core/tests/providers/codebuff-decode.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { decodeCodebuff, toObservations, type CodebuffChatMessage } from '../../src/providers/codebuff/index.js' import { ObservationEnvelope } from '../../src/observations.js' import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import { sourceRefFingerprint } from '../../src/fingerprint.js' import type { DecodeContext } from '../../src/contracts.js' const context: DecodeContext = { privacyKey: 'k', providerId: 'codebuff', sourceRef: '/data/manicode/projects/alpha/chats/2026-04-14T10-00-00.000Z' } @@ -93,7 +94,13 @@ describe('codebuff rich decode (moved to @codeburn/core)', () => { expect(first!.rawBashCommands).toEqual(['npm test']) expect(first!.credits).toBe(42) expect(first!.userMessage).toBe('implement the feature') - expect(first!.deduplicationKey).toBe(`codebuff:${context.sourceRef}:a1`) + // The dedup key threads a FINGERPRINT of the chat directory (the source + // ref), never the raw absolute path: dedupKey ships on the envelope, so + // the raw-path form (`codebuff:${context.sourceRef}:a1`) was the defect — + // do not restore it. The expectation is DERIVED from the same fingerprint + // function the decoder uses, so the golden pins the contract, not a + // literal. + expect(first!.deduplicationKey).toBe(`codebuff:${sourceRefFingerprint(context.privacyKey, context.sourceRef)}:a1`) expect(second!.model).toBe('claude-haiku-4-5-20251001') expect(second!.inputTokens).toBe(5000) diff --git a/packages/core/tests/providers/devin-decode.test.ts b/packages/core/tests/providers/devin-decode.test.ts index b653c87b..5454f404 100644 --- a/packages/core/tests/providers/devin-decode.test.ts +++ b/packages/core/tests/providers/devin-decode.test.ts @@ -286,6 +286,45 @@ describe('devin rich decode (moved to @codeburn/core)', () => { } }) + it('acceptance: a display-name model name (e.g. "Gemini 3 Flash") is normalized at the observation boundary', () => { + // When no generation_model is recorded, devin falls back to the agent's + // model_name, which real databases carry as a display name ("Gemini 3 + // Flash"). The observation boundary must normalize it to 'unknown' + // instead of rejecting the whole envelope. + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + agent: { name: 'devin', version: '2.0', model_name: 'Gemini 3 Flash' }, + steps: [ + { + step_id: 1, + source: 'assistant', + message: 'working', + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 100 }, + }, + }, + ], + } + const { calls } = decodeDevin({ records: [makeRecord(transcript)], context }) + expect(calls[0]!.generationModel).toBeUndefined() + expect(calls[0]!.modelName).toBe('Gemini 3 Flash') + + const { sessions } = toObservations( + { sessionId: 'sess-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'devin' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.model).toBe('unknown') + expect(JSON.stringify(envelope)).not.toContain('Gemini 3 Flash') + }) + it('extracts user message from ContentPart[] messages', () => { const transcript: DevinAgentTrajectory = { ...BASE_TRANSCRIPT, diff --git a/packages/core/tests/providers/grok-decode.test.ts b/packages/core/tests/providers/grok-decode.test.ts index 6fd3f37a..2f589086 100644 --- a/packages/core/tests/providers/grok-decode.test.ts +++ b/packages/core/tests/providers/grok-decode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { decodeGrok, toObservations } from '../../src/providers/grok/index.js' +import { sourceRefFingerprint } from '../../src/fingerprint.js' import { ObservationEnvelope } from '../../src/observations.js' import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' import type { DecodeContext } from '../../src/contracts.js' @@ -92,6 +93,10 @@ describe('grok rich decode (moved to @codeburn/core)', () => { expect(call.sessionId).toBe('sess-1') expect(call.project).toBe('project') expect(call.projectPath).toBe('/Users/test/project') + // The dedup key threads a FINGERPRINT of the session dir, never the raw + // path — dedupKey ships on the envelope. + expect(call.deduplicationKey).toBe(`grok:${sourceRefFingerprint('k', '/sessions/%2FUsers%2Ftest/sess-1')}:2026-06-19T11:31:12.282793Z:sess-1`) + expect(call.deduplicationKey).not.toContain('/sessions/') }) it('sums fresh input across compaction segments', () => { diff --git a/packages/core/tests/providers/vercel-gateway-decode.test.ts b/packages/core/tests/providers/vercel-gateway-decode.test.ts index 6cf6f228..cd1dac9e 100644 --- a/packages/core/tests/providers/vercel-gateway-decode.test.ts +++ b/packages/core/tests/providers/vercel-gateway-decode.test.ts @@ -109,8 +109,11 @@ describe('vercel-gateway observations', () => { } } - it('produces a schema-valid envelope', () => { - expect(ObservationEnvelope.safeParse(buildEnvelope()).success).toBe(true) + it('normalizes a hostile prompt in model to unknown; the envelope still parses (no whole-batch rejection)', () => { + const env = buildEnvelope() + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + const call = env.sessions[0]?.calls[0] + expect(call?.model).toBe('unknown') }) it('contains at least one call (non-vacuous)', () => { @@ -119,17 +122,39 @@ describe('vercel-gateway observations', () => { expect(callCount).toBeGreaterThan(0) }) - it('emits no free text except the model identifier (identifier-exemption convention)', () => { - const env = buildEnvelope() - const serialized = JSON.stringify(env) - - // The model field is an API identifier and is emitted by design; the planted - // secret in model is therefore expected to appear there and only there. - expect(serialized).toContain(SECRETS.prompt) + it('the hostile envelope serializes with none of the planted secrets', () => { + const serialized = JSON.stringify(buildEnvelope()) + // The prompt was planted in model, the abs path was passed as projectPath; + // neither may survive the boundary. + expect(serialized).not.toContain(SECRETS.prompt) expect(serialized).not.toContain(SECRETS.absPath) - expect(serialized).not.toContain(SECRETS.apiKey) - expect(serialized).not.toContain(SECRETS.commandLine) - expect(serialized).not.toContain(SECRETS.fileContent) + }) + + it('a legitimate identifier-shaped model crosses unchanged', () => { + const { calls } = decodeVercelGateway({ + records: [ + { + day: '2026-07-17', + model: 'anthropic/claude-sonnet-4.6', + total_cost: 1.23, + input_tokens: 100, + output_tokens: 50, + }, + ], + }) + const { sessions } = toObservations( + { sessionId: 'report-2026-07-17', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'vercel-gateway' }, + ) + const env = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + expect(env.sessions[0]?.calls[0]?.model).toBe('anthropic/claude-sonnet-4.6') + const serialized = JSON.stringify(env) + expect(serialized).toContain('anthropic/claude-sonnet-4.6') }) it('exposes the provider-reported cost as measured', () => { diff --git a/packages/core/tests/providers/warp-decode.test.ts b/packages/core/tests/providers/warp-decode.test.ts index 1dc7f9f7..f91d9435 100644 --- a/packages/core/tests/providers/warp-decode.test.ts +++ b/packages/core/tests/providers/warp-decode.test.ts @@ -170,6 +170,29 @@ describe('warp rich decode (moved to @codeburn/core)', () => { expect(calls[0]!.model).toBe('gpt-5.3-codex') }) + it('acceptance: a display-name model the alias map does not cover is normalized at the observation boundary', () => { + // Warp's alias map is closed, so any NEW model id arrives verbatim (spaces + // and all) — e.g. "GPT-5.4 Codex (medium reasoning)" or "Claude Sonnet + // 4.7". The decode passes it through; the observation boundary must + // normalize it to 'unknown' instead of rejecting the whole envelope. + const exchanges: WarpQueryRow[] = [makeExchange('ex-1', { model_id: 'GPT-5.4 Codex (medium reasoning)' })] + const { calls } = decodeWarp({ records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges)], context }) + expect(calls[0]!.model).toBe('GPT-5.4 Codex (medium reasoning)') + + const { sessions } = toObservations( + { sessionId: 'conv-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'warp' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.model).toBe('unknown') + expect(JSON.stringify(envelope)).not.toContain('GPT-5.4 Codex') + }) + it('uses the fallback token budget when conversation usage is absent', () => { const conversation: WarpConversationRow = { ...BASE_CONVERSATION,