From 07b8d014bd8bc7e318bd2a4ca3988cf33b9cd238 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 12:46:55 -0700 Subject: [PATCH] Fix usage attribution for dotdot-prefixed child paths (#3651) * fix: attribute dotdot-prefixed usage paths * Add usage attribution boundary tests Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/codex-usage/scanner.test.ts | 59 ++++++++++++++++++++++++ src/main/codex-usage/scanner.ts | 9 +++- src/main/opencode-usage/scanner.test.ts | 60 ++++++++++++++++++++++++- src/main/opencode-usage/scanner.ts | 9 +++- 4 files changed, 134 insertions(+), 3 deletions(-) diff --git a/src/main/codex-usage/scanner.test.ts b/src/main/codex-usage/scanner.test.ts index d060d6c5b..62777257b 100644 --- a/src/main/codex-usage/scanner.test.ts +++ b/src/main/codex-usage/scanner.test.ts @@ -228,6 +228,65 @@ describe('attributeCodexUsageEvent', () => { expect(attributed?.worktreeId).toBe('repo-2::/workspace/repo/app2') }) + it('attributes cwd paths under dotdot-prefixed child directories to the worktree', async () => { + const attributed = await attributeCodexUsageEvent( + { + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + cwd: '/workspace/repo/..fixtures/session', + model: 'gpt-5.2-codex', + hasInferredPricing: false, + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 25, + reasoningOutputTokens: 10, + totalTokens: 125 + }, + [ + { + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + path: '/workspace/repo', + displayName: 'Repo', + canonicalPath: '/workspace/repo' + } + ] + ) + + expect(attributed?.projectKey).toBe('worktree:repo-1::/workspace/repo') + expect(attributed?.projectLabel).toBe('Repo') + expect(attributed?.worktreeId).toBe('repo-1::/workspace/repo') + }) + + it('does not attribute true parent-directory escapes to the worktree', async () => { + const attributed = await attributeCodexUsageEvent( + { + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + cwd: '/workspace/repo/../other/session', + model: 'gpt-5.2-codex', + hasInferredPricing: false, + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 25, + reasoningOutputTokens: 10, + totalTokens: 125 + }, + [ + { + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + path: '/workspace/repo', + displayName: 'Repo', + canonicalPath: '/workspace/repo' + } + ] + ) + + expect(attributed?.projectKey).toBe('cwd:/workspace/repo/../other/session') + expect(attributed?.worktreeId).toBeNull() + }) + it('does not treat different Windows drives as containing paths', async () => { const attributed = await attributeCodexUsageEvent( { diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index d52de1803..7fa6a56ef 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -454,7 +454,14 @@ function isContainingPath(candidatePath: string, targetPath: string): boolean { const isAbsoluteRelative = useWin32 ? win32.isAbsolute(relativePath) : posix.isAbsolute(relativePath) - return !isAbsoluteRelative && !relativePath.startsWith('..') && relativePath !== '.' + const parentPrefix = useWin32 ? `..${win32.sep}` : `..${posix.sep}` + // Why: `..name` is a valid child path; only `..` and `../...` escape. + return ( + !isAbsoluteRelative && + relativePath !== '..' && + !relativePath.startsWith(parentPrefix) && + relativePath !== '.' + ) } function findContainingWorktree( diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index 625effd4c..d1be3de70 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -1,9 +1,14 @@ +/* eslint-disable max-lines -- Why: OpenCode scanner tests cover multiple DB schema generations and attribution boundaries together so parser regressions stay auditable. */ import Database from 'better-sqlite3' import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it } from 'vitest' -import { parseOpenCodeUsageDatabase, parseOpenCodeUsageRow } from './scanner' +import { + attributeOpenCodeUsageEvent, + parseOpenCodeUsageDatabase, + parseOpenCodeUsageRow +} from './scanner' const WORKTREE = '/workspace/repo' @@ -28,6 +33,21 @@ function worktrees() { ] } +function usageEvent(cwd: string) { + return { + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + cwd, + model: 'anthropic/claude-sonnet-4-5', + estimatedCostUsd: 0.012, + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 25, + reasoningOutputTokens: 10, + totalTokens: 125 + } +} + describe('parseOpenCodeUsageRow', () => { it('reads assistant message tokens, cost, model, cwd, and timestamp', () => { const parsed = parseOpenCodeUsageRow({ @@ -72,6 +92,44 @@ describe('parseOpenCodeUsageRow', () => { }) }) +describe('attributeOpenCodeUsageEvent', () => { + it('attributes cwd paths under dotdot-prefixed child directories to the worktree', async () => { + const attributed = await attributeOpenCodeUsageEvent( + usageEvent(`${WORKTREE}/..fixtures/session`), + worktrees() + ) + + expect(attributed?.projectKey).toBe('worktree:repo-1::/workspace/repo') + expect(attributed?.projectLabel).toBe('Repo') + expect(attributed?.worktreeId).toBe('repo-1::/workspace/repo') + }) + + it('does not attribute true parent-directory escapes to the worktree', async () => { + const attributed = await attributeOpenCodeUsageEvent( + usageEvent(`${WORKTREE}/../other/session`), + worktrees() + ) + + expect(attributed?.projectKey).toBe('cwd:/workspace/repo/../other/session') + expect(attributed?.worktreeId).toBeNull() + }) + + it('does not treat different Windows drives as containing paths', async () => { + const attributed = await attributeOpenCodeUsageEvent(usageEvent('D:\\other\\repo'), [ + { + repoId: 'repo-1', + worktreeId: 'repo-1::C:\\repo', + path: 'C:\\repo', + displayName: 'Repo', + canonicalPath: 'C:\\repo' + } + ]) + + expect(attributed?.projectKey).toBe('cwd:d:/other/repo') + expect(attributed?.worktreeId).toBeNull() + }) +}) + describe('parseOpenCodeUsageDatabase', () => { afterEach(() => { for (const dir of tempDirs) { diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 3066d707e..50b8b3376 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -428,7 +428,14 @@ function isContainingPath(candidatePath: string, targetPath: string): boolean { const isAbsoluteRelative = useWin32 ? win32.isAbsolute(relativePath) : posix.isAbsolute(relativePath) - return !isAbsoluteRelative && !relativePath.startsWith('..') && relativePath !== '.' + const parentPrefix = useWin32 ? `..${win32.sep}` : `..${posix.sep}` + // Why: `..name` is a valid child path; only `..` and `../...` escape. + return ( + !isAbsoluteRelative && + relativePath !== '..' && + !relativePath.startsWith(parentPrefix) && + relativePath !== '.' + ) } async function buildWorktreesWithCanonicalPaths(