Files
Codeman/test/settings-json-write-serialization.test.ts
T
JD 8fa5b340c1 fix(settings): serialize settings.json writes so a quick-start cannot tear the file
Quick-start saved lastUsedCase with an un-awaited, non-atomic read-modify-write, and the settings PUT, model config, case order, case rename, Ralph start and the boot tunnel reset each ran their own. Two overlapping truncate-then-write calls left one write's bytes over the tail of the other (the CI flake in test/quick-start.test.ts), and most writers then read the torn file as {} and wrote back only its own keys, erasing the rest of the user's settings.

All of them now go through one writer that runs one change at a time, replaces the file atomically, and refuses (409 CONFLICT) to overwrite a file that does not parse.
2026-10-26 03:08:09 -04:00

221 lines
9.8 KiB
TypeScript

/**
* @fileoverview Every writer of the server's settings.json is serialized, atomic, and refuses to
* overwrite a file that does not parse (live server, ephemeral port, real file on disk).
*
* The upstream CI flake in test/quick-start.test.ts ("accepts the setting via PUT, then injects")
* was a torn settings.json: `SyntaxError: Unexpected non-whitespace character after JSON at
* position 31`. Quick-start saved `lastUsedCase` with an un-awaited, non-atomic read-modify-write,
* PUT /api/settings ran its own unserialized one, and the two overlapping truncate-then-write
* calls left one write's bytes over the tail of the other. The PUT then read the torn file as `{}`
* and wrote back only its own keys, erasing every other setting.
*
* Writes to settings.json (and its temp files) are slowed down here, which is what a busy CI disk
* does: a writer that answers before its write lands, or two writers that both read before either
* writes, then fail deterministically instead of once in a while.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import fsp from 'node:fs/promises';
import {
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import { getCasesDir } from '../src/config/cases-dir.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
/** How long every write to settings.json (or a temp file beside it) is held before it starts. */
const SLOW_WRITE_MS = 200;
const realWriteFile = fsp.writeFile.bind(fsp);
vi.spyOn(fsp, 'writeFile').mockImplementation((async (file: unknown, data: unknown, options?: unknown) => {
if (typeof file === 'string' && file.startsWith(SETTINGS_PATH)) {
await new Promise((resolve) => setTimeout(resolve, SLOW_WRITE_MS));
}
return realWriteFile(file as string, data as string, options as undefined);
}) as typeof fsp.writeFile);
/** The bytes the CI run left behind: the PUT's 31-byte file over the tail of quick-start's longer one. */
const TORN = '{\n "agentSkillEnabled": true\n}' + 'rt-1761000000000"\n}';
const REFUSAL = {
success: false,
errorCode: 'CONFLICT',
error: `${SETTINGS_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.`,
};
let server: WebServer;
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
const seed = (value: unknown) => writeFileSync(SETTINGS_PATH, JSON.stringify(value, null, 2));
const readSettings = () => JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')) as Record<string, unknown>;
async function send(method: string, path: string, body: unknown) {
const res = await fetch(url(path), {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
}
let caseCounter = 0;
const newCaseName = () => `settings-race-${process.pid}-${++caseCounter}`;
beforeAll(async () => {
// The boot pass resets a persisted tunnelEnabled (the tunnel only starts from the UI); that write
// goes through the same writer and must keep every other key.
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
seed({ tunnelEnabled: true, caseOrder: ['boot'] });
server = new WebServer(0, false, true);
await server.start();
}, 30000);
afterAll(async () => {
await server?.stop();
vi.restoreAllMocks();
}, 30000);
describe('settings.json writes', () => {
it('resets tunnelEnabled at boot without dropping the other keys', () => {
expect(readSettings()).toEqual({ tunnelEnabled: false, caseOrder: ['boot'] });
});
describe('with a valid file', () => {
beforeEach(() => seed({ lastUsedCase: 'seeded' }));
it('answers a quick-start only after its lastUsedCase has landed', async () => {
const caseName = newCaseName();
const res = await send('POST', '/api/quick-start', { caseName });
expect(res.body.success).toBe(true);
expect(readSettings()).toEqual({ lastUsedCase: caseName });
});
it('answers a Ralph loop start only after its lastUsedCase has landed', async () => {
const caseName = newCaseName();
const res = await send('POST', '/api/ralph-loop/start', { caseName, taskDescription: 'x' });
expect(res.body.success).toBe(true);
expect(readSettings()).toEqual({ lastUsedCase: caseName });
});
it('keeps every key when quick-start, the settings PUT, model config and case order write at once', async () => {
const caseName = newCaseName();
const [quick, put, model, order] = await Promise.all([
send('POST', '/api/quick-start', { caseName }),
send('PUT', '/api/settings', { agentSkillEnabled: true }),
send('PUT', '/api/execution/model-config', { defaultModel: 'opus' }),
send('PUT', '/api/cases/order', { order: ['a', 'b'] }),
]);
expect(quick.body.success).toBe(true);
expect(put).toEqual({ status: 200, body: { success: true, data: {} } });
expect(model).toEqual({ status: 200, body: { success: true, data: {} } });
expect(order).toEqual({ status: 200, body: { success: true, data: { order: ['a', 'b'] } } });
expect(readSettings()).toEqual({
lastUsedCase: caseName,
agentSkillEnabled: true,
modelConfig: { defaultModel: 'opus' },
caseOrder: ['a', 'b'],
});
});
it('keeps a settings PUT that lands while a case rename rewrites caseOrder', async () => {
const oldName = newCaseName();
const newName = newCaseName();
mkdirSync(join(getCasesDir(), oldName), { recursive: true });
seed({ lastUsedCase: oldName, caseOrder: [oldName, 'other'] });
const [rename, put] = await Promise.all([
send('POST', `/api/cases/${oldName}/rename`, { newName }),
send('PUT', '/api/settings', { agentSkillEnabled: true }),
]);
expect(rename.body.success).toBe(true);
expect(put).toEqual({ status: 200, body: { success: true, data: {} } });
expect(readSettings()).toEqual({ lastUsedCase: newName, caseOrder: [newName, 'other'], agentSkillEnabled: true });
});
it('writes the file 0600, since it can hold an API key', async () => {
await send('PUT', '/api/settings', { agentSkillEnabled: true });
expect(statSync(SETTINGS_PATH).mode & 0o777).toBe(0o600);
});
});
it('starts from {} when the file is missing or empty', async () => {
rmSync(SETTINGS_PATH, { force: true });
expect(await send('PUT', '/api/settings', { agentSkillEnabled: true })).toEqual({
status: 200,
body: { success: true, data: {} },
});
expect(readFileSync(SETTINGS_PATH, 'utf-8')).toBe('{\n "agentSkillEnabled": true\n}');
writeFileSync(SETTINGS_PATH, ' \n');
await send('PUT', '/api/execution/model-config', { defaultModel: 'opus' });
expect(readSettings()).toEqual({ modelConfig: { defaultModel: 'opus' } });
});
it('writes through a symlinked settings.json to its target', async () => {
const elsewhere = mkdtempSync(join(tmpdir(), 'codeman-settings-link-'));
const target = join(elsewhere, 'settings.json');
writeFileSync(target, JSON.stringify({ lastUsedCase: 'linked' }));
rmSync(SETTINGS_PATH, { force: true });
symlinkSync(target, SETTINGS_PATH);
try {
await send('PUT', '/api/settings', { agentSkillEnabled: true });
expect(lstatSync(SETTINGS_PATH).isSymbolicLink()).toBe(true);
expect(readlinkSync(SETTINGS_PATH)).toBe(target);
expect(JSON.parse(readFileSync(target, 'utf-8'))).toEqual({ lastUsedCase: 'linked', agentSkillEnabled: true });
} finally {
rmSync(SETTINGS_PATH, { force: true });
rmSync(elsewhere, { recursive: true, force: true });
}
});
describe('refuses to overwrite a file that is not a JSON object, and leaves its bytes alone', () => {
for (const [label, bytes] of [
['a torn file', TORN],
['a JSON array', '[1, 2]'],
['JSON null', 'null'],
['a JSON string', '"settings"'],
] as const) {
it(`${label}: every settings write route answers 409 CONFLICT`, async () => {
writeFileSync(SETTINGS_PATH, bytes);
expect(await send('PUT', '/api/settings', { agentSkillEnabled: true })).toEqual({ status: 409, body: REFUSAL });
expect(await send('PUT', '/api/execution/model-config', { defaultModel: 'opus' })).toEqual({
status: 409,
body: REFUSAL,
});
expect(await send('PUT', '/api/cases/order', { order: ['a'] })).toEqual({ status: 409, body: REFUSAL });
expect(readFileSync(SETTINGS_PATH, 'utf-8')).toBe(bytes);
});
}
it('a torn file: quick-start and a Ralph loop start still succeed without touching it', async () => {
writeFileSync(SETTINGS_PATH, TORN);
expect((await send('POST', '/api/quick-start', { caseName: newCaseName() })).body.success).toBe(true);
expect(
(await send('POST', '/api/ralph-loop/start', { caseName: newCaseName(), taskDescription: 'x' })).body.success
).toBe(true);
expect(readFileSync(SETTINGS_PATH, 'utf-8')).toBe(TORN);
});
it('a torn file: a refusal does not wedge later writes once the file is fixed', async () => {
writeFileSync(SETTINGS_PATH, TORN);
expect((await send('PUT', '/api/settings', { agentSkillEnabled: true })).status).toBe(409);
seed({ lastUsedCase: 'fixed' });
expect(await send('PUT', '/api/settings', { agentSkillEnabled: true })).toEqual({
status: 200,
body: { success: true, data: {} },
});
expect(readSettings()).toEqual({ lastUsedCase: 'fixed', agentSkillEnabled: true });
});
});
});