fix(browse): migrate hand-rolled atomic writes to lib/fs-atomic

Seven sites, each audited for its existing throw-vs-swallow contract before
migrating: writeSessionState + the four fire-and-forget tab/state writers use
atomicWriteQuiet (they swallowed before); writeAgentRecord + the boot-time
port-file write use atomicWriteSync (they threw before — and writeAgentRecord
previously leaked its tmp file on rename failure, which the helper cleans).
All carry {mode: 0o600} plus restrictFilePermissions after successful writes,
preserving the Windows ACL hardening that writeSecureFile provided (mode bits
are POSIX-only). server.ts untouched: its three state writes route through
tmpStatePath(), pinned by server-tmp-state-path.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-14 21:12:22 -07:00
co-authored by Claude Fable 5
parent ef0fa9e9ff
commit 2b27d89ae3
4 changed files with 47 additions and 56 deletions
+25 -38
View File
@@ -23,7 +23,8 @@
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { writeSecureFile, restrictFilePermissions, mkdirSecure } from './file-permissions';
import { atomicWriteSync, atomicWriteQuiet } from '../../lib/fs-atomic';
import { safeUnlink } from './error-handling';
import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control';
import { extractPtyCookie } from './pty-session-cookie';
@@ -267,12 +268,9 @@ function writeClaudeAvailable(): void {
checked_at: new Date().toISOString(),
};
const target = path.join(stateDir, 'claude-available.json');
const tmp = path.join(stateDir, `.tmp-claude-${process.pid}`);
try {
writeSecureFile(tmp, JSON.stringify(status, null, 2));
fs.renameSync(tmp, target);
} catch {
safeUnlink(tmp);
// Fire-and-forget state file: a failed write must not break boot.
if (atomicWriteQuiet(target, JSON.stringify(status, null, 2), { mode: 0o600 })) {
restrictFilePermissions(target); // Windows ACL hardening
}
}
@@ -881,12 +879,10 @@ function handleTabState(msg: {
})),
};
const target = path.join(stateDir, 'tabs.json');
const tmp = path.join(stateDir, `.tmp-tabs-${process.pid}`);
try {
writeSecureFile(tmp, JSON.stringify(payload, null, 2));
fs.renameSync(tmp, target);
} catch {
safeUnlink(tmp);
// Fire-and-forget state file: atomic write (via lib/fs-atomic) so
// claude never reads a half-written JSON document; failures swallowed.
if (atomicWriteQuiet(target, JSON.stringify(payload, null, 2), { mode: 0o600 })) {
restrictFilePermissions(target); // Windows ACL hardening
}
}
@@ -896,17 +892,12 @@ function handleTabState(msg: {
const active = msg.active;
if (active && active.url && !active.url.startsWith('chrome://') && !active.url.startsWith('chrome-extension://')) {
const ctxFile = path.join(stateDir, 'active-tab.json');
const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`);
try {
writeSecureFile(tmp, JSON.stringify({
tabId: active.tabId ?? null,
url: active.url,
title: active.title ?? '',
}));
fs.renameSync(tmp, ctxFile);
} catch {
safeUnlink(tmp);
}
const ok = atomicWriteQuiet(ctxFile, JSON.stringify({
tabId: active.tabId ?? null,
url: active.url,
title: active.title ?? '',
}), { mode: 0o600 });
if (ok) restrictFilePermissions(ctxFile); // Windows ACL hardening
}
}
@@ -916,17 +907,13 @@ function handleTabSwitch(msg: { tabId?: number; url?: string; title?: string }):
const stateDir = path.dirname(STATE_FILE);
const ctxFile = path.join(stateDir, 'active-tab.json');
const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`);
try {
writeSecureFile(tmp, JSON.stringify({
tabId: msg.tabId ?? null,
url,
title: msg.title ?? '',
}));
fs.renameSync(tmp, ctxFile);
} catch {
safeUnlink(tmp);
}
// Fire-and-forget: atomic write via lib/fs-atomic, failures swallowed.
const ok = atomicWriteQuiet(ctxFile, JSON.stringify({
tabId: msg.tabId ?? null,
url,
title: msg.title ?? '',
}), { mode: 0o600 });
if (ok) restrictFilePermissions(ctxFile); // Windows ACL hardening
// Best-effort sync to parent server so its activeTabId tracking matches.
// No await; this is fire-and-forget.
@@ -964,11 +951,11 @@ function main() {
}
// Write port file atomically so the parent server can pick it up.
// Throws on failure — a boot without a discoverable port file is broken.
const dir = path.dirname(PORT_FILE);
try { mkdirSecure(dir); } catch {}
const tmp = `${PORT_FILE}.tmp-${process.pid}`;
writeSecureFile(tmp, String(port));
fs.renameSync(tmp, PORT_FILE);
atomicWriteSync(PORT_FILE, String(port), { mode: 0o600 });
restrictFilePermissions(PORT_FILE); // Windows ACL hardening
// Write identity-based agent record (pid + per-boot gen). Replaces the
// v1.43- `pkill -f terminal-agent\.ts` regex teardown that could kill