Skip to content

Windows: FileTime read-gate path normalization mismatch causes false 'must read first' errors #20354

@JosXa

Description

@JosXa

Summary

On Windows, the FileTime read-gate (the "You must read file X before overwriting it" safety check) uses exact string equality on a Map<string, Stamp> to verify that a file was read before allowing writes/edits. However, the read tool normalizes the filepath before storing it, while the write and edit tools do not normalize before looking it up. This causes false-positive "must read first" errors on Windows when forward and backslashes are mixed, even when the file was just successfully read.

Image

Root Cause

packages/opencode/src/file/time.ts:85-90 — The FileTime.assert() function does a raw Map.get(filepath) with no normalization:

const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)

The three tools handle paths differently before interacting with this map:

Tool File Normalization before map operation
Read read.ts:36-38 Filesystem.normalizePath(filepath) on win32 — converts / to \, resolves canonical casing via realpathSync.native
Write write.ts:27 None — raw params.filePath passed to FileTime.assert()
Edit edit.ts:54 None — raw params.filePath passed to FileTime.assert()

What happens

  1. LLM calls Read with C:/Users/foo/bar.ts (forward slashes)
  2. read.ts normalizes to C:\Users\Foo\bar.ts and stores that in the Map
  3. LLM calls Edit with C:/Users/foo/bar.ts (same string it used for Read)
  4. edit.ts passes C:/Users/foo/bar.ts directly to FileTime.assert()
  5. Map.get("C:/Users/foo/bar.ts") returns undefined (stored key is C:\Users\Foo\bar.ts)
  6. Error thrown: "You must read file C:/Users/foo/bar.ts before overwriting it"

Display masking

The TUI normalizes displayed paths to backslashes for ALL tools, so the user sees identical paths in both the Read and Edit tool calls. The mismatch is completely invisible in the UI.

Edge Cases

Beyond forward/backslash differences, this also affects:

  • Case sensitivity: realpathSync.native in normalizePath resolves to the filesystem's true casing. A path like c:\users\... would be stored as C:\Users\... after read, but asserted as c:\users\... by edit.
  • Relative path resolution: read.ts uses path.resolve() while write.ts uses path.join(). These differ for paths containing .. segments.

Proposed Fix

Apply Filesystem.normalizePath() in write.ts and edit.ts the same way read.ts already does, before calling FileTime.assert() and FileTime.read().

In write.ts (~line 27):

let filepath = path.isAbsolute(params.filePath)
  ? params.filePath
  : path.join(Instance.directory, params.filePath)
if (process.platform === "win32") {
  filepath = Filesystem.normalizePath(filepath)
}

Same pattern in edit.ts (~line 54).

Alternatively, normalize inside FileTime.assert() and FileTime.read() themselves so all callers get consistent behavior automatically.

Workaround

Set OPENCODE_DISABLE_FILETIME_CHECK=true to bypass the check entirely.

Environment

  • OS: Windows 11
  • OpenCode: latest (from npm)
  • Shell: PowerShell / NuShell
  • LLM paths use forward slashes (standard Claude/GPT behavior)

Metadata

Metadata

Assignees

Labels

coreAnything pertaining to core functionality of the application (opencode server stuff)windows

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions