Summary
Every filesystem failure in the CLI that is not already tagged exits 5 — the code the README tells scripts to retry on. A permissions problem is not retryable, so a CI job that sleeps-and-retries on 5 loops forever, and civitai login against a read-only config directory reports itself as a network failure.
Mechanism
cmd/civitai/main.go's isNetworkErr ends with:
var netErr net.Error
return errors.As(err, &netErr)
syscall.Errno declares both Timeout() bool and Temporary() bool, so it satisfies net.Error. The filesystem wrapper types do not (measured on go1.25.12 — none of the three declares Temporary()):
| type |
satisfies net.Error? |
syscall.Errno |
yes |
*fs.PathError |
no |
*os.LinkError |
no |
*os.SyscallError |
no |
So errors.As unwraps straight past the *fs.PathError that os.ReadFile / os.Stat / os.MkdirAll return and matches the bare Errno underneath it. exitCode's switch reaches isNetworkErr in its default arm, so any untagged filesystem error lands on exitNetwork.
This is not specific to EACCES — it is every errno. Measured with the classifier at origin/main (569f5dc):
errno Timeout Temporary exitCode(bare) exitCode(*fs.PathError)
EACCES permission denied false false 5 5
ENOENT no such file false false 5 5
ENOTDIR not a directory false false 5 5
EISDIR is a directory false false 5 5
Timeout()/Temporary() being false makes no difference: satisfying the interface is all errors.As looks at.
Measured on the built binary
origin/main @ 569f5dc, six pure-filesystem invocations, no network involved:
| invocation |
rc |
stderr |
app listing set-icon <mode-000 png> |
5 |
open …/icon.png: permission denied |
app listing set-cover <mode-000 png> |
5 |
open …/icon.png: permission denied |
app listing add-screenshot <mode-000 png> |
5 |
open …/icon.png: permission denied |
generate "…" --ecosystem Qwen --image <mode-000 png> --dry-run |
5 |
--image …: open …: permission denied |
app validate <regular-file>/civitai-app.json (ENOTDIR) |
5 |
stat …/block.manifest.json: not a directory |
login --token … with an unwritable XDG_CONFIG_HOME |
5 |
mkdir …/config: permission denied |
Blast radius
grep -E '\bos\.(ReadFile|Open|OpenFile|Stat|Lstat|MkdirAll|Mkdir|WriteFile|Create|Remove|RemoveAll|Rename|ReadDir|Symlink|Link|Chmod|Truncate|Getwd|UserHomeDir|CreateTemp|MkdirTemp)\(' over non-test internal/, pkg/, cmd/ finds 80 call sites across 24 files (top: internal/cmd/download.go 10, internal/manifest/manifest.go 9, internal/scaffold/scaffold.go 7, internal/cmd/generate_state.go 6, internal/blockproto/entrygraph.go 6). That is a floor, not a ceiling — it does not count filepath.Walk/WalkDir or *os.File method errors, which are also *fs.PathError. Every one of those returning an untagged error inherits the misclassification.
Already inconsistent today
generate --input <unreadable json> exits 2, not 5, because that path is asUsageError-tagged and usage is checked before the network arm. So two filesystem failures one flag apart already disagree — a second argument for fixing this in the classifier rather than per call site.
The repo already knows about this. internal/cmd/app_listing.go's loadAndValidateImage comment says in so many words that the fix "belongs in isNetworkErr — where it corrects every filesystem error in the CLI at once — and not in a per-call-site tag here", and internal/cmd/exitcodes_doc_test.go records the exit-5 behaviour as "a separate pre-existing defect in cmd/civitai's isNetworkErr". This issue is that follow-up.
Fix
Centrally, in the classifier. A syscall.Errno — however it is wrapped — is not evidence of a transport failure; only the net stack's own error types are. Genuine network failures (*net.OpError, *net.DNSError, *url.Error, ECONNREFUSED/ECONNRESET, context.DeadlineExceeded, HTTP 502/503/504 after retries) must keep exiting 5.
Summary
Every filesystem failure in the CLI that is not already tagged exits 5 — the code the README tells scripts to retry on. A permissions problem is not retryable, so a CI job that sleeps-and-retries on
5loops forever, andcivitai loginagainst a read-only config directory reports itself as a network failure.Mechanism
cmd/civitai/main.go'sisNetworkErrends with:syscall.Errnodeclares bothTimeout() boolandTemporary() bool, so it satisfiesnet.Error. The filesystem wrapper types do not (measured on go1.25.12 — none of the three declaresTemporary()):net.Error?syscall.Errno*fs.PathError*os.LinkError*os.SyscallErrorSo
errors.Asunwraps straight past the*fs.PathErrorthatos.ReadFile/os.Stat/os.MkdirAllreturn and matches the bareErrnounderneath it.exitCode'sswitchreachesisNetworkErrin its default arm, so any untagged filesystem error lands onexitNetwork.This is not specific to EACCES — it is every errno. Measured with the classifier at
origin/main(569f5dc):Timeout()/Temporary()being false makes no difference: satisfying the interface is allerrors.Aslooks at.Measured on the built binary
origin/main@569f5dc, six pure-filesystem invocations, no network involved:app listing set-icon <mode-000 png>open …/icon.png: permission deniedapp listing set-cover <mode-000 png>open …/icon.png: permission deniedapp listing add-screenshot <mode-000 png>open …/icon.png: permission deniedgenerate "…" --ecosystem Qwen --image <mode-000 png> --dry-run--image …: open …: permission deniedapp validate <regular-file>/civitai-app.json(ENOTDIR)stat …/block.manifest.json: not a directorylogin --token …with an unwritableXDG_CONFIG_HOMEmkdir …/config: permission deniedBlast radius
grep -E '\bos\.(ReadFile|Open|OpenFile|Stat|Lstat|MkdirAll|Mkdir|WriteFile|Create|Remove|RemoveAll|Rename|ReadDir|Symlink|Link|Chmod|Truncate|Getwd|UserHomeDir|CreateTemp|MkdirTemp)\('over non-testinternal/,pkg/,cmd/finds 80 call sites across 24 files (top:internal/cmd/download.go10,internal/manifest/manifest.go9,internal/scaffold/scaffold.go7,internal/cmd/generate_state.go6,internal/blockproto/entrygraph.go6). That is a floor, not a ceiling — it does not countfilepath.Walk/WalkDiror*os.Filemethod errors, which are also*fs.PathError. Every one of those returning an untagged error inherits the misclassification.Already inconsistent today
generate --input <unreadable json>exits 2, not 5, because that path isasUsageError-tagged and usage is checked before the network arm. So two filesystem failures one flag apart already disagree — a second argument for fixing this in the classifier rather than per call site.The repo already knows about this.
internal/cmd/app_listing.go'sloadAndValidateImagecomment says in so many words that the fix "belongs inisNetworkErr— where it corrects every filesystem error in the CLI at once — and not in a per-call-site tag here", andinternal/cmd/exitcodes_doc_test.gorecords the exit-5 behaviour as "a separate pre-existing defect in cmd/civitai's isNetworkErr". This issue is that follow-up.Fix
Centrally, in the classifier. A
syscall.Errno— however it is wrapped — is not evidence of a transport failure; only the net stack's own error types are. Genuine network failures (*net.OpError,*net.DNSError,*url.Error,ECONNREFUSED/ECONNRESET,context.DeadlineExceeded, HTTP 502/503/504 after retries) must keep exiting 5.