fix(settings): harden case sync (privacy, concurrent saves, failures, enable/disable)

- Redact caseSettings for non-admins and never apply a shared map on a read-only page (CASE-SYNC-001)
- Send one case per save and merge it inside mutateSettingsFile, so devices cannot discard each other's cases; rapid saves keep their order (CASE-SYNC-002)
- Check HTTP status and the response body, keep the local choice, report an unsynced save and reuse the forbidden-write handling (CASE-SYNC-003)
- Let the server decide whether sync is on: refresh the cached switch, discard stale refreshes (CASE-SYNC-004)
- Carry shared options through case rename and drop them on delete or unlink
- Give the switch an accessible name; add server, client and two-browser tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Devvyn SatieandClaude Sonnet 5.5 committed 2026-11-09 08:43:46 +08:00
1 parent d62b11586b
commit a50d0db283
12 files changed
+554 -77

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
/**
* @fileoverview Opt-in case sync, server side (live server, ephemeral port, real settings.json): the shared
* per-case options merge inside the serialised settings writer, a non-admin never reads them, and a case
* rename or delete carries them along (CASE-SYNC-001, CASE-SYNC-002).
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
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';
import { settingsForNonAdmin } from '../src/web/routes/system-routes.js';
import {
dropCaseSettings,
MAX_SHARED_CASES,
mergeCaseSettings,
renameCaseSettings,
} from '../src/web/case-state-sync.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const on = { agentTeams: true, opusContext1m: false };
const off = { agentTeams: false, opusContext1m: true };
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, any>;
async function send(method: string, path: string, body?: unknown) {
const res = await fetch(url(path), {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as Record<string, any> };
}
let n = 0;
const caseName = () => `case-sync-${process.pid}-${++n}`;
beforeAll(async () => {
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
seed({});
server = new WebServer(0, false, true);
await server.start();
}, 30000);
afterAll(async () => {
await server?.stop();
vi.restoreAllMocks();
}, 30000);
describe('shared case options', () => {
beforeEach(() => seed({ syncCaseState: true }));
it('merges concurrent saves of different cases instead of letting the second discard the first', async () => {
const [a, b, c] = await Promise.all([
send('PUT', '/api/settings', { caseSettings: { alpha: on } }),
send('PUT', '/api/settings', { caseSettings: { beta: off } }),
send('PUT', '/api/settings', { caseSettings: { gamma: on } }),
]);
expect([a.status, b.status, c.status]).toEqual([200, 200, 200]);
expect(readSettings().caseSettings).toEqual({ alpha: on, beta: off, gamma: on });
});
it('keeps the other cases when one is saved again, and every other setting', async () => {
seed({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: on, beta: off } });
await send('PUT', '/api/settings', { caseSettings: { alpha: off } });
expect(readSettings()).toEqual({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: off, beta: off } });
});
it('refuses a merge that takes the file past the limit and leaves it as it was', async () => {
const full = Object.fromEntries(Array.from({ length: MAX_SHARED_CASES }, (_, i) => [`c${i}`, on]));
seed({ syncCaseState: true, caseSettings: full });
const res = await send('PUT', '/api/settings', { caseSettings: { overflow: on } });
expect(res.status).toBe(400);
expect(Object.keys(readSettings().caseSettings)).toHaveLength(MAX_SHARED_CASES);
expect((await send('PUT', '/api/settings', { caseSettings: { c1: off } })).status).toBe(200);
});
it('moves the options with a renamed case, and drops them when the case is deleted', async () => {
const oldName = caseName();
const newName = caseName();
mkdirSync(join(getCasesDir(), oldName), { recursive: true });
seed({ syncCaseState: true, caseOrder: [oldName], caseSettings: { [oldName]: on, keep: off } });
const renamed = await send('POST', `/api/cases/${oldName}/rename`, { newName });
expect(renamed.body.success).toBe(true);
expect(readSettings().caseSettings).toEqual({ [newName]: on, keep: off });
const deleted = await send('DELETE', `/api/cases/${newName}`);
expect(deleted.body.success).toBe(true);
expect(readSettings().caseSettings).toEqual({ keep: off });
});
});
describe('what a non-admin is given', () => {
it('has the shared map removed, and keeps the switch the page reads', () => {
const out = settingsForNonAdmin({
syncCaseState: true,
caseOrder: ['a'],
lastUsedCase: 'a',
caseSettings: { a: on },
agentSkillEnabled: true,
});
expect(out).toEqual({ syncCaseState: true, agentSkillEnabled: true });
});
});
describe('case map helpers', () => {
it('merge, rename and drop leave the input alone and report nothing to do as null', () => {
const map = { a: on, b: off };
expect(mergeCaseSettings(map, { c: on })).toEqual({ a: on, b: off, c: on });
expect(renameCaseSettings(map, 'a', 'z')).toEqual({ b: off, z: on });
expect(renameCaseSettings(map, 'nope', 'z')).toBeNull();
expect(dropCaseSettings(map, 'a')).toEqual({ b: off });
expect(dropCaseSettings(map, 'nope')).toBeNull();
expect(mergeCaseSettings(undefined, { a: on })).toEqual({ a: on });
expect(map).toEqual({ a: on, b: off });
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* @fileoverview Case sync across two devices, end to end: a real server on an ephemeral port (isolated test
* HOME) and two separate Chromium contexts (each with its own localStorage, like two devices). Saves of
* different cases from both contexts both land, the other context takes them on returning to its tab, and
* once the server says sync is off the selected case stops following.
*/
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
import { getCasesDir } from '../src/config/cases-dir.js';
describe('Sync cases across devices in two browsers', () => {
let server: WebServer;
let browser: Browser;
let a: Page;
let b: Page;
const one = `sync-a-${process.pid}`;
const two = `sync-b-${process.pid}`;
const base = () => `http://localhost:${server.boundPort}`;
const put = (body: unknown) =>
fetch(`${base()}/api/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const serverSettings = async () => ((await (await fetch(`${base()}/api/settings`)).json()) as any).data;
const open = async () => {
const page = await (await browser.newContext({ viewport: { width: 1400, height: 900 } })).newPage();
await page.goto(base(), { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
return page;
};
const opts = (page: Page, name: string) => page.evaluate((n) => (window as any).app.getCaseSettings(n), name);
const returnToTab = (page: Page) =>
page.evaluate(async () => {
const app = (window as any).app;
await app._syncCaseStateFromServer();
});
beforeAll(async () => {
for (const name of [one, two]) mkdirSync(join(getCasesDir(), name), { recursive: true });
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
await put({ syncCaseState: true });
a = await open();
b = await open();
for (const page of [a, b]) {
await page.evaluate(async () => {
const app = (window as any).app;
await app.loadAppSettingsFromServer();
});
}
}, 60000);
afterAll(async () => {
await browser?.close();
await server?.stop();
}, 60000);
it("keeps both devices' saves and lets the other device take them", async () => {
await Promise.all([
a.evaluate((n) => (window as any).app.saveCaseSettings(n, { agentTeams: true, opusContext1m: false }), one),
b.evaluate((n) => (window as any).app.saveCaseSettings(n, { agentTeams: false, opusContext1m: false }), two),
]);
await expect
.poll(async () => Object.keys((await serverSettings()).caseSettings ?? {}).sort(), { timeout: 10000 })
.toEqual([one, two].sort());
await returnToTab(b);
await returnToTab(a);
expect(await opts(b, one)).toEqual({ agentTeams: true, opusContext1m: false });
expect(await opts(a, two)).toEqual({ agentTeams: false, opusContext1m: false });
});
it('follows the selected case while sync is on, and stops once the server says it is off', async () => {
await put({ lastUsedCase: two });
await returnToTab(a);
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
await put({ syncCaseState: false, lastUsedCase: one });
await returnToTab(a);
expect(await a.evaluate(() => (document.getElementById('quickStartCase') as HTMLSelectElement).value)).toBe(two);
expect(await a.evaluate(() => (window as any).app.loadAppSettingsFromStorage().syncCaseState)).toBe(false);
});
});
+188 -48
View File
@@ -1,6 +1,7 @@
/**
* @fileoverview Opt-in case sync: the schema accepts the setting and a per-case map, and the
* session-ui helpers only touch the server when the setting is on. Evaluates the real session-ui.js.
* @fileoverview Opt-in case sync, client side: the schema accepts the setting and a per-case map, and the
* session-ui helpers only touch the server when the setting is on, send one case at a time, report a failed
* save, and obey the server's answer about whether sync is on. Evaluates the real session-ui.js.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
@@ -9,85 +10,224 @@ import { SettingsUpdateSchema } from '../src/web/schemas.js';
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
function makeApp(settings: Record<string, unknown>, server: Record<string, unknown> = {}) {
type Reply = { status?: number; body?: unknown } | 'throw';
type Handler = (url: string, init?: { method?: string; body?: string }) => Reply | Promise<Reply>;
function makeApp(settings: Record<string, unknown>, onFetch: Handler = () => ({ body: { success: true, data: {} } })) {
const store = new Map<string, string>();
const puts: unknown[] = [];
const calls: { method: string; body: unknown }[] = [];
const toasts: string[] = [];
const select = { value: 'alpha' };
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: () => null, addEventListener: () => {} },
document: { getElementById: () => select, addEventListener: () => {} },
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
},
fetch: async (_url: string, init?: { method?: string; body?: string }) => {
if (init?.method === 'PUT') puts.push(JSON.parse(init.body!));
return { ok: true, json: async () => ({ success: true, data: server }) };
fetch: async (url: string, init?: { method?: string; body?: string }) => {
calls.push({ method: init?.method ?? 'GET', body: init?.body ? JSON.parse(init.body) : undefined });
const r = await onFetch(url, init);
if (r === 'throw') throw new Error('offline');
const status = r.status ?? 200;
const res = { ok: status < 400, status, json: async () => r.body ?? {}, clone: () => res };
return res;
},
console,
});
vm.runInContext(SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
app.loadAppSettingsFromStorage = () => settings;
return { app, store, puts };
app.saveAppSettingsToStorage = (s: Record<string, unknown>) => Object.assign(settings, s);
app.showToast = (m: string) => void toasts.push(m);
app.cases = [{ name: 'alpha' }, { name: 'beta' }];
app.updateDirDisplayForCase = () => {};
app.updateMobileCaseLabel = () => {};
app.updateCasePickerInput = () => {};
return { app, store, calls, toasts, select, settings };
}
describe('syncCaseState', () => {
it('is accepted by the settings schema with a per-case map, and rejects junk', () => {
expect(
SettingsUpdateSchema.safeParse({
syncCaseState: true,
caseSettings: { demo: { agentTeams: true, opusContext1m: false } },
}).success
).toBe(true);
const settle = () => new Promise((r) => setTimeout(r, 0));
const entry = { agentTeams: true, opusContext1m: false };
describe('syncCaseState schema', () => {
it('accepts the setting with a per-case map, and rejects junk', () => {
expect(SettingsUpdateSchema.safeParse({ syncCaseState: true, caseSettings: { demo: entry } }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ caseSettings: { demo: { agentTeams: 'yes' } } }).success).toBe(false);
expect(
SettingsUpdateSchema.safeParse({ caseSettings: { demo: { agentTeams: true, opusContext1m: true, x: 1 } } })
.success
).toBe(false);
});
});
it('saves only to this browser while the setting is off', async () => {
const { app, store, puts } = makeApp({});
app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: true });
await new Promise((r) => setTimeout(r, 0));
describe('saving a case', () => {
it('stays on this browser while the setting is off', async () => {
const { app, store, calls } = makeApp({});
app.saveCaseSettings('demo', entry);
await settle();
expect(store.get('caseSettings_demo')).toContain('agentTeams');
expect(puts).toEqual([]);
expect(calls).toEqual([]);
});
it('pushes a merge into the server map while on, and never when settings are read-only', async () => {
const server = { caseSettings: { other: { agentTeams: false, opusContext1m: true } } };
const on = makeApp({ syncCaseState: true }, server);
on.app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: false });
await new Promise((r) => setTimeout(r, 0));
expect(on.puts).toEqual([
{
caseSettings: {
other: { agentTeams: false, opusContext1m: true },
demo: { agentTeams: true, opusContext1m: false },
},
},
]);
it('sends only that case (no read of the server map first), and nothing when read-only', async () => {
const on = makeApp({ syncCaseState: true });
on.app.saveCaseSettings('demo', entry);
await settle();
expect(on.calls).toEqual([{ method: 'PUT', body: { caseSettings: { demo: entry } } }]);
const ro = makeApp({ syncCaseState: true });
ro.app._settingsReadOnly = () => true;
ro.app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: true });
await new Promise((r) => setTimeout(r, 0));
expect(ro.puts).toEqual([]);
ro.app.saveCaseSettings('demo', entry);
await settle();
expect(ro.calls).toEqual([]);
});
it('takes the server copy into local keys only while the server says sync is on', () => {
const a = makeApp({});
a.app._applyServerCaseSettings({
syncCaseState: true,
caseSettings: { demo: { agentTeams: true, opusContext1m: false } },
it('sends rapid saves one after another, in the order they were made', async () => {
let release!: () => void;
const first = new Promise<void>((r) => (release = r));
let n = 0;
const { app, calls } = makeApp({ syncCaseState: true }, async () => {
if (++n === 1) await first;
return { body: { success: true, data: {} } };
});
expect(JSON.parse(a.store.get('caseSettings_demo')!)).toEqual({ agentTeams: true, opusContext1m: false });
app.saveCaseSettings('demo', { agentTeams: true, opusContext1m: true });
app.saveCaseSettings('demo', { agentTeams: false, opusContext1m: false });
await settle();
expect(calls).toHaveLength(1); // the second waits for the first
release();
await app._caseSyncChain;
expect(calls.map((c) => (c.body as any).caseSettings.demo.agentTeams)).toEqual([true, false]);
});
it.each([
['an HTTP 500', { status: 500, body: { success: false, error: 'boom' } }],
['a refusal answered with HTTP 200', { body: { success: false, error: 'settings.json is not valid' } }],
['a dropped connection', 'throw' as const],
])('keeps the local choice and says it was not synced after %s', async (_label, reply) => {
const { app, store, toasts } = makeApp({ syncCaseState: true }, () => reply as Reply);
app.saveCaseSettings('demo', entry);
await app._caseSyncChain;
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual(entry);
expect(toasts).toHaveLength(1);
expect(toasts[0]).toContain('not synced');
});
it('hands an admin-only refusal to the read-only handling, without a toast', async () => {
const forbidden = { status: 403, body: { success: false, errorCode: 'FORBIDDEN' } };
const { app, toasts } = makeApp({ syncCaseState: true }, () => forbidden);
app._noteSettingsWriteRefusal = async (res: { status: number }) => {
if (res.status !== 403) return false;
app._settingsWriteForbidden = true;
return true;
};
app._settingsReadOnly = () => app._settingsWriteForbidden === true;
app.saveCaseSettings('demo', entry);
await app._caseSyncChain;
expect(toasts).toEqual([]);
expect(app._caseStateSyncOn()).toBe(false); // nothing more is sent from this page
});
});
describe('taking the server copy', () => {
const server = { syncCaseState: true, caseSettings: { demo: entry } };
it('writes the options into the local keys only while the server says sync is on', () => {
const a = makeApp({});
a.app._applyServerCaseSettings(server);
expect(JSON.parse(a.store.get('caseSettings_demo')!)).toEqual(entry);
const b = makeApp({});
b.app._applyServerCaseSettings({
syncCaseState: false,
caseSettings: { demo: { agentTeams: true, opusContext1m: false } },
b.app._applyServerCaseSettings({ ...server, syncCaseState: false });
expect(b.store.size).toBe(0);
});
it('never applies a map to a read-only identity, even one the server handed over', () => {
const { app, store } = makeApp({ syncCaseState: true });
app._settingsReadOnly = () => true;
app._applyServerCaseSettings(server);
expect(store.size).toBe(0);
});
it('leaves a case alone while this device is still saving it', async () => {
let release!: () => void;
const hold = new Promise<void>((r) => (release = r));
const { app, store } = makeApp({ syncCaseState: true }, async () => {
await hold;
return { body: { success: true, data: {} } };
});
app.saveCaseSettings('demo', { agentTeams: false, opusContext1m: false });
app._applyServerCaseSettings({ syncCaseState: true, caseSettings: { demo: entry, other: entry } });
expect(JSON.parse(store.get('caseSettings_demo')!)).toEqual({ agentTeams: false, opusContext1m: false });
expect(JSON.parse(store.get('caseSettings_other')!)).toEqual(entry);
release();
await app._caseSyncChain;
});
});
describe('returning to the tab', () => {
const env = (data: Record<string, unknown>) => ({ body: { success: true, data } });
it('follows the selected case and the options while sync is on', async () => {
const { app, select, store } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: true, lastUsedCase: 'beta', caseSettings: { beta: entry } })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('beta');
expect(JSON.parse(store.get('caseSettings_beta')!)).toEqual(entry);
});
it('stops following when another device turned sync off, and the cached switch follows the server', async () => {
const { app, select, store, settings } = makeApp({ syncCaseState: true }, () =>
env({ syncCaseState: false, lastUsedCase: 'beta', caseSettings: { beta: entry } })
);
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
expect(store.size).toBe(0);
expect(settings.syncCaseState).toBe(false);
expect(app._caseStateSyncOn()).toBe(false);
});
it('starts following when another device turned sync on', async () => {
const { app, select, settings } = makeApp({}, () => env({ syncCaseState: true, lastUsedCase: 'beta' }));
await app._syncCaseStateFromServer();
expect(settings.syncCaseState).toBe(true);
expect(select.value).toBe('beta');
});
it('does nothing for a read-only identity', async () => {
const { app, calls } = makeApp({ syncCaseState: true });
app._settingsReadOnly = () => true;
await app._syncCaseStateFromServer();
expect(calls).toEqual([]);
});
it('keeps what it has when the request fails', async () => {
const { app, select } = makeApp({ syncCaseState: true }, () => ({ status: 500, body: { error: 'x' } }));
await app._syncCaseStateFromServer();
expect(select.value).toBe('alpha');
});
it('discards an older refresh that answers after a newer one', async () => {
const releases: (() => void)[] = [];
const answers = [
env({ syncCaseState: true, lastUsedCase: 'beta' }), // the older request
env({ syncCaseState: true, lastUsedCase: 'alpha' }), // the newer request
];
let i = 0;
const { app, select } = makeApp({ syncCaseState: true }, async () => {
const mine = answers[i++];
await new Promise<void>((r) => releases.push(r));
return mine;
});
expect(b.store.size).toBe(0);
const older = app._syncCaseStateFromServer();
await settle();
const newer = app._syncCaseStateFromServer();
await settle();
releases[1](); // the newer answers first
await newer;
releases[0](); // then the older one arrives late
await older;
expect(select.value).toBe('alpha');
});
});