Files
openchamber/packages/web/server/lib/opencode/settings-normalization-runtime.js
T
Bohdan Triapitsyn 997116c736 fix(opencode): robust Windows CLI discovery and launch across surfaces
Launching: the VS Code extension spawned .cmd shims with shell:true,
which builds an unquoted command line — a path like
"C:\Program Files\nodejs\opencode.cmd" broke with "'C:\Program' is not
recognized". It now spawns cmd.exe directly with the shim path as its
own argv element (the web server's existing cmd-wrapper pattern), and the
web server's final launch-spec fallback routes raw .cmd/.bat through the
same wrapper instead of handing them to spawn.

Configured paths: wrapping quote pairs (Windows "Copy as path" pastes)
are now stripped everywhere a binary path enters — the VS Code setting
and shared settings.json, env vars on both surfaces, the server's
directory-path normalization, and the desktop settings input.

Discovery: added the system-wide npm prefix (Program Files\nodejs) and
scoop's .exe shim to the Windows candidates, Linuxbrew on Linux, and the
where-probe now runs with windowsHide. The macOS desktop-app exclusion
also covers the OpenCode Dev/Beta app bundles.
2026-07-05 10:39:09 +03:00

451 lines
13 KiB
JavaScript

export const createSettingsNormalizationRuntime = (dependencies) => {
const {
os,
path,
processLike,
realpathSync,
tunnelBootstrapTtlDefaultMs,
tunnelBootstrapTtlMinMs,
tunnelBootstrapTtlMaxMs,
tunnelSessionTtlDefaultMs,
tunnelSessionTtlMinMs,
tunnelSessionTtlMaxMs,
} = dependencies;
const normalizeDirectoryPath = (value) => {
if (typeof value !== 'string') {
return value;
}
let trimmed = value.trim();
// Paths pasted from Windows "Copy as path" (or quoted shell snippets)
// arrive wrapped in quotes — a literal quote character can never be part
// of a real path, and it breaks every fs.stat/executable check.
if (trimmed.length >= 2
&& ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
trimmed = trimmed.slice(1, -1).trim();
}
if (!trimmed) {
return trimmed;
}
if (trimmed === '~') {
return os.homedir();
}
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
return path.join(os.homedir(), trimmed.slice(2));
}
return trimmed;
};
// Resolve symlinks, falling back to the original value on failure.
const safeRealpathSync = (value) => {
if (!realpathSync || typeof value !== 'string' || !value) {
return value;
}
try {
return realpathSync(value);
} catch {
return value;
}
};
const normalizePathForPersistence = (value, options = {}) => {
if (typeof value !== 'string') {
return value;
}
const normalized = normalizeDirectoryPath(value);
if (typeof normalized !== 'string') {
return normalized;
}
const trimmed = normalized.trim();
if (!trimmed) {
return trimmed;
}
const resolved = options.resolveRealpath === false ? trimmed : safeRealpathSync(trimmed);
if (processLike.platform !== 'win32') {
return resolved;
}
return resolved.replace(/\//g, '\\');
};
const areStringArraysEqual = (a, b) => {
if (!Array.isArray(a) || !Array.isArray(b)) {
return false;
}
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
};
const normalizeStringArray = (input) => {
if (!Array.isArray(input)) {
return [];
}
return Array.from(
new Set(
input.filter((entry) => typeof entry === 'string' && entry.length > 0)
)
);
};
const sanitizeProjects = (input) => {
if (!Array.isArray(input)) {
return undefined;
}
const hexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
const normalizeIconBackground = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return hexColorPattern.test(trimmed) ? trimmed.toLowerCase() : null;
};
const result = [];
const seenIds = new Set();
const seenPaths = new Set();
for (const entry of input) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
const resolvedPath = rawPath ? safeRealpathSync(path.resolve(normalizeDirectoryPath(rawPath))) : '';
const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath, { resolveRealpath: false }) : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : '';
const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object'
? candidate.iconImage
: null;
const iconBackground = normalizeIconBackground(candidate.iconBackground);
const color = typeof candidate.color === 'string' ? candidate.color.trim() : '';
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
? Number(candidate.lastOpenedAt)
: null;
if (!id || !normalizedPath) continue;
if (seenIds.has(id)) continue;
if (seenPaths.has(normalizedPath)) continue;
seenIds.add(id);
seenPaths.add(normalizedPath);
const project = {
id,
path: normalizedPath,
...(label ? { label } : {}),
...(icon ? { icon } : {}),
...(iconBackground ? { iconBackground } : {}),
...(color ? { color } : {}),
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
};
if (candidate.iconImage === null) {
project.iconImage = null;
} else if (iconImage) {
const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : '';
const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt)
? Math.max(0, Math.round(iconImage.updatedAt))
: 0;
const source = iconImage.source === 'custom' || iconImage.source === 'auto'
? iconImage.source
: null;
if (mime && updatedAt > 0 && source) {
project.iconImage = { mime, updatedAt, source };
}
}
if (candidate.iconBackground === null) {
project.iconBackground = null;
}
if (typeof candidate.sidebarCollapsed === 'boolean') {
project.sidebarCollapsed = candidate.sidebarCollapsed;
}
result.push(project);
}
return result;
};
const normalizeSettingsPaths = (input) => {
const settings = input && typeof input === 'object' ? input : {};
let next = settings;
let changed = false;
const ensureNext = () => {
if (next === settings) {
next = { ...settings };
}
};
const normalizePathField = (key) => {
if (typeof settings[key] !== 'string' || settings[key].length === 0) {
return;
}
const normalized = normalizePathForPersistence(settings[key]);
if (normalized !== settings[key]) {
ensureNext();
next[key] = normalized;
changed = true;
}
};
const normalizePathArrayField = (key) => {
if (!Array.isArray(settings[key])) {
return;
}
const normalized = normalizeStringArray(
settings[key]
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
.filter((entry) => typeof entry === 'string' && entry.length > 0)
);
if (!areStringArraysEqual(normalized, settings[key])) {
ensureNext();
next[key] = normalized;
changed = true;
}
};
normalizePathField('lastDirectory');
normalizePathField('homeDirectory');
normalizePathArrayField('pinnedDirectories');
if (Array.isArray(settings.projects)) {
const normalizedProjects = sanitizeProjects(settings.projects) || [];
if (JSON.stringify(normalizedProjects) !== JSON.stringify(settings.projects)) {
ensureNext();
next.projects = normalizedProjects;
changed = true;
}
}
return { settings: next, changed };
};
const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value));
const normalizeTunnelBootstrapTtlMs = (value) => {
if (value === null) {
return null;
}
if (!Number.isFinite(value)) {
return tunnelBootstrapTtlDefaultMs;
}
return clampNumber(Math.round(value), tunnelBootstrapTtlMinMs, tunnelBootstrapTtlMaxMs);
};
const normalizeTunnelSessionTtlMs = (value) => {
if (!Number.isFinite(value)) {
return tunnelSessionTtlDefaultMs;
}
return clampNumber(Math.round(value), tunnelSessionTtlMinMs, tunnelSessionTtlMaxMs);
};
const normalizeManagedRemoteTunnelHostname = (value) => {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const parsed = (() => {
try {
if (trimmed.includes('://')) {
return new URL(trimmed);
}
return new URL(`https://${trimmed}`);
} catch {
return null;
}
})();
const hostname = parsed?.hostname?.trim().toLowerCase() || '';
if (!hostname) {
return undefined;
}
return hostname;
};
const normalizeManagedRemoteTunnelPresets = (value) => {
if (!Array.isArray(value)) {
return undefined;
}
const result = [];
const seenIds = new Set();
const seenHostnames = new Set();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
const hostname = normalizeManagedRemoteTunnelHostname(candidate.hostname);
if (!id || !name || !hostname) continue;
if (seenIds.has(id) || seenHostnames.has(hostname)) continue;
seenIds.add(id);
seenHostnames.add(hostname);
result.push({ id, name, hostname });
}
return result;
};
const normalizeManagedRemoteTunnelPresetTokens = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const result = {};
for (const [rawId, rawToken] of Object.entries(value)) {
const id = typeof rawId === 'string' ? rawId.trim() : '';
const token = typeof rawToken === 'string' ? rawToken.trim() : '';
if (!id || !token) {
continue;
}
result[id] = token;
}
return Object.keys(result).length > 0 ? result : undefined;
};
const isUnsafeSkillRelativePath = (value) => {
if (typeof value !== 'string' || value.length === 0) {
return true;
}
const normalized = value.replace(/\\/g, '/');
if (path.posix.isAbsolute(normalized)) {
return true;
}
return normalized.split('/').some((segment) => segment === '..');
};
const sanitizeTypographySizesPartial = (input) => {
if (!input || typeof input !== 'object') {
return undefined;
}
const candidate = input;
const result = {};
let populated = false;
const assign = (key) => {
if (typeof candidate[key] === 'string' && candidate[key].length > 0) {
result[key] = candidate[key];
populated = true;
}
};
assign('markdown');
assign('code');
assign('uiHeader');
assign('uiLabel');
assign('meta');
assign('micro');
return populated ? result : undefined;
};
const sanitizeModelRefs = (input, limit) => {
if (!Array.isArray(input)) {
return undefined;
}
const result = [];
const seen = new Set();
for (const entry of input) {
if (!entry || typeof entry !== 'object') continue;
const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : '';
const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : '';
if (!providerID || !modelID) continue;
const key = `${providerID}/${modelID}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({ providerID, modelID });
if (result.length >= limit) break;
}
return result;
};
const sanitizeSkillCatalogs = (input) => {
if (!Array.isArray(input)) {
return undefined;
}
const result = [];
const seen = new Set();
for (const entry of input) {
if (!entry || typeof entry !== 'object') continue;
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
const label = typeof entry.label === 'string' ? entry.label.trim() : '';
const source = typeof entry.source === 'string' ? entry.source.trim() : '';
const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : '';
const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : '';
if (!id || !label || !source) continue;
if (seen.has(id)) continue;
seen.add(id);
result.push({
id,
label,
source,
...(subpath ? { subpath } : {}),
...(gitIdentityId ? { gitIdentityId } : {}),
});
}
return result;
};
return {
normalizeDirectoryPath,
normalizePathForPersistence,
normalizeSettingsPaths,
normalizeTunnelBootstrapTtlMs,
normalizeTunnelSessionTtlMs,
normalizeManagedRemoteTunnelHostname,
normalizeManagedRemoteTunnelPresets,
normalizeManagedRemoteTunnelPresetTokens,
isUnsafeSkillRelativePath,
sanitizeTypographySizesPartial,
normalizeStringArray,
sanitizeModelRefs,
sanitizeSkillCatalogs,
sanitizeProjects,
};
};