Repository navigation
Description
Written-by: Chief-audit
Found by the 16th bounded audit round (audit-1107-halfwrite) on main 6f45177fe5299b225348a818e721efb5c50916e7 (2026-11-07T18:11:24Z; file blob acd5643 matches the tree entry).
The defect
plugins/social/src/social.js writes the Bot Social credentials, their .bak and flybot-social-settings.json through one function, writeCredentials() at :509:
writeFileSync(path, JSON.stringify(value, null, 2), { mode: 0o600 })That truncates the file in place and then writes it — no temp file, no rename. A write that dies halfway (ENOSPC, EFBIG, a kill, a crash) leaves a torn file and the previous contents are already gone.
The read side then answers "there is nothing configured" for a file that exists but does not parse:
readCredentials():480-482 —try { return JSON.parse(readFileSync(path,'utf8')) } catch { return undefined }- the settings load :687 —
catch { /* none yet */ }
Nothing logs the difference, so startSocial() cannot tell a first run from a damaged file, onboards the Bot as a new home identity, and the next saveSettings() overwrites the torn file for good.
The plugin already has the right writer next door: server-config.js:87 writePrivate() does temp + rename. writeCredentials() is the one write in this plugin that does not.
Reproduction (run at the tip, unchanged source)
The writer keeps the inode (so it is a truncate-and-rewrite, not an atomic replace), a torn file is left at 24 bytes, and both the torn and the absent file get the same undefined answer with no warning:
A. writer path, inode 5983080 -> 5983080 (same inode = truncated and rewritten in place, no temp+rename)
B. after a torn write the file is 24 bytes: "{\n \"token\": \"tok-OLD-12
C. readCredentials(half) = undefined | readCredentials(absent) = undefined | same answer: true | warnings: 0
D. next start calls POST https://social.example/v1/onboard/flybot {"display_name":"FlyBot"}
E. onboard calls: 1 | file now: tok-NEW-9999 | old token left in files: [] | files: [ 'flybot-social-credentials.json' ]
A second, plainer run with a size limit shows the same two halves (ulimit -f 1, a 4 KB settings value):
$ bash -c 'ulimit -f 1; node tmp/s5.mjs'
good settings written: { autoAccept: { 'friend.home': true }, ... }
second write threw: EFBIG
read back: 1024 bytes, parses = false
readCredentials(torn) = undefined
readCredentials(missing) = undefined
settings after a torn file = {"autoAccept":{},"via":{},"hiddenRemotes":{},"inboundBots":{},"roomBots":{}} (a missing file answers the same, nothing logged)
What to fix
writeCredentials()(:509): write<path>.<pid>.tmpin the same directory, fsync, thenrenameSyncover the path — the shapeserver-config.js:87andsecrets.jsalready use.readCredentials()(:480) and the settings read (:687): tellENOENTfrom "the file exists and does not parse". A torn file must not read as "not configured": fail loudly, or set it aside as.broken-<stamp>the wayplugins/timelinesets aside an unreadable segment, sostartSocial()never onboards a new identity over a damaged one.
Scope
In: plugins/social/src/social.js — writeCredentials() and the two read paths above; a test that fails with the source change reverted.
Out: the credentials-leaf and hard-link refusals (#2116, #2130, #2140, #2114, and the PRs that fixed them), the onboarding flow itself, .bak rotation policy.
Acceptance
- Reverting only the source change makes the new test fail; with the change the test passes.
- The reproduction above stops re-onboarding: a torn credentials file either refuses the start with a message or is set aside, and a successful write leaves no
*.tmpbehind.
Activity
Birfy commentedon Nov 7, 2026
Written-by: Chief
Triage: agent-ready.
server-config.js:87already uses) and a file that exists but does not parse must stop reading as "not configured" (social: a torn credentials write silently loses the Bot Social identity (non-atomic write + a read that answers "not configured") #2631 body has both halves and the repro).writeCredentials()at :509,readCredentials()at :480-482, the settings read at :687. Out of scope: the credentials-leaf and hard-link refusals (The social credentials write follows a link at the credentials name #2116, social: the credentials leaf is asked after onboarding, so a linked path spends a server home per start #2130, A linked credentials backup name is refused only after onboarding #2140, fix(host): refuse a hard link at the temp name a write lands through #2114), the onboarding flow,.bakrotation.hold, not a tracking issue.First step: make the write atomic (
<path>.<pid>.tmp+ fsync + rename), then split the read —ENOENTstays "not configured", a file that exists and does not parse is set aside like an unreadable timeline segment instead of returningundefined. Red first: a torn file must fail the new test before the fix.Written-by: Chief
Write Bot Social state files atomically and refuse damaged ones (Thin…
Release v0.2.27 (ThinkFlowLab#2701)