From 89630af98d434f9fb8ccbe04c0ccca18bc83f4e1 Mon Sep 17 00:00:00 2001 From: Isaac Sanchez-Hawkins <266845420+isanchez404@users.noreply.github.com> Date: Tue, 12 May 2026 04:12:17 -0400 Subject: [PATCH] fix(settings): constrain plan path remapping (#1203) Co-authored-by: Isaac Sanchez --- .../server/lib/opencode/settings-runtime.js | 5 +- .../lib/opencode/settings-runtime.test.js | 85 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 packages/web/server/lib/opencode/settings-runtime.test.js diff --git a/packages/web/server/lib/opencode/settings-runtime.js b/packages/web/server/lib/opencode/settings-runtime.js index b7e6afcc..7d4379ed 100644 --- a/packages/web/server/lib/opencode/settings-runtime.js +++ b/packages/web/server/lib/opencode/settings-runtime.js @@ -105,12 +105,13 @@ export const createSettingsRuntime = (deps) => { return entry; } const trimmedPath = entry.path.trim(); - if (!trimmedPath.startsWith(fromDir)) { + const relativePath = path.relative(fromDir, trimmedPath); + if (relativePath && (relativePath.startsWith('..') || path.isAbsolute(relativePath))) { return entry; } return { ...entry, - path: `${toDir}${trimmedPath.slice(fromDir.length)}`, + path: relativePath ? path.join(toDir, relativePath) : toDir, }; }); }; diff --git a/packages/web/server/lib/opencode/settings-runtime.test.js b/packages/web/server/lib/opencode/settings-runtime.test.js new file mode 100644 index 00000000..185e3cf9 --- /dev/null +++ b/packages/web/server/lib/opencode/settings-runtime.test.js @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import crypto from 'crypto'; +import fsPromises from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { createProjectIdFromPath } from '../projects/project-id.js'; +import { createSettingsRuntime } from './settings-runtime.js'; + +const createRuntime = async () => { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-')); + const settingsFilePath = path.join(tempRoot, 'settings.json'); + const runtime = createSettingsRuntime({ + fsPromises, + 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 () => {}, + }); + + return { + runtime, + settingsFilePath, + tempRoot, + cleanup: async () => { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + }, + }; +}; + +describe('settings runtime', () => { + it('only remaps project plan paths within the migrated storage directory', async () => { + const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime(); + try { + const projectPath = path.join(tempRoot, 'project'); + const oldProjectId = 'legacy-project-id'; + const newProjectId = createProjectIdFromPath(projectPath); + const projectsRoot = path.join(path.dirname(settingsFilePath), 'projects'); + const oldStorageDir = path.join(projectsRoot, oldProjectId); + const newStorageDir = path.join(projectsRoot, newProjectId); + const siblingStorageDir = `${oldStorageDir}-sibling`; + + await fsPromises.mkdir(projectPath, { recursive: true }); + await fsPromises.mkdir(projectsRoot, { recursive: true }); + await fsPromises.writeFile( + settingsFilePath, + JSON.stringify({ + projects: [{ id: oldProjectId, path: projectPath, addedAt: 1, lastOpenedAt: 1 }], + activeProjectId: oldProjectId, + }, null, 2), + 'utf8', + ); + await fsPromises.writeFile( + path.join(projectsRoot, `${oldProjectId}.json`), + JSON.stringify({ + projectPlanFiles: [ + { id: 'inside', path: path.join(oldStorageDir, 'plans', 'inside.md') }, + { id: 'sibling', path: path.join(siblingStorageDir, 'plans', 'outside.md') }, + ], + }, null, 2), + 'utf8', + ); + + await runtime.readSettingsFromDiskMigrated(); + + const migratedConfig = JSON.parse(await fsPromises.readFile(path.join(projectsRoot, `${newProjectId}.json`), 'utf8')); + expect(migratedConfig.projectPlanFiles).toEqual([ + { id: 'inside', path: path.join(newStorageDir, 'plans', 'inside.md') }, + { id: 'sibling', path: path.join(siblingStorageDir, 'plans', 'outside.md') }, + ]); + } finally { + await cleanup(); + } + }); +});