From 13ffe167db32005ebd9149d27474e5b3b47c922a Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Mon, 5 Oct 2026 13:20:44 +0300 Subject: [PATCH] feat(proxy): add per-project allowlist for Claude Code OTLP analytics - filter at the hook: untracked projects start no daemon, skip the SSO check and forward nothing - store CODEMIE_ANALYTICS_PROJECT_FILTER in ~/.claude/settings.json; connect/disconnect --scope project add/remove the current project - skip OTEL-only sessions in the completeness gate so untracked data is never sent and gets swept - drop the unused _unresolved.alert spool file - document the allowlist and add tests --- docs/ARCHITECTURE-PROXY.md | 18 ++ docs/COMMANDS.md | 19 ++ docs/HOOKS.md | 13 ++ src/agents/core/types.ts | 14 +- .../claude-code-otlp.allowlist.test.ts | 58 ++++++ .../__tests__/claude-code-otlp.plugin.test.ts | 49 +++++ .../claude-code-otlp.allowlist.ts | 184 ++++++++++++++++++ .../claude-code-otlp.plugin.ts | 34 +++- .../__tests__/hook.analytics-wiring.test.ts | 45 +++++ src/cli/commands/hook.ts | 11 +- .../__tests__/disconnect-orchestrator.test.ts | 47 +++++ .../commands/proxy/connect-orchestrator.ts | 33 +++- .../__tests__/claude-code-otlp.test.ts | 78 ++++++-- .../proxy/connectors/claude-code-otlp.ts | 113 +++++++++-- .../commands/proxy/disconnect-orchestrator.ts | 12 +- src/cli/commands/proxy/index.ts | 26 ++- .../__tests__/completeness-gate.test.ts | 62 ++++++ .../__tests__/tick-processor.test.ts | 135 +++++++++++++ .../plugins/otlp-spool/completeness-gate.ts | 20 +- .../sso/proxy/plugins/otlp-spool/forwarder.ts | 52 +++-- .../plugins/otlp-spool/session-status.ts | 14 +- .../proxy/plugins/otlp-spool/spool-config.ts | 4 + .../plugins/otlp-spool/tick-processor.ts | 17 ++ .../plugins/sso/proxy/plugins/otlp.plugin.ts | 9 +- 24 files changed, 976 insertions(+), 91 deletions(-) create mode 100644 src/agents/plugins/claude-code-otlp/__tests__/claude-code-otlp.allowlist.test.ts create mode 100644 src/agents/plugins/claude-code-otlp/__tests__/claude-code-otlp.plugin.test.ts create mode 100644 src/agents/plugins/claude-code-otlp/claude-code-otlp.allowlist.ts create mode 100644 src/cli/commands/__tests__/hook.analytics-wiring.test.ts create mode 100644 src/providers/plugins/sso/proxy/plugins/otlp-spool/__tests__/completeness-gate.test.ts create mode 100644 src/providers/plugins/sso/proxy/plugins/otlp-spool/__tests__/tick-processor.test.ts diff --git a/docs/ARCHITECTURE-PROXY.md b/docs/ARCHITECTURE-PROXY.md index e2ce35089..7532b67e4 100644 --- a/docs/ARCHITECTURE-PROXY.md +++ b/docs/ARCHITECTURE-PROXY.md @@ -917,6 +917,24 @@ sequenceDiagram PX-->>VS: Byte-preserved SSE stream ``` +### 6.14 Claude Code OTLP Per-Project Allowlist + +The OTLP plugin (`otlp.plugin.ts`) receives Claude Code OTel data and hook events into a disk spool (`otlp-spool/`). Which projects are tracked is decided by the allowlist `CODEMIE_ANALYTICS_PROJECT_FILTER` (JSON array of absolute paths, stored as a string in `env` of `~/.claude/settings.json`). An absent or empty list tracks every project; a `cwd` inside any entry (nested directories included) is tracked; an invalid value tracks nothing. `codemie proxy connect --claude-code-otlp` writes it (default `--scope user` resets it to `[]`; `--scope project` adds the current project root). See [COMMANDS.md](COMMANDS.md#claude-code-otlp-analytics). + +**Filtering is hook-side only.** The daemon has no allowlist logic because OTLP carries no `cwd`. The Claude Code OTLP plugin (`src/agents/plugins/claude-code-otlp/claude-code-otlp.plugin.ts`) reads the allowlist on every hook event. For an untracked project it returns early: no daemon start (`ensureOtlpProxy` is not called), no SSO check, no forward to the spool. + +**OTEL-only invariant.** The OTel environment is global, so untracked sessions still export OTEL data to the daemon, but they never receive hooks data. The completeness gate (`otlp-spool/completeness-gate.ts`) only sends sessions that have hooks data; an OTEL-only session gets `wait` and, once `waitTicks >= OTLP_SEND_MAX_ATTEMPTS` (limit resolved by `hooksOnlyWaitTicks()` in `otlp-spool/spool-config.ts`), `skip`. On `skip` the tick processor advances the OTEL cursors to EOF without sending. No code path may send OTEL-only data without first adding daemon-side project filtering. + +**Deletion timeline for untracked data** + +| Stage | When | Result | +| ------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| Spooled | OTEL data arrives | Held on disk, never sent | +| Skipped | After `OTLP_SEND_MAX_ATTEMPTS` ticks (`wait` counted per send tick) | Cursors advanced to EOF, session counts as drained | +| Swept | Drained and idle longer than `OTLP_ABANDONED_SESSION_GRACE_MINUTES` (measured from last write) | Spool deleted by the sweeper | + +The decision is per hook event: a session that starts outside and later enters a tracked project becomes sendable from then on, but a tracked session whose first hook arrives after the wait limit loses the OTEL bytes received before that. + --- ## 7. Quality Attributes diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 1f92679b0..3eb314a14 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1578,3 +1578,22 @@ codemie version - CLI version - Node.js version - Package name and description + +### Claude Code OTLP analytics + +`codemie proxy connect --claude-code-otlp` writes the hooks and OTel environment variables into the user-level `~/.claude/settings.json`, plus `CODEMIE_ANALYTICS_PROJECT_FILTER`: a JSON array (stored as a string) of absolute project paths that are tracked. + +| Command | Allowlist result | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `connect --claude-code-otlp` (default `--scope user`) | `[]` - every project is tracked (resets any existing list) | +| `connect --claude-code-otlp --scope project` | adds the current project root (deduplicated); run it in each project to track | +| `disconnect --claude-code-otlp --scope project` | removes the current project root; when the list becomes empty everything is disconnected | +| `disconnect --claude-code-otlp` | removes hooks, OTel variables and the allowlist | + +A `cwd` inside any listed path is tracked (nested directories included). An invalid value stops `connect` before anything is written; fix it manually or rerun with `--force` to discard it. `--force` never edits a valid list. + +**How the filter works.** Only the hook process reads the allowlist. For an untracked project the hook does nothing: no proxy start, no SSO check, nothing forwarded. The daemon has no allowlist logic; its completeness gate (`otlp-spool/completeness-gate.ts`) only sends sessions that have hooks data, and never sends OTEL-only sessions. The decision is per hook event, so a tracked session that moves fully outside the project loses hook events from that stretch, and a session that starts outside and later enters a tracked project becomes sendable from then on. + +**Privacy note.** The OTel environment is global, so every Claude Code session, including untracked projects, exports telemetry (with tool details) to the local daemon whenever it runs. That data stays on disk: it is skipped after the send wait limit (`OTLP_SEND_MAX_ATTEMPTS` ticks) and deleted by the sweeper after the abandoned grace period (`OTLP_ABANDONED_SESSION_GRACE_MINUTES`). Nothing from untracked projects is sent to the backend. + +Allowlist changes apply immediately; the first-time setup needs a Claude Code restart. diff --git a/docs/HOOKS.md b/docs/HOOKS.md index c612e74e0..b10e29b1a 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -10,6 +10,7 @@ The CodeMie Code hooks system allows you to execute custom shell commands or LLM - [Hook Events](#hook-events) - [Security Considerations](#security-considerations) - [Examples](#examples) +- [Claude Code OTLP Hook Filter](#claude-code-otlp-hook-filter) - [Troubleshooting](#troubleshooting) ## Overview @@ -568,6 +569,18 @@ fi } ``` +## Claude Code OTLP Hook Filter + +The `claude-code-otlp` integration (`codemie proxy connect --claude-code-otlp`) registers Claude Code hooks in `~/.claude/settings.json` that forward hook events to the CodeMie proxy daemon for analytics. Which projects are tracked is controlled by the allowlist `CODEMIE_ANALYTICS_PROJECT_FILTER`, a JSON array (stored as a string) of absolute project paths in the `env` block of `~/.claude/settings.json`. + +- Absent or `[]`: every project is tracked. +- Non-empty: an event is tracked when its `cwd` equals or is nested inside a listed path. A missing `cwd` or an invalid value is not tracked. +- Managed through `connect`/`disconnect --claude-code-otlp --scope user|project` (see [COMMANDS.md](COMMANDS.md#claude-code-otlp-analytics)). + +The filter runs in the hook process. The plugin (`src/agents/plugins/claude-code-otlp/claude-code-otlp.plugin.ts`), not `hook.ts`, decides whether the daemon is ensured: `hook.ts` passes an `ensureOtlpProxy` callback and the plugin calls it only for tracked projects. For an untracked project the hook does nothing: no daemon start, no SSO check, nothing forwarded to the spool. + +Claude Code OTel telemetry is still exported globally, so untracked sessions reach the daemon as OTEL-only data. The daemon never sends OTEL-only sessions (completeness gate decision `skip`); they are skipped after `OTLP_SEND_MAX_ATTEMPTS` ticks and deleted by the sweeper after `OTLP_ABANDONED_SESSION_GRACE_MINUTES`. Details: [ARCHITECTURE-PROXY.md](ARCHITECTURE-PROXY.md#614-claude-code-otlp-per-project-allowlist). + ## Environment Variables Hooks have access to the following environment variables: diff --git a/src/agents/core/types.ts b/src/agents/core/types.ts index ae718b011..a16a86cd5 100644 --- a/src/agents/core/types.ts +++ b/src/agents/core/types.ts @@ -735,7 +735,19 @@ export interface OtlpAgentAdapter { readonly name: string; readonly type: AgentAdapterType.OTLP; - processOtlpEvent(rawHookInput: string): Promise; + /** + * Handles one hook event. The adapter owns the decision of whether the OTLP + * daemon is needed: it MUST call `deps.ensureProxy()` before forwarding + * anything to the daemon and MAY skip it for events it will not forward. + * + * INVARIANT: events from untracked projects must never reach the daemon + * spool. + */ + processOtlpEvent(rawHookInput: string, deps: OtlpAdapterDeps): Promise; +} + +export interface OtlpAdapterDeps { + ensureOtlpProxy: (agentName: string) => Promise; } /** diff --git a/src/agents/plugins/claude-code-otlp/__tests__/claude-code-otlp.allowlist.test.ts b/src/agents/plugins/claude-code-otlp/__tests__/claude-code-otlp.allowlist.test.ts new file mode 100644 index 000000000..f5150fefe --- /dev/null +++ b/src/agents/plugins/claude-code-otlp/__tests__/claude-code-otlp.allowlist.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { + addProjectPath, + isPathInside, + isProjectTracked, + parseAllowlist, + removeProjectPath, +} from '../claude-code-otlp.allowlist.js'; + +describe('parseAllowlist', () => { + it('maps values to states', () => { + expect(parseAllowlist(undefined)).toEqual({ kind: 'absent' }); + expect(parseAllowlist('[]')).toEqual({ kind: 'valid', paths: [] }); + expect(parseAllowlist('["/a/b"]')).toEqual({ kind: 'valid', paths: ['/a/b'] }); + for (const bad of ['nope', '{}', '["rel/path"]', '[""]', '[1]', 5]) { + expect(parseAllowlist(bad)).toEqual({ kind: 'invalid' }); + } + }); +}); + +describe('isPathInside', () => { + it('uses path semantics, not string prefixes', () => { + expect(isPathInside('/a/b', '/a/b')).toBe(true); + expect(isPathInside('/a/b/c', '/a/b')).toBe(true); + expect(isPathInside('/a/bc', '/a/b')).toBe(false); + expect(isPathInside('/a', '/a/b')).toBe(false); + }); + + it('treats a child directory literally named "..foo" as inside', () => { + expect(isPathInside('/a/b/..foo', '/a/b')).toBe(true); + expect(isPathInside('/a/b/..foo/c', '/a/b')).toBe(true); + }); + + it('treats a parent-relative sibling as outside', () => { + expect(isPathInside('/a/x', '/a/b')).toBe(false); + expect(isPathInside('/a/b/../x', '/a/b')).toBe(false); + }); +}); + +describe('isProjectTracked', () => { + it('handles each state', async () => { + expect(await isProjectTracked('/x', { kind: 'absent' })).toBe(true); + expect(await isProjectTracked('/x', { kind: 'valid', paths: [] })).toBe(true); + expect(await isProjectTracked('/x', { kind: 'invalid' })).toBe(false); + expect(await isProjectTracked(undefined, { kind: 'valid', paths: ['/a/b'] })).toBe(false); + expect(await isProjectTracked('/a/b/c', { kind: 'valid', paths: ['/a/b/'] })).toBe(true); + expect(await isProjectTracked('/a/bc', { kind: 'valid', paths: ['/a/b'] })).toBe(false); + }); +}); + +describe('add/removeProjectPath', () => { + it('dedupes on add and removes by raw string', async () => { + const once = await addProjectPath([], '/nonexistent/proj'); + expect(await addProjectPath(once, '/nonexistent/proj/')).toEqual(once); + expect(await removeProjectPath(once, '/nonexistent/proj')).toEqual([]); + expect(await removeProjectPath(['/gone/raw'], '/gone/raw')).toEqual([]); + }); +}); diff --git a/src/agents/plugins/claude-code-otlp/__tests__/claude-code-otlp.plugin.test.ts b/src/agents/plugins/claude-code-otlp/__tests__/claude-code-otlp.plugin.test.ts new file mode 100644 index 000000000..17c9d37df --- /dev/null +++ b/src/agents/plugins/claude-code-otlp/__tests__/claude-code-otlp.plugin.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../claude-code-otlp.allowlist.js', () => ({ + readAllowlistState: vi.fn(async () => ({ kind: 'valid', paths: ['/proj'] })), + isProjectTracked: vi.fn(), +})); +vi.mock('../../utils.js', () => ({ forwardOtlpEventToSpool: vi.fn() })); +vi.mock('@/providers/plugins/sso/sso.auth-gate.js', () => ({ ensureCodeMieSsoAuth: vi.fn() })); +vi.mock('@/utils/config.js', () => ({ ConfigLoader: { load: vi.fn(async () => ({})) } })); +vi.mock('@/utils/logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { ClaudeCodeOtlpPlugin } from '../claude-code-otlp.plugin.js'; +import { isProjectTracked } from '../claude-code-otlp.allowlist.js'; +import { forwardOtlpEventToSpool } from '../../utils.js'; +import { ensureCodeMieSsoAuth } from '@/providers/plugins/sso/sso.auth-gate.js'; + +const event = (name: string) => JSON.stringify({ session_id: 's', transcript_path: '', cwd: '/x', hook_event_name: name }); + +describe('ClaudeCodeOtlpPlugin.processOtlpEvent', () => { + const plugin = new ClaudeCodeOtlpPlugin(); + const ensureOtlpProxy = vi.fn(async () => {}); + + beforeEach(() => vi.clearAllMocks()); + + it('does nothing for untracked projects, for every event', async () => { + vi.mocked(isProjectTracked).mockResolvedValue(false); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + for (const name of ['SessionStart', 'UserPromptSubmit', 'Stop']) { + await plugin.processOtlpEvent(event(name), { ensureOtlpProxy }); + } + expect(ensureOtlpProxy).not.toHaveBeenCalled(); + expect(ensureCodeMieSsoAuth).not.toHaveBeenCalled(); + expect(forwardOtlpEventToSpool).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + log.mockRestore(); + }); + + it('ensures the proxy before forwarding for tracked projects', async () => { + vi.mocked(isProjectTracked).mockResolvedValue(true); + await plugin.processOtlpEvent(event('SessionStart'), { ensureOtlpProxy }); + expect(ensureOtlpProxy).toHaveBeenCalledTimes(1); + expect(forwardOtlpEventToSpool).toHaveBeenCalledTimes(1); + expect(vi.mocked(ensureOtlpProxy).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(forwardOtlpEventToSpool).mock.invocationCallOrder[0] + ); + }); +}); diff --git a/src/agents/plugins/claude-code-otlp/claude-code-otlp.allowlist.ts b/src/agents/plugins/claude-code-otlp/claude-code-otlp.allowlist.ts new file mode 100644 index 000000000..d88b46f48 --- /dev/null +++ b/src/agents/plugins/claude-code-otlp/claude-code-otlp.allowlist.ts @@ -0,0 +1,184 @@ +/** + * Per-project allowlist for Claude Code OTLP analytics. + * + * The allowlist is a JSON array of absolute paths stored as a string in + * `~/.claude/settings.json` -> `env.CODEMIE_ANALYTICS_PROJECT_FILTER`. The + * connector writes it; the hook process (the OTLP plugin) is its only runtime + * consumer. The daemon has no allowlist logic. + */ + +import { realpath, readFile } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { logger } from '@/utils/logger.js'; +import { resolveHomeDir } from '@/utils/paths.js'; + +export const CODEMIE_ANALYTICS_PROJECT_FILTER_ENV = 'CODEMIE_ANALYTICS_PROJECT_FILTER'; + +export type AllowlistState = + | { kind: 'absent' } + | { kind: 'valid'; paths: string[] } + | { kind: 'invalid' }; + +/** + * Parses the raw env value. Invalid = unparsable JSON, not an array, or any + * item that is not a non-empty absolute path string. + */ +export function parseAllowlist(value: unknown): AllowlistState { + if (value === undefined) { + return { kind: 'absent' }; + } + + if (typeof value !== 'string') { + return { kind: 'invalid' }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return { kind: 'invalid' }; + } + + if (!Array.isArray(parsed)) { + return { kind: 'invalid' }; + } + + const paths: string[] = []; + for (const item of parsed) { + if (typeof item !== 'string' || item.length === 0 || !isAbsolute(item)) { + return { kind: 'invalid' }; + } + paths.push(item); + } + return { kind: 'valid', paths }; +} + +function stripTrailingSeparators(p: string): string { + let end = p.length; + while (end > 1 && (p[end - 1] === '/' || p[end - 1] === '\\')) end--; + // Keep a Windows drive root such as `C:\` intact + if (/^[A-Za-z]:$/.test(p.slice(0, end))) { + return p.slice(0, end) + sep; + } + return p.slice(0, end); +} + +/** + * Resolves symlinks (falls back to `path.resolve` for missing dirs) and strips + * trailing separators. Casing is preserved; use {@link comparable} to compare. + */ +export async function canonicalizePath(p: string): Promise { + let resolved: string; + try { + resolved = await realpath(p); + } catch { + resolved = resolve(p); + } + return stripTrailingSeparators(resolved); +} + +/** Case-insensitive on win32 and darwin, for comparison only. */ +function comparable(p: string): string { + return process.platform === 'win32' || process.platform === 'darwin' ? p.toLowerCase() : p; +} + +/** True if `child` equals or is nested under `parent`. Never a string-prefix check. */ +export function isPathInside(child: string, parent: string): boolean { + const rel = relative(comparable(parent), comparable(child)); + return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +/** Path of the user-level Claude Code settings file that holds the allowlist. */ +export function getClaudeSettingsPath(): string { + return join(resolveHomeDir(), '.claude', 'settings.json'); +} + +/** Reads the allowlist from `~/.claude/settings.json`. Never throws. */ +export async function readAllowlistState(): Promise { + let raw: string; + try { + raw = await readFile(getClaudeSettingsPath(), 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { kind: 'absent' }; + } + logger.debug('[Claude Code OTLP allowlist] Failed to read settings, treating allowlist as invalid'); + return { kind: 'invalid' }; + } + + if (raw.trim().length === 0) { + return { kind: 'absent' }; + } + + try { + const settings: unknown = JSON.parse(raw); + if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) { + return { kind: 'invalid' }; + } + const env = (settings as { env?: unknown }).env; + if (env === undefined) { + return { kind: 'absent' }; + } + if (typeof env !== 'object' || env === null || Array.isArray(env)) { + return { kind: 'invalid' }; + } + return parseAllowlist((env as Record)[CODEMIE_ANALYTICS_PROJECT_FILTER_ENV]); + } catch { + return { kind: 'invalid' }; + } +} + +/** + * absent or empty list => tracked (all projects); invalid => not tracked; + * otherwise `cwd` must sit inside at least one entry. + */ +export async function isProjectTracked(cwd: string | undefined, state: AllowlistState): Promise { + if (state.kind === 'absent') { + return true; + } + if (state.kind === 'invalid') { + return false; + } + if (state.paths.length === 0) { + return true; + } + if (!cwd) { + return false; + } + + const canonicalCwd = await canonicalizePath(cwd); + for (const entry of state.paths) { + if (isPathInside(canonicalCwd, await canonicalizePath(entry))) { + return true; + } + } + return false; +} + +/** Returns a new list with `projectPath` added (canonical dedupe). */ +export async function addProjectPath(paths: string[], projectPath: string): Promise { + const canonical = await canonicalizePath(projectPath); + for (const existing of paths) { + if (comparable(await canonicalizePath(existing)) === comparable(canonical)) { + return [...paths]; + } + } + return [...paths, canonical]; +} + +/** + * Returns a new list without `projectPath`. Matches by canonical path and by + * raw string so an entry for a deleted project can still be removed. + */ +export async function removeProjectPath(paths: string[], projectPath: string): Promise { + const canonical = comparable(await canonicalizePath(projectPath)); + const kept: string[] = []; + for (const existing of paths) { + const matches = + existing === projectPath || comparable(await canonicalizePath(existing)) === canonical; + if (!matches) { + kept.push(existing); + } + } + return kept; +} diff --git a/src/agents/plugins/claude-code-otlp/claude-code-otlp.plugin.ts b/src/agents/plugins/claude-code-otlp/claude-code-otlp.plugin.ts index 144d1c6f7..f7241e0e9 100644 --- a/src/agents/plugins/claude-code-otlp/claude-code-otlp.plugin.ts +++ b/src/agents/plugins/claude-code-otlp/claude-code-otlp.plugin.ts @@ -2,36 +2,52 @@ import { AuthGateResult, ensureCodeMieSsoAuth } from '@/providers/plugins/sso/sso.auth-gate.js'; import { logger } from '@/utils/logger.js'; import { ConfigLoader } from '@/utils/config.js'; -import { AgentAdapterType, OtlpAgentAdapter } from '@/agents/core/types.js'; +import { AgentAdapterType, OtlpAdapterDeps, OtlpAgentAdapter } from '@/agents/core/types.js'; import { CLAUDE_CODE_OTLP_AGENT_NAME } from './claude-code-otlp.constants.js'; import { ForwardDecision, toBaseClaudeCodeHookEvent } from './claude-code-otlp.types.js'; import { forwardOtlpEventToSpool } from '../utils.js'; +import { isProjectTracked, readAllowlistState } from './claude-code-otlp.allowlist.js'; export class ClaudeCodeOtlpPlugin implements OtlpAgentAdapter { public readonly name = CLAUDE_CODE_OTLP_AGENT_NAME; public readonly type = AgentAdapterType.OTLP; - public async processOtlpEvent(rawEvent: string): Promise { - const evaluation = await this.evaluate(rawEvent); + public async processOtlpEvent(rawEvent: string, { ensureOtlpProxy }: OtlpAdapterDeps): Promise { + const event = toBaseClaudeCodeHookEvent(JSON.parse(rawEvent)); + + // INVARIANT - do not weaken. An untracked project must produce NO hooks data + // in the daemon spool, must not start the daemon, and must not run the SSO + // check. The daemon has no allowlist of its own. Its completeness gate + // (`otlp-spool/completeness-gate.ts`) only sends sessions that have hooks + // data, and skips sessions that only have OTEL data. Forwarding a hook event + // for an untracked project would make the session sendable and leak its + // data to the backend. + const isTracked = await isProjectTracked(event.cwd, await readAllowlistState()); + if (!isTracked) { + logger.debug('[Claude Code OTLP plugin] project not in analytics allowlist, ignoring hook event'); + return; + } + + await ensureOtlpProxy(this.name); + + const evaluation = await this.evaluate(event.hookEventName, rawEvent); if (evaluation.decision === 'block') { logger.error(`[Claude Code OTLP plugin] Blocking prompt: ${evaluation.reason}`); console.log(JSON.stringify(evaluation)); return; } this.forwardToSpool(evaluation.payload); - }; - - private async evaluate(rawEvent: string): Promise { - const event = toBaseClaudeCodeHookEvent(JSON.parse(rawEvent)); + } - if (event.hookEventName === 'UserPromptSubmit') { + private async evaluate(hookEventName: string, rawEvent: string): Promise { + if (hookEventName === 'UserPromptSubmit') { return await this.onUserPromptSubmit(rawEvent); } return { decision: 'forward', payload: rawEvent, - } + }; } private async ensureProxyAuth(): Promise { diff --git a/src/cli/commands/__tests__/hook.analytics-wiring.test.ts b/src/cli/commands/__tests__/hook.analytics-wiring.test.ts new file mode 100644 index 000000000..ce30d39a6 --- /dev/null +++ b/src/cli/commands/__tests__/hook.analytics-wiring.test.ts @@ -0,0 +1,45 @@ +/** + * The hook command delegates analytics agents to processOtlpEvent and hands it + * the ensureOtlpProxy callback. + * @group unit + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Readable } from 'node:stream'; + +vi.mock('../proxy/connect-orchestrator.js', () => ({ ensureOtlpProxy: vi.fn() })); + +import { createHookCommand } from '../hook.js'; +import { ensureOtlpProxy } from '../proxy/connect-orchestrator.js'; +import { AgentRegistry } from '../../../agents/registry.js'; + +describe('hook command analytics wiring', () => { + const originalStdin = Object.getOwnPropertyDescriptor(process, 'stdin'); + const originalExitCode = process.exitCode; + + beforeEach(() => { + Object.defineProperty(process, 'stdin', { + value: Readable.from([JSON.stringify({ hook_event_name: 'UserPromptSubmit', cwd: '/w/a' })]), + configurable: true, + }); + }); + + afterEach(() => { + if (originalStdin) Object.defineProperty(process, 'stdin', originalStdin); + process.exitCode = originalExitCode; + vi.restoreAllMocks(); + }); + + it('calls processOtlpEvent with the parsed input and { ensureOtlpProxy }', async () => { + const processOtlpEvent = vi.fn().mockResolvedValue(undefined); + vi.spyOn(AgentRegistry, 'getAnalyticsAgent').mockReturnValue({ processOtlpEvent } as never); + + await createHookCommand().parseAsync(['--agent', 'claude-code-otlp'], { from: 'user' }); + + expect(AgentRegistry.getAnalyticsAgent).toHaveBeenCalledWith('claude-code-otlp'); + expect(processOtlpEvent).toHaveBeenCalledTimes(1); + const [input, deps] = processOtlpEvent.mock.calls[0]; + expect(JSON.parse(input as string)).toMatchObject({ hook_event_name: 'UserPromptSubmit' }); + expect(deps).toEqual({ ensureOtlpProxy }); + expect((deps as { ensureOtlpProxy: unknown }).ensureOtlpProxy).toBe(ensureOtlpProxy); + }); +}); diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index 12e523eca..436130461 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -822,8 +822,12 @@ async function createSessionRecord(event: SessionStartEvent, sessionId: string, } = await import('../../agents/core/session/session-origin-audit.js'); existing.status = 'active'; - if (gitBranch) existing.gitBranch = gitBranch; - if (remoteRepository) existing.repository = remoteRepository; + if (gitBranch) { + existing.gitBranch = gitBranch; + } + if (remoteRepository) { + existing.repository = remoteRepository; + } existing.correlation = { ...existing.correlation, status: 'matched', @@ -1513,8 +1517,7 @@ export function createHookCommand(): Command { const analyticsAgent = AgentRegistry.getAnalyticsAgent(opts.agent!); if (analyticsAgent) { - await ensureOtlpProxy(analyticsAgent.name); - await analyticsAgent.processOtlpEvent(input); + await analyticsAgent.processOtlpEvent(input, { ensureOtlpProxy }); await logger.close(); process.exitCode = 0; return; diff --git a/src/cli/commands/proxy/__tests__/disconnect-orchestrator.test.ts b/src/cli/commands/proxy/__tests__/disconnect-orchestrator.test.ts index 40abdd523..ab278e1ec 100644 --- a/src/cli/commands/proxy/__tests__/disconnect-orchestrator.test.ts +++ b/src/cli/commands/proxy/__tests__/disconnect-orchestrator.test.ts @@ -102,6 +102,53 @@ describe('disconnectTargets', () => { expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('nothing to disconnect')); expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('disconnected (')); }); + + it('lists the remaining tracked projects when only an allowlist entry was removed', async () => { + vi.doMock('../connectors/claude-code-otlp.js', () => ({ + removeClaudeCodeOtlpConfig: vi.fn().mockResolvedValue({ + removed: true, usedBackup: false, path: settingsPath, mode: 'entry-removed', + allowlist: ['/work/a', '/work/b'], + }), + })); + const { disconnectTargets } = await import('../disconnect-orchestrator.js'); + + await disconnectTargets({ targets: { claudeCodeOtlp: true }, scope: 'project' }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Project removed from Claude Code OTLP tracking')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/work/a')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/work/b')); + expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('Claude Code OTLP disconnected')); + }); + + it('reports a full disconnect for mode "full"', async () => { + vi.doMock('../connectors/claude-code-otlp.js', () => ({ + removeClaudeCodeOtlpConfig: vi.fn().mockResolvedValue({ + removed: true, usedBackup: false, path: settingsPath, mode: 'full', + }), + })); + const { disconnectTargets } = await import('../disconnect-orchestrator.js'); + + await disconnectTargets({ targets: { claudeCodeOtlp: true } }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Claude Code OTLP disconnected')); + expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('Still tracked')); + }); + + it('includes the reason in the no-op message for mode "noop"', async () => { + vi.doMock('../connectors/claude-code-otlp.js', () => ({ + removeClaudeCodeOtlpConfig: vi.fn().mockResolvedValue({ + removed: false, usedBackup: false, path: settingsPath, mode: 'noop', + reason: 'CODEMIE_ANALYTICS_PROJECT_FILTER is not set', + }), + })); + const { disconnectTargets } = await import('../disconnect-orchestrator.js'); + + await disconnectTargets({ targets: { claudeCodeOtlp: true }, scope: 'project' }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('nothing to disconnect (CODEMIE_ANALYTICS_PROJECT_FILTER is not set)') + ); + }); }); it('sets a failing exit code when removal throws', async () => { diff --git a/src/cli/commands/proxy/connect-orchestrator.ts b/src/cli/commands/proxy/connect-orchestrator.ts index 068fd6a2c..a8dd7402d 100644 --- a/src/cli/commands/proxy/connect-orchestrator.ts +++ b/src/cli/commands/proxy/connect-orchestrator.ts @@ -72,7 +72,10 @@ export interface ConnectOptions { verbose?: boolean; /** Pin a specific model for the Codex desktop target. */ model?: string; - /** Settings scope for Claude Code OTLP plugin: writes to ~/.claude (user) or .claude (project). Defaults to "user". */ + /** + * Claude Code OTLP tracking scope. Settings always live in ~/.claude/settings.json. + * "user" (default) tracks all projects and resets the allowlist; "project" adds only the current project root to it. + */ scope?: "user" | "project"; } @@ -656,7 +659,17 @@ async function runClaudeCodeOtlp(options: ClaudeCodeOtlpRunOptions): Promise 0) { + console.log(chalk.dim(' Tracked projects:')); + for (const projectPath of result.allowlist) { + console.log(chalk.dim(` - ${projectPath}`)); + } + } else { + console.log(chalk.dim(' Tracked projects: all projects')); + } + console.log(chalk.dim(' OTel env is user-level: every Claude Code session exports telemetry to the daemon while it runs;')); + console.log(chalk.dim(' data from untracked projects is skipped and swept, never sent.')); + console.log(chalk.yellow(' First-time setup needs a Claude Code restart; allowlist changes apply immediately.')); return { label, ok: true }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -759,9 +772,15 @@ export async function connectTargets(opts: ConnectOptions): Promise { // Per-target dispatch (spec §3.4) — each writer runs independently. const results: TargetResult[] = []; - if (targets.claudeDesktop) results.push(await runClaudeDesktop(state, verbose)); - if (targets.vscode) results.push(await runVscodeByok(state, insiders, config, verbose)); - if (targets.vscodeClaudeCode) results.push(await runVscodeClaudeCode(state, insiders)); + if (targets.claudeDesktop) { + results.push(await runClaudeDesktop(state, verbose)); + } + if (targets.vscode) { + results.push(await runVscodeByok(state, insiders, config, verbose)); + } + if (targets.vscodeClaudeCode) { + results.push(await runVscodeClaudeCode(state, insiders)); + } if (targets.codexDesktop) { results.push(await runCodexDesktop(state, { force: Boolean(opts.force), @@ -770,7 +789,9 @@ export async function connectTargets(opts: ConnectOptions): Promise { verbose, })); } - if (targets.claudeCodeOtlp) results.push(await runClaudeCodeOtlp({ force: Boolean(opts.force), scope: opts.scope })); + if (targets.claudeCodeOtlp) { + results.push(await runClaudeCodeOtlp({ force: Boolean(opts.force), scope: opts.scope })); + } const anyFailed = results.some((r) => !r.ok); const allFailed = results.every((r) => !r.ok); diff --git a/src/cli/commands/proxy/connectors/__tests__/claude-code-otlp.test.ts b/src/cli/commands/proxy/connectors/__tests__/claude-code-otlp.test.ts index 9abcfb045..80647050b 100644 --- a/src/cli/commands/proxy/connectors/__tests__/claude-code-otlp.test.ts +++ b/src/cli/commands/proxy/connectors/__tests__/claude-code-otlp.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { existsSync } from 'node:fs'; -import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, mkdir, writeFile, readFile, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -15,6 +15,7 @@ import { removeClaudeCodeOtlpConfig, } from '../claude-code-otlp.js'; import { readState } from '../../daemon-manager.js'; +import { CODEMIE_ANALYTICS_PROJECT_FILTER_ENV } from '@/agents/plugins/claude-code-otlp/claude-code-otlp.allowlist.js'; import { resolveProjectRoot } from '@/utils/project-root.js'; import { resolveHomeDir } from '@/utils/paths.js'; import { logger } from '@/utils/logger.js'; @@ -108,6 +109,7 @@ describe('claude-code-otlp connector', () => { backupPath: null, hookEvents: HOOK_EVENTS.length, envVars: CODEMIE_ENV_KEYS.length, + allowlist: [], }); expect(existsSync(join(homeDir, '.claude'))).toBe(true); @@ -115,20 +117,45 @@ describe('claude-code-otlp connector', () => { expect(existsSync(expectedPath + SETTINGS_BACKUP_SUFFIX)).toBe(false); const settings = await readJson(expectedPath); - expect(settings.env).toEqual(buildExpectedEnv(mockState)); + expect(settings.env).toEqual({ ...buildExpectedEnv(mockState), [CODEMIE_ANALYTICS_PROJECT_FILTER_ENV]: '[]' }); for (const event of HOOK_EVENTS) { expect(settings.hooks[event]).toEqual([codemieHookGroup()]); } }); - it('writes to project root when scope is "project"', async () => { + it('writes to the user-level settings and tracks the project root when scope is "project"', async () => { const result = await writeClaudeCodeOtlpConfig({ scope: 'project' }); - const expectedPath = join(projectDir, '.claude', 'settings.json'); + const expectedPath = join(homeDir, '.claude', 'settings.json'); expect(result.path).toBe(expectedPath); - expect(existsSync(expectedPath)).toBe(true); - expect(existsSync(join(homeDir, '.claude', 'settings.json'))).toBe(false); + expect(existsSync(join(projectDir, '.claude', 'settings.json'))).toBe(false); + + const canonicalRoot = await realpath(projectDir); + expect(result.allowlist).toEqual([canonicalRoot]); + expect((await readJson(expectedPath)).env[CODEMIE_ANALYTICS_PROJECT_FILTER_ENV]).toBe(JSON.stringify([canonicalRoot])); + + const again = await writeClaudeCodeOtlpConfig({ scope: 'project' }); + expect(again.allowlist).toEqual([canonicalRoot]); + }); + + it('resets the allowlist to [] on a user-scope rerun', async () => { + await writeClaudeCodeOtlpConfig({ scope: 'project' }); + const result = await writeClaudeCodeOtlpConfig({ scope: 'user' }); + expect(result.allowlist).toEqual([]); + }); + + it('aborts before writing when the existing allowlist is invalid, unless forced', async () => { + const settingsPath = join(homeDir, '.claude', 'settings.json'); + await mkdir(join(homeDir, '.claude'), { recursive: true }); + const original = JSON.stringify({ env: { [CODEMIE_ANALYTICS_PROJECT_FILTER_ENV]: 'not-json' } }); + await writeFile(settingsPath, original); + + await expect(writeClaudeCodeOtlpConfig()).rejects.toBeInstanceOf(ConfigurationError); + expect(await readRaw(settingsPath)).toBe(original); + + const forced = await writeClaudeCodeOtlpConfig({ force: true }); + expect(forced.allowlist).toEqual([]); }); it('writes to home dir when scope is "user"', async () => { @@ -249,7 +276,7 @@ describe('claude-code-otlp connector', () => { expect(result.written).toBe(true); const merged = await readJson(settingsPath); - expect(merged.env).toEqual({ KEEP_ME: 'yes', ...buildExpectedEnv(mockState) }); + expect(merged.env).toEqual({ KEEP_ME: 'yes', ...buildExpectedEnv(mockState), [CODEMIE_ANALYTICS_PROJECT_FILTER_ENV]: '[]' }); }); it('does not require force when existing env values already match the desired codemie values', async () => { @@ -325,7 +352,7 @@ describe('claude-code-otlp connector', () => { expect(await readRaw(result.backupPath!)).toBe(''); const merged = await readJson(settingsPath); - expect(merged.env).toEqual(buildExpectedEnv(mockState)); + expect(merged.env).toEqual({ ...buildExpectedEnv(mockState), [CODEMIE_ANALYTICS_PROJECT_FILTER_ENV]: '[]' }); for (const event of HOOK_EVENTS) { expect(merged.hooks[event]).toEqual([codemieHookGroup()]); } @@ -542,14 +569,14 @@ describe('claude-code-otlp connector', () => { it('returns removed:false and touches nothing when no settings file exists', async () => { const result = await removeClaudeCodeOtlpConfig(); - expect(result).toEqual({ removed: false, usedBackup: false, path: null }); + expect(result).toEqual({ mode: "noop", reason: "no Claude Code settings file found", removed: false, usedBackup: false, path: null }); expect(existsSync(join(homeDir, '.claude'))).toBe(false); }); it('resolves the project root when scope is "project"', async () => { const result = await removeClaudeCodeOtlpConfig({ scope: 'project' }); - expect(result).toEqual({ removed: false, usedBackup: false, path: null }); + expect(result).toEqual({ mode: "noop", reason: "no Claude Code settings file found", removed: false, usedBackup: false, path: null }); }); it('does not require a live proxy daemon', async () => { @@ -578,7 +605,7 @@ describe('claude-code-otlp connector', () => { const result = await removeClaudeCodeOtlpConfig(); - expect(result).toEqual({ removed: true, usedBackup: false, path: settingsPath }); + expect(result).toEqual({ mode: "full", removed: true, usedBackup: false, path: settingsPath }); const final = await readJson(settingsPath); expect(final.theme).toBe('dark'); expect(final.env).toEqual({ FOO: 'bar' }); @@ -625,7 +652,7 @@ describe('claude-code-otlp connector', () => { const result = await removeClaudeCodeOtlpConfig(); - expect(result).toEqual({ removed: true, usedBackup: true, path: settingsPath }); + expect(result).toEqual({ mode: "full", removed: true, usedBackup: true, path: settingsPath }); expect(await readJson(settingsPath)).toEqual(originalBackup); expect(existsSync(backupPath)).toBe(false); }); @@ -652,7 +679,7 @@ describe('claude-code-otlp connector', () => { const result = await removeClaudeCodeOtlpConfig(); - expect(result).toEqual({ removed: true, usedBackup: false, path: settingsPath }); + expect(result).toEqual({ mode: "full", removed: true, usedBackup: false, path: settingsPath }); expect(existsSync(settingsPath)).toBe(false); }); @@ -767,7 +794,7 @@ describe('claude-code-otlp connector', () => { const result = await removeClaudeCodeOtlpConfig(); - expect(result).toEqual({ removed: false, usedBackup: false, path: settingsPath }); + expect(result).toEqual({ mode: "noop", reason: "no CodeMie entries found", removed: false, usedBackup: false, path: settingsPath }); expect(await readRaw(settingsPath)).toBe(raw); expect(logger.info).not.toHaveBeenCalled(); }); @@ -779,11 +806,28 @@ describe('claude-code-otlp connector', () => { const result = await removeClaudeCodeOtlpConfig(); - expect(result).toEqual({ removed: false, usedBackup: false, path: settingsPath }); + expect(result).toEqual({ mode: "noop", reason: "no CodeMie entries found", removed: false, usedBackup: false, path: settingsPath }); expect(await readRaw(settingsPath)).toBe('{}'); expect(existsSync(backupPath)).toBe(true); }); + it('reports an "absent" reason for project scope when the allowlist key is not set', async () => { + const settingsPath = await seedSettings( + JSON.stringify({ theme: 'dark', env: { OTEL_LOGS_EXPORTER: 'otlp' }, hooks: { Stop: [codemieHookGroup()] } }) + ); + + const result = await removeClaudeCodeOtlpConfig({ scope: 'project' }); + + expect(result).toEqual({ + mode: 'noop', + reason: `${CODEMIE_ANALYTICS_PROJECT_FILTER_ENV} is not set`, + removed: false, + usedBackup: false, + path: settingsPath, + }); + expect(result.reason).toBe('CODEMIE_ANALYTICS_PROJECT_FILTER is not set'); + }); + it('returns removed:true when only codemie env keys are present (no hooks)', async () => { const settingsPath = await seedSettings( JSON.stringify({ theme: 'dark', env: { FOO: 'bar', OTEL_LOGS_EXPORTER: 'otlp' } }) @@ -791,7 +835,7 @@ describe('claude-code-otlp connector', () => { const result = await removeClaudeCodeOtlpConfig(); - expect(result).toEqual({ removed: true, usedBackup: false, path: settingsPath }); + expect(result).toEqual({ mode: "full", removed: true, usedBackup: false, path: settingsPath }); const final = await readJson(settingsPath); expect(final.env).toEqual({ FOO: 'bar' }); expect(final.theme).toBe('dark'); @@ -804,7 +848,7 @@ describe('claude-code-otlp connector', () => { const result = await removeClaudeCodeOtlpConfig(); - expect(result).toEqual({ removed: true, usedBackup: false, path: settingsPath }); + expect(result).toEqual({ mode: "full", removed: true, usedBackup: false, path: settingsPath }); const final = await readJson(settingsPath); expect(final.hooks).toBeUndefined(); expect(final.theme).toBe('dark'); diff --git a/src/cli/commands/proxy/connectors/claude-code-otlp.ts b/src/cli/commands/proxy/connectors/claude-code-otlp.ts index 53ad8a426..cb54831c4 100644 --- a/src/cli/commands/proxy/connectors/claude-code-otlp.ts +++ b/src/cli/commands/proxy/connectors/claude-code-otlp.ts @@ -7,15 +7,21 @@ import { existsSync } from 'node:fs'; import { copyFile, readFile, unlink } from 'node:fs/promises'; -import { join } from 'node:path'; import { ConfigurationError } from '@/utils/errors.js'; import { logger } from '@/utils/logger.js'; import { sanitizeLogArgs } from '@/utils/security.js'; import { resolveProjectRoot } from '@/utils/project-root.js'; -import { resolveHomeDir } from '@/utils/paths.js'; import { readState } from '../daemon-manager.js'; import { writeAtomically } from './vscode.js'; import { CLAUDE_CODE_OTLP_AGENT_NAME } from '@/agents/plugins/claude-code-otlp/claude-code-otlp.constants.js'; +import { + CODEMIE_ANALYTICS_PROJECT_FILTER_ENV, + addProjectPath, + canonicalizePath, + getClaudeSettingsPath, + parseAllowlist, + removeProjectPath, +} from '@/agents/plugins/claude-code-otlp/claude-code-otlp.allowlist.js'; interface WriteClaudeCodeOtlpOptions { force?: boolean; @@ -28,6 +34,8 @@ interface WriteClaudeCodeOtlpResult { backupPath: string | null; hookEvents: number; envVars: number; + /** Tracked project roots; empty means all projects. */ + allowlist: string[]; } interface RemoveClaudeCodeOtlpOptions { @@ -38,6 +46,12 @@ interface RemoveClaudeCodeOtlpResult { removed: boolean; usedBackup: boolean; path: string | null; + /** 'entry-removed': only this project's allowlist entry was dropped; 'full': all codemie wiring removed. */ + mode: 'entry-removed' | 'full' | 'noop'; + /** Human-readable reason when `removed` is false. */ + reason?: string; + /** Remaining tracked project roots after an 'entry-removed' operation. */ + allowlist?: string[]; } interface HookEntry { @@ -59,6 +73,7 @@ interface ClaudeSettings { [key: string]: unknown; } +// CODEMIE_ANALYTICS_PROJECT_FILTER_ENV is deliberately not listed here: its value is managed per scope, not fixed. export const CODEMIE_ENV_KEYS = [ 'CLAUDE_CODE_ENABLE_TELEMETRY', 'CLAUDE_CODE_ENHANCED_TELEMETRY_BETA', @@ -90,7 +105,10 @@ export const SETTINGS_BACKUP_SUFFIX = '.codemie-backup'; export const CODEMIE_COMMAND_MARKER = `hook --agent ${CLAUDE_CODE_OTLP_AGENT_NAME}`; async function readSettingsFile(settingsPath: string): Promise { - if (!existsSync(settingsPath)) return {}; + if (!existsSync(settingsPath)) { + return {}; + } + let raw: string; try { raw = await readFile(settingsPath, 'utf-8'); @@ -100,7 +118,11 @@ async function readSettingsFile(settingsPath: string): Promise { `${error instanceof Error ? error.message : String(error)}` ); } - if (raw.trim().length === 0) return {}; + + if (raw.trim().length === 0) { + return {}; + } + try { const parsed: unknown = JSON.parse(raw); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { @@ -108,7 +130,9 @@ async function readSettingsFile(settingsPath: string): Promise { } return parsed as ClaudeSettings; } catch (error) { - if (error instanceof ConfigurationError) throw error; + if (error instanceof ConfigurationError) { + throw error; + } throw new ConfigurationError( `Claude Code settings at ${settingsPath} is not valid JSON and was not changed.` ); @@ -179,8 +203,8 @@ export async function writeClaudeCodeOtlpConfig( throw new ConfigurationError('No live proxy daemon. Run: codemie proxy start'); } - const basePath = opts.scope === 'project' ? resolveProjectRoot() : resolveHomeDir(); - const settingsPath = join(basePath, '.claude', 'settings.json'); + // Always user-level: the allowlist (not the settings location) decides which projects are tracked. + const settingsPath = getClaudeSettingsPath(); const existing = await readSettingsFile(settingsPath); const existingHooksBlock = existing.hooks ?? {}; @@ -231,6 +255,22 @@ export async function writeClaudeCodeOtlpConfig( ); } + // --- Allowlist pre-check (before anything is written) --- + const rawAllowlist: unknown = existingEnv[CODEMIE_ANALYTICS_PROJECT_FILTER_ENV]; + const existingAllowlist = parseAllowlist(rawAllowlist); + if (existingAllowlist.kind === 'invalid' && !opts.force) { + throw new ConfigurationError( + `Claude Code settings contain an invalid ${CODEMIE_ANALYTICS_PROJECT_FILTER_ENV} value: ${JSON.stringify(rawAllowlist)}. ` + + `Fix it manually (expected a JSON array of absolute paths, e.g. '["/path/to/project"]') or re-run with --force to discard it.` + ); + } + + let allowlist: string[] = []; + if (opts.scope === 'project') { + const current = existingAllowlist.kind === 'valid' ? existingAllowlist.paths : []; + allowlist = await addProjectPath(current, resolveProjectRoot()); + } + // --- Backup on first modification (no existing codemie entry, no existing backup) --- let backupPath: string | null = null; if (existsSync(settingsPath)) { @@ -279,7 +319,11 @@ export async function writeClaudeCodeOtlpConfig( } // --- Merge env block --- - const mergedEnv: Record = { ...(existing.env ?? {}), ...codemieEnv }; + const mergedEnv: Record = { + ...(existing.env ?? {}), + ...codemieEnv, + [CODEMIE_ANALYTICS_PROJECT_FILTER_ENV]: JSON.stringify(allowlist), + }; const merged: ClaudeSettings = { ...existing, @@ -295,7 +339,7 @@ export async function writeClaudeCodeOtlpConfig( logger.info( '[proxy] Configured Claude Code analytics', - ...sanitizeLogArgs({ settingsPath, backupPath, hookEvents: hookEventsCount, envVars: envVarsCount }) + ...sanitizeLogArgs({ settingsPath, backupPath, hookEvents: hookEventsCount, envVars: envVarsCount, allowlist }) ); return { @@ -304,21 +348,56 @@ export async function writeClaudeCodeOtlpConfig( backupPath, hookEvents: hookEventsCount, envVars: envVarsCount, + allowlist, }; } export async function removeClaudeCodeOtlpConfig( opts: RemoveClaudeCodeOtlpOptions = {} ): Promise { - const basePath = opts.scope === 'project' ? resolveProjectRoot() : resolveHomeDir(); - const settingsPath = join(basePath, '.claude', 'settings.json'); + const settingsPath = getClaudeSettingsPath(); if (!existsSync(settingsPath)) { - return { removed: false, usedBackup: false, path: null }; + return { removed: false, usedBackup: false, path: null, mode: 'noop', reason: 'no Claude Code settings file found' }; } const existing = await readSettingsFile(settingsPath); + if (opts.scope === 'project') { + const allowlist = parseAllowlist(existing.env?.[CODEMIE_ANALYTICS_PROJECT_FILTER_ENV]); + if (allowlist.kind !== 'valid' || allowlist.paths.length === 0) { + let reason: string; + if (allowlist.kind === 'invalid') { + reason = `${CODEMIE_ANALYTICS_PROJECT_FILTER_ENV} is invalid; fix it manually or run disconnect without --scope project`; + } else if (allowlist.kind === 'absent') { + reason = `${CODEMIE_ANALYTICS_PROJECT_FILTER_ENV} is not set`; + } else { + reason = 'all projects are tracked (no per-project entries)'; + } + return { removed: false, usedBackup: false, path: settingsPath, mode: 'noop', reason }; + } + + const projectRoot = resolveProjectRoot(); + const remaining = await removeProjectPath(allowlist.paths, projectRoot); + if (remaining.length === allowlist.paths.length) { + return { + removed: false, + usedBackup: false, + path: settingsPath, + mode: 'noop', + reason: `project ${await canonicalizePath(projectRoot)} is not in the allowlist`, + }; + } + + if (remaining.length > 0) { + const env = { ...(existing.env ?? {}), [CODEMIE_ANALYTICS_PROJECT_FILTER_ENV]: JSON.stringify(remaining) }; + await writeAtomically(settingsPath, JSON.stringify({ ...existing, env }, null, 2) + '\n'); + logger.info('[proxy] Removed project from Claude Code analytics allowlist', ...sanitizeLogArgs({ settingsPath, remaining })); + return { removed: true, usedBackup: false, path: settingsPath, mode: 'entry-removed', allowlist: remaining }; + } + // Last entry removed: fall through to the full removal below + } + let removedHookCommands = false; const hooks: Record = {}; for (const [eventName, entries] of Object.entries(existing.hooks ?? {})) { @@ -337,7 +416,7 @@ export async function removeClaudeCodeOtlpConfig( const env: Record = { ...(existing.env ?? {}) }; let removedEnvKeys = false; - for (const key of CODEMIE_ENV_KEYS) { + for (const key of [...CODEMIE_ENV_KEYS, CODEMIE_ANALYTICS_PROJECT_FILTER_ENV]) { if (key in env) { removedEnvKeys = true; delete env[key]; @@ -345,7 +424,7 @@ export async function removeClaudeCodeOtlpConfig( } if (!removedHookCommands && !removedEnvKeys) { - return { removed: false, usedBackup: false, path: settingsPath }; + return { removed: false, usedBackup: false, path: settingsPath, mode: 'noop', reason: 'no CodeMie entries found' }; } const stripped: ClaudeSettings = { ...existing }; @@ -369,15 +448,15 @@ export async function removeClaudeCodeOtlpConfig( await writeAtomically(settingsPath, backupContent); await unlink(backupPath); logger.info('[proxy] Removed Claude Code analytics config (restored backup)', ...sanitizeLogArgs({ settingsPath })); - return { removed: true, usedBackup: true, path: settingsPath }; + return { removed: true, usedBackup: true, path: settingsPath, mode: 'full' }; } else { await unlink(settingsPath); logger.info('[proxy] Removed Claude Code analytics config (deleted settings)', ...sanitizeLogArgs({ settingsPath })); - return { removed: true, usedBackup: false, path: settingsPath }; + return { removed: true, usedBackup: false, path: settingsPath, mode: 'full' }; } } await writeAtomically(settingsPath, JSON.stringify(stripped, null, 2) + '\n'); logger.info('[proxy] Removed Claude Code analytics entries from settings', ...sanitizeLogArgs({ settingsPath })); - return { removed: true, usedBackup: false, path: settingsPath }; + return { removed: true, usedBackup: false, path: settingsPath, mode: 'full' }; } diff --git a/src/cli/commands/proxy/disconnect-orchestrator.ts b/src/cli/commands/proxy/disconnect-orchestrator.ts index db7198e18..ac90d9820 100644 --- a/src/cli/commands/proxy/disconnect-orchestrator.ts +++ b/src/cli/commands/proxy/disconnect-orchestrator.ts @@ -62,7 +62,17 @@ async function disconnectClaudeCodeOtlp(scope?: 'user' | 'project'): Promise', - `Settings scope for --${CLAUDE_CODE_OTLP_AGENT_NAME}: "user" (default) or "project"`, + `Tracking scope for --${CLAUDE_CODE_OTLP_AGENT_NAME}: "user" (default) tracks all projects and resets the project list; "project" adds only the current project`, ) .default('user') .choices(['user', 'project']), @@ -331,11 +341,11 @@ export function createProxyCommand(): Command { .command('disconnect') .description('Remove CodeMie proxy configuration from a client') .option('--codex-desktop', 'Remove the CodeMie block from ~/.codex/config.toml') - .option(`--${CLAUDE_CODE_OTLP_AGENT_NAME}`, 'Configure Claude Code analytics hooks and OTLP settings') + .option(`--${CLAUDE_CODE_OTLP_AGENT_NAME}`, 'Remove Claude Code analytics hooks, OTLP settings and project allowlist') .addOption( new Option( '--scope ', - `Settings scope for --${CLAUDE_CODE_OTLP_AGENT_NAME}: "user" (default) or "project"`, + `Tracking scope for --${CLAUDE_CODE_OTLP_AGENT_NAME}: "user" (default) removes all CodeMie wiring; "project" removes only the current project from the tracked list`, ) .default('user') .choices(['user', 'project']), diff --git a/src/providers/plugins/sso/proxy/plugins/otlp-spool/__tests__/completeness-gate.test.ts b/src/providers/plugins/sso/proxy/plugins/otlp-spool/__tests__/completeness-gate.test.ts new file mode 100644 index 000000000..d5a5e5aa8 --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/otlp-spool/__tests__/completeness-gate.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { gateDecision } from '../completeness-gate.js'; +import { hooksOnlyWaitTicks } from '../spool-config.js'; +import { createStatus } from '../session-status.js'; +import type { SpoolState, StreamState } from '../spool-state.js'; + +const stream = (size: number): StreamState => ({ exists: size > 0, size, cursor: 0, mtimeMs: size > 0 ? 1 : null }); + +function spool(data: { hooks?: number; logs?: number }): SpoolState { + return { + sessionId: 's1', + streams: { + hooks: stream(data.hooks ?? 0), + logs: stream(data.logs ?? 0), + metrics: stream(0), + traces: stream(0), + }, + latestWriteMs: null, + }; +} + +const statusWith = (waitTicks: number) => ({ ...createStatus(), waitTicks }); + +describe('gateDecision', () => { + const original = process.env.OTLP_ALLOW_HOOKS_ONLY_FORWARD; + afterEach(() => { + if (original === undefined) { + delete process.env.OTLP_ALLOW_HOOKS_ONLY_FORWARD; + } + else process.env.OTLP_ALLOW_HOOKS_ONLY_FORWARD = original; + }); + + it('sends when hooks and OTEL are both present', () => { + expect(gateDecision(spool({ hooks: 1, logs: 1 }), statusWith(0))).toBe('send'); + }); + + it('is a noop when neither group is present', () => { + expect(gateDecision(spool({}), statusWith(0))).toBe('noop'); + }); + + it('waits for OTEL-only sessions below the limit and skips at the limit', () => { + const limit = hooksOnlyWaitTicks(); + expect(gateDecision(spool({ logs: 1 }), statusWith(limit - 1))).toBe('wait'); + expect(gateDecision(spool({ logs: 1 }), statusWith(limit))).toBe('skip'); + }); + + it('never sends OTEL-only data, for any waitTicks and hooks-only setting', () => { + for (const setting of ['true', 'false']) { + process.env.OTLP_ALLOW_HOOKS_ONLY_FORWARD = setting; + for (let ticks = 0; ticks < 50; ticks++) { + expect(['wait', 'skip']).toContain(gateDecision(spool({ logs: 1 }), statusWith(ticks))); + } + } + }); + + it('keeps the hooks-only behavior', () => { + const limit = hooksOnlyWaitTicks(); + process.env.OTLP_ALLOW_HOOKS_ONLY_FORWARD = 'true'; + expect(gateDecision(spool({ hooks: 1 }), statusWith(limit - 1))).toBe('wait'); + expect(gateDecision(spool({ hooks: 1 }), statusWith(limit))).toBe('hooks-only-force'); + }); +}); diff --git a/src/providers/plugins/sso/proxy/plugins/otlp-spool/__tests__/tick-processor.test.ts b/src/providers/plugins/sso/proxy/plugins/otlp-spool/__tests__/tick-processor.test.ts new file mode 100644 index 000000000..9fb6c5a66 --- /dev/null +++ b/src/providers/plugins/sso/proxy/plugins/otlp-spool/__tests__/tick-processor.test.ts @@ -0,0 +1,135 @@ +/** + * Tick processor invariant: OTEL-only sessions are never forwarded. + * @group unit + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { SSOCredentials } from '../../../../../../core/types.js'; + +const state = vi.hoisted(() => ({ root: '' })); + +vi.mock('../spool-paths.js', async (importOriginal) => { + const actual = await importOriginal(); + const { join: pathJoin } = await import('node:path'); + return { + ...actual, + spoolRoot: (): string => state.root, + streamFile: (sessionId: string, stream: string): string => { + const ext: Record = { + hooks: '.hooks.ndjson', + logs: '.otel_logs.bin', + metrics: '.otel_metrics.bin', + traces: '.otel_traces.bin', + }; + return pathJoin(state.root, sessionId + ext[stream]); + }, + statusFile: (sessionId: string): string => pathJoin(state.root, sessionId + '.status'), + }; +}); + +vi.mock('../forwarder.js', () => ({ forwardSession: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../auth-state.js', () => ({ areCredentialsStale: vi.fn().mockReturnValue(false) })); +vi.mock('@/utils/logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const credentials = { cookies: {}, apiUrl: 'http://localhost' } as unknown as SSOCredentials; +const SESSION = 'sess-1'; + +async function seed(files: Record): Promise { + for (const [name, content] of Object.entries(files)) { + await writeFile(join(state.root, name), content); + } +} + +async function readStatusRaw(): Promise<{ cursors: Record; waitTicks: number }> { + const { readStatus } = await import('../session-status.js'); + const status = await readStatus(SESSION); + if (!status) throw new Error('status missing'); + return status; +} + +describe('processSessionTick (OTEL-only invariant)', () => { + beforeEach(async () => { + state.root = await mkdtemp(join(tmpdir(), 'otlp-tick-')); + await mkdir(state.root, { recursive: true }); + delete process.env.OTLP_SEND_MAX_ATTEMPTS; + }); + + afterEach(async () => { + vi.clearAllMocks(); + await rm(state.root, { recursive: true, force: true }); + }); + + async function limit(): Promise { + const { hooksOnlyWaitTicks } = await import('../spool-config.js'); + return hooksOnlyWaitTicks(); + } + + it('skips an OTEL-only session past the wait limit: cursors reach file sizes and nothing is forwarded', async () => { + const { writeStatus, createStatus } = await import('../session-status.js'); + const { forwardSession } = await import('../forwarder.js'); + const { processSessionTick } = await import('../tick-processor.js'); + await seed({ [`${SESSION}.otel_logs.bin`]: 'abcde', [`${SESSION}.otel_traces.bin`]: 'xyz' }); + await writeStatus(SESSION, { ...createStatus(), waitTicks: await limit() }); + + await expect( + Promise.race([ + processSessionTick(SESSION, credentials), + new Promise((_, reject) => setTimeout(() => reject(new Error('deadlock')), 2000)), + ]) + ).resolves.toBeUndefined(); + + const status = await readStatusRaw(); + expect(status.cursors).toEqual({ hooks: 0, logs: 5, metrics: 0, traces: 3 }); + expect(forwardSession).not.toHaveBeenCalled(); + }); + + it('does not rewrite the status when the session is already drained', async () => { + const { writeStatus, createStatus } = await import('../session-status.js'); + const { forwardSession } = await import('../forwarder.js'); + const { processSessionTick } = await import('../tick-processor.js'); + await seed({ [`${SESSION}.otel_logs.bin`]: 'abcde' }); + const status = createStatus(); + status.cursors.logs = 5; + status.waitTicks = await limit(); + await writeStatus(SESSION, status); + const before = await stat(join(state.root, `${SESSION}.status`)); + await new Promise((resolve) => setTimeout(resolve, 20)); + + await processSessionTick(SESSION, credentials); + + const after = await stat(join(state.root, `${SESSION}.status`)); + expect(after.mtimeMs).toBe(before.mtimeMs); + expect(forwardSession).not.toHaveBeenCalled(); + }); + + it('increments waitTicks and keeps the data below the wait limit', async () => { + const { writeStatus, createStatus } = await import('../session-status.js'); + const { forwardSession } = await import('../forwarder.js'); + const { processSessionTick } = await import('../tick-processor.js'); + await seed({ [`${SESSION}.otel_logs.bin`]: 'abcde' }); + await writeStatus(SESSION, { ...createStatus(), waitTicks: 0 }); + + await processSessionTick(SESSION, credentials); + + const status = await readStatusRaw(); + expect(status.waitTicks).toBe(1); + expect(status.cursors.logs).toBe(0); + expect(forwardSession).not.toHaveBeenCalled(); + }); + + it('still forwards when hooks and OTEL are both present', async () => { + const { writeStatus, createStatus } = await import('../session-status.js'); + const { forwardSession } = await import('../forwarder.js'); + const { processSessionTick } = await import('../tick-processor.js'); + await seed({ [`${SESSION}.otel_logs.bin`]: 'abcde', [`${SESSION}.hooks.ndjson`]: '{}\n' }); + await writeStatus(SESSION, createStatus()); + + await processSessionTick(SESSION, credentials); + + expect(forwardSession).toHaveBeenCalledWith(SESSION, false, credentials); + }); +}); diff --git a/src/providers/plugins/sso/proxy/plugins/otlp-spool/completeness-gate.ts b/src/providers/plugins/sso/proxy/plugins/otlp-spool/completeness-gate.ts index 91405a71c..b57eb14c5 100644 --- a/src/providers/plugins/sso/proxy/plugins/otlp-spool/completeness-gate.ts +++ b/src/providers/plugins/sso/proxy/plugins/otlp-spool/completeness-gate.ts @@ -4,20 +4,31 @@ * Stream presence is derived from the spool files themselves (a non-empty file * means the stream was written, even once its cursor reached EOF), so the gate * decision is sticky in the same way the old `*Written` flags were. + * + * INVARIANT - OTEL-only sessions are NEVER forwarded, by design. This is what + * implements the per-project allowlist on the daemon side: the hook process + * does not forward hooks for untracked projects, so their sessions reach + * the daemon as OTEL only (OTLP carries no cwd and cannot be filtered here). + * Never add a path that sends OTEL-only data (for example an "otel-only-force" + * mirroring `hooks-only-force`) without first adding daemon-side project + * filtering, or data from untracked projects will be sent to the backend. */ import { hooksOnlyForwardAllowed, hooksOnlyWaitTicks } from './spool-config.js'; import { hooksGroupPresent, otelGroupPresent, type SpoolState } from './spool-state.js'; import type { SessionStatus } from './session-status.js'; -export type GateDecision = 'send' | 'hooks-only-force' | 'wait' | 'noop'; +export type GateDecision = 'send' | 'hooks-only-force' | 'wait' | 'skip' | 'noop'; /** * Case A (HOOKS and OTEL groups both present) -> 'send' * Case B (hooks only, waited long enough) -> 'hooks-only-force' * (hooks only, not waited enough) -> 'wait' - * (OTEL only) -> 'wait' - * Case C (neither group) -> 'noop' + * Case C (OTEL only, not waited enough) -> 'wait' + * (OTEL only, waited long enough) -> 'skip' (cursors advanced, never sent) + * Case D (neither group) -> 'noop' + * + * 'send' and 'hooks-only-force' are reachable ONLY with hooks data present. */ export function gateDecision(spool: SpoolState, status: SessionStatus): GateDecision { const hooks = hooksGroupPresent(spool); @@ -37,5 +48,6 @@ export function gateDecision(spool: SpoolState, status: SessionStatus): GateDeci : 'wait'; } - return 'wait'; // OTEL only — wait for hooks + // OTEL only: wait for hooks, then skip. Never 'send' - see INVARIANT in the file header. + return status.waitTicks >= hooksOnlyWaitTicks() ? 'skip' : 'wait'; } diff --git a/src/providers/plugins/sso/proxy/plugins/otlp-spool/forwarder.ts b/src/providers/plugins/sso/proxy/plugins/otlp-spool/forwarder.ts index 418973423..358e3e544 100644 --- a/src/providers/plugins/sso/proxy/plugins/otlp-spool/forwarder.ts +++ b/src/providers/plugins/sso/proxy/plugins/otlp-spool/forwarder.ts @@ -53,7 +53,9 @@ interface ForwardContext { function decodeJwtClaims(token: string): Record { const parts = token.split('.'); - if (parts.length < 2) return {}; + if (parts.length < 2) { + return {}; + } try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as Record< string, @@ -67,14 +69,18 @@ function decodeJwtClaims(token: string): Record { function resolveUserEmail(credentials: SSOCredentials | JWTCredentials): string { if (isJWTCredentials(credentials)) { const claims = decodeJwtClaims(credentials.token); - if (typeof claims['email'] === 'string' && claims['email']) return claims['email']; + if (typeof claims['email'] === 'string' && claims['email']) { + return claims['email']; + } } if (isSSOCredentials(credentials)) { const accessToken = credentials.cookies['codemie_access_token']; if (accessToken) { const claims = decodeJwtClaims(accessToken); const email = claims['email'] ?? claims['preferred_username']; - if (typeof email === 'string' && email) return email; + if (typeof email === 'string' && email) { + return email; + } } } return ''; @@ -83,8 +89,12 @@ function resolveUserEmail(credentials: SSOCredentials | JWTCredentials): string function buildAuthHeadersFromCreds( credentials: SSOCredentials | JWTCredentials ): Record | null { - if (isSSOCredentials(credentials)) return buildAuthHeaders(credentials.cookies); - if (isJWTCredentials(credentials)) return buildAuthHeaders(credentials.token); + if (isSSOCredentials(credentials)) { + return buildAuthHeaders(credentials.cookies); + } + if (isJWTCredentials(credentials)) { + return buildAuthHeaders(credentials.token); + } return null; } @@ -97,7 +107,9 @@ async function postToBackend( credentials: SSOCredentials | JWTCredentials ): Promise { const headers = buildAuthHeadersFromCreds(credentials); - if (!headers) throw new Error('Unsupported credential type'); + if (!headers) { + throw new Error('Unsupported credential type'); + } headers['Content-Type'] = contentType; const controller = new AbortController(); @@ -132,7 +144,9 @@ async function send( let response = await postToBackend(url, body, contentType, credentials); if (isAuthFailure(response)) { response = await postToBackend(url, body, contentType, credentials); - if (isAuthFailure(response)) return 'auth-expired'; + if (isAuthFailure(response)) { + return 'auth-expired'; + } } if (response.ok) { return 'ok'; @@ -166,7 +180,9 @@ function hookEventType(hookName: string, event: Record): string } function boundedText(value: unknown, maxChars: number): string { - if (value === undefined || value === null) return ''; + if (value === undefined || value === null) { + return ''; + } const text = typeof value === 'string' ? value @@ -198,7 +214,9 @@ function limitHookPayload(hookEvent: Record): Record { - if (!cwd || ctx.git.branch !== undefined) return; + if (!cwd || ctx.git.branch !== undefined) { + return; + } try { const { detectGitBranch, detectGitRemoteRepo } = await import('@/utils/processes.js'); const [branch, remote] = await Promise.all([ @@ -237,7 +255,9 @@ async function mapHookRecords(records: string[], ctx: ForwardContext): Promise { const pending = await snapshotPendingBytes(sessionId, stream); - if (!pending) return 'idle'; + if (!pending) { + return 'idle'; + } const url = `${ctx.baseUrl}${OTEL_ENDPOINTS[stream]}`; const result = await send( sessionId, stream, url, pending.bytes, 'application/x-protobuf', ctx.credentials ); - if (result !== 'ok') return result; + if (result !== 'ok') { + return result; + } await advanceCursor(sessionId, stream, pending.cursor + pending.bytes.length); return 'ok'; diff --git a/src/providers/plugins/sso/proxy/plugins/otlp-spool/session-status.ts b/src/providers/plugins/sso/proxy/plugins/otlp-spool/session-status.ts index 46de0602e..bb2017aee 100644 --- a/src/providers/plugins/sso/proxy/plugins/otlp-spool/session-status.ts +++ b/src/providers/plugins/sso/proxy/plugins/otlp-spool/session-status.ts @@ -11,7 +11,7 @@ export interface SessionStatus { * file and is only advanced after the backend acknowledges those bytes. */ cursors: SessionCursors; - /** Consecutive ticks a hooks-only session has waited for OTEL data. */ + /** Consecutive ticks a session has waited for its missing group (hooks-only or OTEL-only). */ waitTicks: number; /** * Time when a successfully forwarded hooks batch contained `SessionEnd`. @@ -35,7 +35,9 @@ function toOffset(value: unknown): number { */ function normalizeStatus(raw: unknown): SessionStatus { const status = createStatus(); - if (typeof raw !== 'object' || raw === null) return status; + if (typeof raw !== 'object' || raw === null) { + return status; + } const source = raw as Record; const cursors = (source['cursors'] ?? {}) as Record; @@ -44,7 +46,9 @@ function normalizeStatus(raw: unknown): SessionStatus { } status.waitTicks = toOffset(source['waitTicks']); const endedAt = toOffset(source['endedAt']); - if (endedAt > 0) status.endedAt = endedAt; + if (endedAt > 0) { + status.endedAt = endedAt; + } return status; } @@ -104,7 +108,9 @@ export async function advanceCursor( * Caller MUST already hold the session lock. */ export async function ensureStatusLocked(sessionId: string): Promise { - if (await readStatus(sessionId)) return; + if (await readStatus(sessionId)) { + return; + } await writeStatus(sessionId, createStatus()); } diff --git a/src/providers/plugins/sso/proxy/plugins/otlp-spool/spool-config.ts b/src/providers/plugins/sso/proxy/plugins/otlp-spool/spool-config.ts index af7d9acb3..e0343cfe1 100644 --- a/src/providers/plugins/sso/proxy/plugins/otlp-spool/spool-config.ts +++ b/src/providers/plugins/sso/proxy/plugins/otlp-spool/spool-config.ts @@ -42,6 +42,10 @@ export function sweepIntervalMs(): number { * `sendIntervalMs() * hooksOnlyWaitTicks()` — currently ~6 minutes, chosen to * out-wait one OTEL exporter flush cycle (~60s) plus a send interval. * Revisit this value whenever OTLP_SEND_INTERVAL_MINUTES changes. + * + * The same tick limit also bounds how long an OTEL-only session waits for hooks + * before being skipped, so changing it changes the untracked-data retention + * window and the late-hooks tolerance for tracked sessions. */ export function hooksOnlyWaitTicks(): number { return envCount('OTLP_SEND_MAX_ATTEMPTS', 3); diff --git a/src/providers/plugins/sso/proxy/plugins/otlp-spool/tick-processor.ts b/src/providers/plugins/sso/proxy/plugins/otlp-spool/tick-processor.ts index 97dc7a516..92665bd17 100644 --- a/src/providers/plugins/sso/proxy/plugins/otlp-spool/tick-processor.ts +++ b/src/providers/plugins/sso/proxy/plugins/otlp-spool/tick-processor.ts @@ -3,6 +3,7 @@ import { sanitizeLogArgs } from '@/utils/security.js'; import type { SSOCredentials, JWTCredentials } from '../../../../../core/types.js'; import { withSessionLock } from './session-lock.js'; import { readStatus, writeStatus } from './session-status.js'; +import { OTEL_STREAMS } from './spool-paths.js'; import { hasPendingData, readSpoolState } from './spool-state.js'; import { gateDecision } from './completeness-gate.js'; import { forwardSession } from './forwarder.js'; @@ -50,6 +51,22 @@ export async function processSessionTick( } else if (decision === 'wait') { status.waitTicks += 1; await writeStatus(sessionId, status); + } else if (decision === 'skip') { + // INVARIANT: OTEL-only sessions are never forwarded (per-project allowlist, + // see completeness-gate.ts). Advance cursors to EOF so the session counts + // as drained and the sweeper deletes it. + // Pure in-place mutation: the session lock is not reentrant, so do NOT call + // advanceCursor/updateStatus/ensureStatus here. + // Without valid credentials the tick returns before the gate, so skipping + // resumes after a proxy restart with valid credentials. + // Accepted trade-off: a tracked session whose first hook arrives after the + // wait limit loses the OTEL bytes received before that. + if (pending) { + for (const stream of OTEL_STREAMS) { + status.cursors[stream] = Math.max(status.cursors[stream], spool.streams[stream].size); + } + await writeStatus(sessionId, status); + } } // 'noop': nothing written yet }); diff --git a/src/providers/plugins/sso/proxy/plugins/otlp.plugin.ts b/src/providers/plugins/sso/proxy/plugins/otlp.plugin.ts index cda3df180..2e98367ac 100644 --- a/src/providers/plugins/sso/proxy/plugins/otlp.plugin.ts +++ b/src/providers/plugins/sso/proxy/plugins/otlp.plugin.ts @@ -1,4 +1,3 @@ -import { appendFile, mkdir } from 'node:fs/promises'; import type { IncomingMessage, ServerResponse } from 'http'; import type { ProxyPlugin, PluginContext, ProxyInterceptor } from './types.js'; import type { ProxyContext } from '../proxy-types.js'; @@ -6,7 +5,7 @@ import type { ProxyHTTPClient } from '../proxy-http-client.js'; import type { SSOCredentials, JWTCredentials } from '../../../../core/types.js'; import { logger } from '../../../../../utils/logger.js'; import { sanitizeLogArgs } from '../../../../../utils/security.js'; -import { listSessionIds, spoolRoot } from './otlp-spool/spool-paths.js'; +import { listSessionIds } from './otlp-spool/spool-paths.js'; import { sweepSpool } from './otlp-spool/sweep.js'; import { processSessionTick } from './otlp-spool/tick-processor.js'; import { appendSpool } from './otlp-spool/spool-io.js'; @@ -239,11 +238,7 @@ class OtlpInterceptor implements ProxyInterceptor { const sessionId = match ? match[0] : ''; if (!sessionId) { - // Append to _unresolved.alert for debugging - try { - await mkdir(spoolRoot(), { recursive: true }); - await appendFile(`${spoolRoot()}/_unresolved.alert`, bytes); - } catch { /* best-effort */ } + logger.debug('[otlp-ingest] no session id in payload, dropping', ...sanitizeLogArgs({ signal, bytes: bytes.length })); res.statusCode = 200; res.end(); return true;