Hmbown/CodeWhale · error · io::Error (InvalidInput)
external credential path must name a non-reparse regular…
Error message
external credential path must name a non-reparse regular file
What it means
On Windows, the file is opened with `FILE_FLAG_OPEN_REPARSE_POINT` so symlinks/junctions are not followed, and the resulting metadata must be a plain regular file with the `FILE_ATTRIBUTE_REPARSE_POINT` bit clear. Anything that is itself a reparse point (symlink, mount point, OneDrive placeholder, AppExecLink) is rejected with `InvalidInput`, because a reparse point could redirect the consented read to a different secret.
Solutions
- Grant the real, physical file location (the junction/symlink target) instead of the link itself.
- Move the credential file out of OneDrive/placeholder-backed folders into a local path, or mark the file as always-available offline so it dehydrates to a regular file.
- Replace the symlink with a hard copy of the file (`Copy-Item`) rather than `New-Item -ItemType SymbolicLink`.
Example fix
# before PS> New-Item -ItemType SymbolicLink -Path $env:USERPROFILE\.codewhale\claude.json -Target D:\secrets\claude.json # after PS> Copy-Item D:\secrets\claude.json $env:USERPROFILE\.codewhale\credentials\claude.json
Defensive patterns
Strategy: validation
Validate before calling
fn ensure_no_reparse(p: &std::path::Path) -> bool {
use std::os::windows::fs::MetadataExt;
match std::fs::metadata(p) {
Ok(md) => md.file_attributes() & 0x400 == 0, // FILE_ATTRIBUTE_REPARSE_POINT
Err(_) => false,
}
} Try / catch
match read_to_string(&grant) {
Err(e) if e.to_string().contains("non-reparse") => eprintln!("credential path is a symlink/junction/placeholder; grant the physical file instead"),
other => other.map(|_| ()).map_err(Into::into),
} Prevention
- Do not symlink or junction the credentials directory/file; copy instead.
- Keep credentials out of OneDrive/Dropbox placeholder-backed folders.
- Copy the real target file into the expected path rather than linking it.
When it happens
Trigger: Granting a Windows symlink or junction as the credential path; the credential file lives under a OneDrive-synced folder (cloud placeholder attributes) or is an AppExecLink; the user aliased the real file with `mklink`.
Common situations: Users storing dotfiles/config under OneDrive/Dropbox folders where placeholders are reparse points; developers symlinking `~/.codewhale` into another location; CI runners with redirected user profiles.
Understand the failure class
Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.
Related errors
- Automation lock must not be a reparse point
- is a reparse point, not a workspace-owned file
- plugin directory is a reparse point or non-directory
- plugin identity probe found a reparse point, hard link, or…
- skill state lock at must be a regular, non-reparse file
AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15).
Data as JSON: /api/errors/4af2cce45507b83e.
Report an issue: GitHub.
Appendix: source
Thrown at crates/tui/src/external_credentials.rs:280
io::ErrorKind::InvalidInput,
"external credential path must be absolute and lexically normalized",
));
}
// Reject every reparse-point component before the final open. The final
// handle is opened as the reparse point itself, checked again, and its
// kernel-resolved path is compared below. A second component pass catches
// replacement during the open window.
reject_windows_reparse_components(path)?;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)?;
let metadata = file.metadata()?;
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|| !metadata.file_type().is_file()
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"external credential path must name a non-reparse regular file",
));
}
reject_windows_reparse_components(path)?;
let handle = file.as_raw_handle();
// Compare the spelling Windows actually opened rather than asking it to
// expand the path into its normalized long form. A valid caller path can
// contain an 8.3 component such as `RUNNER~1`; normalizing only the handle
// side would make that exact path look redirected. FILE_NAME_OPENED keeps
// the comparison handle-relative while the pre/post component checks above
// continue to reject reparse points and swaps.
let flags = FILE_NAME_OPENED | VOLUME_NAME_DOS;
// SAFETY: the handle remains owned by `file`; null output asks Windows for
// the required UTF-16 buffer length.
let needed = unsafe { GetFinalPathNameByHandleW(handle, std::ptr::null_mut(), 0, flags) };
if needed == 0 {View on GitHub (pinned to 433685b202)