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

+48
View File
@@ -0,0 +1,48 @@
/**
* @fileoverview Pure helpers for the opt-in shared per-case run options (`caseSettings` in
* settings.json, written while `syncCaseState` is on). They run inside `mutateSettingsFile()`, so
* every change is serialised with the file's other writers: two devices saving different cases
* both land, where a replacement map would let the second discard the first.
*/
export interface CaseRunOptions {
agentTeams: boolean;
opusContext1m: boolean;
}
/** Most cases the shared map holds, counted after a merge (the schema bounds one request, not the file). */
export const MAX_SHARED_CASES = 500;
export class SharedCaseLimitError extends Error {
constructor() {
super(`Shared case options are limited to ${MAX_SHARED_CASES} cases`);
this.name = 'SharedCaseLimitError';
}
}
function asMap(value: unknown): Record<string, CaseRunOptions> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, CaseRunOptions>) : {};
}
/** The stored map with `incoming` merged in per case. Throws SharedCaseLimitError past the limit. */
export function mergeCaseSettings(current: unknown, incoming: Record<string, CaseRunOptions>) {
const merged = { ...asMap(current), ...incoming };
if (Object.keys(merged).length > MAX_SHARED_CASES) throw new SharedCaseLimitError();
return merged;
}
/** The map with `from` moved to `to`, or null when there is nothing to move. */
export function renameCaseSettings(current: unknown, from: string, to: string) {
const map = asMap(current);
if (!Object.hasOwn(map, from)) return null;
const { [from]: moved, ...rest } = map;
return { ...rest, [to]: moved };
}
/** The map without `name`, or null when it has no entry for it. */
export function dropCaseSettings(current: unknown, name: string) {
const map = asMap(current);
if (!Object.hasOwn(map, name)) return null;
const { [name]: _gone, ...rest } = map;
return rest;
}
+1 -1
View File
@@ -3577,7 +3577,7 @@
<span class="set-row-label">Sync cases across devices</span>
<span class="set-row-desc">Keeps each case's Agent Teams and Opus 1M options on the server and switches this device to the case you last chose on another one when you return to the tab. Off by default: each browser keeps its own. The case names are stored in the shared settings file.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSyncCaseState"><span class="slider"></span></label>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSyncCaseState" aria-label="Sync cases across devices"><span class="slider"></span></label>
</div>
</div>
</div>
+69 -18
View File
@@ -3673,7 +3673,7 @@ Object.assign(CodemanApp.prototype, {
saveCaseSettings(caseName, settings) {
localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings));
if (this._caseStateSyncOn()) this._pushCaseSettings(caseName, settings);
if (this._caseStateSyncOn()) this._queueCaseSync(caseName, settings);
},
// ── Opt-in case sync (`syncCaseState`): case options live on the server, the selected case follows other devices ──
@@ -3683,43 +3683,94 @@ Object.assign(CodemanApp.prototype, {
return this.loadAppSettingsFromStorage().syncCaseState === true && !this._settingsReadOnly?.();
},
async _pushCaseSettings(caseName, settings) {
/**
* Send one case's options. Saves from this device run one after another in the order they were made
* (one chain), and each carries only its own case: the server merges it into the shared map, so a save
* from another device is never overwritten. `_caseSyncPending` counts a case's saves still in flight,
* so an import of the server's copy leaves that case alone.
*/
_queueCaseSync(caseName, settings) {
const entry = { agentTeams: !!settings.agentTeams, opusContext1m: !!settings.opusContext1m };
const pending = this._caseSyncPending || (this._caseSyncPending = new Map());
pending.set(caseName, (pending.get(caseName) || 0) + 1);
const run = async () => {
try {
await this._pushCaseSettings(caseName, entry);
} finally {
const left = (pending.get(caseName) || 1) - 1;
if (left > 0) pending.set(caseName, left);
else pending.delete(caseName);
}
};
this._caseSyncChain = (this._caseSyncChain || Promise.resolve()).then(run, run);
return this._caseSyncChain;
},
/** Why a settings write did not land, or '' when it did (HTTP status and the response's own error both count). */
async _caseSyncError(res) {
if (!res) return 'the server could not be reached';
let body = null;
try {
// PUT replaces the whole map, so merge into the server's current one rather than this device's copy.
const current = await fetch('/api/settings').then((r) => r.json()).then((env) => env?.data ?? {});
await fetch('/api/settings', {
body = await (typeof res.clone === 'function' ? res.clone() : res).json();
} catch { /* not JSON: the status is all there is */ }
if (!res.ok) return body?.error || `HTTP ${res.status}`;
if (body && (body.success === false || body.error)) return body.error || 'the server refused it';
return '';
},
async _pushCaseSettings(caseName, entry) {
let res = null;
try {
res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseSettings: {
...(current.caseSettings || {}),
[caseName]: { agentTeams: !!settings.agentTeams, opusContext1m: !!settings.opusContext1m },
},
}),
body: JSON.stringify({ caseSettings: { [caseName]: entry } }),
});
} catch (err) {
console.error('Failed to sync case settings:', err);
}
// The admin-only refusal marks this page read-only (settings-ui.js), so nothing more is sent from it.
if (await this._noteSettingsWriteRefusal?.(res)) return;
const error = await this._caseSyncError(res);
if (error) this.showToast?.(`Options for "${caseName}" saved on this device, not synced: ${error}`, 'warning');
},
/** Server copy wins: write the synced per-case options into the local keys getCaseSettings() reads. */
/** Server copy wins, except for a case this device is still saving: write its options into the local keys getCaseSettings() reads. */
_applyServerCaseSettings(server) {
if (server?.syncCaseState !== true || !server.caseSettings) return;
if (server?.syncCaseState !== true || !server.caseSettings || typeof server.caseSettings !== 'object') return;
// A non-admin never receives the map (the server redacts it) and must not apply one it was handed.
if (this._settingsReadOnly?.()) return;
try {
for (const [name, s] of Object.entries(server.caseSettings)) {
localStorage.setItem('caseSettings_' + name, JSON.stringify(s));
if (!s || typeof s !== 'object' || this._caseSyncPending?.has(name)) continue;
localStorage.setItem('caseSettings_' + name, JSON.stringify({ agentTeams: s.agentTeams === true, opusContext1m: s.opusContext1m === true }));
}
} catch { /* storage blocked: this device keeps what it has */ }
},
/** On returning to the tab: take the other devices' case options and selected case. */
/** The server decides whether sync is on: when it says otherwise, this device's cached switch follows it. */
_adoptServerCaseSyncPreference(server) {
if (typeof server?.syncCaseState !== 'boolean') return;
const cached = this.loadAppSettingsFromStorage();
if ((cached.syncCaseState === true) === server.syncCaseState) return;
this.saveAppSettingsToStorage?.({ ...cached, syncCaseState: server.syncCaseState });
},
/** On returning to the tab: take the other devices' case options and selected case, if the server has sync on. */
async _syncCaseStateFromServer() {
if (!this._caseStateSyncOn()) return;
if (this._settingsReadOnly?.()) return;
const seq = (this._caseSyncRefresh = (this._caseSyncRefresh || 0) + 1);
try {
const server = await fetch('/api/settings').then((r) => r.json()).then((env) => env?.data ?? null);
await this._caseSyncChain; // this device's own saves land first, so the copy read next includes them
const res = await fetch('/api/settings');
if (!res.ok) return;
const server = (await res.json())?.data ?? null;
if (seq !== this._caseSyncRefresh || !server) return; // a newer refresh owns the answer
this._adoptServerCaseSyncPreference(server);
if (server.syncCaseState !== true) return;
this._applyServerCaseSettings(server);
const select = document.getElementById('quickStartCase');
const last = server?.lastUsedCase;
const last = server.lastUsedCase;
if (select && last && last !== select.value && this.cases?.some((c) => c.name === last)) {
select.value = last;
this.updateDirDisplayForCase(last);
+1 -1
View File
@@ -5245,7 +5245,7 @@ Object.assign(CodemanApp.prototype, {
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env);
if (settings) {
// Extract notification prefs before merging app settings
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
const { notificationPreferences, voiceSettings, respawnPresets, runMode, caseSettings: _sharedCases, ...appSettings } = settings;
// Display settings are device-specific (mobile vs desktop) and must not be synced
// from the server: see _displaySettingKeySet().
const displayKeys = this._displaySettingKeySet();
+19 -3
View File
@@ -87,6 +87,7 @@ import {
settingsWriteErrorResponse,
} from '../settings-writer.js';
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../linked-cases-writer.js';
import { dropCaseSettings, renameCaseSettings } from '../case-state-sync.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import type { AuthUser } from '../../types.js';
import { SseEvent } from '../sse-events.js';
@@ -1747,6 +1748,14 @@ export function registerCaseRoutes(
// a stale value is ignored at creation anyway).
// An admin deleting another user's remote/Docker case clears that owner's too.
const forgetCaseDefault = async (caseOwner?: string): Promise<void> => {
// The shared run options (syncCaseState) are the admin's server-wide map: a non-admin's own case of
// the same name must leave them alone. Best-effort, like the default group.
if (isAdmin(req)) {
await mutateSettingsFile((current) => {
const rest = dropCaseSettings(current.caseSettings, name);
return rest ? { ...current, caseSettings: rest } : undefined;
}).catch(() => undefined);
}
const owners = new Set([ownerLayoutKey(ownerFor(req)), ...(caseOwner ? [ownerLayoutKey(caseOwner)] : [])]);
for (const owner of owners) {
try {
@@ -2023,11 +2032,18 @@ export function registerCaseRoutes(
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 } : {}) };
// The shared run options (syncCaseState) follow the case to its new name.
const shared = renameCaseSettings(existing.caseSettings, name, newName);
if (!order && !last && !shared) return undefined;
return {
...existing,
...(order ? { caseOrder: order } : {}),
...(last ? { lastUsedCase: last } : {}),
...(shared ? { caseSettings: shared } : {}),
};
});
if (after) {
const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]);
const keys = ['caseOrder', 'lastUsedCase', 'caseSettings'].filter((k) => after[k] !== before[k]);
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
}
}
+18 -4
View File
@@ -49,6 +49,7 @@ import {
SETTINGS_PATH,
} from '../route-helpers.js';
import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js';
import { mergeCaseSettings, SharedCaseLimitError } from '../case-state-sync.js';
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
@@ -160,8 +161,8 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
* What GET /api/settings answers a NON-ADMIN in multi-user mode: settings.json minus what
* belongs to the server's admin rather than to the reader. `voiceSettings.apiKey` is the
* Deepgram key an admin saved, a credential (the reader's browser seeded it into its own
* voice config and dictated on the admin's account); `caseOrder` and `lastUsedCase` name the
* admin's cases. Everything else stays readable, because the page reads the synced feature
* voice config and dictated on the admin's account); `caseOrder`, `lastUsedCase` and `caseSettings` name
* the admin's cases. Everything else stays readable, because the page reads the synced feature
* switches from it. Writes are admin-only there (PUT /api/settings). Exported for unit testing.
*/
export function settingsForNonAdmin(settings: unknown): unknown {
@@ -169,6 +170,7 @@ export function settingsForNonAdmin(settings: unknown): unknown {
const out: Record<string, unknown> = { ...(settings as Record<string, unknown>) };
delete out.caseOrder;
delete out.lastUsedCase;
delete out.caseSettings;
const voice = out.voiceSettings;
if (voice && typeof voice === 'object' && !Array.isArray(voice) && 'apiKey' in voice) {
const voiceOut: Record<string, unknown> = { ...(voice as Record<string, unknown>) };
@@ -1141,8 +1143,14 @@ 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 };
// caseSettings merges per case inside the serialised writer, so two devices saving different cases both land.
const { caseSettings: incomingCases, ...plainSettings } = settingsToStore;
const { before: existing, after } = await mutateSettingsFile((current) => ({
...current,
...plainSettings,
...(incomingCases ? { caseSettings: mergeCaseSettings(current.caseSettings, incomingCases as never) } : {}),
}));
const merged = after ?? { ...existing, ...plainSettings };
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
// Already-evicted history cannot be recovered on either version.
@@ -1244,6 +1252,12 @@ export function registerSystemRoutes(
return {};
} catch (err) {
if (err instanceof SharedCaseLimitError) {
throw Object.assign(err, {
statusCode: 400,
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message),
});
}
return settingsWriteErrorResponse(err);
}
});