Merge pull request 'chore: bring upstream v1.20.0 into custom' (#2) from release/v1.20.0 into custom
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user