From 48b9907de943caaefeb299ef2b89859a6ee0445f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 04:57:41 -0700 Subject: [PATCH] fix: attribute dotdot-prefixed usage paths --- src/main/codex-usage/scanner.test.ts | 30 +++++++++++++++++++++++++ src/main/codex-usage/scanner.ts | 9 +++++++- src/main/opencode-usage/scanner.test.ts | 30 ++++++++++++++++++++++++- src/main/opencode-usage/scanner.ts | 9 +++++++- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/main/codex-usage/scanner.test.ts b/src/main/codex-usage/scanner.test.ts index d060d6c5b2e..f95b05c8031 100644 --- a/src/main/codex-usage/scanner.test.ts +++ b/src/main/codex-usage/scanner.test.ts @@ -228,6 +228,36 @@ 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 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 d52de180342..7fa6a56ef35 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 625effd4ce6..781600abf6c 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -3,7 +3,11 @@ 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' @@ -72,6 +76,30 @@ describe('parseOpenCodeUsageRow', () => { }) }) +describe('attributeOpenCodeUsageEvent', () => { + it('attributes cwd paths under dotdot-prefixed child directories to the worktree', async () => { + const attributed = await attributeOpenCodeUsageEvent( + { + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + cwd: `${WORKTREE}/..fixtures/session`, + model: 'anthropic/claude-sonnet-4-5', + estimatedCostUsd: 0.012, + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 25, + reasoningOutputTokens: 10, + totalTokens: 125 + }, + worktrees() + ) + + expect(attributed?.projectKey).toBe('worktree:repo-1::/workspace/repo') + expect(attributed?.projectLabel).toBe('Repo') + expect(attributed?.worktreeId).toBe('repo-1::/workspace/repo') + }) +}) + 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 3066d707e7b..50b8b337685 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(