fix(multi-user): skip server-wide settings writes for a non-admin browser

With PUT /api/settings admin-only in multi-user mode, a non-admin's page must
not run into the 403. `_settingsReadOnly()` (settings-ui.js) reads the role
from window.__codemanUser; single-user mode and admins are unchanged.

For a non-admin, App Settings Save/Apply still saves the per-device keys to
localStorage exactly as before, but sends neither the settings PUT nor the
model-config PUT, and keeps every synced key at the server's value (the next
page load would put it back anyway). Only when the user really changed a
synced setting do they get one toast that server-wide settings can only be
changed by an admin; a form baseline taken at open keeps an untouched default
that no admin ever stored from reading as a change. The background writes
(run mode, respawn presets and their load-time migration, last used case,
case order, the tunnel toggles) are not sent, and a 403 FORBIDDEN on a tunnel
enable is no longer mistaken for the no-password prompt. zh-CN for both toasts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-16 12:12:50 +02:00
1 parent df0ece6fb4
commit a798c461db
4 files changed
+173 -14

No files matched your search

+5
View File
@@ -847,6 +847,11 @@
'Settings applied': '设置已应用', 'Settings applied': '设置已应用',
'Save and keep Settings open': '保存并保持设置打开', 'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机', 'Settings saved locally': '设置已保存到本机',
// Multi-user mode, a non-admin (settings-ui.js / session-ui.js): server-wide settings
// writes are admin-only there.
'Saved on this device. Server-wide settings can only be changed by an admin.':
'已保存到本设备。服务器全局设置只能由管理员更改。',
'Server-wide settings can only be changed by an admin': '服务器全局设置只能由管理员更改',
'Tunnel active': '隧道已启用', 'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…', 'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
'Push notifications enabled': '推送通知已启用', 'Push notifications enabled': '推送通知已启用',
+4 -1
View File
@@ -597,7 +597,10 @@ Object.assign(CodemanApp.prototype, {
// Update local cache + legacy localStorage // Update local cache + legacy localStorage
this._serverRespawnPresets = custom; this._serverRespawnPresets = custom;
localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom)); localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom));
// Persist to server (cross-device sync) // Persist to server (cross-device sync). Not for a non-admin in multi-user mode: the
// presets are a key of the server-wide settings.json there, and its PUT is admin-only
// (403), so they stay on this device (_settingsReadOnly, settings-ui.js).
if (this._settingsReadOnly?.()) return;
this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {}); this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {});
}, },
+16 -2
View File
@@ -627,6 +627,9 @@ Object.assign(CodemanApp.prototype, {
}, },
async saveLastUsedCase(caseName) { async saveLastUsedCase(caseName) {
// Multi-user non-admin: lastUsedCase is a key of the server-wide settings.json, whose
// PUT is admin-only (403), so it is not sent (_settingsReadOnly, settings-ui.js).
if (this._settingsReadOnly?.()) return;
try { try {
await fetch('/api/settings', { await fetch('/api/settings', {
method: 'PUT', method: 'PUT',
@@ -715,8 +718,9 @@ Object.assign(CodemanApp.prototype, {
this._runMode = mode; this._runMode = mode;
try { localStorage.setItem('codeman_runMode', mode); } catch {} try { localStorage.setItem('codeman_runMode', mode); } catch {}
this._applyRunMode(); this._applyRunMode();
// Sync to server for cross-device persistence // Sync to server for cross-device persistence. Not for a non-admin in multi-user mode,
this._apiPut('/api/settings', { runMode: mode }).catch(() => {}); // where settings.json is server-wide and its PUT admin-only: the mode stays on this device.
if (!this._settingsReadOnly?.()) this._apiPut('/api/settings', { runMode: mode }).catch(() => {});
// Close menu // Close menu
document.getElementById('runModeMenu')?.classList.remove('active'); document.getElementById('runModeMenu')?.classList.remove('active');
}, },
@@ -4881,6 +4885,16 @@ Object.assign(CodemanApp.prototype, {
}, },
async saveCaseOrder(order) { async saveCaseOrder(order) {
// Multi-user non-admin: the case order is ONE server-wide list (settings.json caseOrder)
// and PUT /api/cases/order is admin-only (403), so the move is not sent: say why, and put
// the list back the way the server orders it.
if (this._settingsReadOnly?.()) {
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
const select = document.getElementById('quickStartCase');
await this.loadQuickStartCases(select?.value);
this.renderCaseManageList();
return;
}
try { try {
await fetch('/api/cases/order', { await fetch('/api/cases/order', {
method: 'PUT', method: 'PUT',
+148 -11
View File
@@ -711,6 +711,10 @@ Object.assign(CodemanApp.prototype, {
// Activate focus trap // Activate focus trap
this.activeFocusTrap = new FocusTrap(modal); this.activeFocusTrap = new FocusTrap(modal);
this.activeFocusTrap.activate(); this.activeFocusTrap.activate();
// Multi-user non-admin only: remember what the form shows now, so Save can tell a real
// change to a server-wide setting from an untouched one (see the method).
this._captureSettingsFormBaseline();
}, },
/** /**
@@ -1976,6 +1980,11 @@ Object.assign(CodemanApp.prototype, {
async toggleTunnelFromWelcome() { async toggleTunnelFromWelcome() {
const btn = document.getElementById('welcomeTunnelBtn'); const btn = document.getElementById('welcomeTunnelBtn');
if (!btn) return; if (!btn) return;
// The tunnel is a server-wide setting (PUT /api/settings), admin-only in multi-user mode.
if (this._settingsReadOnly()) {
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
return;
}
const isActive = btn.classList.contains('active'); const isActive = btn.classList.contains('active');
btn.disabled = true; btn.disabled = true;
try { try {
@@ -2302,12 +2311,22 @@ Object.assign(CodemanApp.prototype, {
async _handleTunnelEnableRefusal(res) { async _handleTunnelEnableRefusal(res) {
if (!res || res.ok) return false; if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.'; let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
let errorCode = '';
try { try {
const body = await res.json(); const body = await res.json();
if (body && body.error) message = body.error; if (body && body.error) message = body.error;
if (body && typeof body.errorCode === 'string') errorCode = body.errorCode;
} catch { } catch {
/* non-JSON body — use the default message */ /* non-JSON body — use the default message */
} }
// A non-admin in multi-user mode: PUT /api/settings is admin-only there (403 FORBIDDEN).
// That is not the no-password refusal below, so no security prompt and no retry. The
// callers skip the PUT for a known non-admin; this covers a role that had not resolved yet.
if (res.status === 403 && errorCode === 'FORBIDDEN') {
this._dismissTunnelConnecting?.();
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
return true;
}
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the // 403 = the no-password safety refusal (COD-55). Warn loudly and let the
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment. // operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
if (res.status === 403) { if (res.status === 403) {
@@ -2356,6 +2375,12 @@ Object.assign(CodemanApp.prototype, {
}, },
async _tunnelPanelToggle(enable) { async _tunnelPanelToggle(enable) {
// The tunnel is a server-wide setting (PUT /api/settings), admin-only in multi-user mode.
if (this._settingsReadOnly()) {
this.showToast('Server-wide settings can only be changed by an admin', 'warning');
this.closeTunnelPanel();
return;
}
try { try {
const res = await fetch('/api/settings', { const res = await fetch('/api/settings', {
method: 'PUT', method: 'PUT',
@@ -2692,6 +2717,13 @@ Object.assign(CodemanApp.prototype, {
}, },
}; };
// One-shot form read for _captureSettingsFormBaseline(): hand back what the form
// shows, before anything below is checked, stored, applied or sent.
if (this._readSettingsFormOnce === true) {
this._readSettingsFormOnce = false;
return settings;
}
// SettingsUpdateSchema is .strict() and checks codexModel with this same // SettingsUpdateSchema is .strict() and checks codexModel with this same
// pattern, so one bad character 400s the WHOLE settings PUT while the toast // pattern, so one bad character 400s the WHOLE settings PUT while the toast
// still says "Settings saved". Refuse it here, before anything is persisted. // still says "Settings saved". Refuse it here, before anything is persisted.
@@ -2714,6 +2746,14 @@ Object.assign(CodemanApp.prototype, {
// general-settings DOM), so the fresh rebuild would drop them on every save. // general-settings DOM), so the fresh rebuild would drop them on every save.
if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides; if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides;
// Multi-user mode, signed in as a non-admin: settings.json is one file for the whole
// server and PUT /api/settings answers 403 (system-routes.ts), so nothing is sent
// below. The per-device keys still save here exactly as before; every SYNCED key
// keeps the server's value, which the next page load would put back anyway, so this
// device never shows a server-wide change that did not happen.
const settingsReadOnly = this._settingsReadOnly();
const syncedChangeRefused = settingsReadOnly && this._keepServerSyncedSettings(settings, _prev);
// Save to localStorage // Save to localStorage
this.saveAppSettingsToStorage(settings); this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState(); this._updateLocalEchoState();
@@ -2934,7 +2974,9 @@ Object.assign(CodemanApp.prototype, {
let webhookError = ''; let webhookError = '';
let serverSaved = false; let serverSaved = false;
try { try {
const res = await this._apiPut('/api/settings', { // A non-admin's save is not sent at all (see settingsReadOnly above): the server
// would refuse it with a 403, the plan-usage collection flip included.
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', {
...serverSettings, ...serverSettings,
...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}), ...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}),
notificationPreferences: notifPrefsToSave, notificationPreferences: notifPrefsToSave,
@@ -2958,8 +3000,9 @@ Object.assign(CodemanApp.prototype, {
// evidence the server kept the flags the Apply refresh below reads. // evidence the server kept the flags the Apply refresh below reads.
serverSaved = !!res?.ok; serverSaved = !!res?.ok;
// Save model configuration separately // Save model configuration separately. For a non-admin it is not sent either, and the
await this.saveModelConfigFromSettings(); // answer says whether this save would have changed it.
const modelConfigRefused = (await this.saveModelConfigFromSettings()) === true;
// The webhook is server state in its own 0600 file (its URL is a secret, kept out of // The webhook is server state in its own 0600 file (its URL is a secret, kept out of
// settings.json), so like the model config above it is saved beside the settings PUT, not in // settings.json), so like the model config above it is saved beside the settings PUT, not in
@@ -2968,12 +3011,17 @@ Object.assign(CodemanApp.prototype, {
webhookError = this._webhookPending() ? await this.saveWebhook() : ''; webhookError = this._webhookPending() ? await this.saveWebhook() : '';
if (webhookError) { if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning'); this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else if (syncedChangeRefused || modelConfigRefused) {
// ONE toast for the whole save, and only when the user really changed a server-wide
// setting: a non-admin saving a font size hears "Settings saved" as everyone does.
this.showToast('Saved on this device. Server-wide settings can only be changed by an admin.', 'warning');
} else { } else {
this.showToast(keepOpen ? 'Settings applied' : 'Settings saved', 'success'); this.showToast(keepOpen ? 'Settings applied' : 'Settings saved', 'success');
} }
// Show tunnel-specific feedback if toggled on // Show tunnel-specific feedback if toggled on (never for a non-admin, whose save
if (settings.tunnelEnabled) { // starts nothing: the box only shows what the server already has)
if (settings.tunnelEnabled && !settingsReadOnly) {
this.showToast('Tunnel starting — QR code will appear when ready...', 'info'); this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
} }
} catch (err) { } catch (err) {
@@ -2999,7 +3047,9 @@ Object.assign(CodemanApp.prototype, {
// gestureControlEnabled from settings.json), so a change only takes effect on // gestureControlEnabled from settings.json), so a change only takes effect on
// reload. Reload when it actually changed — the server PUT above already // reload. Reload when it actually changed — the server PUT above already
// persisted the new value. // persisted the new value.
if (settings.gestureControlEnabled !== _prevGestureEnabled) { // `?? false`: a non-admin's save puts a synced key the server never stored back to
// absent (_keepServerSyncedSettings), and absent means off, as in _prevGestureEnabled.
if ((settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
this.showToast( this.showToast(
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…', settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
'info' 'info'
@@ -3008,6 +3058,72 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
/**
* True only for a signed-in NON-ADMIN in multi-user mode, where every server-wide settings
* write is refused with 403 FORBIDDEN (PUT /api/settings, PUT /api/execution/model-config and
* PUT /api/cases/order: settings.json is one file for the whole server). Single-user mode and
* admins answer false. Read at call time, because `window.__codemanUser` resolves after page
* load (admin-ui.js fetches /api/me). Other modules call it as `this._settingsReadOnly?.()`,
* so a harness that loads one of them without this file reads it as writable, which is what
* an admin gets.
*/
_settingsReadOnly() {
// typeof guard: the load path calls this, and some vm harnesses define no `window`.
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
return !!(me.multiUser && me.role !== 'admin');
},
/**
* What the App Settings form showed when it opened, read the way saveAppSettings() reads it
* (its one-shot `_readSettingsFormOnce` form read), so a non-admin's save can tell whether the
* user changed a SYNCED setting. The stored blob cannot answer that: a key no admin has ever
* saved is absent from it while the form shows the default, which would read as a change on
* every save. Taken only for a non-admin; nobody else's save asks for it.
*/
_captureSettingsFormBaseline() {
this._settingsFormBaseline = null;
if (!this._settingsReadOnly()) return;
this._readSettingsFormOnce = true;
let pending;
try {
pending = this.saveAppSettings();
} finally {
// Consumed by the read itself; cleared here too in case it threw before reaching it.
this._readSettingsFormOnce = false;
}
Promise.resolve(pending)
.then((form) => {
this._settingsFormBaseline = form && typeof form === 'object' ? form : null;
})
.catch(() => {});
},
/**
* A non-admin's save in multi-user mode (saveAppSettings): put every SYNCED key of `settings`
* back to the value this device last had from the server (`prev`; deleted when it had none),
* and answer whether the form changed one since it opened. Synced means outside the per-device
* `displayKeys` merge policy of loadAppSettingsFromServer(), whose own list this reads, never a
* copy: those are the keys the server's settings.json overwrites on every page load. A
* per-device key is left exactly as the user set it.
* @returns {boolean} true when the user changed at least one synced setting
*/
_keepServerSyncedSettings(settings, prev) {
const displayKeys = this._displaySettingKeys;
if (!(displayKeys instanceof Set)) return false;
// Without a baseline (the form was read before the role was known) the stored blob stands
// in, and a key it lacks is not counted: no toast is better than one on every save.
const baseline = this._settingsFormBaseline || prev || {};
let changed = false;
for (const key of Object.keys(settings)) {
// shortcutOverrides is carried over from `prev` already and never comes from the form.
if (displayKeys.has(key) || key === 'shortcutOverrides') continue;
if (key in baseline && JSON.stringify(settings[key]) !== JSON.stringify(baseline[key])) changed = true;
if (prev && key in prev) settings[key] = prev[key];
else delete settings[key];
}
return changed;
},
/** /**
* After Apply: bring the groups whose contents depend on a SAVED value up to date without * After Apply: bring the groups whose contents depend on a SAVED value up to date without
* reopening the modal. openAppSettings does the same on open; this is the part of it that * reopening the modal. openAppSettings does the same on open; this is the part of it that
@@ -3058,10 +3174,12 @@ Object.assign(CodemanApp.prototype, {
} catch (err) { } catch (err) {
console.warn('Failed to load model config:', err); console.warn('Failed to load model config:', err);
} }
// What the form shows now, so a non-admin's save can tell whether it was changed.
this._modelConfigBaseline = JSON.stringify(this._modelConfigFromSettingsForm());
}, },
// Save model configuration from settings modal to server /** The model configuration as the settings modal's form shows it (the PUT body). */
async saveModelConfigFromSettings() { _modelConfigFromSettingsForm() {
const defaultModelEl = document.getElementById('appSettingsDefaultModel'); const defaultModelEl = document.getElementById('appSettingsDefaultModel');
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations'); const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
const exploreEl = document.getElementById('appSettingsModelExplore'); const exploreEl = document.getElementById('appSettingsModelExplore');
@@ -3075,11 +3193,26 @@ Object.assign(CodemanApp.prototype, {
if (testEl?.value) agentTypeOverrides.test = testEl.value; if (testEl?.value) agentTypeOverrides.test = testEl.value;
if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value; if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value;
const config = { return {
defaultModel: defaultModelEl?.value || '', defaultModel: defaultModelEl?.value || '',
showRecommendations: showRecsEl?.checked ?? true, showRecommendations: showRecsEl?.checked ?? true,
agentTypeOverrides, agentTypeOverrides,
}; };
},
/**
* Save model configuration from settings modal to server.
* @returns {Promise<boolean|undefined>} true only when a non-admin in multi-user mode changed it:
* the config is a key of the server-wide settings.json and its PUT is admin-only (403), so it is
* not sent, and saveAppSettings() folds this into its one admin-only toast
*/
async saveModelConfigFromSettings() {
const config = this._modelConfigFromSettingsForm();
if (this._settingsReadOnly()) {
const baseline = this._modelConfigBaseline;
return typeof baseline === 'string' && JSON.stringify(config) !== baseline;
}
try { try {
await fetch('/api/execution/model-config', { await fetch('/api/execution/model-config', {
@@ -4393,6 +4526,8 @@ Object.assign(CodemanApp.prototype, {
// and should not be synced from the server to avoid overriding mobile defaults. // and should not be synced from the server to avoid overriding mobile defaults.
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled) // NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
// are NOT display keys — they control server-side behavior and must sync from server. // are NOT display keys — they control server-side behavior and must sync from server.
// Published below for _keepServerSyncedSettings() (a non-admin's save in multi-user
// mode), which needs this same split and must never keep a copy of it.
const displayKeys = new Set([ const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost', 'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton', 'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
@@ -4411,6 +4546,7 @@ Object.assign(CodemanApp.prototype, {
'showTileGridButton', 'showTileGridButton',
'tileDetachEnabled', 'tileDetachEnabled',
]); ]);
this._displaySettingKeys = displayKeys;
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON, // The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
// handheld default OFF): desktop can show it while mobile stays hidden. Drop // handheld default OFF): desktop can show it while mobile stays hidden. Drop
// the server's stored value here so it is NEVER seeded into a device that // the server's stored value here so it is NEVER seeded into a device that
@@ -4461,8 +4597,9 @@ Object.assign(CodemanApp.prototype, {
this._serverRespawnPresets = respawnPresets; this._serverRespawnPresets = respawnPresets;
// Also update localStorage for offline access // Also update localStorage for offline access
localStorage.setItem('codeman-respawn-presets', JSON.stringify(respawnPresets)); localStorage.setItem('codeman-respawn-presets', JSON.stringify(respawnPresets));
} else { } else if (!this._settingsReadOnly()) {
// Migration: push existing localStorage presets to server // Migration: push existing localStorage presets to server. Not for a non-admin in
// multi-user mode: the presets are a server-wide setting there (admin-only PUT).
const localPresets = localStorage.getItem('codeman-respawn-presets'); const localPresets = localStorage.getItem('codeman-respawn-presets');
if (localPresets) { if (localPresets) {
const parsed = JSON.parse(localPresets); const parsed = JSON.parse(localPresets);