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:
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user