import { execFileSync } from 'node:child_process'; 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'; import { describe, expect, it } from 'vitest'; import { DEFAULT_INPUT_HISTORY_LIMIT, } from './input-history-scope.js'; import { createSettingsHelpers } from './settings-helpers.js'; import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js'; const testFilePath = fileURLToPath(import.meta.url); const packagesWebDir = join(dirname(testFilePath), '..', '..', '..'); const createTestHelpers = () => 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: () => undefined, normalizeManagedRemoteTunnelPresetTokens: () => undefined, sanitizeTypographySizesPartial: () => undefined, normalizeStringArray: (input) => input, sanitizeModelRefs: () => undefined, sanitizeSkillCatalogs: () => undefined, sanitizeProjects: () => undefined, }); const createTestHelpersWithRealSanitizers = () => { const runtime = createSettingsNormalizationRuntime({ os: { homedir: () => '/home/testuser' }, path: { resolve: (...args) => args[args.length - 1], sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/', }, processLike: { platform: 'linux', env: {} }, realpathSync: (p) => p, tunnelBootstrapTtlDefaultMs: 600000, tunnelBootstrapTtlMinMs: 60000, tunnelBootstrapTtlMaxMs: 3600000, tunnelSessionTtlDefaultMs: 86400000, tunnelSessionTtlMinMs: 3600000, tunnelSessionTtlMaxMs: 604800000, }); return 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: () => undefined, normalizeManagedRemoteTunnelPresetTokens: () => undefined, sanitizeTypographySizesPartial: () => undefined, normalizeStringArray: runtime.normalizeStringArray, sanitizeModelRefs: runtime.sanitizeModelRefs, sanitizeSkillCatalogs: () => undefined, sanitizeProjects: () => undefined, }); }; 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'); const extractDir = join(tempRoot, 'extract'); try { mkdirSync(packDir); mkdirSync(extractDir); execFileSync('npm', ['pack', '--silent', '--pack-destination', packDir], { cwd: packagesWebDir, stdio: 'pipe', }); const tarballName = readdirSync(packDir).find((entry) => entry.endsWith('.tgz')); expect(tarballName).toBeTruthy(); execFileSync('tar', ['-xzf', join(packDir, tarballName), '-C', extractDir], { stdio: 'pipe', }); const extractedModule = await import( pathToFileURL(join(extractDir, 'package', 'server', 'lib', 'opencode', 'settings-helpers.js')).href ); const helpers = extractedModule.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: () => undefined, normalizeManagedRemoteTunnelPresetTokens: () => undefined, sanitizeTypographySizesPartial: () => undefined, normalizeStringArray: (input) => input, sanitizeModelRefs: () => undefined, sanitizeSkillCatalogs: () => undefined, sanitizeProjects: () => undefined, }); expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'global' })).toEqual({ inputHistoryScope: 'global', }); expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'session' })).toEqual({ inputHistoryScope: 'session', }); expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 40 })).toEqual({ inputHistoryLimit: 40, }); expect(helpers.formatSettingsResponse({})).toMatchObject({ inputHistoryScope: 'session', inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT, }); } finally { rmSync(tempRoot, { recursive: true, force: true }); } }); it('accepts only booleans for draft starter visibility', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: true })).toEqual({ draftStartersVisible: true }); expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: false })).toEqual({ draftStartersVisible: false }); expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: 'false' })).toEqual({}); }); it('sanitizes both Enter settings independently', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ enterToSend: true })).toEqual({ enterToSend: true }); expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: false })).toEqual({ enterToSendConfigured: false }); expect(helpers.sanitizeSettingsUpdate({ enterToSend: 'true', enterToSendConfigured: 1 })).toEqual({}); }); it('sanitizes shared sidebar display preferences', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ sidebarProjectDisplayMode: 'single', sidebarSessionGroupingMode: 'flat', sidebarProjectSortOrder: 'z-a', sidebarShowRecentSection: false, })).toEqual({ sidebarProjectDisplayMode: 'single', sidebarSessionGroupingMode: 'flat', sidebarProjectSortOrder: 'z-a', sidebarShowRecentSection: false, }); expect(helpers.sanitizeSettingsUpdate({ sidebarProjectDisplayMode: 'grid', sidebarSessionGroupingMode: 'project', sidebarProjectSortOrder: 'random', sidebarShowRecentSection: 'false', })).toEqual({}); }); it('persists valid tool JSON view modes', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'summary' })).toEqual({ toolJsonViewMode: 'summary' }); expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'formatted' })).toEqual({ toolJsonViewMode: 'formatted' }); expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'raw' })).toEqual({ toolJsonViewMode: 'raw' }); expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'unknown' })).toEqual({}); }); it('accepts only booleans for wide chat layout', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: true })).toEqual({ wideChatLayoutEnabled: true }); expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: false })).toEqual({ wideChatLayoutEnabled: false }); expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: 'true' })).toEqual({}); }); it('accepts only booleans for collapsible user messages', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: true })).toEqual({ collapsibleUserMessages: true }); expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: false })).toEqual({ collapsibleUserMessages: false }); expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: 'true' })).toEqual({}); }); it('sanitizes and returns the persisted editor font size', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ editorFontSize: 20.6 })).toEqual({ editorFontSize: 21 }); expect(helpers.sanitizeSettingsUpdate({ editorFontSize: 8 })).toEqual({ editorFontSize: 9 }); expect(helpers.sanitizeSettingsUpdate({ editorFontSize: 33 })).toEqual({ editorFontSize: 32 }); expect(helpers.sanitizeSettingsUpdate({ editorFontSize: Number.NaN })).toEqual({}); expect(helpers.formatSettingsResponse({ editorFontSize: 20 })).toMatchObject({ editorFontSize: 20 }); }); it('accepts messageStreamTransport as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'ws' })).toEqual({ messageStreamTransport: 'ws', }); expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'sse' })).toEqual({ messageStreamTransport: 'sse', }); expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'auto' })).toEqual({ messageStreamTransport: 'auto', }); }); it('rejects invalid messageStreamTransport values', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'websocket' })).toEqual({}); }); it('accepts inputHistoryScope as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'global' })).toEqual({ inputHistoryScope: 'global', }); expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'session' })).toEqual({ inputHistoryScope: 'session', }); }); it('accepts valid inputHistoryLimit values as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 1 })).toEqual({ inputHistoryLimit: 1, }); expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 40 })).toEqual({ inputHistoryLimit: 40, }); expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 100 })).toEqual({ inputHistoryLimit: 100, }); }); it('rejects invalid inputHistoryLimit values', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 0 })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 101 })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 1.5 })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: '40' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.NaN })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.POSITIVE_INFINITY })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.NEGATIVE_INFINITY })).toEqual({}); }); it('rejects invalid inputHistoryScope values', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'workspace' })).toEqual({}); }); it('defaults inputHistoryScope to session in formatted settings responses', () => { const helpers = createTestHelpers(); expect(helpers.formatSettingsResponse({ inputHistoryScope: 'global' })).toMatchObject({ inputHistoryScope: 'global', }); expect(helpers.formatSettingsResponse({})).toMatchObject({ inputHistoryScope: 'session', }); }); it('defaults missing inputHistoryLimit to 40 in formatted settings responses and preserves valid values', () => { const helpers = createTestHelpers(); expect(helpers.formatSettingsResponse({})).toMatchObject({ inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT, }); expect(helpers.formatSettingsResponse({ inputHistoryLimit: 1 })).toMatchObject({ inputHistoryLimit: 1, }); expect(helpers.formatSettingsResponse({ inputHistoryLimit: 100 })).toMatchObject({ inputHistoryLimit: 100, }); }); it('sanitizes the persisted terminal shell', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ terminalShell: ' ZSH ' })).toEqual({ terminalShell: 'zsh' }); expect(helpers.sanitizeSettingsUpdate({ terminalShell: 'auto' })).toEqual({ terminalShell: 'auto' }); expect(helpers.sanitizeSettingsUpdate({ terminalShell: '/bin/zsh' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ terminalShell: 'zsh -c whoami' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ terminalLoginShells: [' ZSH ', 'bash', 'zsh', '/bin/fish', 42] })).toEqual({ terminalLoginShells: ['zsh', 'bash'], }); expect(helpers.sanitizeSettingsUpdate({ terminalLoginShells: [] })).toEqual({ terminalLoginShells: [] }); }); it('accepts desktopLanAccessEnabled as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ desktopLanAccessEnabled: true })).toEqual({ desktopLanAccessEnabled: true, }); expect(helpers.sanitizeSettingsUpdate({ desktopLanAccessEnabled: false })).toEqual({ desktopLanAccessEnabled: false, }); }); it('accepts desktopKeepAwakeEnabled as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ desktopKeepAwakeEnabled: true })).toEqual({ desktopKeepAwakeEnabled: true, }); expect(helpers.sanitizeSettingsUpdate({ desktopKeepAwakeEnabled: false })).toEqual({ desktopKeepAwakeEnabled: false, }); }); it('accepts desktopMinimizeToTrayEnabled as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ desktopMinimizeToTrayEnabled: true })).toEqual({ desktopMinimizeToTrayEnabled: true, }); expect(helpers.sanitizeSettingsUpdate({ desktopMinimizeToTrayEnabled: false })).toEqual({ desktopMinimizeToTrayEnabled: false, }); }); it('accepts desktopMacMenuBarEnabled as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ desktopMacMenuBarEnabled: true })).toEqual({ desktopMacMenuBarEnabled: true, }); expect(helpers.sanitizeSettingsUpdate({ desktopMacMenuBarEnabled: false })).toEqual({ desktopMacMenuBarEnabled: false, }); expect(helpers.formatSettingsResponse({ desktopMacMenuBarEnabled: false })).toMatchObject({ desktopMacMenuBarEnabled: false, }); }); it('normalizes desktopWindowControlsPosition and maps legacy auto to right', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'left' })).toEqual({ desktopWindowControlsPosition: 'left', }); expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'right' })).toEqual({ desktopWindowControlsPosition: 'right', }); expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'auto' })).toEqual({ desktopWindowControlsPosition: 'right', }); expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'center' })).toEqual({}); }); it('sanitizes desktopWindowControlsStyle and rejects unknown values', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'classic' })).toEqual({ desktopWindowControlsStyle: 'classic', }); expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'traffic-lights' })).toEqual({ desktopWindowControlsStyle: 'traffic-lights', }); expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'macos' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'auto' })).toEqual({}); }); it('sanitizes the persisted permission auto-accept policy', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ permissionAutoAccept: { sessions: { root: true, child: false, invalid: 'true' }, }, })).toEqual({ permissionAutoAccept: { sessions: { root: true, child: false }, revision: 0, }, }); }); it('accepts desktopUiPassword as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ desktopUiPassword: ' secret ' })).toEqual({ desktopUiPassword: 'secret', }); expect(helpers.sanitizeSettingsUpdate({ desktopUiPassword: '' })).toEqual({ desktopUiPassword: '', }); }); it('accepts mobileKeyboardMode as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: 'native' })).toEqual({ mobileKeyboardMode: 'native', }); expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: 'resize-content' })).toEqual({ mobileKeyboardMode: 'resize-content', }); expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: ' resize-content ' })).toEqual({ mobileKeyboardMode: 'resize-content', }); }); it('rejects invalid mobileKeyboardMode values', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: 'fixed-layout' })).toEqual({}); }); it('accepts collapsibleThinkingBlocks as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ collapsibleThinkingBlocks: true })).toEqual({ collapsibleThinkingBlocks: true, }); expect(helpers.sanitizeSettingsUpdate({ collapsibleThinkingBlocks: false })).toEqual({ collapsibleThinkingBlocks: false, }); }); it('accepts shortcut overrides as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ shortcutOverrides: { open_settings: 'mod+comma', new_chat: '__unassigned__', invalid: 123, empty: '', }, })).toEqual({ shortcutOverrides: { open_settings: 'mod+comma', new_chat: '__unassigned__', }, }); }); it('preserves empty shortcut overrides when resetting all shortcuts', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ shortcutOverrides: {} })).toEqual({ shortcutOverrides: {}, }); }); it('accepts OpenCode update notification preference as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ showOpenCodeUpdateNotifications: false })).toEqual({ showOpenCodeUpdateNotifications: false, }); expect(helpers.sanitizeSettingsUpdate({ showOpenCodeUpdateNotifications: true })).toEqual({ showOpenCodeUpdateNotifications: true, }); }); it('accepts and rejects invalid Enter-to-send preference values', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ enterToSend: true, enterToSendConfigured: true })).toEqual({ enterToSend: true, enterToSendConfigured: true, }); expect(helpers.sanitizeSettingsUpdate({ enterToSend: false, enterToSendConfigured: true })).toEqual({ enterToSend: false, enterToSendConfigured: true, }); expect(helpers.sanitizeSettingsUpdate({ enterToSend: false, enterToSendConfigured: false })).toEqual({ enterToSend: false, enterToSendConfigured: false, }); expect(helpers.sanitizeSettingsUpdate({ enterToSend: 'true' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ enterToSend: 1 })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: 'true' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: 1 })).toEqual({}); }); it('accepts dismissed OpenCode update toast version as a persisted shared setting', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ openCodeUpdateToastDismissedVersion: ' 1.16.0 ' })).toEqual({ openCodeUpdateToastDismissedVersion: '1.16.0', }); expect(helpers.sanitizeSettingsUpdate({ openCodeUpdateToastDismissedVersion: '' })).toEqual({ openCodeUpdateToastDismissedVersion: '', }); }); it('rejects non-boolean collapsibleThinkingBlocks values', () => { const helpers = createTestHelpers(); expect(helpers.sanitizeSettingsUpdate({ collapsibleThinkingBlocks: 'true' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ collapsibleThinkingBlocks: 1 })).toEqual({}); }); it('includes collapsibleThinkingBlocks in formatSettingsResponse', () => { const helpers = createTestHelpers(); const response = helpers.formatSettingsResponse({ collapsibleThinkingBlocks: false }); expect(response.collapsibleThinkingBlocks).toBe(false); const responseTrue = helpers.formatSettingsResponse({ collapsibleThinkingBlocks: true }); expect(responseTrue.collapsibleThinkingBlocks).toBe(true); }); it('defaults collapsibleThinkingBlocks to true in formatSettingsResponse when absent', () => { const helpers = createTestHelpers(); const response = helpers.formatSettingsResponse({}); expect(response.collapsibleThinkingBlocks).toBe(true); }); it('includes transient desktop LAN access runtime status in desktop settings response', () => { const helpers = createTestHelpers(); const previousRuntime = process.env.OPENCHAMBER_RUNTIME; const previousActive = process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE; const previousReason = process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON; try { process.env.OPENCHAMBER_RUNTIME = 'desktop'; process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE = 'false'; process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON = 'missing-password'; const response = helpers.formatSettingsResponse({ desktopLanAccessEnabled: true }); expect(response.desktopLanAccessActive).toBe(false); expect(response.desktopLanAccessBlockedReason).toBe('missing-password'); } finally { if (typeof previousRuntime === 'string') process.env.OPENCHAMBER_RUNTIME = previousRuntime; else delete process.env.OPENCHAMBER_RUNTIME; if (typeof previousActive === 'string') process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE = previousActive; else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE; if (typeof previousReason === 'string') process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON = previousReason; else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON; } }); describe('previously-dropped model selector persistence fields', () => { it('round-trips hiddenModels through the sanitizer', () => { const helpers = createTestHelpersWithRealSanitizers(); const input = [ { providerID: 'anthropic', modelID: 'claude-opus-4' }, { providerID: 'openai', modelID: 'gpt-5' }, ]; expect(helpers.sanitizeSettingsUpdate({ hiddenModels: input })).toEqual({ hiddenModels: input, }); }); it('handles empty hiddenModels the same way as empty favoriteModels', () => { const helpers = createTestHelpersWithRealSanitizers(); const hiddenResult = helpers.sanitizeSettingsUpdate({ hiddenModels: [] }); const favoriteResult = helpers.sanitizeSettingsUpdate({ favoriteModels: [] }); expect(hiddenResult.hiddenModels).toEqual([]); expect(favoriteResult.favoriteModels).toEqual([]); expect(hiddenResult.hiddenModels).toEqual(favoriteResult.favoriteModels); }); it('round-trips collapsedModelProviders and recentAgents as string arrays', () => { const helpers = createTestHelpersWithRealSanitizers(); expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: ['anthropic', 'openai'] })).toEqual({ collapsedModelProviders: ['anthropic', 'openai'], }); expect(helpers.sanitizeSettingsUpdate({ recentAgents: ['build', 'plan'] })).toEqual({ recentAgents: ['build', 'plan'], }); }); it('round-trips recentEfforts as a Record', () => { const helpers = createTestHelpersWithRealSanitizers(); const input = { 'anthropic/claude-opus-4': ['high', 'default'], 'openai/gpt-5': ['low'], }; expect(helpers.sanitizeSettingsUpdate({ recentEfforts: input })).toEqual({ recentEfforts: input, }); }); it('rejects garbage hiddenModels input the same way sanitizeModelRefs rejects bad refs', () => { const helpers = createTestHelpersWithRealSanitizers(); expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 'not-an-array' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ hiddenModels: null })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 123 })).toEqual({}); expect( helpers.sanitizeSettingsUpdate({ hiddenModels: [ { providerID: 'anthropic' }, { modelID: 'gpt-5' }, 'not-an-object', null, { providerID: ' ', modelID: 'x' }, { providerID: 'openai', modelID: '' }, ], }) ).toEqual({ hiddenModels: [] }); }); it('rejects garbage collapsedModelProviders and recentAgents input', () => { const helpers = createTestHelpersWithRealSanitizers(); expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: 'anthropic' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: null })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ recentAgents: 42 })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ recentAgents: { build: 1 } })).toEqual({}); }); it('rejects garbage recentEfforts input', () => { const helpers = createTestHelpersWithRealSanitizers(); expect(helpers.sanitizeSettingsUpdate({ recentEfforts: 'not-an-object' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ recentEfforts: [] })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ recentEfforts: null })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': 'high' } })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { '': ['high'] } })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [] } })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [123, ''] } })).toEqual({}); }); it('persists only boolean system prompt optimization values', () => { const helpers = createTestHelpersWithRealSanitizers(); expect(helpers.sanitizeSettingsUpdate({ optimizeSystemPrompt: true })).toEqual({ optimizeSystemPrompt: true }); expect(helpers.sanitizeSettingsUpdate({ optimizeSystemPrompt: false })).toEqual({ optimizeSystemPrompt: false }); expect(helpers.sanitizeSettingsUpdate({ optimizeSystemPrompt: 'true' })).toEqual({}); }); it('survives a full settings.json payload containing all four previously-dropped fields (regression)', () => { const helpers = createTestHelpersWithRealSanitizers(); const payload = { themeId: 'default', hiddenModels: [ { providerID: 'anthropic', modelID: 'claude-opus-4' }, { providerID: 'openai', modelID: 'gpt-5' }, ], collapsedModelProviders: ['anthropic', 'openai'], recentAgents: ['build', 'plan'], recentEfforts: { 'anthropic/claude-opus-4': ['high', 'default'], 'openai/gpt-5': ['low'], }, favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }], recentModels: [{ providerID: 'openai', modelID: 'gpt-5' }], }; const sanitized = helpers.sanitizeSettingsUpdate(payload); expect(sanitized.hiddenModels).toEqual(payload.hiddenModels); expect(sanitized.collapsedModelProviders).toEqual(payload.collapsedModelProviders); expect(sanitized.recentAgents).toEqual(payload.recentAgents); expect(sanitized.recentEfforts).toEqual(payload.recentEfforts); expect(sanitized.favoriteModels).toEqual(payload.favoriteModels); expect(sanitized.recentModels).toEqual(payload.recentModels); }); it('round-trips a valid gitProviders payload through sanitizeSettingsUpdate', () => { const helpers = createTestHelpersWithRealSanitizers(); const payload = { gitProviders: { github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] }, gitlab: { apiBaseUrl: 'gitlab.example.com', detectUrls: [] }, gitea: { apiBaseUrl: '', detectUrls: ['gitea.example.com'] }, }, }; expect(helpers.sanitizeSettingsUpdate(payload)).toEqual({ gitProviders: { github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] }, gitlab: { apiBaseUrl: 'https://gitlab.example.com' }, gitea: { detectUrls: ['gitea.example.com'] }, }, }); }); it('drops malformed gitProviders payloads entirely', () => { const helpers = createTestHelpersWithRealSanitizers(); expect(helpers.sanitizeSettingsUpdate({ gitProviders: 'not-an-object' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ gitProviders: [] })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ gitProviders: null })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ gitProviders: { unknown: { apiBaseUrl: 'https://x.example.com' } } })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ gitProviders: { github: { apiBaseUrl: ' ' } } })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ gitProviders: { github: { detectUrls: 'github.example.com' } } })).toEqual({}); }); it('normalizes gitProviders apiBaseUrl scheme and strips trailing slashes', () => { const helpers = createTestHelpersWithRealSanitizers(); expect(helpers.sanitizeSettingsUpdate({ gitProviders: { github: { apiBaseUrl: 'github.example.com/api/v3/' } }, })).toEqual({ gitProviders: { github: { apiBaseUrl: 'https://github.example.com/api/v3' } }, }); }); }); describe('session retention settings persistence', () => { it('round-trips sessionRetentionAction archive and delete through the sanitizer', () => { const helpers = createTestHelpersWithRealSanitizers(); expect(helpers.sanitizeSettingsUpdate({ sessionRetentionAction: 'archive' })).toEqual({ sessionRetentionAction: 'archive', }); expect(helpers.sanitizeSettingsUpdate({ sessionRetentionAction: 'delete' })).toEqual({ sessionRetentionAction: 'delete', }); }); it('rejects invalid sessionRetentionAction values', () => { const helpers = createTestHelpersWithRealSanitizers(); expect(helpers.sanitizeSettingsUpdate({ sessionRetentionAction: 'remove' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ sessionRetentionAction: true })).toEqual({}); }); it('survives a full settings payload containing sessionRetentionAction (regression)', () => { const helpers = createTestHelpersWithRealSanitizers(); const payload = { autoDeleteEnabled: true, autoDeleteAfterDays: 60, sessionRetentionAction: 'delete', }; const sanitized = helpers.sanitizeSettingsUpdate(payload); expect(sanitized.autoDeleteEnabled).toBe(true); expect(sanitized.autoDeleteAfterDays).toBe(60); expect(sanitized.sessionRetentionAction).toBe('delete'); }); }); }); 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', gitProviderId: 'anthropic', gitModelId: 'claude', gitProviders: { github: { apiBaseUrl: 'https://github.example.com/api' } }, 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({ gitProviderId: 'anthropic', gitModelId: 'claude', }); }); 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({}); }); });