fix(settings): stop a failed settings write from saying it was saved

App Settings' save handled only the multi-user admin-only 403 and the tunnel's no-password 403, so a 409 for a settings.json that does not parse, a 400, a 500 or a dropped connection (`_apiPut` answers null) still toasted "Settings saved" and closed the modal, and the run mode and respawn preset writes failed with no word at all. Those failures now warn with the server's reason (else the HTTP status, else that the server could not be reached), the modal stays open, and nothing that assumes the server stored the save runs: Apply's refresh, the tunnel-starting toast and the gesture-control reload. Since the modal now invites a second Save, the plan-usage collection flip and the gesture reload are judged against the settings the failed save started from until a save lands, so the retry still sends the flip and still reloads.

A save with the tunnel switch on is read as a tunnel refusal only on a 403, so another failure no longer turns the switch off or shows the no-password message, and an accepted no-password retry now resends the whole save instead of the tunnel keys alone, which had left every other setting unsaved behind "Settings saved". Run mode and respawn presets stay saved on this device as before and now say they were not saved on the server. Stopping the tunnel from the welcome screen or the tunnel panel no longer says "Tunnel stopped" when the server refused the write, since the tunnel is then still running.
This commit is contained in:
JD committed 2026-10-28 01:32:47 -04:00
1 parent ecf28fa3be
commit a1406f1753
7 files changed
+639 -21

No files matched your search

+14
View File
@@ -918,6 +918,8 @@
'Saved on this device. Server-wide settings can only be changed by an admin.':
'已保存到本设备。服务器全局设置只能由管理员更改。',
'Server-wide settings can only be changed by an admin': '服务器全局设置只能由管理员更改',
// The reason a settings write gives when no answer came back (settings-ui.js _settingsWriteError).
'the server could not be reached': '无法连接到服务器',
'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
'Push notifications enabled': '推送通知已启用',
@@ -1380,6 +1382,7 @@
[/^Lifetime: (\d+) sessions created$/, (_m, count) => `累计已创建 ${count} 个会话`],
[/^Tunnel active: (.+)$/, (_m, url) => `隧道已启用:${url}`],
[/^Tunnel error: (.+)$/, (_m, error) => `隧道错误:${error}`],
[/^Tunnel not stopped: ([\s\S]+)$/, (_m, why) => `隧道未停止:${why}`],
[/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`],
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
@@ -1448,6 +1451,17 @@
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
],
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
// A settings write the server did not store (settings-ui.js). The server's reason passes
// through untranslated; the one the page supplies itself has its own entry.
[
/^Saved on this device, not on the server: ([\s\S]+)$/,
(_m, why) => `已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
],
[
/^(Run mode|Respawn presets) saved on this device, not on the server: ([\s\S]+)$/,
(_m, what, why) =>
`${what === 'Run mode' ? '运行模式' : '重生预设'}已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
],
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
// notification drawer shows it. The CLI's label passes through.
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
+2 -2
View File
@@ -601,13 +601,13 @@ Object.assign(CodemanApp.prototype, {
// presets are a key of the server-wide settings.json there, and its PUT is admin-only
// (403), so they are kept as their own on this device and win 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.
// role was known) marks the page read-only for the writes after it; any other failure is toasted.
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('respawnPresets', custom);
return;
}
this._apiPut('/api/settings', { respawnPresets: custom })
.then((res) => this._noteSettingsWriteRefusal?.(res))
.then((res) => this._noteSettingsWriteResult?.(res, 'Respawn presets'))
.catch(() => {});
},
+2 -2
View File
@@ -735,12 +735,12 @@ Object.assign(CodemanApp.prototype, {
// where settings.json is server-wide and its PUT admin-only: the mode is kept as their own
// 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).
// read-only for the writes after it (_noteSettingsWriteRefusal); any other failure is toasted.
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('runMode', mode);
} else {
this._apiPut('/api/settings', { runMode: mode })
.then((res) => this._noteSettingsWriteRefusal?.(res))
.then((res) => this._noteSettingsWriteResult?.(res, 'Run mode'))
.catch(() => {});
}
// Close menu
+79 -15
View File
@@ -2300,6 +2300,14 @@ Object.assign(CodemanApp.prototype, {
btn.disabled = false;
return;
}
// A stop the server did not store (a 409 for a settings.json that does not parse) left the
// tunnel running: say so, and leave the button showing it on.
const stopError = newEnabled ? '' : await this._settingsWriteError(res);
if (stopError) {
this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 });
btn.disabled = false;
return;
}
if (newEnabled) {
this._showTunnelConnecting();
// Poll tunnel status as fallback in case SSE event is missed
@@ -2661,9 +2669,11 @@ Object.assign(CodemanApp.prototype, {
* Shows the server's (actionable) message as an error toast. Since #706 this is the
* fallback for when `_confirmTunnelEnable` could not read the tunnel status.
* @param {Response|null} res - the fetch Response from the settings PUT
* @param {object} [sent] - what that PUT sent, resent with the acknowledgement: the refusal
* rejected all of it, so an App Settings save retried with the tunnel keys alone lost the rest
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
*/
async _handleTunnelEnableRefusal(res) {
async _handleTunnelEnableRefusal(res, sent = { tunnelEnabled: true }) {
if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
let errorCode = '';
@@ -2695,7 +2705,7 @@ Object.assign(CodemanApp.prototype, {
const retry = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }),
body: JSON.stringify({ ...sent, acknowledgeUnauthTunnel: true }),
});
if (retry.ok) {
this.showToast('Public tunnel enabling — no password set ⚠️', 'warning');
@@ -2745,6 +2755,13 @@ Object.assign(CodemanApp.prototype, {
this.closeTunnelPanel();
return;
}
// A stop the server did not store left the tunnel running (see toggleTunnelFromWelcome).
const stopError = enable ? '' : await this._settingsWriteError(res);
if (stopError) {
this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 });
this.closeTunnelPanel();
return;
}
if (enable) {
this._updateTunnelIndicator(false);
const indicator = document.getElementById('tunnelIndicator');
@@ -2963,7 +2980,11 @@ Object.assign(CodemanApp.prototype, {
// Gesture overlay is injected at page render (server-side), so a change to it
// only takes effect on reload — remember the prior value to decide below.
const _prev = this.loadAppSettingsFromStorage();
const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true;
// After a save the server did not store, this device's blob is ahead of the server, so the
// two server-side changes judged against it (the gesture reload, the plan-usage collection
// flip) are judged against the blob that save started from until a save lands.
const _prevServer = this._unsavedSettingsBaseline ?? _prev;
const _prevGestureEnabled = (_prevServer.gestureControlEnabled ?? false) === true;
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
// as "previously off" — used below to detect a real OFF→ON flip.
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
@@ -3282,7 +3303,7 @@ Object.assign(CodemanApp.prototype, {
// switch collection off for every desktop, whose chip then went stale with
// no error anywhere. An explicit toggle on any device still writes it, in
// either direction.
const _chipFlip = this.planUsageCollectionFlip(_prev, settings.showPlanUsageLimits);
const _chipFlip = this.planUsageCollectionFlip(_prevServer, settings.showPlanUsageLimits);
const {
localEchoEnabled: _leo,
// Per-device (it is about this screen's keyboard) and absent from
@@ -3355,6 +3376,7 @@ Object.assign(CodemanApp.prototype, {
...serverSettings
} = settings;
let webhookError = '';
let saveError = '';
let serverSaved = false;
try {
// #706: a save that turns the tunnel on asks first when the server has no login
@@ -3368,13 +3390,14 @@ Object.assign(CodemanApp.prototype, {
}
// 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', {
const body = {
...serverSettings,
...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}),
...(tunnelGate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
};
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', body);
// 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
@@ -3389,15 +3412,24 @@ Object.assign(CodemanApp.prototype, {
// 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
// so it doesn't look enabled.
if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
// so it doesn't look enabled. Only a 403: any other failure with the switch on is
// not about the tunnel, and turning the switch off for it would be wrong too.
const tunnelRefused = !refusedAsNonAdmin && settings.tunnelEnabled && res?.status === 403;
if (tunnelRefused && (await this._handleTunnelEnableRefusal(res, body))) {
this._abandonTunnelEnable(settings, keepOpen);
return;
}
// `_apiPut` answers null or a non-ok response instead of throwing, so this is the only
// evidence the server kept the flags the Apply refresh below reads.
serverSaved = !!res?.ok;
// evidence the server kept the flags the Apply refresh below reads (a tunnel 403 still
// here was retried with the whole save and accepted).
serverSaved = !!res?.ok || tunnelRefused;
// Any other failure (a 409 for a settings.json that does not parse, a 400, a 500, a dropped
// connection) stored nothing on the server, so the save must not say it did. A known
// non-admin sent nothing (res is null).
saveError =
settingsReadOnly || refusedAsNonAdmin || tunnelRefused ? '' : await this._settingsWriteError(res);
this._unsavedSettingsBaseline = saveError ? _prevServer : null;
// Save model configuration separately. For a non-admin it is not sent either, and the
// answer says whether this save would have changed it.
@@ -3408,7 +3440,9 @@ Object.assign(CodemanApp.prototype, {
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
if (webhookError) {
if (saveError) {
this.showToast(`Saved on this device, not on the server: ${saveError}`, 'warning', { duration: 0 });
} else if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else if (syncedChangeRefused || modelConfigRefused || refusedAsNonAdmin) {
// ONE toast for the whole save, and only when the user really changed a server-wide
@@ -3421,7 +3455,7 @@ Object.assign(CodemanApp.prototype, {
// 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)
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin) {
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin && !saveError) {
this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
}
} catch (err) {
@@ -3433,9 +3467,10 @@ Object.assign(CodemanApp.prototype, {
// old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch.
if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings);
// A failed settings PUT keeps the modal open too, with the form as the user left it.
if (webhookError) {
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
} else if (!keepOpen) {
} else if (!keepOpen && !saveError) {
this.closeAppSettings();
}
@@ -3446,10 +3481,10 @@ Object.assign(CodemanApp.prototype, {
// The gesture overlay is injected at page render (server reads
// gestureControlEnabled from settings.json), so a change only takes effect on
// reload. Reload when it actually changed — the server PUT above already
// persisted the new value.
// persisted the new value (not after a failed PUT: the reload would render the old one).
// `?? 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) {
if (!saveError && (settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
this.showToast(
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
'info'
@@ -3498,6 +3533,35 @@ Object.assign(CodemanApp.prototype, {
return true;
},
/**
* Why a settings write did not land, or '' when it did: the server's `error` (a 409 when
* settings.json does not parse, a 400, ...), else the HTTP status, else, for the null that
* `_apiPut` answers a dropped connection with, that the server could not be reached. Reads a
* clone, like _noteSettingsWriteRefusal.
* @returns {Promise<string>}
*/
async _settingsWriteError(res) {
if (!res) return 'the server could not be reached';
if (res.ok) return '';
let body = null;
try {
body = await res.clone().json();
} catch {
/* not JSON: the status is all there is */
}
return body?.error || `HTTP ${res.status}`;
},
/**
* The end of a background settings write (run mode, respawn presets): the admin-only refusal is
* _noteSettingsWriteRefusal's, and any other failure says that `what` stayed on this device only.
*/
async _noteSettingsWriteResult(res, what) {
if (await this._noteSettingsWriteRefusal(res)) return;
const error = await this._settingsWriteError(res);
if (error) this.showToast(`${what} saved on this device, not on the server: ${error}`, 'warning', { duration: 0 });
},
/**
* 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