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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to Token-Goat are documented in this file. Format follows Ke

## [Unreleased]

### Fixed

- **macOS `/var` and `/private/var` aliases could produce different cache and index keys for the same file.** Path canonicalization now normalizes the Darwin system alias consistently, restoring relative `skeleton`/`outline`, trace frame detection, project discovery, settings paths, and doc-compact reuse for temporary paths.
- **The test harness could touch the live macOS data directory or fail to start TypeScript race helpers in restricted sandboxes.** Darwin tests now isolate `HOME`/`USERPROFILE`, and subprocess fixtures load `tsx` without its IPC-based CLI.

## [2.6.12] - 2026-07-10

### Fixed
Expand Down
7 changes: 4 additions & 3 deletions src/doc_compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as path from 'path'

import { dataDir } from './constants.js'
import { atomicWriteText } from './util.js'
import { normalizePath } from './paths.js'
import { resolveIndexPath } from './paths.js'

const defaultSentencesPerSection = 2
const headerPrefix = '<!-- token-goat doc-compact source-hash:'
Expand Down Expand Up @@ -56,8 +56,9 @@ function _compactSlug(absPathStr: string): string {
* so same-named docs in different projects never collide.
*/
export function compactPathFor(sourcePath: string): string {
// Route through normalizePath (not just path.resolve) so the sidecar key is identical whether the caller passes a raw path (cmdCompactDoc) or one already normalized upstream (preReadHandler) -- notably including 8.3 short-name expansion, which path.resolve alone does not perform.
const abs = normalizePath(path.resolve(sourcePath))
// Use the same absolute filesystem identity as the symbol index so aliases
// such as macOS /var and /private/var cannot create duplicate sidecars.
const abs = resolveIndexPath(sourcePath)
return path.join(dataDir(), compactSubdir, `${_compactSlug(abs)}.md`)
}

Expand Down
4 changes: 3 additions & 1 deletion src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'

import { resolveIndexPath } from './paths.js'
import { atomicWriteText, backupFile, ensureDirSync } from './util.js'

/** Where to install: the user's home `~/.claude` or the project's `.claude`. */
Expand Down Expand Up @@ -95,7 +96,8 @@ function hookCommand(eventArg: string): string {

/** Return the `~/.claude` or `<cwd>/.claude` settings path for `scope`. */
export function settingsPath(scope: HookScope): string {
const base = scope === 'user' ? path.join(os.homedir(), '.claude') : path.join(process.cwd(), '.claude')
const root = scope === 'user' ? os.homedir() : resolveIndexPath(process.cwd())
const base = path.join(root, '.claude')
return path.join(base, 'settings.json')
}

Expand Down
18 changes: 18 additions & 0 deletions src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,27 @@ export function normalizePath(p: string): string {
// Step 3: lowercase the drive-letter prefix (C: -> c:) on all platforms. WSL processes emit Windows-format paths on Linux; both must produce the same cache key, so lowercasing is unconditional.
s = lowercaseDriveLetter(s)

// Step 4: macOS reports the same temp path with two system aliases depending
// on whether it came from os.tmpdir() or process.cwd().
s = normalizeDarwinSystemAlias(s)

return s
}

/**
* Normalize macOS's public `/var` alias to its physical `/private/var` path.
*
* `os.tmpdir()` commonly returns `/var/...`, while `process.cwd()` returns
* `/private/var/...` after chdir. Keep this lexical so deleted/future paths
* normalize too, without resolving arbitrary user symlinks.
*/
export function normalizeDarwinSystemAlias(p: string): string {
if (process.platform !== 'darwin') return p
if (p.toLowerCase() === '/var') return `/private${p}`
if (p.slice(0, 5).toLowerCase() === '/var/') return `/private${p}`
return p
}

/**
* Resolve a user-supplied path to the canonical key form the symbol index uses.
*
Expand Down
12 changes: 10 additions & 2 deletions src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { extractErrorMessage, foldPath } from './util.js';
import { lowercaseDriveLetter, expandShortPath } from './paths.js';
import { lowercaseDriveLetter, expandShortPath, normalizeDarwinSystemAlias } from './paths.js';

/**
* Windows drive prefixes that resolve to the same NTFS location.
Expand Down Expand Up @@ -93,6 +93,10 @@ export function canonicalize(inputPath: string | URL, baseDir?: string): string
// Expand a Windows 8.3 short-name segment (e.g. `JOHNDO~1.ACM`) to its long form: %TEMP%/%USERPROFILE% can be pinned to short form, which every os.tmpdir()-based path inherits, while git always emits long form, so without this the same physical path canonicalizes two different ways depending on its source. Shared with normalizePath (paths.ts) via expandShortPath so the rule can't drift between the two call sites.
normalized = expandShortPath(normalized);

// macOS exposes /var as /private/var after chdir. Normalize that system alias
// so existing, deleted, and future paths all have one canonical form.
normalized = normalizeDarwinSystemAlias(normalized);

// Lowercase drive letter on Windows (e.g., "C:/foo" → "c:/foo"). Shared with
// normalizePath (paths.ts) via lowercaseDriveLetter so the rule can't drift.
normalized = lowercaseDriveLetter(normalized);
Expand Down Expand Up @@ -240,7 +244,11 @@ export function findProject(cwd: string): Project | null {
// comparing -- matching the platform-gated convention used elsewhere (isUnderBlockedRoot,
// assertWalkableRoot, pruneDeletedFiles) -- or this guard silently stops matching and the
// walk continues past the temp boundary.
if (sysTemp && foldPath(current) === foldPath(sysTemp)) {
if (
sysTemp &&
normalizeDarwinSystemAlias(foldPath(current)) ===
normalizeDarwinSystemAlias(foldPath(sysTemp))
) {
break;
}

Expand Down
5 changes: 3 additions & 2 deletions src/text_commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { walkProject } from './baseline.js'
import { loadConfig } from './config.js'
import { tokenGoatHome } from './disk_cache.js'
import { FILTERS } from './filters.js'
import { normalizeDarwinSystemAlias } from './paths.js'
import { canonicalize, findProject } from './project.js'
import { clearAll, loadEntries, setEntry, unsetEntry } from './project_memory.js'
import { getSessionFiles } from './session.js'
Expand Down Expand Up @@ -232,8 +233,8 @@ function isProjectFrame(framePath: string, cwd: string): boolean {
// canonicalize only lowercases the drive letter, not the rest of the path, so fold both
// sides through foldPath (util.ts) to restore case-insensitive comparison on Windows/macOS
// (matching the platform-gated convention used elsewhere, e.g. isUnderBlockedRoot).
const normalCwd = foldPath(canonicalize(cwd))
const normalAbs = foldPath(canonicalize(framePath, cwd))
const normalCwd = normalizeDarwinSystemAlias(foldPath(canonicalize(cwd)))
const normalAbs = normalizeDarwinSystemAlias(foldPath(canonicalize(framePath, cwd)))
if (normalAbs.startsWith(normalCwd)) {
// Ensure it's a real directory boundary: the path is either exactly the cwd,
// or the next character after cwd is a path separator. This prevents false matches
Expand Down
9 changes: 8 additions & 1 deletion tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -901,7 +901,14 @@ describe('skill-compact --path / skill-list --json (isolated data dir)', () => {
function runIsolated(args: string[], dataDir: string, extraEnv?: Record<string, string>): RunResult {
const res = spawnSync(process.execPath, [BUNDLE, ...args], {
encoding: 'utf8',
env: { ...process.env, LOCALAPPDATA: dataDir, XDG_DATA_HOME: dataDir, ...extraEnv },
env: {
...process.env,
HOME: dataDir,
USERPROFILE: dataDir,
LOCALAPPDATA: dataDir,
XDG_DATA_HOME: dataDir,
...extraEnv,
},
})
return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' }
}
Expand Down
6 changes: 6 additions & 0 deletions tests/helpers/tsx_process.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** Build Node args for a TypeScript child without invoking tsx's IPC-based CLI. */
export function tsxProcessArgs(entry: string, ...args: string[]): string[] {
// --import is unavailable on early Node 18 releases still allowed by package.json.
const loaderFlag = process.allowedNodeEnvironmentFlags.has('--import') ? '--import' : '--loader'
return [loaderFlag, 'tsx', entry, ...args]
}
3 changes: 2 additions & 1 deletion tests/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { installHooks, isInstalled, settingsPath, uninstallHooks } from '../src/install.js'
import { resolveIndexPath } from '../src/paths.js'

let TMP: string
let origCwd: string
Expand All @@ -30,7 +31,7 @@ describe('settingsPath', () => {

it('project scope ends in settings.json under cwd/.claude', () => {
const p = settingsPath('project')
expect(p).toBe(path.join(TMP, '.claude', 'settings.json'))
expect(p).toBe(path.join(resolveIndexPath(TMP), '.claude', 'settings.json'))
})
})

Expand Down
22 changes: 21 additions & 1 deletion tests/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import * as path from 'node:path'

import { afterEach, describe, expect, it, vi } from 'vitest'

import { lowercaseDriveLetter, normalizePath, resolveIndexPath, safeJoin } from '../src/paths.js'
import {
lowercaseDriveLetter,
normalizeDarwinSystemAlias,
normalizePath,
resolveIndexPath,
safeJoin,
} from '../src/paths.js'

describe('safeJoin', () => {
it('joins normally when no part contains a colon', () => {
Expand Down Expand Up @@ -205,6 +211,20 @@ describe('lowercaseDriveLetter', () => {
})
})

describe('normalizeDarwinSystemAlias', () => {
it('normalizes only the /var path boundary on macOS', () => {
const expectedRoot = process.platform === 'darwin' ? '/private/var' : '/var'
const expectedChild = process.platform === 'darwin' ? '/private/var/folders/example' : '/var/folders/example'
expect(normalizeDarwinSystemAlias('/var')).toBe(expectedRoot)
expect(normalizeDarwinSystemAlias('/var/folders/example')).toBe(expectedChild)
expect(normalizeDarwinSystemAlias('/VAR/FOLDERS/example')).toBe(
process.platform === 'darwin' ? '/private/VAR/FOLDERS/example' : '/VAR/FOLDERS/example',
)
expect(normalizeDarwinSystemAlias('/private/var/folders/example')).toBe('/private/var/folders/example')
expect(normalizeDarwinSystemAlias('/variant/example')).toBe('/variant/example')
})
})

describe('resolveIndexPath', () => {
// These assert on WHICH resolver is invoked (path.win32.resolve vs the ambient, host-native
// path.resolve), not just the output value: on a real Windows host, the ambient path.resolve
Expand Down
10 changes: 4 additions & 6 deletions tests/session_store_race.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,10 @@ import { fileURLToPath } from 'node:url'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { ROOT } from './helpers/bundle.js'
import { tsxProcessArgs } from './helpers/tsx_process.js'

const HERE = path.dirname(fileURLToPath(import.meta.url))
const WORKER = path.join(HERE, 'fixtures', 'session_race_worker.ts')
// Spawn tsx's own CLI entry via `node`, not the node_modules/.bin/tsx(.cmd) shim -- the
// shim is a shell script / batch file on POSIX/Windows respectively, and Node's spawn()
// cannot exec those directly without shell:true.
const TSX_CLI = path.join(ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs')

let tmpHome: string

beforeEach(() => {
Expand All @@ -45,7 +41,9 @@ afterEach(() => {
/** Spawn one race worker process; resolves on a clean exit, rejects otherwise. */
function runWorker(sessionId: string, prefix: string, count: number): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [TSX_CLI, WORKER, sessionId, prefix, String(count)], {
// Loading tsx through Node avoids the tsx CLI's IPC socket, which is denied
// in restricted macOS sandboxes even though the worker itself needs no IPC.
const child = spawn(process.execPath, tsxProcessArgs(WORKER, sessionId, prefix, String(count)), {
cwd: ROOT,
env: { ...process.env, TOKEN_GOAT_HOME: tmpHome },
stdio: ['ignore', 'pipe', 'pipe'],
Expand Down
7 changes: 7 additions & 0 deletions tests/setup/isolate-home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ try {
}
process.env['LOCALAPPDATA'] = dataHome
process.env['XDG_DATA_HOME'] = dataHome
// macOS ignores LOCALAPPDATA/XDG_DATA_HOME and derives Application Support
// from HOME. Redirect it too so DATA_DIR never points at the developer's live
// index or worker. USERPROFILE keeps spawned cross-platform tests consistent.
if (process.platform === 'darwin') {
process.env['HOME'] = dataHome
process.env['USERPROFILE'] = dataHome
}
process.on('exit', () => {
try {
fs.rmSync(dataHome, { recursive: true, force: true })
Expand Down
17 changes: 9 additions & 8 deletions tests/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,10 @@ import type * as fs from 'node:fs'

import { atomicWriteBytes, atomicWriteText, ensureDirSync, runGit, sleepSync, noWindowCreationFlags, withFileLock } from '../src/util.js'
import { ROOT } from './helpers/bundle.js'
import { tsxProcessArgs } from './helpers/tsx_process.js'

const HERE = path.dirname(fileURLToPath(import.meta.url))
const LOCK_HOLDER = path.join(HERE, 'fixtures', 'lock_holder.ts')
// Spawn tsx's own CLI entry via `node`, not the node_modules/.bin/tsx(.cmd) shim -- the shim
// is a shell script / batch file on POSIX/Windows respectively, and Node's spawn() cannot
// exec those directly without shell:true (same rationale as tests/session_store_race.test.ts).
const TSX_CLI = path.join(ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs')

describe('sleepSync', () => {
it('blocks for approximately the requested duration', () => {
Expand Down Expand Up @@ -230,10 +227,14 @@ describe('withFileLock', () => {
const holdMs = 8000
const staleMs = 4000
const holderExit = new Promise<string>((resolve, reject) => {
const child = spawn(process.execPath, [TSX_CLI, LOCK_HOLDER, lockPath, String(holdMs), String(staleMs)], {
cwd: ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
})
const child = spawn(
process.execPath,
tsxProcessArgs(LOCK_HOLDER, lockPath, String(holdMs), String(staleMs)),
{
cwd: ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
},
)
let stdout = ''
let stderr = ''
child.stdout.on('data', (d: Buffer) => (stdout += d.toString()))
Expand Down
8 changes: 7 additions & 1 deletion tests/worker_daemon_e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ function runBundle(args: string[], env: NodeJS.ProcessEnv, cwd: string): RunResu
}

function tgEnv(base: string): NodeJS.ProcessEnv {
return { ...process.env, LOCALAPPDATA: base, XDG_DATA_HOME: base }
return {
...process.env,
HOME: base,
USERPROFILE: base,
LOCALAPPDATA: base,
XDG_DATA_HOME: base,
}
}

/**
Expand Down
14 changes: 11 additions & 3 deletions tests/worker_index_e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,13 @@ describe('built bundle keys a relative-root index on the absolute path', () => {
let relData: string

function relEnv(): NodeJS.ProcessEnv {
return { ...process.env, LOCALAPPDATA: relData, XDG_DATA_HOME: relData }
return {
...process.env,
HOME: relData,
USERPROFILE: relData,
LOCALAPPDATA: relData,
XDG_DATA_HOME: relData,
}
}

function runRel(args: string[]): { status: number | null; stdout: string; stderr: string } {
Expand All @@ -285,6 +291,9 @@ describe('built bundle keys a relative-root index on the absolute path', () => {
// Mirror constants.ts::defaultDataDir so the test can open the same global DB the bundle wrote to under the redirected data dir.
function globalDbFor(base: string): string {
if (process.platform === 'win32') return path.join(base, 'dfk-helper', 'token-goat', 'global.db')
if (process.platform === 'darwin') {
return path.join(base, 'Library', 'Application Support', 'token-goat', 'global.db')
}
return path.join(base, 'token-goat', 'global.db')
}

Expand Down Expand Up @@ -312,8 +321,7 @@ describe('built bundle keys a relative-root index on the absolute path', () => {
if (relRepo) fs.rmSync(relRepo, { recursive: true, force: true })
})

// The darwin data dir ignores LOCALAPPDATA/XDG_DATA_HOME, so the DB would live in the real home dir; skip the direct-DB probe there. CI runs Windows.
it.skipIf(process.platform === 'darwin')(
it(
'stores the symbol file_path as the absolute-normalized key',
() => {
const dbPath = globalDbFor(relData)
Expand Down
Loading