You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug]: writeFileStringAtomically widens a file's mode on replace and is not crash-durable #14967
I searched existing issues and did not find a duplicate.
I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
Create a file the server later rewrites through writeFileStringAtomically (for example a settings file), and chmod 600 it.
Make a change that causes the server to save that file.
ls -l the file.
Separately, for durability: a power loss or kernel panic right after one of these writes can leave the file empty or stale on ext4.
Expected behavior
An atomic replace keeps the target's existing permission bits (or uses a restrictive default for a new file), and it survives a crash: write temp → fsync temp → rename → fsync the directory.
Actual behavior
writeFileStringAtomically (apps/server/src/atomicWrite.ts) writes the temp file with fs.writeFileString (:22) and renames it over the target (:23), with no other steps:
Mode is not preserved. The temp file is created at the default 0o666 & ~umask (typically 0644), and the rename replaces the target's inode. A file that was 0600 comes back 0644 after the next save.
Not crash-durable. Neither the temp file nor the parent directory is fsynced. The rename can reach disk before the data, so a crash can leave a zero-length or stale file.
The atomicity itself is fine (same-directory rename, random mkdtemp directory). The issue is mode preservation and durability. auth/ServerSecretStore.ts already uses the right pattern (explicit mode + sync).
Linux (ext4); found by code reading and reproduced in a fork (T3o).
Logs or stack traces
No response
Workaround
No response
A reference fix from our fork: atomicWrite.ts at bd87f4d. It copies the target's mode (0600 when the file is new), fsyncs the temp file, and fsyncs the directory after the rename.
Thanks for the careful report and the reference fix, @samkelly880! The mode bug is real and still present on main; apps/server/src/atomicWrite.ts is unchanged since c0995d2.
writeFileStringAtomically writes a temp file with FileSystem.writeFileString and renames it over the target without passing a mode. Node's fs.writeFile uses 0o666 masked by the process umask, and rename replaces the destination inode, so the previous mode is lost. With umask 022, a 0600 file comes back 0644 after the next save. We reproduced that sequence: the new contents landed, and only the mode changed.
Nothing in this repo sets a mode on the files this helper writes (settings.json, keybindings.json, server-runtime.json, the provider status cache, and the ACP sign-in flag). A file created here is already 0644 under a typical umask, so the regression is a later save undoing a mode the user tightened. That matters because settings.json can hold some secrets in plaintext (providers.opencode.serverPassword, the Antigravity apiKey). Provider environment secrets, Bitbucket tokens, and usage-limit management keys go to the secret store first, so they aren't in this file. Since ~/.t3/userdata is created with the default mkdir mode (0755 at umask 022), a file that flips back to 0644 is readable by any local user who can traverse the home directory.
The crash-durability part is only half right. The helper never fsyncs the temp file or the parent directory, so the rename is atomic for concurrent readers but not power-fail durable. On default ext4 (data=ordered with auto_da_alloc), this replace-via-rename pattern is specifically handled so a crash doesn't leave a zero-length file; the usual result is the previous contents, which is the normal cost of skipping fsync. server-runtime.json is already ignored when its pid is dead, and the cache and ACP-auth readers tolerate a missing file.
One small correction: ServerSecretStore isn't quite the pattern described. Its set (the replace path) chmods 0600 before and after the rename but doesn't sync. create opens with mode 0600 and calls file.sync, but it's an exclusive create rather than a replace, and it doesn't fsync the directory.
No other issue tracks this. A fix that preserves the existing permission bits, and uses 0600 when the file doesn't exist yet, fits these callers. Note that Node's stat.mode includes the file-type bits (0o100644), so the chmod needs to mask to 0o777. Directory fsync would be separate hardening rather than a required part of this fix.
Activity
juliusmarminge commentedon Oct 3, 2026
Note
Grok responding on behalf of Julius.
Triage
Thanks for the careful report and the reference fix, @samkelly880! The mode bug is real and still present on
main;apps/server/src/atomicWrite.tsis unchanged sincec0995d2.writeFileStringAtomicallywrites a temp file withFileSystem.writeFileStringand renames it over the target without passing a mode. Node'sfs.writeFileuses0o666masked by the process umask, andrenamereplaces the destination inode, so the previous mode is lost. With umask022, a0600file comes back0644after the next save. We reproduced that sequence: the new contents landed, and only the mode changed.Nothing in this repo sets a mode on the files this helper writes (
settings.json,keybindings.json,server-runtime.json, the provider status cache, and the ACP sign-in flag). A file created here is already0644under a typical umask, so the regression is a later save undoing a mode the user tightened. That matters becausesettings.jsoncan hold some secrets in plaintext (providers.opencode.serverPassword, the AntigravityapiKey). Provider environment secrets, Bitbucket tokens, and usage-limit management keys go to the secret store first, so they aren't in this file. Since~/.t3/userdatais created with the default mkdir mode (0755at umask022), a file that flips back to0644is readable by any local user who can traverse the home directory.The crash-durability part is only half right. The helper never fsyncs the temp file or the parent directory, so the rename is atomic for concurrent readers but not power-fail durable. On default ext4 (
data=orderedwithauto_da_alloc), this replace-via-rename pattern is specifically handled so a crash doesn't leave a zero-length file; the usual result is the previous contents, which is the normal cost of skippingfsync.server-runtime.jsonis already ignored when its pid is dead, and the cache and ACP-auth readers tolerate a missing file.One small correction:
ServerSecretStoreisn't quite the pattern described. Itsset(the replace path) chmods0600before and after the rename but doesn't sync.createopens with mode0600and callsfile.sync, but it's an exclusive create rather than a replace, and it doesn't fsync the directory.No other issue tracks this. A fix that preserves the existing permission bits, and uses
0600when the file doesn't exist yet, fits these callers. Note that Node'sstat.modeincludes the file-type bits (0o100644), so the chmod needs to mask to0o777. Directory fsync would be separate hardening rather than a required part of this fix.