Merge pull request 'chore: bring upstream v1.20.0 into custom' (#2) from release/v1.20.0 into custom

This commit is contained in:
2026-08-28 22:05:23 -04:00
281 changed files with 10504 additions and 2180 deletions
@@ -207,7 +207,8 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
- `readSettingsFromDiskMigrated()`
- `writeSettingsToDisk(settings)`
- `persistSettings(changes)`
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
- 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`.
## Public exports (settings-helpers.js)
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
@@ -9,6 +9,11 @@ import {
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
const CUSTOM_PROVIDER_NPM_PACKAGES = new Set([
OPENAI_COMPATIBLE_NPM,
'@ai-sdk/openai',
'@ai-sdk/anthropic',
]);
function getProviderSources(providerId, workingDirectory) {
const layers = readConfigLayers(workingDirectory);
@@ -42,7 +47,7 @@ function getProviderSources(providerId, workingDirectory) {
}
/**
* Validate a custom OpenAI-compatible provider config payload before persistence.
* Validate a custom provider config payload before persistence.
* Returns { ok: true, value } or { ok: false, error }.
*
* Credentials: either config.env contains a variable name, or hasStoredAuth is true
@@ -63,8 +68,8 @@ function validateCustomProviderConfig(providerId, config, options = {}) {
}
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
if (npm !== OPENAI_COMPATIBLE_NPM) {
return { ok: false, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
if (!CUSTOM_PROVIDER_NPM_PACKAGES.has(npm)) {
return { ok: false, error: 'Custom providers must use @ai-sdk/openai-compatible, @ai-sdk/openai, or @ai-sdk/anthropic' };
}
const optionsBlock = isPlainObject(config.options) ? config.options : null;
@@ -102,7 +107,7 @@ function validateCustomProviderConfig(providerId, config, options = {}) {
}
const normalized = {
npm: OPENAI_COMPATIBLE_NPM,
npm,
name,
options: {
baseURL,
@@ -71,6 +71,33 @@ describe('custom provider config persistence', () => {
}).ok).toBe(true);
});
test('accepts the OpenCode Responses and Anthropic adapter packages', () => {
for (const npm of ['@ai-sdk/openai', '@ai-sdk/anthropic']) {
const result = validateCustomProviderConfig('ok', {
name: 'X',
npm,
env: ['MY_KEY'],
options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } },
});
expect(result.ok).toBe(true);
expect(result.value.config.npm).toBe(npm);
}
});
test('rejects unsupported adapter packages', () => {
const result = validateCustomProviderConfig('ok', {
name: 'X',
npm: '@example/unsupported',
env: ['MY_KEY'],
options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } },
});
expect(result.ok).toBe(false);
expect(result.error).toContain('@ai-sdk/openai');
});
test('upsertProviderConfig writes and round-trips project config', () => {
const result = upsertProviderConfig('campus-llm', {
name: 'Campus LLM',
@@ -30,6 +30,9 @@ export const createSettingsHelpers = (dependencies) => {
const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']);
const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']);
const TERMINAL_SHELL_VALUES = new Set(['auto', 'bash', 'zsh', 'sh', 'fish', 'pwsh', 'powershell', 'cmd', 'dash', 'ksh', 'nu']);
const SIDEBAR_PROJECT_DISPLAY_MODE_VALUES = new Set(['all', 'single']);
const SIDEBAR_SESSION_GROUPING_MODE_VALUES = new Set(['by-worktree', 'flat']);
const SIDEBAR_PROJECT_SORT_ORDER_VALUES = new Set(['manual', 'a-z', 'z-a', 'date-added', 'recent']);
const HIDDEN_MODELS_MAX = 1024;
const RECENT_EFFORTS_MAX_KEYS = 128;
const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
@@ -244,6 +247,18 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
result.activeProjectId = candidate.activeProjectId;
}
if (SIDEBAR_PROJECT_DISPLAY_MODE_VALUES.has(candidate.sidebarProjectDisplayMode)) {
result.sidebarProjectDisplayMode = candidate.sidebarProjectDisplayMode;
}
if (SIDEBAR_SESSION_GROUPING_MODE_VALUES.has(candidate.sidebarSessionGroupingMode)) {
result.sidebarSessionGroupingMode = candidate.sidebarSessionGroupingMode;
}
if (SIDEBAR_PROJECT_SORT_ORDER_VALUES.has(candidate.sidebarProjectSortOrder)) {
result.sidebarProjectSortOrder = candidate.sidebarProjectSortOrder;
}
if (typeof candidate.sidebarShowRecentSection === 'boolean') {
result.sidebarShowRecentSection = candidate.sidebarShowRecentSection;
}
if (Array.isArray(candidate.securityScopedBookmarks)) {
result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks);
@@ -66,6 +66,28 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: 'false' })).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('accepts only booleans for wide chat layout', () => {
const helpers = createTestHelpers();
@@ -155,6 +155,7 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
const iconBackground = normalizeIconBackground(candidate.iconBackground);
const color = typeof candidate.color === 'string' ? candidate.color.trim() : '';
const defaultModel = typeof candidate.defaultModel === 'string' ? candidate.defaultModel.trim() : '';
const defaultVariant = typeof candidate.defaultVariant === 'string' ? candidate.defaultVariant.trim() : '';
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
? Number(candidate.lastOpenedAt)
@@ -175,6 +176,8 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
...(iconBackground ? { iconBackground } : {}),
...(color ? { color } : {}),
...(defaultModel && defaultModel.includes('/') ? { defaultModel } : {}),
// A variant is meaningless without the model it belongs to.
...(defaultModel && defaultModel.includes('/') && defaultVariant ? { defaultVariant } : {}),
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
};
@@ -106,6 +106,22 @@ describe('settings normalization runtime - symlink resolution', () => {
expect(result[0].path).toBe('/resolved/missing/path');
});
it('keeps a default thinking level next to its model and drops it alone', () => {
const runtime = createTestRuntime({
realpathSync: (p) => p,
path: { resolve: (p) => p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' },
});
const projects = [
{ id: 'proj1', path: '/a', defaultModel: 'anthropic/claude-opus-5', defaultVariant: 'high' },
{ id: 'proj2', path: '/b', defaultVariant: 'high' },
];
const result = runtime.sanitizeProjects(projects);
expect(result[0].defaultVariant).toBe('high');
expect(result[1].defaultVariant).toBe(undefined);
});
it('deduplicates projects that resolve to the same realpath', () => {
const runtime = createTestRuntime({
realpathSync: (p) => p.startsWith('/symlink') ? '/real/project' : p,
@@ -547,25 +547,41 @@ export const createSettingsRuntime = (deps) => {
// briefly opens the target file. Preserve atomic rename everywhere it works,
// but fall back to a direct replacement so settings persistence does not
// get permanently wedged on Windows desktop installs.
await fsPromises.copyFile(tmp, target);
await fsPromises.rm(tmp, { force: true });
try {
await fsPromises.copyFile(tmp, target);
} finally {
await fsPromises.rm(tmp, { force: true }).catch(() => {});
}
};
const cleanupOrphanedSettingsTempFiles = async (directory) => {
try {
const entries = await fsPromises.readdir(directory, { withFileTypes: true });
const cleanupTasks = entries
.filter((entry) => entry.isFile() && entry.name.startsWith('settings.json.tmp-'))
.map((entry) => fsPromises.rm(path.join(directory, entry.name), { force: true }).catch(() => {}));
await Promise.all(cleanupTasks);
} catch {
// Best-effort cleanup: errors reading directory must not fail settings operations
}
};
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
// 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)}`;
try {
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
// 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)}`;
await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), { 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);
} catch (error) {
await fsPromises.rm(tmp, { force: true }).catch(() => {});
console.warn('Failed to write settings file:', error);
throw error;
}
@@ -854,7 +870,13 @@ export const createSettingsRuntime = (deps) => {
return { settings: next, changed: true };
};
let hasCleanedOrphanedTempFiles = false;
const readSettingsFromDiskMigrated = async () => {
if (!hasCleanedOrphanedTempFiles) {
hasCleanedOrphanedTempFiles = true;
await cleanupOrphanedSettingsTempFiles(path.dirname(SETTINGS_FILE_PATH));
}
const current = await readSettingsFromDisk();
const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
@@ -39,6 +39,24 @@ const createRuntime = async () => {
};
describe('settings runtime', () => {
it('round-trips shared sidebar preferences through settings.json', async () => {
const { runtime, settingsFilePath, cleanup } = await createRuntime();
const preferences = {
sidebarProjectDisplayMode: 'single',
sidebarSessionGroupingMode: 'flat',
sidebarProjectSortOrder: 'date-added',
sidebarShowRecentSection: false,
};
try {
await runtime.persistSettings(preferences);
await expect(runtime.readSettingsFromDisk()).resolves.toEqual(preferences);
await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify(preferences, null, 2));
} finally {
await cleanup();
}
});
it.skipIf(process.platform === 'win32')('writes settings with restrictive directory and file permissions', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
@@ -133,4 +151,71 @@ describe('settings runtime', () => {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('cleans up orphaned settings.json.tmp files during startup migration', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
const settingsDir = path.dirname(settingsFilePath);
const orphan1 = path.join(settingsDir, 'settings.json.tmp-1234-11111-abc');
const orphan2 = path.join(settingsDir, 'settings.json.tmp-5678-22222-def');
const unrelated = path.join(settingsDir, 'other-file.json');
await fsPromises.writeFile(orphan1, '{"broken": true}', 'utf8');
await fsPromises.writeFile(orphan2, '{"broken": true}', 'utf8');
await fsPromises.writeFile(unrelated, '{"keep": true}', 'utf8');
await fsPromises.writeFile(settingsFilePath, '{"theme": "light"}', 'utf8');
await runtime.readSettingsFromDiskMigrated();
const files = await fsPromises.readdir(settingsDir);
expect(files).toContain('settings.json');
expect(files).toContain('other-file.json');
expect(files).not.toContain('settings.json.tmp-1234-11111-abc');
expect(files).not.toContain('settings.json.tmp-5678-22222-def');
} finally {
await cleanup();
}
});
it('removes temp file when writeSettingsToDisk encounters a write error', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
const settingsFilePath = path.join(tempRoot, 'settings.json');
let capturedTmp = null;
const wrappedFs = {
...fsPromises,
rename: async (src, dst) => {
capturedTmp = src;
const error = new Error('unexpected disk failure');
error.code = 'EIO';
throw error;
},
};
const runtime = createSettingsRuntime({
fsPromises: wrappedFs,
path,
crypto,
SETTINGS_FILE_PATH: settingsFilePath,
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
sanitizeSettingsUpdate: (settings) => settings,
mergePersistedSettings: (_current, changes) => changes,
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
formatSettingsResponse: (settings) => settings,
resolveDirectoryCandidate: (value) => value,
normalizeManagedRemoteTunnelHostname: (value) => value,
normalizeManagedRemoteTunnelPresets: (value) => value,
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
syncManagedRemoteTunnelConfigWithPresets: async () => {},
upsertManagedRemoteTunnelToken: async () => {},
});
try {
await expect(runtime.writeSettingsToDisk({ theme: 'dark' })).rejects.toThrow('unexpected disk failure');
expect(capturedTmp).toBeTruthy();
const files = await fsPromises.readdir(tempRoot);
expect(files.some((f) => f.startsWith('settings.json.tmp-'))).toBe(false);
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
});