diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 9cea9701..e2949177 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -230,7 +230,7 @@ on. Details: [Custom Model Endpoints](Custom-Model-Endpoints#a-model-that-runs-o | Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. | | Allow network access | Starts new local Codex sessions (Run menu, Resume and the API; not scheduled jobs) with `sandbox_workspace_write.network_access=true`, so commands in Codex's workspace-write sandbox can use the network. The [Codeman Skill](Codeman-Skill) needs it to reach this server from a Codex session. It applies to every such session, and those commands can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even when a password is set: see [Security](Security#what-codeman-does-not-protect-against). It applies where Codex runs the folder in workspace-write (a project you trusted in Codex) and does not change Codex's sandbox mode: a folder Codex runs read-only keeps the network closed, and Codex asks for approval per command. Synced, off by default; forced off for users without the privileged-commands grant in multi-user mode. | | Animated status effects | Cosmetic. | -| Sync cases across devices | Keeps each case's **Agent Teams** and **Opus 1M** options on the server (`caseSettings` in `settings.json`) and, when you return to a tab, switches it to the case you last chose on another device. Synced, off by default. Without it each browser keeps its own options for a case (the case list itself is always shared). The case names are stored in the shared settings file, so a non-admin in multi-user mode neither reads, writes nor follows them. Each save sends only that case and the server merges it, so devices saving different cases never overwrite each other; a case renamed in Codeman keeps its options, and deleting or unlinking it removes them. A save the server refuses leaves the choice on that device and says so. If another device turns this off, this one stops following. A device that already has options for a case keeps them until a device changes that case's options while this is on. | +| Sync cases across devices | Keeps each case's **Agent Teams** and **Opus 1M** options on the server (`caseSettings` in `settings.json`) and, when you return to a tab, switches it to the case you last chose on another device. Synced, off by default. Without it each browser keeps its own options for a case (the case list itself is always shared). The case names are stored in the shared settings file, so a non-admin in multi-user mode neither reads, writes nor follows them. Each save sends only that case and the server merges it, so devices saving different cases never overwrite each other; a case renamed in Codeman keeps its options, and deleting or unlinking it removes them. A save the server refuses leaves the choice on that device and says so. If another device turns this off, this one stops following, and the server ignores case options sent while it is off. Turning it off leaves the stored options in `settings.json`, so turning it back on brings them back. A device that already has options for a case keeps them until a device changes that case's options while this is on. | | MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. | ### Notifications diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index d4d9e95c..ff5aea9c 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -3759,6 +3759,9 @@ Object.assign(CodemanApp.prototype, { /** On returning to the tab: take the other devices' case options and selected case, if the server has sync on. */ async _syncCaseStateFromServer() { if (this._settingsReadOnly?.()) return; + // Off on this device: no request on every tab return. Turning it on elsewhere is learned from the + // next settings load; turning it off elsewhere still reaches a device whose cached switch is on. + if (this.loadAppSettingsFromStorage().syncCaseState !== true) return; const seq = (this._caseSyncRefresh = (this._caseSyncRefresh || 0) + 1); try { await this._caseSyncChain; // this device's own saves land first, so the copy read next includes them diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index d8ac669a..8b662a7a 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -1145,10 +1145,14 @@ export function registerSystemRoutes( if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled; // caseSettings merges per case inside the serialised writer, so two devices saving different cases both land. const { caseSettings: incomingCases, ...plainSettings } = settingsToStore; + // The map is read and written only while syncCaseState is on, so a PUT that carries it with the setting off + // (as stored, or as this same PUT sets it) leaves the stored map alone. const { before: existing, after } = await mutateSettingsFile((current) => ({ ...current, ...plainSettings, - ...(incomingCases ? { caseSettings: mergeCaseSettings(current.caseSettings, incomingCases as never) } : {}), + ...(incomingCases && (plainSettings.syncCaseState ?? current.syncCaseState) === true + ? { caseSettings: mergeCaseSettings(current.caseSettings, incomingCases as never) } + : {}), })); const merged = after ?? { ...existing, ...plainSettings }; diff --git a/test/sync-case-state-server.test.ts b/test/sync-case-state-server.test.ts index 74b123fb..e01b037e 100644 --- a/test/sync-case-state-server.test.ts +++ b/test/sync-case-state-server.test.ts @@ -64,6 +64,17 @@ describe('shared case options', () => { expect(readSettings().caseSettings).toEqual({ alpha: on, beta: off, gamma: on }); }); + it('ignores a caseSettings PUT while syncCaseState is off, and takes one that turns it on', async () => { + seed({ syncCaseState: false, caseSettings: { kept: off } }); + expect((await send('PUT', '/api/settings', { caseSettings: { alpha: on } })).status).toBe(200); + expect(readSettings().caseSettings).toEqual({ kept: off }); + seed({}); + await send('PUT', '/api/settings', { caseSettings: { alpha: on } }); + expect(readSettings().caseSettings).toBeUndefined(); + await send('PUT', '/api/settings', { syncCaseState: true, caseSettings: { alpha: on } }); + expect(readSettings().caseSettings).toEqual({ alpha: on }); + }); + it('keeps the other cases when one is saved again, and every other setting', async () => { seed({ syncCaseState: true, lastUsedCase: 'x', caseSettings: { alpha: on, beta: off } }); await send('PUT', '/api/settings', { caseSettings: { alpha: off } }); diff --git a/test/sync-case-state.test.ts b/test/sync-case-state.test.ts index 26071bb1..0fb48d35 100644 --- a/test/sync-case-state.test.ts +++ b/test/sync-case-state.test.ts @@ -188,11 +188,11 @@ describe('returning to the tab', () => { expect(app._caseStateSyncOn()).toBe(false); }); - it('starts following when another device turned sync on', async () => { - const { app, select, settings } = makeApp({}, () => env({ syncCaseState: true, lastUsedCase: 'beta' })); + it('asks for nothing on a tab return while this device has sync off', async () => { + const { app, select, calls } = makeApp({}, () => env({ syncCaseState: true, lastUsedCase: 'beta' })); await app._syncCaseStateFromServer(); - expect(settings.syncCaseState).toBe(true); - expect(select.value).toBe('beta'); + expect(calls).toEqual([]); + expect(select.value).toBe('alpha'); }); it('does nothing for a read-only identity', async () => {