mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
fix(multi-user): read a refused settings write as admin-only in the browser
Two gaps in how a regular user's browser handled the admin-only settings writes: - The role comes from GET /api/me, which answers after page load, and on a failed request admin-ui.js falls back to a single-user admin. A save made then was sent, came back 403, and still said "Settings saved", while the refused server-wide change stayed on the device. A 403 FORBIDDEN on a settings write now ends that save like a known non-admin's: synced keys back to the server's value, the one admin-only notice, and the page is read-only from then on (_noteSettingsWriteRefusal). The display-key list moved into one method (_displaySettingKeySet), so that split no longer depends on GET /api/settings having loaded. - A regular user's run mode and respawn presets were skipped for the server but overwritten by the server's on the next page load, while "Saved preset" said otherwise. They are now kept as that user's own on the device and put back over the server's after the settings load and on codeman:me (_rememberOwnSetting, _applyOwnSettings). Admins and single-user mode are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
24a1c6c427
commit
5a297cd3e4
5 files changed
+325
-52
No files matched your search
@@ -599,9 +599,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom));
|
localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom));
|
||||||
// Persist to server (cross-device sync). Not for a non-admin in multi-user mode: the
|
// 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
|
// 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).
|
// (403), so they are kept as their own on this device and win over the server's on the
|
||||||
if (this._settingsReadOnly?.()) return;
|
// next load (_rememberOwnSetting, settings-ui.js). A PUT refused that way (sent before the
|
||||||
this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {});
|
// role was known) marks the page read-only for the writes after it.
|
||||||
|
if (this._settingsReadOnly?.()) {
|
||||||
|
this._rememberOwnSetting?.('respawnPresets', custom);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._apiPut('/api/settings', { respawnPresets: custom })
|
||||||
|
.then((res) => this._noteSettingsWriteRefusal?.(res))
|
||||||
|
.catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
renderPresetDropdown() {
|
renderPresetDropdown() {
|
||||||
|
|||||||
@@ -719,8 +719,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
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. Not for a non-admin in multi-user mode,
|
// Sync to server for cross-device persistence. Not for a non-admin in multi-user mode,
|
||||||
// where settings.json is server-wide and its PUT admin-only: the mode stays on this device.
|
// where settings.json is server-wide and its PUT admin-only: the mode is kept as their own
|
||||||
if (!this._settingsReadOnly?.()) this._apiPut('/api/settings', { runMode: mode }).catch(() => {});
|
// on this device and wins over the server's on the next load (_rememberOwnSetting,
|
||||||
|
// settings-ui.js). A PUT refused that way (sent before the role was known) marks the page
|
||||||
|
// read-only for the writes after it (_noteSettingsWriteRefusal).
|
||||||
|
if (this._settingsReadOnly?.()) {
|
||||||
|
this._rememberOwnSetting?.('runMode', mode);
|
||||||
|
} else {
|
||||||
|
this._apiPut('/api/settings', { runMode: mode })
|
||||||
|
.then((res) => this._noteSettingsWriteRefusal?.(res))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
// Close menu
|
// Close menu
|
||||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||||
},
|
},
|
||||||
|
|||||||
+142
-37
@@ -2987,11 +2987,22 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
voiceSettings,
|
voiceSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Multi-user mode, a non-admin whose role was not known when this save started
|
||||||
|
// (/api/me had not answered, or failed and admin-ui.js fell back to a single-user
|
||||||
|
// admin): the server refused the PUT as admin-only. End it the way a known non-admin's save
|
||||||
|
// ends: every synced key back to the server's value on this device, and the one admin
|
||||||
|
// toast below instead of "Settings saved". The page is read-only from here on.
|
||||||
|
const refusedAsNonAdmin = !settingsReadOnly && (await this._noteSettingsWriteRefusal(res));
|
||||||
|
if (refusedAsNonAdmin) {
|
||||||
|
this._keepServerSyncedSettings(settings, _prev);
|
||||||
|
this.saveAppSettingsToStorage(settings);
|
||||||
|
}
|
||||||
|
|
||||||
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
|
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
|
||||||
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
|
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
|
||||||
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
|
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
|
||||||
// to localStorage above still apply locally.
|
// to localStorage above still apply locally.
|
||||||
if (settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
|
if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
|
||||||
settings.tunnelEnabled = false;
|
settings.tunnelEnabled = false;
|
||||||
this.saveAppSettingsToStorage(settings);
|
this.saveAppSettingsToStorage(settings);
|
||||||
const cb = document.getElementById('appSettingsTunnelEnabled');
|
const cb = document.getElementById('appSettingsTunnelEnabled');
|
||||||
@@ -3015,9 +3026,10 @@ 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) {
|
} else if (syncedChangeRefused || modelConfigRefused || refusedAsNonAdmin) {
|
||||||
// ONE toast for the whole save, and only when the user really changed a server-wide
|
// 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.
|
// setting: a non-admin saving a font size hears "Settings saved" as everyone does.
|
||||||
|
// A refused PUT always gets it: the server said this user is not an admin.
|
||||||
this.showToast('Saved on this device. Server-wide settings can only be changed by an admin.', 'warning');
|
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');
|
||||||
@@ -3025,7 +3037,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
// Show tunnel-specific feedback if toggled on (never for a non-admin, whose save
|
// Show tunnel-specific feedback if toggled on (never for a non-admin, whose save
|
||||||
// starts nothing: the box only shows what the server already has)
|
// starts nothing: the box only shows what the server already has)
|
||||||
if (settings.tunnelEnabled && !settingsReadOnly) {
|
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin) {
|
||||||
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) {
|
||||||
@@ -3067,16 +3079,92 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* write is refused with 403 FORBIDDEN (PUT /api/settings, PUT /api/execution/model-config and
|
* 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
|
* 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
|
* 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?.()`,
|
* load (admin-ui.js fetches /api/me). Also true once the server has refused one of these
|
||||||
* so a harness that loads one of them without this file reads it as writable, which is what
|
* writes as admin-only on this page (_noteSettingsWriteRefusal): before /api/me answers, and
|
||||||
* an admin gets.
|
* after it failed (admin-ui.js then falls back to a single-user admin), the role alone reads
|
||||||
|
* as writable. 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() {
|
_settingsReadOnly() {
|
||||||
|
if (this._settingsWriteForbidden === true) return true;
|
||||||
// typeof guard: the load path calls this, and some vm harnesses define no `window`.
|
// typeof guard: the load path calls this, and some vm harnesses define no `window`.
|
||||||
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
|
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
|
||||||
return !!(me.multiUser && me.role !== 'admin');
|
return !!(me.multiUser && me.role !== 'admin');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `res` (a settings write's response) is the multi-user admin-only refusal: 403 with
|
||||||
|
* errorCode FORBIDDEN. The tunnel's no-password refusal is a 403 too, with another code, and
|
||||||
|
* is not this. When it is, the page reads as read-only from now on (_settingsReadOnly), so a
|
||||||
|
* write sent before the role was known is the last one. Reads a clone, so the caller can
|
||||||
|
* still read the body.
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async _noteSettingsWriteRefusal(res) {
|
||||||
|
if (!res || res.status !== 403) return false;
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = await (typeof res.clone === 'function' ? res.clone() : res).json();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (body?.errorCode !== 'FORBIDDEN') return false;
|
||||||
|
this._settingsWriteForbidden = true;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a non-admin's OWN run mode and respawn presets live (multi-user mode). Both are keys of
|
||||||
|
* the server-wide settings.json, whose PUT is admin-only, so this user's picks are kept on this
|
||||||
|
* device under their name instead (_rememberOwnSetting) and put back over the server's on every
|
||||||
|
* page load (_applyOwnSettings): loadAppSettingsFromServer() writes the server's values into
|
||||||
|
* `codeman_runMode` and `codeman-respawn-presets` each time, which used to drop them on reload.
|
||||||
|
*/
|
||||||
|
_ownSettingsKey() {
|
||||||
|
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
|
||||||
|
return `codeman:own-settings:${me.username || ''}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_readOwnSettings() {
|
||||||
|
try {
|
||||||
|
const own = JSON.parse(localStorage.getItem(this._ownSettingsKey()) || '{}');
|
||||||
|
return own && typeof own === 'object' && !Array.isArray(own) ? own : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Keep a non-admin's own pick of a server-wide `key` on this device (see _ownSettingsKey). */
|
||||||
|
_rememberOwnSetting(key, value) {
|
||||||
|
try {
|
||||||
|
const own = this._readOwnSettings();
|
||||||
|
own[key] = value;
|
||||||
|
localStorage.setItem(this._ownSettingsKey(), JSON.stringify(own));
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable: the pick lasts until the next page load */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A non-admin's own run mode and respawn presets win over the server's (see _ownSettingsKey).
|
||||||
|
* Called after the server's values are applied at load, and again on `codeman:me`, because the
|
||||||
|
* role can resolve either side of that load. A no-op for admins and single-user mode.
|
||||||
|
*/
|
||||||
|
_applyOwnSettings() {
|
||||||
|
if (!this._settingsReadOnly()) return;
|
||||||
|
const own = this._readOwnSettings();
|
||||||
|
if (Array.isArray(own.respawnPresets)) {
|
||||||
|
this._serverRespawnPresets = own.respawnPresets;
|
||||||
|
try { localStorage.setItem('codeman-respawn-presets', JSON.stringify(own.respawnPresets)); } catch {}
|
||||||
|
}
|
||||||
|
if (typeof own.runMode === 'string' && own.runMode) {
|
||||||
|
this.runMode = own.runMode;
|
||||||
|
try { localStorage.setItem('codeman_runMode', own.runMode); } catch {}
|
||||||
|
this._applyRunMode?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the App Settings form showed when it opened, read the way saveAppSettings() reads it
|
* 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
|
* (its one-shot `_readSettingsFormOnce` form read), so a non-admin's save can tell whether the
|
||||||
@@ -3106,14 +3194,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* A non-admin's save in multi-user mode (saveAppSettings): put every SYNCED key of `settings`
|
* 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),
|
* 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
|
* 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
|
* `displayKeys` merge policy of loadAppSettingsFromServer() (_displaySettingKeySet(), the one
|
||||||
* copy: those are the keys the server's settings.json overwrites on every page load. A
|
* list both read): those are the keys the server's settings.json overwrites on every page load.
|
||||||
* per-device key is left exactly as the user set it.
|
* A per-device key is left exactly as the user set it.
|
||||||
* @returns {boolean} true when the user changed at least one synced setting
|
* @returns {boolean} true when the user changed at least one synced setting
|
||||||
*/
|
*/
|
||||||
_keepServerSyncedSettings(settings, prev) {
|
_keepServerSyncedSettings(settings, prev) {
|
||||||
const displayKeys = this._displaySettingKeys;
|
const displayKeys = this._displaySettingKeySet();
|
||||||
if (!(displayKeys instanceof Set)) return false;
|
|
||||||
// Without a baseline (the form was read before the role was known) the stored blob stands
|
// 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.
|
// in, and a key it lacks is not counted: no toast is better than one on every save.
|
||||||
const baseline = this._settingsFormBaseline || prev || {};
|
const baseline = this._settingsFormBaseline || prev || {};
|
||||||
@@ -4501,6 +4588,37 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-device DISPLAY keys. They are device-specific (mobile vs desktop) and must not be
|
||||||
|
* synced from the server, to avoid overriding mobile defaults: loadAppSettingsFromServer()
|
||||||
|
* seeds one from the server only when this device has no value for it. A non-admin's save in
|
||||||
|
* multi-user mode reads the same split (_keepServerSyncedSettings()), so the list lives here,
|
||||||
|
* once, and is there even when GET /api/settings failed at load.
|
||||||
|
* NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
|
||||||
|
* are NOT display keys: they control server-side behavior and must sync from server.
|
||||||
|
*/
|
||||||
|
_displaySettingKeySet() {
|
||||||
|
const displayKeys = new Set([
|
||||||
|
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||||
|
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||||
|
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||||
|
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||||
|
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||||
|
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||||
|
'language',
|
||||||
|
'terminalWheelLocalScrollback',
|
||||||
|
'autoCopySelection', 'copyStripMargin',
|
||||||
|
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
|
||||||
|
'showTabDetachButton',
|
||||||
|
'mobileOverviewEnabled',
|
||||||
|
'sessionLineageLines',
|
||||||
|
'showSplitButton',
|
||||||
|
'showTileGridButton',
|
||||||
|
'tileDetachEnabled',
|
||||||
|
]);
|
||||||
|
return displayKeys;
|
||||||
|
},
|
||||||
|
|
||||||
async loadAppSettingsFromServer(settingsPromise = null) {
|
async loadAppSettingsFromServer(settingsPromise = null) {
|
||||||
// One-time migration: showPlanUsageLimits became a per-device display setting.
|
// One-time migration: showPlanUsageLimits became a per-device display setting.
|
||||||
// Before this, it synced from the server, so the (separate) mobile settings blob
|
// Before this, it synced from the server, so the (separate) mobile settings blob
|
||||||
@@ -4526,31 +4644,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (settings) {
|
if (settings) {
|
||||||
// Extract notification prefs before merging app settings
|
// Extract notification prefs before merging app settings
|
||||||
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
|
const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
|
||||||
// Filter out display settings — these are device-specific (mobile vs desktop)
|
// Display settings are device-specific (mobile vs desktop) and must not be synced
|
||||||
// and should not be synced from the server to avoid overriding mobile defaults.
|
// from the server: see _displaySettingKeySet().
|
||||||
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
|
const displayKeys = this._displaySettingKeySet();
|
||||||
// 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([
|
|
||||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
|
||||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
|
||||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
|
||||||
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
|
||||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
|
||||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
|
||||||
'language',
|
|
||||||
'terminalWheelLocalScrollback',
|
|
||||||
'autoCopySelection', 'copyStripMargin',
|
|
||||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
|
|
||||||
'showTabDetachButton',
|
|
||||||
'mobileOverviewEnabled',
|
|
||||||
'sessionLineageLines',
|
|
||||||
'showSplitButton',
|
|
||||||
'showTileGridButton',
|
|
||||||
'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
|
||||||
@@ -4609,7 +4705,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const parsed = JSON.parse(localPresets);
|
const parsed = JSON.parse(localPresets);
|
||||||
if (parsed.length > 0) {
|
if (parsed.length > 0) {
|
||||||
this._serverRespawnPresets = parsed;
|
this._serverRespawnPresets = parsed;
|
||||||
this._apiPut('/api/settings', { respawnPresets: parsed }).catch(() => {});
|
this._apiPut('/api/settings', { respawnPresets: parsed })
|
||||||
|
.then((res) => this._noteSettingsWriteRefusal(res))
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4621,6 +4719,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._applyRunMode();
|
this._applyRunMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multi-user non-admin: their own run mode and presets, kept on this device, win over
|
||||||
|
// the server's just applied (no-op otherwise; `codeman:me` below covers a late role).
|
||||||
|
this._applyOwnSettings();
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -5037,6 +5139,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// all, and a module-level statement that throws there fails the whole file's
|
// all, and a module-level statement that throws there fails the whole file's
|
||||||
// evaluation, not just this feature.
|
// evaluation, not just this feature.
|
||||||
document.addEventListener?.('codeman:me', () => {
|
document.addEventListener?.('codeman:me', () => {
|
||||||
|
// A non-admin's own run mode and respawn presets (multi-user mode) win over the server's,
|
||||||
|
// which the settings load may already have applied before the role was known.
|
||||||
|
window.app?._applyOwnSettings?.();
|
||||||
window.app?._applyCustomModelAdminGate?.();
|
window.app?._applyCustomModelAdminGate?.();
|
||||||
window.app?._applyCliManagementAdminGate?.();
|
window.app?._applyCliManagementAdminGate?.();
|
||||||
window.app?._applyMcpSyncAdminGate?.();
|
window.app?._applyMcpSyncAdminGate?.();
|
||||||
|
|||||||
@@ -11,7 +11,13 @@
|
|||||||
* really changed a synced setting (an untouched synced key that no admin ever
|
* really changed a synced setting (an untouched synced key that no admin ever
|
||||||
* stored must not read as a change: that is what the form baseline is for).
|
* stored must not read as a change: that is what the form baseline is for).
|
||||||
* - The background writes (run mode, respawn presets, last used case, case order,
|
* - The background writes (run mode, respawn presets, last used case, case order,
|
||||||
* the respawn-preset migration on load, the tunnel toggles) are not sent.
|
* the respawn-preset migration on load, the tunnel toggles) are not sent, and the
|
||||||
|
* non-admin's own run mode and respawn presets stay theirs on this device across a
|
||||||
|
* reload (the server's would otherwise overwrite them on every load).
|
||||||
|
* - A save sent before the role was known (/api/me slow or failed) that the server
|
||||||
|
* refuses with 403 FORBIDDEN ends like a known non-admin's: synced keys back to the
|
||||||
|
* server's value, the one admin toast (never "Settings saved"), read-only from then.
|
||||||
|
* - The display-key split does not depend on GET /api/settings having loaded.
|
||||||
* - Admins and single-user mode send everything as before.
|
* - Admins and single-user mode send everything as before.
|
||||||
* - Both toasts read in Chinese under zh-CN.
|
* - Both toasts read in Chinese under zh-CN.
|
||||||
*
|
*
|
||||||
@@ -40,11 +46,24 @@ const SAVE_TOAST = 'Saved on this device. Server-wide settings can only be chang
|
|||||||
const ADMIN_TOAST = 'Server-wide settings can only be changed by an admin';
|
const ADMIN_TOAST = 'Server-wide settings can only be changed by an admin';
|
||||||
|
|
||||||
type Settings = Record<string, unknown>;
|
type Settings = Record<string, unknown>;
|
||||||
type Who = 'non-admin' | 'admin' | 'single-user';
|
type Who = 'non-admin' | 'admin' | 'single-user' | 'unknown';
|
||||||
const IDENTITY: Record<Who, Settings> = {
|
/** `unknown`: GET /api/me has not answered yet, so admin-ui.js has set no identity. */
|
||||||
|
const IDENTITY: Record<Who, Settings | undefined> = {
|
||||||
'non-admin': { username: 'bob', role: 'user', multiUser: true },
|
'non-admin': { username: 'bob', role: 'user', multiUser: true },
|
||||||
admin: { username: 'root', role: 'admin', multiUser: true },
|
admin: { username: 'root', role: 'admin', multiUser: true },
|
||||||
'single-user': { username: 'admin', role: 'admin', multiUser: false },
|
'single-user': { username: 'admin', role: 'admin', multiUser: false },
|
||||||
|
unknown: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What PUT /api/settings answers a non-admin in multi-user mode (system-routes.ts). */
|
||||||
|
const forbiddenResponse = () => {
|
||||||
|
const res = {
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
json: async () => ({ success: false, error: 'You do not have permission', errorCode: 'FORBIDDEN' }),
|
||||||
|
clone: () => res,
|
||||||
|
};
|
||||||
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +93,11 @@ const REAL = new Set([
|
|||||||
'_settingsReadOnly',
|
'_settingsReadOnly',
|
||||||
'_captureSettingsFormBaseline',
|
'_captureSettingsFormBaseline',
|
||||||
'_keepServerSyncedSettings',
|
'_keepServerSyncedSettings',
|
||||||
|
'_displaySettingKeySet',
|
||||||
|
'_noteSettingsWriteRefusal',
|
||||||
|
'_applyOwnSettings',
|
||||||
|
'_readOwnSettings',
|
||||||
|
'_ownSettingsKey',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Methods the load/save paths call that other modules add to the prototype. */
|
/** Methods the load/save paths call that other modules add to the prototype. */
|
||||||
@@ -97,7 +121,7 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|||||||
* this device's blob and `server` as GET /api/settings (merged in by the real loader, which
|
* this device's blob and `server` as GET /api/settings (merged in by the real loader, which
|
||||||
* is also what publishes the display-key list the non-admin save reads).
|
* is also what publishes the display-key list the non-admin save reads).
|
||||||
*/
|
*/
|
||||||
async function boot(who: Who, stored: Settings, server: Settings) {
|
async function boot(who: Who, stored: Settings, server: Settings | null) {
|
||||||
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
|
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
|
||||||
const win = dom.window as unknown as Window &
|
const win = dom.window as unknown as Window &
|
||||||
typeof globalThis & { eval(src: string): void; __App: any; __codemanUser: Settings; fetch: unknown };
|
typeof globalThis & { eval(src: string): void; __App: any; __codemanUser: Settings; fetch: unknown };
|
||||||
@@ -128,7 +152,7 @@ async function boot(who: Who, stored: Settings, server: Settings) {
|
|||||||
'window.__App = CodemanApp;',
|
'window.__App = CodemanApp;',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
);
|
);
|
||||||
win.__codemanUser = IDENTITY[who];
|
win.__codemanUser = IDENTITY[who] as Settings;
|
||||||
const target = Object.create(win.__App.prototype);
|
const target = Object.create(win.__App.prototype);
|
||||||
target.sessions = new Map();
|
target.sessions = new Map();
|
||||||
target.sessionOrder = [];
|
target.sessionOrder = [];
|
||||||
@@ -227,6 +251,69 @@ describe('a non-admin in multi-user mode saving App Settings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('a save sent before the role was known, refused as admin-only', () => {
|
||||||
|
it('ends like a known non-admin save: synced key kept at the server value, one admin toast', async () => {
|
||||||
|
const { app, target, box, storedBlob, toasts, puts } = await boot('unknown', { ...SERVER }, SERVER);
|
||||||
|
target._apiPut = vi.fn(async () => forbiddenResponse());
|
||||||
|
await open_(app);
|
||||||
|
box('appSettingsTabTwoRows').checked = true; // per-device
|
||||||
|
box('appSettingsAutoNameSessions').checked = true; // synced
|
||||||
|
await app.saveAppSettings();
|
||||||
|
|
||||||
|
// The one PUT that went out before anything knew better, and nothing after it: the model
|
||||||
|
// config is not sent, and "Settings saved" (the old answer to a 403) is never shown.
|
||||||
|
expect(target._apiPut).toHaveBeenCalledTimes(1);
|
||||||
|
expect(puts()).toEqual([]);
|
||||||
|
expect(toasts()).toEqual([SAVE_TOAST]);
|
||||||
|
expect(storedBlob().tabTwoRows).toBe(true);
|
||||||
|
expect(storedBlob().autoNameSessions).toBe(false);
|
||||||
|
|
||||||
|
// Read-only from here on: the next save sends nothing.
|
||||||
|
expect(app._settingsReadOnly()).toBe(true);
|
||||||
|
await open_(app);
|
||||||
|
box('appSettingsAutoNameSessions').checked = true;
|
||||||
|
await app.saveAppSettings();
|
||||||
|
expect(target._apiPut).toHaveBeenCalledTimes(1);
|
||||||
|
expect(toasts()).toEqual([SAVE_TOAST, SAVE_TOAST]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the same for a page whose /api/me failed (admin-ui.js falls back to a single-user admin)', async () => {
|
||||||
|
const { app, target, box, storedBlob, toasts } = await boot('single-user', { ...SERVER }, SERVER);
|
||||||
|
target._apiPut = vi.fn(async () => forbiddenResponse());
|
||||||
|
await open_(app);
|
||||||
|
box('appSettingsAutoNameSessions').checked = true;
|
||||||
|
await app.saveAppSettings();
|
||||||
|
expect(toasts()).toEqual([SAVE_TOAST]);
|
||||||
|
expect(storedBlob().autoNameSessions).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not confused with the tunnel no-password 403 (another errorCode): that still prompts', async () => {
|
||||||
|
const { app, target } = await boot('admin', { ...SERVER }, SERVER);
|
||||||
|
const res = {
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
json: async () => ({ success: false, error: 'no password', errorCode: 'OPERATION_FAILED' }),
|
||||||
|
};
|
||||||
|
expect(await app._noteSettingsWriteRefusal({ ...res, clone: () => res })).toBe(false);
|
||||||
|
expect(app._settingsReadOnly()).toBe(false);
|
||||||
|
expect(target._settingsWriteForbidden).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a non-admin save when GET /api/settings failed at page load', () => {
|
||||||
|
it('still keeps the synced keys at the stored value and toasts once', async () => {
|
||||||
|
const { app, box, storedBlob, toasts, puts } = await boot('non-admin', { ...SERVER }, null);
|
||||||
|
await open_(app);
|
||||||
|
box('appSettingsTabTwoRows').checked = true;
|
||||||
|
box('appSettingsAutoNameSessions').checked = true;
|
||||||
|
await app.saveAppSettings();
|
||||||
|
expect(puts()).toEqual([]);
|
||||||
|
expect(storedBlob().tabTwoRows).toBe(true);
|
||||||
|
expect(storedBlob().autoNameSessions).toBe(false);
|
||||||
|
expect(toasts()).toEqual([SAVE_TOAST]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('an admin, and single-user mode, saving App Settings', () => {
|
describe('an admin, and single-user mode, saving App Settings', () => {
|
||||||
for (const who of ['admin', 'single-user'] as const) {
|
for (const who of ['admin', 'single-user'] as const) {
|
||||||
it(`${who}: sends the settings PUT and the model config as before`, async () => {
|
it(`${who}: sends the settings PUT and the model config as before`, async () => {
|
||||||
@@ -256,8 +343,7 @@ async function open_(app: { openAppSettings(): void }) {
|
|||||||
|
|
||||||
// ─── Background writes (vm harness: settings-ui.js + session-ui.js + respawn-ui.js) ───
|
// ─── Background writes (vm harness: settings-ui.js + session-ui.js + respawn-ui.js) ───
|
||||||
|
|
||||||
function loadBackground(who: Who) {
|
function loadBackground(who: Who, storage = new Map<string, string>()) {
|
||||||
const storage = new Map<string, string>();
|
|
||||||
const elements: Record<string, any> = {
|
const elements: Record<string, any> = {
|
||||||
quickStartCase: { value: 'mine' },
|
quickStartCase: { value: 'mine' },
|
||||||
welcomeTunnelBtn: { classList: { contains: () => false }, disabled: false },
|
welcomeTunnelBtn: { classList: { contains: () => false }, disabled: false },
|
||||||
@@ -268,15 +354,20 @@ function loadBackground(who: Who) {
|
|||||||
const CodemanApp = function CodemanApp(this: any) {};
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: {} }) }));
|
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: {} }) }));
|
||||||
const confirmMock = vi.fn(() => true);
|
const confirmMock = vi.fn(() => true);
|
||||||
|
const listeners: Record<string, (() => void)[]> = {};
|
||||||
|
const win: Record<string, unknown> = { __codemanUser: IDENTITY[who] };
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
CodemanApp,
|
CodemanApp,
|
||||||
VoiceInput: {},
|
VoiceInput: {},
|
||||||
window: { __codemanUser: IDENTITY[who] },
|
window: win,
|
||||||
localStorage: {
|
localStorage: {
|
||||||
getItem: (key: string) => storage.get(key) ?? null,
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
setItem: (key: string, value: string) => storage.set(key, value),
|
setItem: (key: string, value: string) => storage.set(key, value),
|
||||||
},
|
},
|
||||||
document: { getElementById: (id: string) => elements[id] ?? null },
|
document: {
|
||||||
|
getElementById: (id: string) => elements[id] ?? null,
|
||||||
|
addEventListener: (type: string, fn: () => void) => (listeners[type] ||= []).push(fn),
|
||||||
|
},
|
||||||
fetch: fetchMock,
|
fetch: fetchMock,
|
||||||
confirm: confirmMock,
|
confirm: confirmMock,
|
||||||
console,
|
console,
|
||||||
@@ -297,7 +388,13 @@ function loadBackground(who: Who) {
|
|||||||
app._dismissTunnelConnecting = vi.fn();
|
app._dismissTunnelConnecting = vi.fn();
|
||||||
app._updateWelcomeTunnelBtn = vi.fn();
|
app._updateWelcomeTunnelBtn = vi.fn();
|
||||||
app._updateTunnelIndicator = vi.fn();
|
app._updateTunnelIndicator = vi.fn();
|
||||||
return { app, fetchMock, confirmMock, storage };
|
win.app = app;
|
||||||
|
/** What admin-ui.js does once GET /api/me answers. */
|
||||||
|
const resolveMe = (identity: Settings) => {
|
||||||
|
win.__codemanUser = identity;
|
||||||
|
for (const fn of listeners['codeman:me'] || []) fn();
|
||||||
|
};
|
||||||
|
return { app, fetchMock, confirmMock, storage, resolveMe };
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsPuts = (b: ReturnType<typeof loadBackground>) => [
|
const settingsPuts = (b: ReturnType<typeof loadBackground>) => [
|
||||||
@@ -348,6 +445,59 @@ describe('background writes', () => {
|
|||||||
expect(b.app.showToast.mock.calls.map((c: unknown[]) => c[0])).toEqual([ADMIN_TOAST]);
|
expect(b.app.showToast.mock.calls.map((c: unknown[]) => c[0])).toEqual([ADMIN_TOAST]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a non-admin's own run mode and respawn presets survive a reload over the server's", async () => {
|
||||||
|
const before = loadBackground('non-admin');
|
||||||
|
before.app.setRunMode('codex');
|
||||||
|
before.app.saveRespawnPresets([{ id: 'mine', name: 'mine' }]);
|
||||||
|
expect(settingsPuts(before)).toEqual([]);
|
||||||
|
|
||||||
|
// The next page load, on the same device: the server holds an admin's picks.
|
||||||
|
const after = loadBackground('non-admin', before.storage);
|
||||||
|
await after.app.loadAppSettingsFromServer(
|
||||||
|
Promise.resolve({ runMode: 'shell', respawnPresets: [{ id: 'admin', name: 'admin' }] })
|
||||||
|
);
|
||||||
|
expect(after.app.runMode).toBe('codex');
|
||||||
|
expect(after.storage.get('codeman_runMode')).toBe('codex');
|
||||||
|
// What loadRespawnPresets() reads before anything else (respawn-ui.js).
|
||||||
|
expect(after.app._serverRespawnPresets).toEqual([{ id: 'mine', name: 'mine' }]);
|
||||||
|
expect(JSON.parse(after.storage.get('codeman-respawn-presets')!)).toEqual([{ id: 'mine', name: 'mine' }]);
|
||||||
|
expect(settingsPuts(after)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('they win too when the role resolves only after the settings load (codeman:me)', async () => {
|
||||||
|
const before = loadBackground('non-admin');
|
||||||
|
before.app.setRunMode('codex');
|
||||||
|
const after = loadBackground('unknown', before.storage);
|
||||||
|
await after.app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'shell' }));
|
||||||
|
expect(after.app.runMode).toBe('shell');
|
||||||
|
after.resolveMe(IDENTITY['non-admin'] as Settings);
|
||||||
|
expect(after.app.runMode).toBe('codex');
|
||||||
|
expect(after.storage.get('codeman_runMode')).toBe('codex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("they are the user's own: another user, or an admin, on the same device gets the server's", async () => {
|
||||||
|
const before = loadBackground('non-admin');
|
||||||
|
before.app.setRunMode('codex');
|
||||||
|
const other = loadBackground('unknown', before.storage);
|
||||||
|
await other.app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'shell' }));
|
||||||
|
other.resolveMe({ username: 'carol', role: 'user', multiUser: true });
|
||||||
|
expect(other.app.runMode).toBe('shell');
|
||||||
|
const admin = loadBackground('admin', before.storage);
|
||||||
|
await admin.app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'shell' }));
|
||||||
|
expect(admin.app.runMode).toBe('shell');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a background PUT refused as admin-only (role not known yet) makes the page read-only', async () => {
|
||||||
|
const b = loadBackground('unknown');
|
||||||
|
b.app._apiPut = vi.fn(() => Promise.resolve(forbiddenResponse()));
|
||||||
|
b.app.setRunMode('codex');
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
expect(b.app._settingsReadOnly()).toBe(true);
|
||||||
|
b.app.saveRespawnPresets([{ id: 'p' }]);
|
||||||
|
expect(b.app._apiPut).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
for (const who of ['admin', 'single-user'] as const) {
|
for (const who of ['admin', 'single-user'] as const) {
|
||||||
it(`${who}: every background write is still sent`, async () => {
|
it(`${who}: every background write is still sent`, async () => {
|
||||||
const b = loadBackground(who);
|
const b = loadBackground(who);
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ const REAL = new Set([
|
|||||||
// Real so the save builds the body a browser would send (the schema check).
|
// Real so the save builds the body a browser would send (the schema check).
|
||||||
'resolveHeaderStatsStyle',
|
'resolveHeaderStatsStyle',
|
||||||
'resolveSessionSidebarFontSize',
|
'resolveSessionSidebarFontSize',
|
||||||
|
// The display-key list the load merge reads (settings-ui.js keeps it in one method).
|
||||||
|
'_displaySettingKeySet',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user