Follow-ups promised on merge, plus review findings on the batch itself: - chat: task-tool output now respects the 512KiB render cap; quick-open icon is visible at rest on coarse pointers and reachable by keyboard (row keydown no longer swallows inner-button Enter/Space); composer inline-code decoration drops the metric-shifting padding; a btw fork send carries only the boundary instruction, never the promotion notice - sync: cascade revert/unrevert aborts busy descendants, busy state is read from every child store at the moment of use; rule 9 documents redo clearing all descendant revert markers - electron: renderer recovery keeps memory-eviction (a valid render-process-gone reason) and both windows share one attachRendererRecovery helper - vscode: process registry is a thin re-export of the web module (provider-env-aliases precedent) with ordered register/unregister writes and an awaited close - server/cli: managed-process registry takes injectable deps (fixes the unreaped-orphans ReferenceError), corrupt settings errors name the file, getWorktrees test restores console.warn - tests: module-mock harnesses removed (AgentsSidebar, SettingsView mobile focus — behaviors stay live but uncovered, accepted trade), QuestionMarkdown asserts rendered DOM - i18n: German gains the debug-panel request keys, Japanese/German drop removed worktree keys, Ukrainian unit spacing fixed - changelog: Copilot AI Credits entries (main + VS Code)
112 lines
4.2 KiB
JavaScript
112 lines
4.2 KiB
JavaScript
// Minimal settings.json access for CLI contexts (connect-url, pairing
|
|
// candidate building) that must not load the full web settings runtime.
|
|
//
|
|
// The running app already treats settings.json as a shared store — the relay
|
|
// identity, tunnels, notifications, the Electron main, and ssh-manager all
|
|
// read-modify-write it. This accessor must therefore mirror the settings
|
|
// runtime's guarantees or it will corrupt or regenerate shared state:
|
|
//
|
|
// - ATOMIC writes (write tmp, rename into place). A plain writeFile can
|
|
// interleave with a concurrent reader in the running app; the reader sees
|
|
// a half-written file, its lenient read maps it to `{}`, and relay
|
|
// identity logic then mints a NEW serverId — orphaning every paired
|
|
// device. The tmp+rename below means no reader can ever observe a partial
|
|
// file.
|
|
//
|
|
// - A STRICT read that THROWS on corrupt/unreadable payloads, gating relay
|
|
// identity regeneration. Only a genuinely missing file means "no
|
|
// settings"; any other failure (corrupt JSON, EACCES, transient I/O,
|
|
// non-object payload) must propagate so callers never confuse a broken
|
|
// read with first run and mint a replacement signing/encryption keypair.
|
|
|
|
export const createSettingsAccessors = ({ fsPromises, path, dataDir, settingsFileName }) => {
|
|
const settingsPath = path.join(dataDir, settingsFileName);
|
|
|
|
const readSettingsFromDiskMigrated = async () => {
|
|
try {
|
|
return JSON.parse(await fsPromises.readFile(settingsPath, 'utf8'));
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const readSettingsStrict = async () => {
|
|
let raw;
|
|
try {
|
|
raw = await fsPromises.readFile(settingsPath, 'utf8');
|
|
} catch (error) {
|
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
const corruptSettingsError = (cause) =>
|
|
new Error(`Settings file is corrupt or unreadable: ${settingsPath} (fix or remove it, then retry)`, { cause });
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (error) {
|
|
throw corruptSettingsError(error);
|
|
}
|
|
if (!parsed || typeof parsed !== 'object') {
|
|
throw corruptSettingsError(new Error('non-object payload'));
|
|
}
|
|
return parsed;
|
|
};
|
|
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const isTransientWindowsReplaceError = (error) => {
|
|
if (process.platform !== 'win32' || !error || typeof error !== 'object') {
|
|
return false;
|
|
}
|
|
return error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY';
|
|
};
|
|
|
|
const replaceFile = async (tmp, target) => {
|
|
const maxAttempts = process.platform === 'win32' ? 6 : 1;
|
|
let lastError = null;
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
try {
|
|
await fsPromises.rename(tmp, target);
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (!isTransientWindowsReplaceError(error) || attempt === maxAttempts) {
|
|
break;
|
|
}
|
|
await sleep(25 * attempt);
|
|
}
|
|
}
|
|
|
|
if (!isTransientWindowsReplaceError(lastError)) {
|
|
throw lastError;
|
|
}
|
|
|
|
// Windows can transiently reject the atomic replace while another process
|
|
// briefly holds the target open. Fall back to copying the COMPLETE tmp file
|
|
// so persistence never wedges. Note: copyFile is NOT atomic — this is a
|
|
// last-resort path confined to Windows, matching the settings runtime's
|
|
// fallback, not a substitute for the atomic rename used everywhere else.
|
|
await fsPromises.copyFile(tmp, target);
|
|
await fsPromises.rm(tmp, { force: true });
|
|
};
|
|
|
|
const writeSettingsToDisk = async (settings) => {
|
|
await fsPromises.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
const tmp = `${settingsPath}.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, settingsPath);
|
|
if (process.platform !== 'win32') {
|
|
await fsPromises.chmod(settingsPath, 0o600);
|
|
}
|
|
};
|
|
|
|
return { readSettingsFromDiskMigrated, readSettingsStrict, writeSettingsToDisk };
|
|
};
|