merge: resolve v1.23.0 upstream conflicts, preserve custom git provider config

Resolved conflicts in 8 files by taking upstream refactored code:
- desktop.ts: re-export DesktopSettings from registry
- openchamberConfig.ts: simplified project setup client
- persistence.ts: registry-derived settings, add git provider hydration
- search.ts: upstream search entries + git provider entries
- useConfigStore.ts: loadDesktopSettings() path
- settings-helpers.js: add gitProviderId/gitModelId/gitProviders sanitization
- DOCUMENTATION.md: upstream walkthrough docs
- vite.config.ts: upstream SW glob patterns

Custom fork additions preserved:
- gitProviderId, gitModelId, gitProviders fields in settings registry
- Git provider domain store hydration in persistence.ts
- Git provider search entries in search.ts
- Git provider sanitization in settings-helpers.js
This commit is contained in:
2026-09-10 10:11:50 +00:00
559 changed files with 36139 additions and 9087 deletions
@@ -212,7 +212,12 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
- `persistSettings(changes)`
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
- Queued follow-up messages live in `<data-dir>/message-queue.json`, not in settings; execution ownership lives in `lib/message-queue/`.
- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter `settings.json`.
- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter either settings file.
- Two files (`settings-files.js`): `settings.json` holds instance facts and any legacy or unknown keys; `preferences.json` beside it holds every key the generated registry snapshot (`settings-registry.json`) marks `profile`, as `{ version: 1, fields: { key: { value, updatedAt, surfaces? } } }`. Keys the snapshot marks `perSurface` are stored per surface kind: `GET`/`PUT /api/config/settings` read the client's kind from the `surface` query parameter (`settingsSurfaceOf`; the legacy `x-openchamber-surface` header is still honoured, but a header forces a CORS preflight that cross-origin shells and older instances refuse, so clients must not send one) (`web`, `desktop`, `vscode`, `mobile`; anything else means base), `persistSettings(changes, { surface })` writes a changed per-surface key under `surfaces[surface]` and never touches its base, and `readSettingsFromDisk({ surface })` resolves that kind's value first, the base otherwise. Callers without a surface (migrations, the seed, server-side feature writers) read and write the base. `readSettingsFromDisk()` returns the merged document and seeds `preferences.json` once from an existing `settings.json` (which it leaves intact). An existing `preferences.json` that fails to parse is a failure, not an empty profile: it is never seeded or overwritten, the merged read serves the instance part, and `persistSettings` drops profile keys with a warning until the file is fixed or removed. `writeSettingsToDisk(document)` splits by scope and writes `settings.json` as the instance part plus a copy of the profile's base values (`legacySettingsDocumentOf`): a build from before the split reads only that file, so a rollback keeps the user's preferences, while current builds ignore the copy because `preferences.json` wins in the merge; device keys are dropped from writes. Modules that read one profile key off the disk on a hot path use `readMergedSettingsSync`.
## Public exports (settings-files.js)
- `parsePreferencesDocument(raw)`, `serializePreferencesDocument(fields)`, `flattenPreferences(fields)`, `buildPreferencesFields(previousFields, document, now)`, `instancePartOf(document)`, `seedPreferencesFrom(document, now)`, `readMergedSettingsSync({ fs, path, settingsFilePath })`, `getSettingsScope(key)`, `isProfileSettingsKey(key)`, `isDeviceSettingsKey(key)`, `preferencesFilePathFor(settingsFilePath, path)`.
- The VS Code extension host writes the same two files with the same shape (`packages/vscode/src/settings-files.ts`); format changes go to both.
## Public exports (settings-helpers.js)
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
@@ -363,9 +368,20 @@ before starting managed OpenCode. The managed custom tool therefore receives
an authoritative loopback callback URL even when OpenChamber binds port `0`.
## Public exports (openchamber-routes.js)
Browser completion checks use `appType=web&updateStatus=true` to stay on the
Desktop Host's native updater. A rejected native restart is retained in the
server process and returned to these polls as `DESKTOP_UPDATE_RESTART_FAILED`;
ordinary availability checks remain usable so a browser reload can offer a
retry. Starting another installation clears the previous restart error.
The shared UI's `lib/web-update.ts` parses install/check responses and waits
for the installed native target version, rather than treating absence of a
newer release as installation success. Poll requests have individual deadlines
within a ten-minute overall deadline.
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
- `GET /api/openchamber/update-check`
- `POST /api/openchamber/update-install`
- Desktop-managed hosts delegate authenticated Web update requests to the Electron main process, which checks, downloads, and applies the update through `electron-updater` before restarting the host.
- Foreground servers running under a systemd user unit queue installation in
a separate transient unit and restart the configured service afterwards.
`OPENCHAMBER_SYSTEMD_UNIT` overrides the default `openchamber.service`.
+2
View File
@@ -63,6 +63,7 @@ export const createBootstrapRuntime = (dependencies) => {
getCachedZenModels,
setAutoAcceptSession,
agentToolRuntime,
desktopUpdater,
} = options;
const uiAuthController = createUiAuth({
@@ -153,6 +154,7 @@ export const createBootstrapRuntime = (dependencies) => {
readSettingsFromDiskMigrated,
fetchFreeZenModels,
getCachedZenModels,
desktopUpdater,
});
return {
@@ -13,6 +13,7 @@ import { registerDevServerRoutes } from '../dev-servers/routes.js';
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
import { registerSessionFoldersRoutes } from '../session-folders/routes.js';
import { registerProjectContextRoutes } from '../project-context/routes.js';
import { registerProjectSetupRoutes } from '../projects/routes.js';
import { registerAgentMemoryRoutes } from '../agent-memory/routes.js';
import { registerSessionKnowledgeRoutes } from '../session-knowledge/routes.js';
import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
@@ -330,6 +331,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
openchamberDataDir,
});
registerProjectContextRoutes(app, { projectContextRuntime });
registerProjectSetupRoutes(app, { projectConfigRuntime });
registerAgentMemoryRoutes(app, { agentMemoryRuntime, isAgentMemoryEnabled });
registerSessionKnowledgeRoutes(app, { sessionKnowledgeRuntime });
@@ -29,11 +29,13 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
readSettingsFromDiskMigrated,
fetchFreeZenModels,
getCachedZenModels,
desktopUpdater,
} = dependencies;
let desktopRestartError = null;
app.get('/api/openchamber/update-check', async (req, res) => {
try {
const { checkForUpdates } = await import('../package-manager.js');
const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
const parseReportUsage = (value) => {
if (typeof value !== 'string') return true;
@@ -49,8 +51,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
return 'desktop';
};
const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '';
const updateInfo = await checkForUpdates({
const updateRequest = {
appType: parseString(req.query.appType),
deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent),
platform: parseString(req.query.platform),
@@ -59,7 +60,31 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
currentVersion: parseString(req.query.currentVersion),
installId: parseString(req.query.installId),
reportUsage: parseReportUsage(parseString(req.query.reportUsage)),
});
};
let updateInfo;
if (process.env.OPENCHAMBER_RUNTIME === 'desktop' && updateRequest.appType === 'web') {
if (desktopRestartError && req.query.updateStatus === 'true') {
return res.status(503).json({
code: 'DESKTOP_UPDATE_RESTART_FAILED',
error: desktopRestartError,
});
}
if (typeof desktopUpdater?.check !== 'function') {
return res.status(503).json({
available: false,
code: 'DESKTOP_UPDATER_UNAVAILABLE',
error: 'The desktop updater is not available.',
});
}
updateInfo = {
...await desktopUpdater.check(),
packageManager: 'electron',
updateOwner: 'electron-updater',
};
} else {
const { checkForUpdates } = await import('../package-manager.js');
updateInfo = await checkForUpdates(updateRequest);
}
res.json(updateInfo);
} catch (error) {
console.error('Failed to check for updates:', error);
@@ -72,6 +97,41 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
app.post('/api/openchamber/update-install', async (_req, res) => {
try {
if (process.env.OPENCHAMBER_RUNTIME === 'desktop') {
if (typeof desktopUpdater?.install !== 'function' || typeof desktopUpdater?.restart !== 'function') {
return res.status(503).json({
code: 'DESKTOP_UPDATER_UNAVAILABLE',
error: 'The desktop updater is not available.',
});
}
desktopRestartError = null;
const updateInfo = await desktopUpdater.install();
if (!updateInfo?.available) {
return res.status(400).json({ error: 'No update available' });
}
res.json({
success: true,
message: 'Desktop update downloaded, host will restart shortly',
version: updateInfo.version,
packageManager: 'electron',
updateOwner: 'electron-updater',
autoRestart: true,
restartManager: 'electron-updater',
});
setImmediate(() => {
Promise.resolve()
.then(() => desktopUpdater.restart())
.catch((error) => {
desktopRestartError = error instanceof Error ? error.message : 'Failed to restart after desktop update';
console.error('Failed to restart after desktop update:', error);
});
});
return;
}
const { spawn: spawnChild, spawnSync } = await import('child_process');
const {
checkForUpdates,
@@ -18,7 +18,7 @@ const childProcess = await import('child_process');
const packageManager = await import('../package-manager.js');
const { registerOpenChamberRoutes } = await import('./openchamber-routes.js');
const createApp = ({ environment = {}, storedOptions = {} } = {}) => {
const createApp = ({ environment = {}, storedOptions = {}, desktopUpdater } = {}) => {
const app = express();
const dependencies = {
fs: {
@@ -47,6 +47,7 @@ const createApp = ({ environment = {}, storedOptions = {} } = {}) => {
readSettingsFromDiskMigrated: vi.fn(),
fetchFreeZenModels: vi.fn(),
getCachedZenModels: vi.fn(),
desktopUpdater,
};
registerOpenChamberRoutes(app, dependencies);
@@ -69,6 +70,132 @@ afterEach(() => {
vi.clearAllMocks();
});
describe('OpenChamber desktop host update route', () => {
it('reports a restart rejection until the user retries installation', async () => {
const desktopUpdater = {
check: vi.fn(async () => ({ available: true, currentVersion: '1.17.0', version: '1.17.1' })),
install: vi.fn(async () => ({ available: true, version: '1.17.1' })),
restart: vi.fn().mockRejectedValueOnce(new Error('Signature rejected')).mockResolvedValue(undefined),
};
const { app } = createApp({ environment: { OPENCHAMBER_RUNTIME: 'desktop' }, desktopUpdater });
const logError = vi.spyOn(console, 'error').mockImplementation(() => {});
await request(app).post('/api/openchamber/update-install').expect(200);
await new Promise(resolve => setImmediate(resolve));
await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true').expect(503, {
code: 'DESKTOP_UPDATE_RESTART_FAILED', error: 'Signature rejected',
});
expect(desktopUpdater.check).not.toHaveBeenCalled();
expect(logError).toHaveBeenCalledOnce();
// Availability remains reachable after a browser reload, so users can retry.
await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
await request(app).post('/api/openchamber/update-install').expect(200);
await new Promise(resolve => setImmediate(resolve));
const response = await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
expect(response.body.currentVersion).toBe('1.17.0');
expect(response.body.updateOwner).toBe('electron-updater');
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
});
it('rejects native checks without a bridge and preserves explicit non-web checks', async () => {
const { app } = createApp({ environment: { OPENCHAMBER_RUNTIME: 'desktop' } });
await request(app).get('/api/openchamber/update-check?appType=web').expect(503, {
available: false, code: 'DESKTOP_UPDATER_UNAVAILABLE', error: 'The desktop updater is not available.',
});
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
await request(app).get('/api/openchamber/update-check?appType=desktop-electron').expect(200);
expect(packageManager.checkForUpdates).toHaveBeenCalledOnce();
});
it('uses electron-updater to check for Web client updates', async () => {
const desktopUpdater = {
check: vi.fn(async () => ({
available: true,
currentVersion: '1.17.0',
version: '1.17.1',
})),
install: vi.fn(),
restart: vi.fn(),
};
const { app } = createApp({
environment: {
OPENCHAMBER_RUNTIME: 'desktop',
},
desktopUpdater,
});
await request(app)
.get('/api/openchamber/update-check?appType=web&reportUsage=false')
.expect(200, {
available: true,
currentVersion: '1.17.0',
version: '1.17.1',
packageManager: 'electron',
updateOwner: 'electron-updater',
});
expect(desktopUpdater.check).toHaveBeenCalledOnce();
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
});
it('installs through electron-updater and restarts after responding', async () => {
const desktopUpdater = {
check: vi.fn(),
install: vi.fn(async () => ({
available: true,
version: '1.17.1',
})),
restart: vi.fn(),
};
const { app } = createApp({
environment: {
OPENCHAMBER_RUNTIME: 'desktop',
},
desktopUpdater,
});
await request(app)
.post('/api/openchamber/update-install')
.expect(200, {
success: true,
message: 'Desktop update downloaded, host will restart shortly',
version: '1.17.1',
packageManager: 'electron',
updateOwner: 'electron-updater',
autoRestart: true,
restartManager: 'electron-updater',
});
await new Promise((resolve) => setImmediate(resolve));
expect(desktopUpdater.install).toHaveBeenCalledOnce();
expect(desktopUpdater.restart).toHaveBeenCalledOnce();
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
expect(packageManager.detectPackageManagerDetails).not.toHaveBeenCalled();
expect(packageManager.getUpdateCommand).not.toHaveBeenCalled();
expect(childProcess.spawn).not.toHaveBeenCalled();
expect(childProcess.spawnSync).not.toHaveBeenCalled();
});
it('fails safely when the Electron updater bridge is unavailable', async () => {
const { app } = createApp({
environment: {
OPENCHAMBER_RUNTIME: 'desktop',
},
});
await request(app)
.post('/api/openchamber/update-install')
.expect(503, {
code: 'DESKTOP_UPDATER_UNAVAILABLE',
error: 'The desktop updater is not available.',
});
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
expect(childProcess.spawn).not.toHaveBeenCalled();
});
});
describe('OpenChamber foreground update route', () => {
it('rejects a foreground update when the server is not owned by systemd', async () => {
const { app } = createApp();
+5 -3
View File
@@ -7,6 +7,7 @@ import {
} from './config-mutation-response.js';
import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
import { OPENCODE_CONFIG_DIR } from './shared.js';
import { settingsSurfaceOf } from './settings-files.js';
export const registerOpenCodeRoutes = (app, dependencies) => {
const {
@@ -208,9 +209,10 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
}
};
app.get('/api/config/settings', async (_req, res) => {
app.get('/api/config/settings', async (req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
// The surface kind resolves the per-surface profile keys; absent means base.
const settings = await readSettingsFromDiskMigrated({ surface: settingsSurfaceOf(req) });
res.json(formatSettingsResponse(settings));
} catch (error) {
console.error('Failed to read settings:', error);
@@ -422,7 +424,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
app.put('/api/config/settings', async (req, res) => {
try {
const updated = await persistSettings(req.body ?? {});
const updated = await persistSettings(req.body ?? {}, { surface: settingsSurfaceOf(req) });
res.json(updated);
} catch (error) {
console.error('[API:PUT /api/config/settings] Failed to save settings:', error);
@@ -0,0 +1,214 @@
// The two settings files and how a merged document is split between them.
//
// `settings.json` holds instance facts (and, untouched, whatever legacy keys
// older builds left there). `preferences.json` holds the user's profile: the
// keys the settings registry marks `profile`, each with the time the store
// last accepted a new value for it. Device keys never reach either file.
//
// The VS Code extension host writes the same two files with the same shape
// (`packages/vscode/src/settings-files.ts`); keep the format changes in sync.
import { createRequire } from 'node:module';
const registry = createRequire(import.meta.url)('./settings-registry.json');
const PREFERENCES_FILE_NAME = 'preferences.json';
const PREFERENCES_DOCUMENT_VERSION = 1;
/** The registry scope for a key, or `null` when the registry does not know it. */
const getSettingsScope = (key) => registry.fields[key]?.scope ?? null;
export const isProfileSettingsKey = (key) => getSettingsScope(key) === 'profile';
export const isDeviceSettingsKey = (key) => getSettingsScope(key) === 'device';
/** Profile keys the owner chose to store per surface kind (a change on a phone stays on phones). */
const isPerSurfaceSettingsKey = (key) => registry.fields[key]?.perSurface === true;
const SETTINGS_SURFACES = Object.freeze(['web', 'desktop', 'vscode', 'mobile']);
export const normalizeSettingsSurface = (value) => (
typeof value === 'string' && SETTINGS_SURFACES.includes(value.trim()) ? value.trim() : null
);
/**
* Which surface kind a settings request comes from; `null` means "base".
* Clients send `?surface=<kind>` (a query parameter keeps the request
* CORS-simple for cross-origin shells and older instances); the
* `x-openchamber-surface` header is still honoured for clients that sent it.
*/
export const settingsSurfaceOf = (req) => (
normalizeSettingsSurface(req.query?.surface) ?? normalizeSettingsSurface(req.get?.('x-openchamber-surface'))
);
export const preferencesFilePathFor = (settingsFilePath, path) => path.join(path.dirname(settingsFilePath), PREFERENCES_FILE_NAME);
const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const sameValue = (left, right) => {
if (left === right) return true;
if (left === undefined || right === undefined) return false;
return JSON.stringify(left) === JSON.stringify(right);
};
const parseStamp = (value) => (Number.isFinite(value) ? value : 0);
/**
* Parse the text of a preferences file. A missing file is the caller's case
* (ENOENT); anything that is not a version-1 document with a `fields` object
* is a failure, never an empty profile.
*
* An entry is `{ value, updatedAt }` for the base value, optionally with
* `surfaces: { [surface]: { value, updatedAt } }` for per-surface keys; a
* per-surface key that was only ever set from one surface kind has no base.
*/
export const parsePreferencesDocument = (raw) => {
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
return { ok: false, reason: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` };
}
if (!isPlainObject(parsed) || parsed.version !== PREFERENCES_DOCUMENT_VERSION || !isPlainObject(parsed.fields)) {
return { ok: false, reason: 'not a version-1 preferences document' };
}
const fields = {};
for (const [key, entry] of Object.entries(parsed.fields)) {
if (!isPlainObject(entry) || (!('value' in entry) && !isPlainObject(entry.surfaces))) {
return { ok: false, reason: `field "${key}" is not a { value, updatedAt } entry` };
}
const next = { updatedAt: parseStamp(entry.updatedAt) };
if ('value' in entry) next.value = entry.value;
if (isPlainObject(entry.surfaces)) {
next.surfaces = {};
for (const [surface, surfaceEntry] of Object.entries(entry.surfaces)) {
if (!SETTINGS_SURFACES.includes(surface) || !isPlainObject(surfaceEntry) || !('value' in surfaceEntry)) {
return { ok: false, reason: `field "${key}" has an invalid surface entry "${surface}"` };
}
next.surfaces[surface] = { value: surfaceEntry.value, updatedAt: parseStamp(surfaceEntry.updatedAt) };
}
}
fields[key] = next;
}
return { ok: true, fields };
};
export const serializePreferencesDocument = (fields) => JSON.stringify({ version: PREFERENCES_DOCUMENT_VERSION, fields }, null, 2);
/**
* The plain key → value view of preference fields as one surface kind sees it:
* that surface's own value first, the base value otherwise; a key with neither
* is absent (the client keeps what it holds, or its default).
*/
export const flattenPreferences = (fields, surface = null) => {
const values = {};
for (const [key, entry] of Object.entries(fields)) {
const own = surface && entry.surfaces ? entry.surfaces[surface] : undefined;
if (own) {
values[key] = own.value;
} else if ('value' in entry) {
values[key] = entry.value;
}
}
return values;
};
/**
* The next preference fields for a merged document: every profile key it
* carries, stamped `now` when its value differs from what the file held and
* keeping the earlier stamp otherwise. Profile keys the document no longer
* carries are dropped (that is how a cleared key leaves the file).
*
* Per-surface keys: when the write comes from a surface kind (`surface`) and
* the key is among the keys that write changed (`changedKeys`), the value goes
* under `surfaces[surface]` and the base is left as it was; a per-surface key
* the write did not change keeps its whole entry (the document only carries
* that surface's resolved view of it). Without a surface (migrations, the
* one-time seed) the base is written.
*/
export const buildPreferencesFields = (previousFields, document, now, { surface = null, changedKeys = null } = {}) => {
const fields = {};
const changed = changedKeys ? new Set(changedKeys) : null;
for (const [key, value] of Object.entries(document)) {
if (value === undefined || !isProfileSettingsKey(key)) continue;
const previous = previousFields[key];
if (isPerSurfaceSettingsKey(key) && surface) {
if (changed && !changed.has(key)) {
if (previous) fields[key] = previous;
continue;
}
const previousOwn = previous?.surfaces?.[surface];
const own = previousOwn && sameValue(previousOwn.value, value) ? previousOwn : { value, updatedAt: now };
fields[key] = {
...(previous ?? { updatedAt: 0 }),
surfaces: { ...(previous?.surfaces ?? {}), [surface]: own },
};
continue;
}
if (previous && 'value' in previous && sameValue(previous.value, value)) {
fields[key] = previous;
} else {
fields[key] = { ...(previous ?? {}), value, updatedAt: now };
}
}
return fields;
};
/**
* The part of a merged document that belongs in `settings.json`: everything
* that is not a profile key. Device keys older builds persisted stay in place
* as a read-once seed for clients; the write path never adds new ones.
*/
export const instancePartOf = (document) => {
const instance = {};
for (const [key, value] of Object.entries(document)) {
if (value === undefined || isProfileSettingsKey(key)) continue;
instance[key] = value;
}
return instance;
};
/** The profile keys of a document (the part `instancePartOf` leaves out). */
export const profilePartOf = (document) => {
const profile = {};
for (const [key, value] of Object.entries(document)) {
if (value !== undefined && isProfileSettingsKey(key)) profile[key] = value;
}
return profile;
};
/**
* What `settings.json` holds after a write: the instance part plus a copy of
* the profile's base values. The copy is for builds that predate the split —
* they read only this file, so a rollback still finds the user's preferences.
* Current builds ignore it: `preferences.json` wins in the merged read.
*/
export const legacySettingsDocumentOf = (document, preferenceFields) => ({
...instancePartOf(document),
...flattenPreferences(preferenceFields),
});
/** The profile keys of a document, as they would seed a fresh preferences file. */
export const seedPreferencesFrom = (document, now) => buildPreferencesFields({}, document, now);
/**
* Synchronous merged read for server modules that consult one or two profile
* keys on a hot path (small-model resolution, goal/assist toggles). A missing
* or unreadable preferences file contributes nothing, and the caller's own
* default applies — the same "missing is not default" rule the clients use.
*/
export const readMergedSettingsSync = ({ fs, path, settingsFilePath }) => {
let settings = {};
try {
const parsed = JSON.parse(fs.readFileSync(settingsFilePath, 'utf8'));
if (isPlainObject(parsed)) settings = parsed;
} catch {
settings = {};
}
let preferences = {};
try {
const parsed = parsePreferencesDocument(fs.readFileSync(preferencesFilePathFor(settingsFilePath, path), 'utf8'));
if (parsed.ok) preferences = flattenPreferences(parsed.fields);
} catch {
preferences = {};
}
return { ...settings, ...preferences };
};
@@ -1,5 +1,33 @@
import { sanitizeGitProviders } from '../git-providers/config.js';
import { createRequire } from 'node:module';
import { isAgentMemoryFeatureAvailable } from '../agent-memory/feature-flag.js';
import { sanitizeGitProviders } from '../git-providers/config.js';
// Generated from packages/ui/src/lib/settings/registry.ts by
// `bun run settings-registry:generate`; `registry.test.ts` fails when stale.
// The server is plain ESM without a bundler, so the snapshot is read with
// `createRequire` (import attributes differ across the Node versions we run on).
const settingsRegistry = createRequire(import.meta.url)('./settings-registry.json');
/**
* Whether a client may persist this key through PUT /api/config/settings:
* it must be a registry key, not a server-computed flag, not a device field
* that only lives in the browser, and not one the desktop shell writes itself.
*/
const isPersistableSettingsKey = (key) => {
const field = settingsRegistry.fields[key];
if (!field) return false;
if (field.computed || field.local) return false;
if (field.owner === 'desktop-shell') return false;
return true;
};
/** Keys accepted on write but never returned by a read. */
const SECRET_SETTINGS_KEYS = Object.freeze(
Object.entries(settingsRegistry.fields)
.filter(([, field]) => field.secret === true)
.map(([key]) => key),
);
import {
DEFAULT_INPUT_HISTORY_LIMIT,
DEFAULT_INPUT_HISTORY_SCOPE,
@@ -19,7 +47,6 @@ export const createSettingsHelpers = (dependencies) => {
normalizeManagedRemoteTunnelHostname,
normalizeManagedRemoteTunnelPresets,
normalizeManagedRemoteTunnelPresetTokens,
sanitizeTypographySizesPartial,
normalizeStringArray,
sanitizeModelRefs,
sanitizeSkillCatalogs,
@@ -204,6 +231,9 @@ export const createSettingsHelpers = (dependencies) => {
...new Set(candidate.workStatusHiddenSections.filter((entry) => typeof entry === 'string' && entry.length > 0)),
];
}
if (typeof candidate.workStatusHiddenSectionsExplicit === 'boolean') {
result.workStatusHiddenSectionsExplicit = candidate.workStatusHiddenSectionsExplicit;
}
if (typeof candidate.desktopLanAccessEnabled === 'boolean') {
result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled;
}
@@ -314,9 +344,6 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) {
result.monoFont = candidate.monoFont;
}
if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
result.markdownDisplayMode = candidate.markdownDisplayMode;
}
if (typeof candidate.githubClientId === 'string') {
const trimmed = candidate.githubClientId.trim();
if (trimmed.length > 0) {
@@ -332,6 +359,45 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
if (typeof candidate.streamingAutoFollowEnabled === 'boolean') {
result.streamingAutoFollowEnabled = candidate.streamingAutoFollowEnabled;
}
if (typeof candidate.codeBlockLineWrap === 'boolean') {
result.codeBlockLineWrap = candidate.codeBlockLineWrap;
}
if (typeof candidate.autoSaveEnabled === 'boolean') {
result.autoSaveEnabled = candidate.autoSaveEnabled;
}
if (typeof candidate.diffWrapLines === 'boolean') {
result.diffWrapLines = candidate.diffWrapLines;
}
if (typeof candidate.persistChatDraft === 'boolean') {
result.persistChatDraft = candidate.persistChatDraft;
}
if (typeof candidate.allowPromptingSubagentSessions === 'boolean') {
result.allowPromptingSubagentSessions = candidate.allowPromptingSubagentSessions;
}
if (typeof candidate.showOpenCodeRestartConfirm === 'boolean') {
result.showOpenCodeRestartConfirm = candidate.showOpenCodeRestartConfirm;
}
if (typeof candidate.sessionTabsEnabled === 'boolean') {
result.sessionTabsEnabled = candidate.sessionTabsEnabled;
}
if (typeof candidate.largeTextPasteBehavior === 'string') {
const mode = candidate.largeTextPasteBehavior.trim();
if (mode === 'ask' || mode === 'attach' || mode === 'inline') {
result.largeTextPasteBehavior = mode;
}
}
if (typeof candidate.fileEditorKeymap === 'string') {
const mode = candidate.fileEditorKeymap.trim();
if (mode === 'default' || mode === 'vim') {
result.fileEditorKeymap = mode;
}
}
if (Array.isArray(candidate.providerOrder)) {
result.providerOrder = normalizeStringArray(candidate.providerOrder);
}
if (typeof candidate.sessionRecapEnabled === 'boolean') {
result.sessionRecapEnabled = candidate.sessionRecapEnabled;
}
@@ -453,11 +519,6 @@ export const createSettingsHelpers = (dependencies) => {
result.managedRemoteTunnelSelectedPresetId = id || undefined;
}
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
if (typography) {
result.typographySizes = typography;
}
if (typeof candidate.defaultModel === 'string') {
const trimmed = candidate.defaultModel.trim();
result.defaultModel = trimmed.length > 0 ? trimmed : undefined;
@@ -527,12 +588,6 @@ export const createSettingsHelpers = (dependencies) => {
result.mobileKeyboardMode = mode;
}
}
if (typeof candidate.toolCallExpansion === 'string') {
const mode = candidate.toolCallExpansion.trim();
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') {
result.toolCallExpansion = mode;
}
}
if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
}
@@ -624,9 +679,6 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.promptNavigatorEnabled === 'boolean') {
result.promptNavigatorEnabled = candidate.promptNavigatorEnabled;
}
if (typeof candidate.expandedEditorToolbar === 'boolean') {
result.expandedEditorToolbar = candidate.expandedEditorToolbar;
}
if (typeof candidate.wideChatLayoutEnabled === 'boolean') {
result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled;
}
@@ -729,11 +781,6 @@ export const createSettingsHelpers = (dependencies) => {
}
}
// Message limit — single setting for fetch / trim / Load More chunk
if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit)));
}
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
if (skillCatalogs) {
result.skillCatalogs = skillCatalogs;
@@ -917,6 +964,16 @@ export const createSettingsHelpers = (dependencies) => {
}
}
// The registry is the last word on what a client may persist: a key the
// code above still names but the registry no longer lists is dropped here,
// so the two cannot drift apart silently (settings-helpers.test.js checks
// the other direction).
for (const key of Object.keys(result)) {
if (!isPersistableSettingsKey(key)) {
delete result[key];
}
}
return result;
};
@@ -927,13 +984,6 @@ export const createSettingsHelpers = (dependencies) => {
? current.securityScopedBookmarks
: [];
const nextTypographySizes = changes.typographySizes
? {
...(current.typographySizes || {}),
...changes.typographySizes
}
: current.typographySizes;
const next = {
...current,
...changes,
@@ -942,7 +992,6 @@ export const createSettingsHelpers = (dependencies) => {
baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0)
)
),
typographySizes: nextTypographySizes
};
return next;
@@ -950,9 +999,12 @@ export const createSettingsHelpers = (dependencies) => {
const formatSettingsResponse = (settings) => {
const sanitized = sanitizeSettingsUpdate(settings);
delete sanitized.managedRemoteTunnelToken;
for (const key of SECRET_SETTINGS_KEYS) {
delete sanitized[key];
}
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
const hasManagedRemoteTunnelToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0;
const hasDesktopUiPassword = typeof settings?.desktopUiPassword === 'string' && settings.desktopUiPassword.trim().length > 0;
const pwaAppName = normalizePwaAppName(settings?.pwaAppName, '');
const pwaOrientation = normalizePwaOrientation(settings?.pwaOrientation, 'system');
const mobileKeyboardMode = normalizeMobileKeyboardMode(settings?.mobileKeyboardMode, 'native');
@@ -962,6 +1014,7 @@ export const createSettingsHelpers = (dependencies) => {
return {
...sanitized,
hasManagedRemoteTunnelToken,
hasDesktopUiPassword,
// Tells the client whether agent memory exists in this build at all, so
// its settings row and panel tab can be absent rather than merely off.
agentMemoryFeatureAvailable: isAgentMemoryFeatureAvailable(),
@@ -972,7 +1025,6 @@ export const createSettingsHelpers = (dependencies) => {
inputHistoryLimit,
securityScopedBookmarks: bookmarks,
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
...(process.env.OPENCHAMBER_RUNTIME === 'desktop'
? {
desktopLanAccessActive: process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE === 'true',
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -69,6 +69,19 @@ const createTestHelpersWithRealSanitizers = () => {
};
describe('settings helpers', () => {
it('round-trips telemetry opt-in with the hidden list and preserves it across unrelated writes', () => {
const helpers = createTestHelpers();
const legacy = helpers.sanitizeSettingsUpdate({ workStatusHiddenSections: [] });
expect(legacy.workStatusHiddenSectionsExplicit).toBeUndefined();
const changes = helpers.sanitizeSettingsUpdate({ workStatusHiddenSections: [], workStatusHiddenSectionsExplicit: true });
const saved = helpers.mergePersistedSettings(legacy, changes);
const reloaded = helpers.formatSettingsResponse(JSON.parse(JSON.stringify(saved)));
expect(reloaded.workStatusHiddenSections).toEqual([]);
expect(reloaded.workStatusHiddenSectionsExplicit).toBe(true);
const next = helpers.mergePersistedSettings(reloaded, helpers.sanitizeSettingsUpdate({ workStatusPanelEnabled: false }));
expect(helpers.formatSettingsResponse(next).workStatusHiddenSectionsExplicit).toBe(true);
expect(helpers.sanitizeSettingsUpdate({ workStatusHiddenSectionsExplicit: 'true' }).workStatusHiddenSectionsExplicit).toBeUndefined();
});
it('imports from the packed @openchamber/web tarball without escaping the published package', async () => {
const tempRoot = mkdtempSync(join(tmpdir(), 'settings-helpers-pack-'));
const packDir = join(tempRoot, 'pack');
@@ -751,3 +764,155 @@ describe('settings helpers', () => {
});
});
});
describe('settings registry gate', () => {
const registryPath = join(dirname(testFilePath), 'settings-registry.json');
const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
const persistableKeys = Object.entries(registry.fields)
.filter(([, field]) => !field.computed && !field.local && field.owner !== 'desktop-shell')
.map(([key]) => key);
// One valid value per persistable registry key. The test below fails when a
// key is added to the registry without a line here, and when the sanitizer
// stops accepting a key the registry still lists — that is the drift the
// registry exists to end.
const validValues = {
themeId: 'openchamber-dark', useSystemTheme: true, themeVariant: 'dark', lightThemeId: 'openchamber-light', darkThemeId: 'openchamber-dark',
splashBgLight: '#fff', splashFgLight: '#000', splashBgDark: '#000', splashFgDark: '#fff',
lastDirectory: '/home/testuser/project', homeDirectory: '/home/testuser', opencodeBinary: '/usr/local/bin/opencode',
projects: [{ id: 'p', path: '/home/testuser/project' }], activeProjectId: 'p',
securityScopedBookmarks: ['bookmark'], pinnedDirectories: ['/home/testuser/project'],
desktopLanAccessEnabled: true, desktopKeepAwakeEnabled: true, desktopMinimizeToTrayEnabled: true, desktopMacMenuBarEnabled: true,
desktopUiPassword: 'secret', githubClientId: 'client', githubScopes: 'repo', skillCatalogs: [{ id: 'c', label: 'C', source: 'https://x' }],
defaultGitIdentityId: 'global', permissionAutoAccept: { sessions: { s: true }, revision: 1 },
agentControlToolEnabled: true, agentWebToolEnabled: true, agentMemoryToolEnabled: true, openCodeUpdateToastDismissedVersion: '1.0.0',
autoDeleteEnabled: true, autoDeleteAfterDays: 30, sessionRetentionAction: 'archive', terminalShell: 'zsh', terminalLoginShells: ['zsh'],
openInAppId: 'vscode', dictationEnabled: true, sttProvider: 'local', sttServerUrl: 'http://localhost:8001/v1', sttModel: 'm', sttLocalModel: 'm', sttLanguage: 'en',
tunnelProvider: 'cloudflare', tunnelMode: 'quick', tunnelBootstrapTtlMs: 600000, tunnelSessionTtlMs: 86400000, managedLocalTunnelConfigPath: '/tmp/x',
managedRemoteTunnelHostname: 'x.example', managedRemoteTunnelToken: 'token', managedRemoteTunnelPresets: [{ id: 'a', name: 'A', hostname: 'a.example' }],
managedRemoteTunnelSelectedPresetId: 'a', managedRemoteTunnelPresetTokens: { a: 'token' },
sidebarProjectDisplayMode: 'all', sidebarSessionGroupingMode: 'flat', sidebarProjectSortOrder: 'manual', sidebarShowRecentSection: true,
workStatusPanelEnabled: true, workStatusHiddenSections: ['mcp'], workStatusHiddenSectionsExplicit: true,
showReasoningTraces: true, streamingAutoFollowEnabled: true, collapsibleThinkingBlocks: true, showTextJustificationActivity: true,
chatRenderMode: 'live', activityRenderMode: 'summary', mermaidRenderingMode: 'svg', userMessageRenderingMode: 'markdown', collapsibleUserMessages: true,
stickyUserHeader: true, promptNavigatorEnabled: true, wideChatLayoutEnabled: true, showSplitAssistantMessageActions: true, showToolFileIcons: true,
codeBlockLineWrap: true, showTurnChangedFiles: true, showExpandedBashTools: true, showExpandedEditTools: true, toolJsonViewMode: 'raw',
timeFormatPreference: '24h', weekStartPreference: 'monday', messageStreamTransport: 'ws', diffLayoutPreference: 'inline', diffWrapLines: true,
gitChangesViewMode: 'tree', gitmojiEnabled: true, defaultFileViewerPreview: true, directoryShowHidden: true, filesViewShowGitignored: true,
fileEditorKeymap: 'vim', autoSaveEnabled: true, autoCreateWorktree: true, sessionTabsEnabled: true, showOpenCodeRestartConfirm: true,
allowPromptingSubagentSessions: true, inputSpellcheckEnabled: true, enterToSend: true, enterToSendConfigured: true, persistChatDraft: true,
largeTextPasteBehavior: 'attach', followUpBehavior: 'steer', queueModeEnabled: true, inputHistoryScope: 'global', inputHistoryLimit: 40,
draftStarters: [{ type: 'command', name: 'plan-feature' }], draftStartersVisible: true, draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true,
fontSize: 100, terminalFontSize: 14, editorFontSize: 14, uiFont: 'inter', monoFont: 'jetbrains-mono', padding: 100, cornerRadius: 8,
shortcutOverrides: { 'chat.send': 'mod+enter' },
defaultModel: 'anthropic/claude', defaultVariant: 'high', defaultAgent: 'build', smallModelUseDefault: false, smallModelOverride: 'anthropic/haiku',
walkthroughModelOverride: 'anthropic/claude', zenModel: 'zen/model',
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude' }], hiddenModels: [{ providerID: 'openai', modelID: 'gpt' }], collapsedModelProviders: ['openai'],
recentModels: [{ providerID: 'anthropic', modelID: 'claude' }], recentAgents: ['build'], recentEfforts: { 'anthropic/claude': ['high'] }, providerOrder: ['anthropic'],
sessionRecapEnabled: true, sessionSuggestionEnabled: true, sessionGoalEnabled: true, sessionGoalDefaultBudgetEnabled: true, sessionGoalDefaultBudget: 5,
summarizeLastMessage: true, summaryThreshold: 100, summaryLength: 50, maxLastMessageLength: 200, showDeletionDialog: true,
nativeNotificationsEnabled: true, notificationMode: 'always', notifyOnSubtasks: true, notifyOnCompletion: true, notifyOnError: true, notifyOnQuestion: true,
notificationTemplates: { completion: { title: 't', message: 'm' } }, showOpenCodeUpdateNotifications: true, reportUsage: true,
usageDisplayMode: 'usage', usageDropdownProviders: ['anthropic'], usageSelectedModels: { anthropic: ['claude'] }, usageCollapsedFamilies: { anthropic: ['f'] },
usageExpandedFamilies: { anthropic: ['f'] }, usageModelGroups: { anthropic: { customGroups: [{ id: 'g', label: 'G', models: ['claude'], order: 0 }] } },
globalBehaviorPrompt: 'Be brief.', responseStyleEnabled: true, responseStylePreset: 'concise', responseStyleCustomInstructions: 'x', optimizeSystemPrompt: true,
pwaAppName: 'OpenChamber', pwaOrientation: 'portrait', mobileKeyboardMode: 'native', desktopWindowControlsPosition: 'left', desktopWindowControlsStyle: 'classic',
inputBarOffset: 10,
};
it('accepts a valid value for every persistable registry key (no server-side drift)', () => {
// The shared test helpers stub the injected list sanitizers to `undefined`
// (they are covered by their own suites); here they must pass values through
// so a key is judged by the sanitizer's own branch, not by a stub.
const helpers = createSettingsHelpers({
normalizePathForPersistence: (value) => value,
normalizeDirectoryPath: (value) => value,
normalizeTunnelBootstrapTtlMs: (value) => value,
normalizeTunnelSessionTtlMs: (value) => value,
normalizeTunnelProvider: (value) => value,
normalizeTunnelMode: (value) => value,
normalizeOptionalPath: (value) => value,
normalizeManagedRemoteTunnelHostname: (value) => value,
normalizeManagedRemoteTunnelPresets: (value) => value,
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
normalizeStringArray: (input) => input,
sanitizeModelRefs: (value) => value,
sanitizeSkillCatalogs: (value) => value,
sanitizeProjects: (value) => value,
});
const missingFixture = persistableKeys.filter((key) => !(key in validValues));
expect(missingFixture).toEqual([]);
const rejected = persistableKeys.filter((key) => {
const result = helpers.sanitizeSettingsUpdate({ [key]: validValues[key] });
// `queueModeEnabled` is absorbed into `followUpBehavior` on purpose.
const landedAs = key === 'queueModeEnabled' ? 'followUpBehavior' : key;
return result[landedAs] === undefined;
});
expect(rejected).toEqual([]);
});
it('drops keys the registry does not list, computed flags, and desktop-shell-owned keys', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({
markdownDisplayMode: 'raw',
toolCallExpansion: 'collapsed',
expandedEditorToolbar: true,
typographySizes: { base: 14 },
gitProviderId: 'anthropic',
gitModelId: 'claude',
messageLimit: 200,
agentMemoryFeatureAvailable: true,
desktopHosts: [],
notARealKey: 1,
})).toEqual({});
});
it('never returns secret keys from a formatted response', () => {
const helpers = createTestHelpers();
const secretKeys = Object.entries(registry.fields).filter(([, field]) => field.secret).map(([key]) => key);
expect(secretKeys).toContain('managedRemoteTunnelToken');
expect(secretKeys).toContain('desktopUiPassword');
expect(secretKeys).toContain('managedRemoteTunnelPresetTokens');
const response = helpers.formatSettingsResponse({
managedRemoteTunnelToken: 'token',
desktopUiPassword: 'pw',
managedRemoteTunnelPresetTokens: { a: 'tok' },
themeId: 'x',
});
for (const key of secretKeys) {
expect(response).not.toHaveProperty(key);
}
expect(response.hasManagedRemoteTunnelToken).toBe(true);
expect(response.hasDesktopUiPassword).toBe(true);
expect(helpers.formatSettingsResponse({ desktopUiPassword: '' }).hasDesktopUiPassword).toBe(false);
});
it('accepts the newly shared profile fields', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({
providerOrder: ['b', 'a', 'a'],
diffWrapLines: true,
persistChatDraft: false,
largeTextPasteBehavior: 'inline',
fileEditorKeymap: 'vim',
allowPromptingSubagentSessions: true,
showOpenCodeRestartConfirm: false,
codeBlockLineWrap: true,
streamingAutoFollowEnabled: false,
autoSaveEnabled: false,
})).toEqual({
providerOrder: ['b', 'a'],
diffWrapLines: true,
persistChatDraft: false,
largeTextPasteBehavior: 'inline',
fileEditorKeymap: 'vim',
allowPromptingSubagentSessions: true,
showOpenCodeRestartConfirm: false,
codeBlockLineWrap: true,
streamingAutoFollowEnabled: false,
autoSaveEnabled: false,
});
expect(helpers.sanitizeSettingsUpdate({ largeTextPasteBehavior: 'maybe', fileEditorKeymap: 'emacs' })).toEqual({});
});
});
@@ -0,0 +1,747 @@
{
"version": 1,
"fields": {
"themeId": {
"scope": "profile",
"perSurface": true
},
"useSystemTheme": {
"scope": "profile",
"perSurface": true
},
"themeVariant": {
"scope": "profile",
"derived": true
},
"lightThemeId": {
"scope": "profile",
"perSurface": true
},
"darkThemeId": {
"scope": "profile",
"perSurface": true
},
"lastDirectory": {
"scope": "instance",
"adopt": "bootstrap-only"
},
"homeDirectory": {
"scope": "instance"
},
"opencodeBinary": {
"scope": "instance"
},
"projects": {
"scope": "instance"
},
"activeProjectId": {
"scope": "instance",
"adopt": "bootstrap-only"
},
"securityScopedBookmarks": {
"scope": "instance",
"surfaces": [
"desktop"
]
},
"pinnedDirectories": {
"scope": "instance"
},
"desktopLanAccessEnabled": {
"scope": "instance",
"surfaces": [
"desktop"
]
},
"desktopKeepAwakeEnabled": {
"scope": "instance",
"surfaces": [
"desktop"
]
},
"desktopMinimizeToTrayEnabled": {
"scope": "instance",
"surfaces": [
"desktop"
]
},
"desktopMacMenuBarEnabled": {
"scope": "instance",
"surfaces": [
"desktop"
]
},
"desktopUiPassword": {
"scope": "instance",
"surfaces": [
"desktop"
],
"secret": true
},
"hasDesktopUiPassword": {
"scope": "instance",
"surfaces": [
"desktop"
],
"computed": true
},
"desktopLanAccessActive": {
"scope": "instance",
"surfaces": [
"desktop"
],
"computed": true
},
"desktopLanAccessBlockedReason": {
"scope": "instance",
"surfaces": [
"desktop"
],
"computed": true
},
"githubClientId": {
"scope": "instance"
},
"githubScopes": {
"scope": "instance"
},
"skillCatalogs": {
"scope": "instance"
},
"defaultGitIdentityId": {
"scope": "instance"
},
"permissionAutoAccept": {
"scope": "instance"
},
"agentControlToolEnabled": {
"scope": "instance"
},
"agentWebToolEnabled": {
"scope": "instance"
},
"agentMemoryToolEnabled": {
"scope": "instance"
},
"agentMemoryFeatureAvailable": {
"scope": "instance",
"computed": true
},
"openCodeUpdateToastDismissedVersion": {
"scope": "instance"
},
"autoDeleteEnabled": {
"scope": "instance"
},
"autoDeleteAfterDays": {
"scope": "instance"
},
"sessionRetentionAction": {
"scope": "instance"
},
"terminalShell": {
"scope": "instance"
},
"terminalLoginShells": {
"scope": "instance"
},
"openInAppId": {
"scope": "instance"
},
"dictationEnabled": {
"scope": "profile"
},
"sttProvider": {
"scope": "instance"
},
"sttServerUrl": {
"scope": "instance"
},
"sttModel": {
"scope": "instance"
},
"sttLocalModel": {
"scope": "instance"
},
"sttLanguage": {
"scope": "profile"
},
"tunnelProvider": {
"scope": "instance"
},
"tunnelMode": {
"scope": "instance"
},
"tunnelBootstrapTtlMs": {
"scope": "instance"
},
"tunnelSessionTtlMs": {
"scope": "instance"
},
"managedLocalTunnelConfigPath": {
"scope": "instance"
},
"managedRemoteTunnelHostname": {
"scope": "instance"
},
"managedRemoteTunnelToken": {
"scope": "instance",
"secret": true
},
"hasManagedRemoteTunnelToken": {
"scope": "instance",
"computed": true
},
"managedRemoteTunnelPresets": {
"scope": "instance"
},
"managedRemoteTunnelSelectedPresetId": {
"scope": "instance"
},
"managedRemoteTunnelPresetTokens": {
"scope": "instance",
"secret": true
},
"sidebarProjectDisplayMode": {
"scope": "profile"
},
"sidebarSessionGroupingMode": {
"scope": "profile"
},
"sidebarProjectSortOrder": {
"scope": "profile"
},
"sidebarShowRecentSection": {
"scope": "profile"
},
"workStatusPanelEnabled": {
"scope": "profile"
},
"workStatusHiddenSections": {
"scope": "profile"
},
"workStatusHiddenSectionsExplicit": {
"scope": "profile"
},
"showReasoningTraces": {
"scope": "profile"
},
"streamingAutoFollowEnabled": {
"scope": "profile",
"perSurface": true
},
"collapsibleThinkingBlocks": {
"scope": "profile"
},
"showTextJustificationActivity": {
"scope": "profile"
},
"chatRenderMode": {
"scope": "profile"
},
"activityRenderMode": {
"scope": "profile"
},
"mermaidRenderingMode": {
"scope": "profile"
},
"userMessageRenderingMode": {
"scope": "profile"
},
"collapsibleUserMessages": {
"scope": "profile"
},
"stickyUserHeader": {
"scope": "profile",
"perSurface": true
},
"promptNavigatorEnabled": {
"scope": "profile",
"perSurface": true
},
"wideChatLayoutEnabled": {
"scope": "profile",
"perSurface": true
},
"showSplitAssistantMessageActions": {
"scope": "profile"
},
"showToolFileIcons": {
"scope": "profile"
},
"codeBlockLineWrap": {
"scope": "profile"
},
"showTurnChangedFiles": {
"scope": "profile"
},
"showExpandedBashTools": {
"scope": "profile"
},
"showExpandedEditTools": {
"scope": "profile"
},
"toolJsonViewMode": {
"scope": "profile"
},
"timeFormatPreference": {
"scope": "profile"
},
"weekStartPreference": {
"scope": "profile"
},
"messageStreamTransport": {
"scope": "profile"
},
"diffLayoutPreference": {
"scope": "profile"
},
"diffWrapLines": {
"scope": "profile"
},
"gitChangesViewMode": {
"scope": "profile"
},
"gitmojiEnabled": {
"scope": "profile"
},
"defaultFileViewerPreview": {
"scope": "profile"
},
"directoryShowHidden": {
"scope": "profile"
},
"filesViewShowGitignored": {
"scope": "profile"
},
"fileEditorKeymap": {
"scope": "profile"
},
"autoSaveEnabled": {
"scope": "profile"
},
"autoCreateWorktree": {
"scope": "profile"
},
"sessionTabsEnabled": {
"scope": "profile",
"surfaces": [
"web",
"desktop",
"vscode"
]
},
"showOpenCodeRestartConfirm": {
"scope": "profile"
},
"allowPromptingSubagentSessions": {
"scope": "profile"
},
"inputSpellcheckEnabled": {
"scope": "profile"
},
"enterToSend": {
"scope": "profile"
},
"enterToSendConfigured": {
"scope": "profile"
},
"persistChatDraft": {
"scope": "profile"
},
"largeTextPasteBehavior": {
"scope": "profile"
},
"followUpBehavior": {
"scope": "profile"
},
"queueModeEnabled": {
"scope": "profile"
},
"inputHistoryScope": {
"scope": "profile"
},
"inputHistoryLimit": {
"scope": "profile"
},
"draftStarters": {
"scope": "profile"
},
"draftStartersVisible": {
"scope": "profile"
},
"draftStartersCraftGoalAdded": {
"scope": "profile"
},
"draftStartersScheduleTaskAdded": {
"scope": "profile"
},
"fontSize": {
"scope": "profile",
"perSurface": true
},
"terminalFontSize": {
"scope": "profile",
"perSurface": true
},
"editorFontSize": {
"scope": "profile",
"perSurface": true
},
"uiFont": {
"scope": "profile"
},
"monoFont": {
"scope": "profile"
},
"padding": {
"scope": "profile",
"perSurface": true
},
"cornerRadius": {
"scope": "profile",
"perSurface": true
},
"shortcutOverrides": {
"scope": "profile"
},
"defaultModel": {
"scope": "profile"
},
"defaultVariant": {
"scope": "profile"
},
"defaultAgent": {
"scope": "profile"
},
"smallModelUseDefault": {
"scope": "profile"
},
"smallModelOverride": {
"scope": "profile"
},
"walkthroughModelOverride": {
"scope": "profile"
},
"zenModel": {
"scope": "profile"
},
"favoriteModels": {
"scope": "profile"
},
"hiddenModels": {
"scope": "profile"
},
"collapsedModelProviders": {
"scope": "profile"
},
"recentModels": {
"scope": "profile"
},
"recentAgents": {
"scope": "profile"
},
"recentEfforts": {
"scope": "profile"
},
"providerOrder": {
"scope": "profile"
},
"sessionRecapEnabled": {
"scope": "profile"
},
"sessionSuggestionEnabled": {
"scope": "profile"
},
"sessionGoalEnabled": {
"scope": "profile"
},
"sessionGoalDefaultBudgetEnabled": {
"scope": "profile"
},
"sessionGoalDefaultBudget": {
"scope": "profile"
},
"summarizeLastMessage": {
"scope": "profile"
},
"summaryThreshold": {
"scope": "profile"
},
"summaryLength": {
"scope": "profile"
},
"maxLastMessageLength": {
"scope": "profile"
},
"showDeletionDialog": {
"scope": "profile"
},
"nativeNotificationsEnabled": {
"scope": "profile"
},
"notificationMode": {
"scope": "profile"
},
"notifyOnSubtasks": {
"scope": "profile"
},
"notifyOnCompletion": {
"scope": "profile"
},
"notifyOnError": {
"scope": "profile"
},
"notifyOnQuestion": {
"scope": "profile"
},
"notificationTemplates": {
"scope": "profile"
},
"showOpenCodeUpdateNotifications": {
"scope": "profile"
},
"reportUsage": {
"scope": "profile"
},
"usageDisplayMode": {
"scope": "profile"
},
"usageDropdownProviders": {
"scope": "profile"
},
"usageSelectedModels": {
"scope": "profile"
},
"usageCollapsedFamilies": {
"scope": "profile"
},
"usageExpandedFamilies": {
"scope": "profile"
},
"usageModelGroups": {
"scope": "profile"
},
"globalBehaviorPrompt": {
"scope": "profile"
},
"responseStyleEnabled": {
"scope": "profile"
},
"responseStylePreset": {
"scope": "profile"
},
"responseStyleCustomInstructions": {
"scope": "profile"
},
"optimizeSystemPrompt": {
"scope": "profile"
},
"pwaAppName": {
"scope": "instance",
"surfaces": [
"web"
]
},
"pwaOrientation": {
"scope": "instance",
"surfaces": [
"web"
]
},
"mobileKeyboardMode": {
"scope": "device",
"surfaces": [
"mobile"
]
},
"desktopWindowControlsPosition": {
"scope": "device",
"surfaces": [
"desktop"
]
},
"desktopWindowControlsStyle": {
"scope": "device",
"surfaces": [
"desktop"
]
},
"inputBarOffset": {
"scope": "device",
"surfaces": [
"mobile",
"web"
]
},
"theme": {
"scope": "device",
"local": true
},
"isSidebarOpen": {
"scope": "device",
"local": true
},
"sidebarWidth": {
"scope": "device",
"local": true
},
"contextPanelByDirectory": {
"scope": "device",
"local": true
},
"contextRailOrder": {
"scope": "device",
"local": true
},
"contextRailHiddenSurfaces": {
"scope": "device",
"local": true
},
"contextEditorTreeVisible": {
"scope": "device",
"local": true
},
"contextEditorTreeWidth": {
"scope": "device",
"local": true
},
"notesPanelHeight": {
"scope": "device",
"local": true
},
"workStatusExpandedSections": {
"scope": "device",
"local": true
},
"workStatusScrollTop": {
"scope": "device",
"local": true
},
"isSessionSwitcherOpen": {
"scope": "device",
"local": true
},
"sidebarSection": {
"scope": "device",
"local": true
},
"settingsPage": {
"scope": "device",
"local": true
},
"settingsHasOpenedOnce": {
"scope": "device",
"local": true
},
"settingsProjectsSelectedId": {
"scope": "device",
"local": true
},
"settingsRemoteInstancesSelectedId": {
"scope": "device",
"local": true
},
"isSessionCreateDialogOpen": {
"scope": "device",
"local": true
},
"autoDeleteLastRunAt": {
"scope": "device",
"local": true
},
"messageLimit": {
"scope": "device",
"local": true
},
"walkthroughTocWidth": {
"scope": "device",
"local": true
},
"linearIssueListStatus": {
"scope": "device",
"local": true
},
"linearIssueListAssignee": {
"scope": "device",
"local": true
},
"linearIssueListTeamIdByRuntime": {
"scope": "device",
"local": true
},
"linearIssueListPriority": {
"scope": "device",
"local": true
},
"showTerminalQuickKeysOnDesktop": {
"scope": "device",
"local": true
},
"dockBadgeEnabled": {
"scope": "device",
"local": true
},
"alwaysShowScrollbars": {
"scope": "device",
"local": true
},
"agentMemoryViewedAt": {
"scope": "device",
"local": true
},
"projectContextSidebarWidth": {
"scope": "device",
"local": true
},
"desktopSplashColors": {
"scope": "instance",
"owner": "desktop-shell",
"surfaces": [
"desktop"
]
},
"desktopHosts": {
"scope": "instance",
"owner": "desktop-shell",
"surfaces": [
"desktop"
]
},
"desktopDefaultHostId": {
"scope": "instance",
"owner": "desktop-shell",
"surfaces": [
"desktop"
]
},
"desktopInstallId": {
"scope": "instance",
"owner": "desktop-shell",
"surfaces": [
"desktop"
]
},
"desktopLocalPort": {
"scope": "instance",
"owner": "desktop-shell",
"surfaces": [
"desktop"
]
},
"desktopSshInstances": {
"scope": "instance",
"owner": "desktop-shell",
"surfaces": [
"desktop"
]
},
"desktopWindowState": {
"scope": "instance",
"owner": "desktop-shell",
"surfaces": [
"desktop"
]
}
}
}
@@ -1,4 +1,18 @@
import { createProjectIdFromPath } from '../projects/project-id.js';
import {
buildPreferencesFields,
flattenPreferences,
instancePartOf,
legacySettingsDocumentOf,
profilePartOf,
isDeviceSettingsKey,
isProfileSettingsKey,
normalizeSettingsSurface,
parsePreferencesDocument,
preferencesFilePathFor,
seedPreferencesFrom,
serializePreferencesDocument,
} from './settings-files.js';
const DEFAULT_NOTIFICATION_TEMPLATES = {
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
@@ -48,6 +62,13 @@ export const createSettingsRuntime = (deps) => {
let persistSettingsLock = Promise.resolve();
const PREFERENCES_FILE_PATH = preferencesFilePathFor(SETTINGS_FILE_PATH, path);
// True while preferences.json exists but cannot be read. Profile writes are
// refused meanwhile so a corrupt file is never overwritten with a seed or a
// partial document; clients keep the values they hold.
let preferencesUnavailable = false;
let preferencesFailureLogged = false;
// Orphan recovery is a one-shot best-effort scan: when orphans can't be
// matched on first pass they stay on disk and every subsequent settings
// read would re-scan them. In-process (Electron) this runs in the main
@@ -472,7 +493,7 @@ export const createSettingsRuntime = (deps) => {
}
};
const readSettingsFromDisk = async () => {
const readInstanceSettingsFromDisk = async () => {
try {
const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
const parsed = JSON.parse(raw);
@@ -489,6 +510,67 @@ export const createSettingsRuntime = (deps) => {
}
};
/**
* `{ status: 'missing' }` when the file does not exist, `{ status: 'ok',
* fields }` when it parsed, `{ status: 'failed' }` for anything else. Only
* "missing" may be seeded; "failed" must leave the file alone.
*/
const readPreferenceFields = async () => {
let raw;
try {
raw = await fsPromises.readFile(PREFERENCES_FILE_PATH, 'utf8');
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return { status: 'missing' };
}
if (!preferencesFailureLogged) {
preferencesFailureLogged = true;
console.warn('Failed to read preferences file:', error);
}
return { status: 'failed' };
}
const parsed = parsePreferencesDocument(raw);
if (!parsed.ok) {
if (!preferencesFailureLogged) {
preferencesFailureLogged = true;
console.warn(`Preferences file is unreadable (${parsed.reason}); profile writes are paused until it is fixed or removed.`);
}
return { status: 'failed' };
}
preferencesFailureLogged = false;
return { status: 'ok', fields: parsed.fields };
};
const writePreferencesToDisk = async (fields) => {
await writeJsonFileAtomic(PREFERENCES_FILE_PATH, serializePreferencesDocument(fields));
};
// The merged document every consumer sees: instance facts from settings.json
// plus the profile from preferences.json. On the first read of an install
// that predates the split, the profile keys still sitting in settings.json
// seed preferences.json. settings.json keeps a copy of the profile's base
// values on every write too, so an older build (which reads only that file)
// still finds everything where it used to be.
const readSettingsFromDisk = async ({ surface = null } = {}) => {
const instance = await readInstanceSettingsFromDisk();
const preferences = await readPreferenceFields();
if (preferences.status === 'failed') {
preferencesUnavailable = true;
return instance;
}
preferencesUnavailable = false;
if (preferences.status === 'missing') {
const seeded = seedPreferencesFrom(instance, Date.now());
try {
await writePreferencesToDisk(seeded);
} catch (error) {
console.warn('Failed to seed preferences file:', error);
}
return instance;
}
return { ...instance, ...flattenPreferences(preferences.fields, normalizeSettingsSurface(surface)) };
};
// Strict variant for callers that REGENERATE persisted identity when a key is
// absent (relay signing/encryption keys). The lenient reader above maps every
// failure — corrupt JSON, EACCES, transient I/O — to `{}`, which such callers
@@ -558,7 +640,7 @@ export const createSettingsRuntime = (deps) => {
try {
const entries = await fsPromises.readdir(directory, { withFileTypes: true });
const cleanupTasks = entries
.filter((entry) => entry.isFile() && entry.name.startsWith('settings.json.tmp-'))
.filter((entry) => entry.isFile() && (entry.name.startsWith('settings.json.tmp-') || entry.name.startsWith('preferences.json.tmp-')))
.map((entry) => fsPromises.rm(path.join(directory, entry.name), { force: true }).catch(() => {}));
await Promise.all(cleanupTasks);
} catch {
@@ -566,27 +648,54 @@ export const createSettingsRuntime = (deps) => {
}
};
const writeSettingsToDisk = async (settings) => {
const settingsDirectory = path.dirname(SETTINGS_FILE_PATH);
await fsPromises.mkdir(settingsDirectory, { recursive: true, mode: 0o700 });
if (process.platform !== 'win32') await fsPromises.chmod(settingsDirectory, 0o700);
// Atomic write: Electron main and ssh-manager read this file via plain
const writeJsonFileAtomic = async (filePath, text) => {
const directory = path.dirname(filePath);
await fsPromises.mkdir(directory, { recursive: true, mode: 0o700 });
if (process.platform !== 'win32') await fsPromises.chmod(directory, 0o700);
// Atomic write: Electron main and ssh-manager read these files via plain
// readFile + JSON.parse and silently coerce parse errors to {}. A
// partial read during a non-atomic writeFile would make their next
// read-modify-write wipe the settings file.
const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// read-modify-write wipe the file.
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), { encoding: 'utf8', mode: 0o600 });
await fsPromises.writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
if (process.platform !== 'win32') await fsPromises.chmod(tmp, 0o600);
await replaceFile(tmp, SETTINGS_FILE_PATH);
if (process.platform !== 'win32') await fsPromises.chmod(SETTINGS_FILE_PATH, 0o600);
await replaceFile(tmp, filePath);
if (process.platform !== 'win32') await fsPromises.chmod(filePath, 0o600);
} catch (error) {
await fsPromises.rm(tmp, { force: true }).catch(() => {});
console.warn('Failed to write settings file:', error);
console.warn(`Failed to write ${path.basename(filePath)}:`, error);
throw error;
}
};
/**
* Persist a merged document: profile keys go to preferences.json (stamped
* when their value changed), everything else to settings.json. While
* preferences.json is unreadable its part is skipped rather than replaced.
*/
const writeSettingsToDisk = async (settings, { surface = null, changedKeys = null } = {}) => {
const current = preferencesUnavailable ? { status: 'failed' } : await readPreferenceFields();
if (current.status === 'failed') {
// The profile part is not saved; settings.json keeps whatever legacy
// profile copy it already holds rather than losing it too.
preferencesUnavailable = true;
const onDisk = await readInstanceSettingsFromDisk();
await writeJsonFileAtomic(SETTINGS_FILE_PATH, JSON.stringify({
...instancePartOf(settings),
...profilePartOf(onDisk),
}, null, 2));
return;
}
const previousFields = current.status === 'ok' ? current.fields : {};
const nextFields = buildPreferencesFields(previousFields, settings, Date.now(), {
surface: normalizeSettingsSurface(surface),
changedKeys,
});
await writeJsonFileAtomic(SETTINGS_FILE_PATH, JSON.stringify(legacySettingsDocumentOf(settings, nextFields), null, 2));
await writePreferencesToDisk(nextFields);
};
const validateProjectEntries = async (projects) => {
if (!Array.isArray(projects)) {
return [];
@@ -872,7 +981,7 @@ export const createSettingsRuntime = (deps) => {
let hasCleanedOrphanedTempFiles = false;
const readSettingsFromDiskMigrated = async () => {
const readSettingsFromDiskMigrated = async ({ surface = null } = {}) => {
if (!hasCleanedOrphanedTempFiles) {
hasCleanedOrphanedTempFiles = true;
await cleanupOrphanedSettingsTempFiles(path.dirname(SETTINGS_FILE_PATH));
@@ -889,16 +998,28 @@ export const createSettingsRuntime = (deps) => {
if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed || migration7.changed || migration8.changed) {
await writeSettingsToDisk(migration8.settings);
}
return migration8.settings;
// Migrations run on the base view; a surface asks for its own resolution
// of the per-surface keys on top of the migrated files.
return normalizeSettingsSurface(surface) ? readSettingsFromDisk({ surface }) : migration8.settings;
};
const persistSettings = async (changes) => {
const persistSettings = async (changes, { surface = null } = {}) => {
persistSettingsLock = persistSettingsLock.then(async () => {
// Log field names only — changes can carry credentials (UI password,
// client tokens, tunnel tokens) that must never reach the log file.
console.log('[persistSettings] Updating fields:', Object.keys(changes || {}).join(', ') || '(none)');
const current = await readSettingsFromDisk();
const current = await readSettingsFromDisk({ surface });
const sanitized = sanitizeSettingsUpdate(changes);
for (const key of Object.keys(sanitized)) {
// Device state belongs to the install in front of the user, never to
// the instance; a client that still sends it is simply ignored.
if (isDeviceSettingsKey(key)) {
delete sanitized[key];
} else if (preferencesUnavailable && isProfileSettingsKey(key)) {
console.warn(`[persistSettings] Dropping ${key}: preferences file is unreadable`);
delete sanitized[key];
}
}
let next = mergePersistedSettings(current, sanitized);
const normalizedState = normalizeSettingsPaths(next);
@@ -962,7 +1083,7 @@ export const createSettingsRuntime = (deps) => {
}
}
await writeSettingsToDisk(next);
await writeSettingsToDisk(next, { surface, changedKeys: Object.keys(sanitized) });
return formatSettingsResponse(next);
});
@@ -6,7 +6,7 @@ import path from 'path';
import { createProjectIdFromPath } from '../projects/project-id.js';
import { createSettingsRuntime } from './settings-runtime.js';
const createRuntime = async () => {
const createRuntime = async ({ mergePersistedSettings = (_current, changes) => changes } = {}) => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
const settingsFilePath = path.join(tempRoot, 'settings.json');
const runtime = createSettingsRuntime({
@@ -16,7 +16,7 @@ const createRuntime = async () => {
SETTINGS_FILE_PATH: settingsFilePath,
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
sanitizeSettingsUpdate: (settings) => settings,
mergePersistedSettings: (_current, changes) => changes,
mergePersistedSettings,
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
formatSettingsResponse: (settings) => settings,
@@ -68,8 +68,8 @@ describe('settings runtime', () => {
}
});
it('round-trips shared sidebar preferences through settings.json', async () => {
const { runtime, settingsFilePath, cleanup } = await createRuntime();
it('round-trips shared sidebar preferences through preferences.json', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
const preferences = {
sidebarProjectDisplayMode: 'single',
sidebarSessionGroupingMode: 'flat',
@@ -80,7 +80,10 @@ describe('settings runtime', () => {
await runtime.persistSettings(preferences);
await expect(runtime.readSettingsFromDisk()).resolves.toEqual(preferences);
await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify(preferences, null, 2));
// Profile keys live in preferences.json; settings.json keeps a legacy copy for older builds.
expect(JSON.parse(await fsPromises.readFile(settingsFilePath, 'utf8'))).toEqual(preferences);
const stored = JSON.parse(await fsPromises.readFile(path.join(tempRoot, 'preferences.json'), 'utf8'));
expect(Object.fromEntries(Object.entries(stored.fields).map(([key, entry]) => [key, entry.value]))).toEqual(preferences);
} finally {
await cleanup();
}
@@ -248,3 +251,158 @@ describe('settings runtime', () => {
}
});
});
describe('settings runtime: preferences.json split', () => {
const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
it('seeds preferences.json from the profile keys of an existing settings.json and leaves that file intact', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
const legacy = { projects: [], fontSize: 110, themeId: 'openchamber-dark', desktopLanAccessEnabled: true };
await fsPromises.writeFile(settingsFilePath, JSON.stringify(legacy));
const merged = await runtime.readSettingsFromDisk();
expect(merged).toMatchObject(legacy);
const preferences = await readJson(path.join(tempRoot, 'preferences.json'));
expect(preferences.version).toBe(1);
expect(Object.keys(preferences.fields).sort()).toEqual(['fontSize', 'themeId']);
expect(preferences.fields.fontSize.value).toBe(110);
expect(typeof preferences.fields.fontSize.updatedAt).toBe('number');
expect(await readJson(settingsFilePath)).toEqual(legacy);
} finally {
await cleanup();
}
});
it('routes profile keys to preferences.json, keeps a legacy copy of them in settings.json, and drops device keys', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
await runtime.persistSettings({ fontSize: 120, desktopLanAccessEnabled: true, mobileKeyboardMode: 'native' });
const settings = await readJson(settingsFilePath);
expect(settings.desktopLanAccessEnabled).toBe(true);
// Older builds read only settings.json: the profile's base values stay there as a copy.
expect(settings.fontSize).toBe(120);
expect(settings).not.toHaveProperty('mobileKeyboardMode');
const preferences = await readJson(path.join(tempRoot, 'preferences.json'));
expect(preferences.fields.fontSize.value).toBe(120);
expect(preferences.fields).not.toHaveProperty('mobileKeyboardMode');
expect(preferences.fields).not.toHaveProperty('desktopLanAccessEnabled');
expect(await runtime.readSettingsFromDisk()).toMatchObject({ fontSize: 120, desktopLanAccessEnabled: true });
} finally {
await cleanup();
}
});
it('keeps the timestamp of an unchanged profile key and restamps a changed one', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const preferencesPath = path.join(tempRoot, 'preferences.json');
await runtime.persistSettings({ fontSize: 100, padding: 100 });
const first = await readJson(preferencesPath);
await new Promise((resolve) => setTimeout(resolve, 5));
await runtime.persistSettings({ fontSize: 100, padding: 120 });
const second = await readJson(preferencesPath);
expect(second.fields.fontSize.updatedAt).toBe(first.fields.fontSize.updatedAt);
expect(second.fields.padding.updatedAt).toBeGreaterThan(first.fields.padding.updatedAt);
expect(second.fields.padding.value).toBe(120);
} finally {
await cleanup();
}
});
it('treats an unreadable preferences.json as failure: no seed, no overwrite, profile writes refused, instance still served', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
const preferencesPath = path.join(tempRoot, 'preferences.json');
await fsPromises.writeFile(settingsFilePath, JSON.stringify({ desktopLanAccessEnabled: true }));
await fsPromises.writeFile(preferencesPath, '{ not json');
expect(await runtime.readSettingsFromDisk()).toEqual({ desktopLanAccessEnabled: true });
await runtime.persistSettings({ fontSize: 130, desktopKeepAwakeEnabled: true });
expect(await fsPromises.readFile(preferencesPath, 'utf8')).toBe('{ not json');
const settings = await readJson(settingsFilePath);
expect(settings.desktopKeepAwakeEnabled).toBe(true);
// The refused profile write must not land in the legacy copy either.
expect(settings).not.toHaveProperty('fontSize');
} finally {
await cleanup();
}
});
});
describe('settings runtime: per-surface profile keys', () => {
const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
// These sequences persist several times; the default stub replaces the
// document with the changes, the real merge keeps the current document.
const createMergingRuntime = () => createRuntime({ mergePersistedSettings: (current, changes) => ({ ...current, ...changes }) });
it('stores a per-surface key under the writing surface and leaves the base alone', async () => {
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
try {
await runtime.persistSettings({ fontSize: 100 }); // base (no surface): migrations and legacy callers
await runtime.persistSettings({ fontSize: 130, showReasoningTraces: false }, { surface: 'mobile' });
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
expect(stored.fields.fontSize.value).toBe(100);
expect(stored.fields.fontSize.surfaces.mobile.value).toBe(130);
// Not per-surface: written to the base regardless of the surface.
expect(stored.fields.showReasoningTraces.value).toBe(false);
expect(stored.fields.showReasoningTraces.surfaces).toBeUndefined();
expect((await runtime.readSettingsFromDisk({ surface: 'mobile' })).fontSize).toBe(130);
expect((await runtime.readSettingsFromDisk({ surface: 'desktop' })).fontSize).toBe(100);
expect((await runtime.readSettingsFromDisk()).fontSize).toBe(100);
expect((await runtime.readSettingsFromDiskMigrated({ surface: 'mobile' })).fontSize).toBe(130);
} finally {
await cleanup();
}
});
it('a per-surface key set only from one surface has no base and stays absent elsewhere', async () => {
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
try {
await runtime.persistSettings({ stickyUserHeader: false }, { surface: 'mobile' });
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
expect(stored.fields.stickyUserHeader).not.toHaveProperty('value');
expect(stored.fields.stickyUserHeader.surfaces.mobile.value).toBe(false);
expect((await runtime.readSettingsFromDisk({ surface: 'desktop' })).stickyUserHeader).toBeUndefined();
expect((await runtime.readSettingsFromDisk({ surface: 'mobile' })).stickyUserHeader).toBe(false);
} finally {
await cleanup();
}
});
it('a surface write of an unrelated key does not copy the resolved per-surface view into the file', async () => {
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
try {
await runtime.persistSettings({ fontSize: 100 });
await runtime.persistSettings({ fontSize: 130 }, { surface: 'mobile' });
await runtime.persistSettings({ showReasoningTraces: true }, { surface: 'mobile' });
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
expect(stored.fields.fontSize.value).toBe(100);
expect(stored.fields.fontSize.surfaces.mobile.value).toBe(130);
expect(stored.fields.fontSize.surfaces.desktop).toBeUndefined();
} finally {
await cleanup();
}
});
it('ignores an unknown surface header value and writes the base', async () => {
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
try {
await runtime.persistSettings({ fontSize: 90 }, { surface: 'toaster' });
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
expect(stored.fields.fontSize.value).toBe(90);
expect(stored.fields.fontSize.surfaces).toBeUndefined();
} finally {
await cleanup();
}
});
});