Merge pull request #719: fix(settings): serialize settings.json writes so a quick-start cannot tear the file

Conflict in src/web/routes/system-routes.ts (PUT /api/settings) resolved by
keeping both sides: #718's line that never stores an unauthorized
tunnelEnabled:true runs first, then #719's serialized mutateSettingsFile()
write replaces the inline read and plain writeFile.

test/routes/system-routes-tunnel-guard.test.ts: #718's new tests asserted
plain writeFile calls on settings.json. With #719 every write goes to a temp
file renamed over it, so the assertions read writes through a settingsWrites()
helper (writeFile contents keyed by their rename), and the concurrent-stop
harness applies a write and the stop at the rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-27 02:10:35 +01:00
commit 82ef8b1edd
20 files changed
+532 -131

No files matched your search

+33 -32
View File
@@ -78,6 +78,12 @@ import {
parseBody,
readJsonConfig,
} from '../route-helpers.js';
import {
assertSettingsWritable,
mutateSettingsFile,
SettingsWriteRefusedError,
settingsWriteErrorResponse,
} from '../settings-writer.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import type { AuthUser } from '../../types.js';
import { SseEvent } from '../sse-events.js';
@@ -1849,6 +1855,17 @@ export function registerCaseRoutes(
if (open.some((s) => !canAccessOwned(user, s.owner))) {
return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open");
}
// The admin's rename writes settings.json only after the sessions are ended and the folder moved, and
// the writer refuses a file that is not a JSON object: check it by the writer's own rule now, so no
// session is ended for a rename that cannot complete. Not between the starting check and the lock below.
if (isAdmin(req)) {
try {
await assertSettingsWritable();
} catch (err) {
if (err instanceof SettingsWriteRefusedError) return fail(409, ApiErrorCode.CONFLICT, err.message);
throw err;
}
}
// A session being created in the folder is not in ctx.sessions yet, so it could be neither listed nor
// stopped: it would start in the old path after the move. Checked in the same tick that takes the lock
// below (nothing is awaited in between), and the Session constructor refuses the folder from then on.
@@ -1876,27 +1893,23 @@ export function registerCaseRoutes(
}
if (moveFolder) await moveCaseFolder(oldPath, newPath);
const originalLinks = isLinked ? await readLinkedCases() : undefined;
// The settings.json content before this rename wrote it, set only once a write is attempted (an
// absent file reads as {}, which must not be written back as a file that never existed).
let originalSettings: Record<string, unknown> | undefined;
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
// these keys alone, so a settings save that landed in between is kept.
let settingsUndo: Record<string, unknown> | undefined;
try {
// settings.json is server-wide and caseOrder/lastUsedCase name the admin's cases, so only the
// admin's rename touches it (a non-admin's own rename must not rewrite someone else's list).
// Written before linked-cases.json so a failure here leaves the registry untouched.
if (isAdmin(req)) {
const existing = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const order = renameInOrder(existing.caseOrder, name, newName);
const last = existing.lastUsedCase === name ? newName : undefined;
if (order || last) {
originalSettings = existing;
await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(
{ ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) },
null,
2
)
);
const { before, after } = await mutateSettingsFile((existing) => {
const order = renameInOrder(existing.caseOrder, name, newName);
const last = existing.lastUsedCase === name ? newName : undefined;
if (!order && !last) return undefined;
return { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) };
});
if (after) {
const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]);
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
}
}
if (originalLinks) {
@@ -1913,9 +1926,8 @@ export function registerCaseRoutes(
} catch (err) {
// The folder moved but the registries did not follow: put settings.json and the folder back
// rather than leave a case whose name and folder disagree.
if (originalSettings) {
await fs.writeFile(SETTINGS_PATH, JSON.stringify(originalSettings, null, 2)).catch(() => {});
}
const undo = settingsUndo;
if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {});
if (moveFolder) {
const movedBack = await fs.rename(newPath, oldPath).then(
() => true,
@@ -1970,22 +1982,11 @@ export function registerCaseRoutes(
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
try {
const dir = getDataDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
const merged = { ...existing, caseOrder: order };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
await mutateSettingsFile((current) => ({ ...current, caseOrder: order }));
ctx.broadcast(SseEvent.CaseOrderChanged, { order });
return { success: true, data: { order } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
return settingsWriteErrorResponse(err);
}
});
+2 -2
View File
@@ -128,8 +128,8 @@ export async function runConfigBackup(
reason: string,
opts: { force?: boolean } = {}
): Promise<{ result: ConfigBackupResult; removed: string[] } | null> {
// Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error or a read
// that lands inside PUT /api/settings's non-atomic write used to prune with the defaults (20 / 30
// Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error (or, before
// settings writes became atomic, a read that landed inside one) used to prune with the defaults (20 / 30
// days) and drop the longer history the user asked to keep.
const raw = await readSettingsForBackup();
const unreadable = raw === 'unreadable';
+6 -14
View File
@@ -5,7 +5,7 @@
*/
import { FastifyInstance } from 'fastify';
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { join, resolve, relative, isAbsolute } from 'node:path';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
@@ -20,10 +20,10 @@ import {
ownerFor,
resolveCasesDir,
sessionCapacityMessage,
SETTINGS_PATH,
findSessionOrFail,
parseBody,
} from '../route-helpers.js';
import { mutateSettingsFile } from '../settings-writer.js';
import { resolveClaudeModeForUsername } from '../../user-store.js';
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
@@ -470,18 +470,10 @@ export function registerRalphRoutes(
// in single-user mode.
if (isAdmin(req)) {
try {
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* non-critical */
await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName }));
} catch (err) {
// Non-critical: the loop starts regardless.
console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err));
}
}
+6 -20
View File
@@ -114,11 +114,11 @@ import {
resolveCasesDir,
resolveParentSessionId,
sessionCapacityMessage,
SETTINGS_PATH,
validatePathWithinBase,
waitCapacityResponse,
abortOnClientHangUp,
} from '../route-helpers.js';
import { mutateSettingsFile } from '../settings-writer.js';
import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js';
import { agentInbox } from '../agent-inbox.js';
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
@@ -4576,28 +4576,14 @@ export function registerSessionRoutes(
// settings.json, readable by every user through GET /api/settings, so a non-admin's
// case name is not written there in multi-user mode (writes are admin-only, like
// PUT /api/settings). isAdmin is always true in single-user mode.
// Awaited and serialized with every other settings.json writer (mutateSettingsFile): the
// un-awaited write this replaced could land after the response, over a settings save.
if (isAdmin(req)) {
try {
const settingsFilePath = SETTINGS_PATH;
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
settings.lastUsedCase = caseName;
const dir = dirname(settingsFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName }));
} catch (err) {
// Non-critical but log for debugging
console.warn('[Server] Failed to prepare settings update:', err);
// Non-critical: the session is already running.
console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err));
}
}
+9 -34
View File
@@ -48,6 +48,7 @@ import {
toggleService,
SETTINGS_PATH,
} from '../route-helpers.js';
import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js';
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
@@ -1076,11 +1077,10 @@ export function registerSystemRoutes(
app.get('/api/settings', async (req) => {
// A plain read. This route must NEVER write settings.json: readJsonConfig()
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE, a read
// that lands inside PUT's non-atomic write), not only for a missing file,
// and every page load calls this route, so a "persist the default when the
// key is absent" reconcile here replaced a whole settings file with one key
// on the first unlucky read. The plan-usage default is resolved by the
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE), not
// only for a missing file, and every page load calls this route, so a
// "persist the default when the key is absent" reconcile here replaced a
// whole settings file with one key on the first unlucky read. The plan-usage default is resolved by the
// READERS instead: an absent `showPlanUsageLimits` means ON to
// readPlanUsageTelemetryEnabled() (hooks-config.ts), the same way an absent
// `workspaceHooksEnabled` means ON, and the client resolves its own display
@@ -1133,16 +1133,6 @@ export function registerSystemRoutes(
}
try {
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
// acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip
// it before persisting so settings.json stays clean.
const { acknowledgeUnauthTunnel, ...settingsToStore } = settings;
@@ -1150,8 +1140,8 @@ export function registerSystemRoutes(
// It must never write tunnelEnabled:true: a concurrent stop has written false by now,
// and true on disk would show a stopped tunnel as on. The stored value stays as it is.
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled;
const { before: existing } = await mutateSettingsFile((current) => ({ ...current, ...settingsToStore }));
const merged = { ...existing, ...settingsToStore };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
// Already-evicted history cannot be recovered on either version.
@@ -1251,7 +1241,7 @@ export function registerSystemRoutes(
return {};
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
return settingsWriteErrorResponse(err);
}
});
@@ -1269,25 +1259,10 @@ export function registerSystemRoutes(
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
try {
let existingSettings: Record<string, unknown> = {};
try {
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
existingSettings = JSON.parse(content);
} catch {
// File doesn't exist yet, start fresh
}
existingSettings.modelConfig = modelConfig;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
await fs.writeFile(SETTINGS_PATH, JSON.stringify(existingSettings, null, 2));
await mutateSettingsFile((current) => ({ ...current, modelConfig }));
return {};
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
return settingsWriteErrorResponse(err);
}
});
+5 -8
View File
@@ -174,6 +174,7 @@ import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
import { SseEvent } from './sse-events.js';
import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js';
import { mutateSettingsFile } from './settings-writer.js';
import { getLatestPlanUsage, setLatestCodexPlanUsage, setLatestCopilotPlanUsage } from './plan-usage-latest.js';
import { telemetrySignature } from '../usage-telemetry.js';
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
@@ -3351,16 +3352,12 @@ export class WebServer extends EventEmitter {
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
if (await this.isTunnelEnabled()) {
const settingsPath = dataPath('settings.json');
try {
const content = await fs.readFile(settingsPath, 'utf-8');
const settings = JSON.parse(content);
settings.tunnelEnabled = false;
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
} catch {
/* ignore */
await mutateSettingsFile((settings) => ({ ...settings, tunnelEnabled: false }));
console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)');
} catch (err) {
console.warn('Could not reset the Cloudflare tunnel setting:', getErrorMessage(err));
}
console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)');
}
// Start team watcher for agent team awareness (always on — lightweight polling)
+118
View File
@@ -0,0 +1,118 @@
/**
* @fileoverview The writer of the server's `settings.json` (`SETTINGS_PATH`).
*
* Every read-modify-write of that file goes through `mutateSettingsFile()`, the same shape as
* `mutateRegistryFile()` (config/cli-registry/registry-writer.ts). The one direct writer is
* config-backup restore (`restoreConfigBackup()`, as for `clis.json`): it must work on a file that
* does not parse, which this writer refuses.
*
* - **Serialized.** Mutations run one at a time on a single promise chain, each one reading,
* changing and writing before the next starts. The writers used to run unserialized with a
* plain truncate-then-write `fs.writeFile`, and quick-start did not even await its
* `lastUsedCase` write, so a quick-start followed by a settings save could tear the file (one
* write's bytes over the tail of a longer one) or drop the other writer's key.
* - **Atomic.** Each write goes to its own temp file beside the target and is renamed over it,
* so a reader never sees a half-written file.
* - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file
* that cannot be read or does not parse is left alone and the write throws
* `SettingsWriteRefusedError`: the writers used to read such a file as `{}` and write back only
* their own keys, which erased every other setting.
*/
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { dirname } from 'node:path';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../types.js';
import { SETTINGS_PATH } from './route-helpers.js';
export type SettingsFile = Record<string, unknown>;
/** A write refused because the existing `settings.json` must not be overwritten. The message is user-facing. */
export class SettingsWriteRefusedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SettingsWriteRefusedError';
}
}
async function readSettingsForWrite(path: string): Promise<SettingsFile> {
let raw: string;
try {
raw = await fs.readFile(path, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw new SettingsWriteRefusedError(
`Cannot read ${path} (${(err as Error).message}), so this change was not saved.`
);
}
// An empty file holds no settings to lose.
if (raw.trim() === '') return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = undefined;
}
// The parser's own message is not repeated: it can quote the file, which may hold an API key.
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new SettingsWriteRefusedError(
`${path} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`
);
}
return parsed as SettingsFile;
}
async function writeSettingsFile(path: string, settings: SettingsFile): Promise<void> {
// Through a symlink, as the plain writeFile this replaces did: the rename lands on the real file.
const target = await fs.realpath(path).catch(() => path);
await fs.mkdir(dirname(target), { recursive: true });
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(settings, null, 2), { mode: 0o600 });
await fs.rename(tmp, target);
} catch (err) {
await fs.rm(tmp, { force: true }).catch(() => {});
throw err;
}
}
let mutationChain: Promise<unknown> = Promise.resolve();
/**
* Run one change to `settings.json`. `fn` receives the current settings and returns the settings
* to write, or `undefined` to write nothing. Resolves with the settings before and after the
* change (`after` is `undefined` when nothing was written). A refused or failed mutation rejects
* its own caller only; the chain keeps going.
*/
export function mutateSettingsFile(
fn: (current: SettingsFile) => SettingsFile | undefined
): Promise<{ before: SettingsFile; after: SettingsFile | undefined }> {
const run = mutationChain.then(async () => {
const before = await readSettingsForWrite(SETTINGS_PATH).catch((err: unknown) => {
console.warn(`[settings] write refused: ${getErrorMessage(err)}`);
throw err;
});
const after = fn({ ...before });
if (after) await writeSettingsFile(SETTINGS_PATH, after);
return { before, after };
});
mutationChain = run.catch(() => {});
return run;
}
/**
* Rejects with `SettingsWriteRefusedError` when `mutateSettingsFile()` would refuse a write right
* now: the writer's own read-for-write rule, run on its own. For a route that does something it
* cannot take back (case rename ends sessions) before its settings write. A check only, outside
* the chain (writes are atomic, so it never reads a half-written file); the file can still change
* before the write.
*/
export async function assertSettingsWritable(): Promise<void> {
await readSettingsForWrite(SETTINGS_PATH);
}
/** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. */
export function settingsWriteErrorResponse(err: unknown): ApiResponse<never> {
if (err instanceof SettingsWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}