-
Notifications
You must be signed in to change notification settings - Fork 14.5k
Windows: FileTime read-gate path normalization mismatch causes false 'must read first' errors #20354
Description
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.
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
- LLM calls Read with
C:/Users/foo/bar.ts(forward slashes) read.tsnormalizes toC:\Users\Foo\bar.tsand stores that in the Map- LLM calls Edit with
C:/Users/foo/bar.ts(same string it used for Read) edit.tspassesC:/Users/foo/bar.tsdirectly toFileTime.assert()Map.get("C:/Users/foo/bar.ts")returnsundefined(stored key isC:\Users\Foo\bar.ts)- 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.nativeinnormalizePathresolves to the filesystem's true casing. A path likec:\users\...would be stored asC:\Users\...after read, but asserted asc:\users\...by edit. - Relative path resolution:
read.tsusespath.resolve()whilewrite.tsusespath.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)