fix: resolve symlinks in project directory paths (#1316)

* fix: resolve symlinks in project directory paths

OpenCode stores sessions using the canonical (realpath) directory, but
OpenChamber passed the unresolved symlink path in several places. The
string-match directory filter would fail when a project was accessed via
a symlink, making sessions invisible.

Changes:

- Add safeRealpathSync to settings normalization — project paths and
  lastDirectory are canonicalized at persistence time
- Add Express middleware before the API proxy to resolve symlinks in
  ?directory= query params on in-flight requests
- Resolve symlinks in /api/fs/list so the directory browser returns
  canonical paths, allowing the "already added" check to work correctly
- Reconcile the in-memory projects store when the server responds with
  normalized paths, preventing temporary duplicates

Fixes #1315

* fix: avoid sync realpath in opencode proxy

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
jeremysamuel13
2026-05-24 15:46:11 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 90b3d4760e
commit c5862cc6ee
12 changed files with 695 additions and 12 deletions
@@ -1,3 +1,5 @@
import { createRealpathCache } from '../path-realpath-cache.js';
export const createProjectDirectoryRuntime = (dependencies) => {
const {
fsPromises,
@@ -7,6 +9,9 @@ export const createProjectDirectoryRuntime = (dependencies) => {
getReadSettingsFromDiskMigrated,
sanitizeProjects,
} = dependencies;
const realpathCache = createRealpathCache({
realpath: fsPromises.realpath.bind(fsPromises),
});
const resolveDirectoryCandidate = (value) => {
if (typeof value !== 'string') {
@@ -30,7 +35,8 @@ export const createProjectDirectoryRuntime = (dependencies) => {
if (!stats.isDirectory()) {
return { ok: false, error: 'Specified path is not a directory' };
}
return { ok: true, directory: resolved };
const realPath = await realpathCache.resolve(resolved);
return { ok: true, directory: realPath };
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'ENOENT') {
@@ -0,0 +1,226 @@
import { describe, expect, it } from 'vitest';
import { createProjectDirectoryRuntime } from './project-directory-runtime.js';
const createTestRuntime = (overrides = {}) => {
const defaults = {
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async (p) => p,
},
path: {
resolve: (p) => p,
},
normalizeDirectoryPath: (value) => value,
readSettingsFromDiskMigrated: async () => ({}),
getReadSettingsFromDiskMigrated: () => async () => ({}),
sanitizeProjects: (input) => input,
};
return createProjectDirectoryRuntime({ ...defaults, ...overrides });
};
describe('project directory runtime', () => {
describe('validateDirectoryPath', () => {
it('returns resolved real path for a valid directory', async () => {
const runtime = createTestRuntime();
const result = await runtime.validateDirectoryPath('/home/user/project');
expect(result).toEqual({ ok: true, directory: '/home/user/project' });
});
it('resolves symlinks via fsPromises.realpath', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => '/real/path/to/project',
},
});
const result = await runtime.validateDirectoryPath('/symlink/path/to/project');
expect(result).toEqual({ ok: true, directory: '/real/path/to/project' });
});
it('returns error when candidate is empty', async () => {
const runtime = createTestRuntime();
const result = await runtime.validateDirectoryPath('');
expect(result).toEqual({ ok: false, error: 'Directory parameter is required' });
});
it('returns error when candidate is not a string', async () => {
const runtime = createTestRuntime();
const result = await runtime.validateDirectoryPath(null);
expect(result).toEqual({ ok: false, error: 'Directory parameter is required' });
});
it('returns error when path is not a directory', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => false }),
realpath: async (p) => p,
},
});
const result = await runtime.validateDirectoryPath('/some/file.txt');
expect(result).toEqual({ ok: false, error: 'Specified path is not a directory' });
});
it('returns error when path does not exist', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => { throw { code: 'ENOENT' }; },
realpath: async (p) => p,
},
});
const result = await runtime.validateDirectoryPath('/nonexistent');
expect(result).toEqual({ ok: false, error: 'Directory not found' });
});
it('returns error when access is denied', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => { throw { code: 'EACCES' }; },
realpath: async (p) => p,
},
});
const result = await runtime.validateDirectoryPath('/restricted');
expect(result).toEqual({ ok: false, error: 'Access to directory denied' });
});
it('returns error when realpath fails after stat succeeds', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => { throw { code: 'ENOENT' }; },
},
});
const result = await runtime.validateDirectoryPath('/deleted-after-stat');
expect(result).toEqual({ ok: false, error: 'Directory not found' });
});
});
describe('resolveProjectDirectory', () => {
it('resolves symlinks in x-opencode-directory header', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => '/real/workspace/project',
},
});
const req = {
get: (header) => header === 'x-opencode-directory' ? '/home/user/workspace/project' : null,
query: {},
};
const result = await runtime.resolveProjectDirectory(req);
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
it('resolves symlinks in query directory parameter', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => '/real/workspace/project',
},
});
const req = {
get: () => null,
query: { directory: '/home/user/workspace/project' },
};
const result = await runtime.resolveProjectDirectory(req);
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
it('resolves symlinks in lastDirectory from settings', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => '/real/workspace/project',
},
getReadSettingsFromDiskMigrated: () => async () => ({
lastDirectory: '/home/user/workspace/project',
}),
});
const req = {
get: () => null,
query: {},
};
const result = await runtime.resolveProjectDirectory(req);
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
it('resolves symlinks in active project path from settings', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => '/real/workspace/project',
},
getReadSettingsFromDiskMigrated: () => async () => ({
projects: [{ id: 'proj-1', path: '/home/user/workspace/project' }],
activeProjectId: 'proj-1',
}),
sanitizeProjects: (input) => input,
});
const req = {
get: () => null,
query: {},
};
const result = await runtime.resolveProjectDirectory(req);
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
});
describe('resolveOptionalProjectDirectory', () => {
it('returns null directory when no directory is requested', async () => {
const runtime = createTestRuntime();
const req = {
get: () => null,
query: {},
};
const result = await runtime.resolveOptionalProjectDirectory(req);
expect(result).toEqual({ directory: null, error: null });
});
it('resolves symlinks when directory is provided', async () => {
const runtime = createTestRuntime({
fsPromises: {
stat: async () => ({ isDirectory: () => true }),
realpath: async () => '/real/workspace/project',
},
});
const req = {
get: (header) => header === 'x-opencode-directory' ? '/symlink/workspace/project' : null,
query: {},
};
const result = await runtime.resolveOptionalProjectDirectory(req);
expect(result).toEqual({ directory: '/real/workspace/project', error: null });
});
});
});
+43
View File
@@ -5,6 +5,31 @@ import {
collectForwardProxyHeaders,
shouldForwardProxyResponseHeader,
} from '../../proxy-headers.js';
import { createRealpathCache } from '../path-realpath-cache.js';
export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } = {}) => {
const realpathCache = createRealpathCache({ fallbackOnError: true, realpath, ...cacheOptions });
return async (requestUrl) => {
if (typeof requestUrl !== 'string' || !requestUrl.includes('directory=')) {
return requestUrl;
}
const url = new URL(requestUrl, 'http://localhost');
const directory = url.searchParams.get('directory');
if (!directory) {
return requestUrl;
}
const canonicalDirectory = await realpathCache.resolve(directory);
if (!canonicalDirectory || canonicalDirectory === directory) {
return requestUrl;
}
url.searchParams.set('directory', canonicalDirectory);
return `${url.pathname}${url.search}`;
};
};
export const waitForSseDrain = (res, signal) => new Promise((resolve) => {
if (signal?.aborted || res.writableEnded || res.destroyed) {
@@ -94,6 +119,9 @@ export const registerOpenCodeProxy = (app, deps) => {
const isAbortError = (error) => error?.name === 'AbortError';
const FALLBACK_PROXY_TARGET = 'http://127.0.0.1:3902';
const canonicalizeDirectoryQuery = createDirectoryQueryCanonicalizer({
realpath: fs?.promises?.realpath?.bind(fs.promises),
});
const normalizeProxyTarget = (candidate) => {
if (typeof candidate !== 'string') {
@@ -416,5 +444,20 @@ export const registerOpenCodeProxy = (app, deps) => {
},
});
// Best-effort fallback for stale clients still sending symlink paths.
// Settings and project selection normalize at source; this cached async path
// avoids blocking the proxy hot path on every directory-scoped request.
app.use('/api', async (req, _res, next) => {
try {
const rewrittenUrl = await canonicalizeDirectoryQuery(req.url);
if (rewrittenUrl !== req.url) {
req.url = rewrittenUrl;
}
} catch {
// Pass through as-is if URL parsing or realpath resolution fails.
}
next();
});
app.use('/api', apiProxy);
};
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { createDirectoryQueryCanonicalizer } from './proxy.js';
describe('createDirectoryQueryCanonicalizer', () => {
it('canonicalizes directory query params and preserves other params', async () => {
const canonicalize = createDirectoryQueryCanonicalizer({
realpath: async (value) => value === '/link/project' ? '/real/project' : value,
});
await expect(canonicalize('/session?foo=1&directory=/link/project&bar=2'))
.resolves.toBe('/session?foo=1&directory=%2Freal%2Fproject&bar=2');
});
it('caches directory realpath lookups', async () => {
let calls = 0;
const canonicalize = createDirectoryQueryCanonicalizer({
realpath: async () => {
calls += 1;
return '/real/project';
},
});
await expect(canonicalize('/session?directory=/link/project')).resolves.toBe('/session?directory=%2Freal%2Fproject');
await expect(canonicalize('/session?directory=/link/project')).resolves.toBe('/session?directory=%2Freal%2Fproject');
expect(calls).toBe(1);
});
it('deduplicates concurrent directory realpath lookups', async () => {
let calls = 0;
let release = () => undefined;
const pending = new Promise((resolve) => {
release = () => resolve('/real/project');
});
const canonicalize = createDirectoryQueryCanonicalizer({
realpath: async () => {
calls += 1;
return pending;
},
});
const first = canonicalize('/session?directory=/link/project');
const second = canonicalize('/session?directory=/link/project');
await Promise.resolve();
expect(calls).toBe(1);
release();
await expect(Promise.all([first, second])).resolves.toEqual([
'/session?directory=%2Freal%2Fproject',
'/session?directory=%2Freal%2Fproject',
]);
});
it('falls back to the original URL when realpath fails', async () => {
const canonicalize = createDirectoryQueryCanonicalizer({
realpath: async () => {
throw new Error('missing');
},
});
await expect(canonicalize('/session?foo=1&directory=/missing/project'))
.resolves.toBe('/session?foo=1&directory=/missing/project');
});
it('leaves URLs without directory params unchanged', async () => {
const canonicalize = createDirectoryQueryCanonicalizer({
realpath: async () => '/real/project',
});
await expect(canonicalize('/session?foo=1')).resolves.toBe('/session?foo=1');
});
});
@@ -3,6 +3,7 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
os,
path,
processLike,
realpathSync,
tunnelBootstrapTtlDefaultMs,
tunnelBootstrapTtlMinMs,
tunnelBootstrapTtlMaxMs,
@@ -32,7 +33,19 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
return trimmed;
};
const normalizePathForPersistence = (value) => {
// 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;
}
@@ -47,11 +60,13 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
return trimmed;
}
const resolved = options.resolveRealpath === false ? trimmed : safeRealpathSync(trimmed);
if (processLike.platform !== 'win32') {
return trimmed;
return resolved;
}
return trimmed.replace(/\//g, '\\');
return resolved.replace(/\//g, '\\');
};
const areStringArraysEqual = (a, b) => {
@@ -107,8 +122,8 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
const candidate = entry;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
const resolvedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : '';
const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath) : '';
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'
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js';
const createTestRuntime = (overrides = {}) => {
const defaults = {
os: { homedir: () => '/home/testuser' },
path: {
resolve: (...args) => args[args.length - 1],
sep: '/',
dirname: (p) => p.split('/').slice(0, -1).join('/') || '/',
},
processLike: { platform: 'linux', env: {} },
realpathSync: (p) => p,
tunnelBootstrapTtlDefaultMs: 600000,
tunnelBootstrapTtlMinMs: 60000,
tunnelBootstrapTtlMaxMs: 3600000,
tunnelSessionTtlDefaultMs: 86400000,
tunnelSessionTtlMinMs: 3600000,
tunnelSessionTtlMaxMs: 604800000,
};
return createSettingsNormalizationRuntime({ ...defaults, ...overrides });
};
describe('settings normalization runtime - symlink resolution', () => {
describe('normalizePathForPersistence', () => {
it('resolves symlinks via realpathSync', () => {
const runtime = createTestRuntime({
realpathSync: (p) =>
p === '/home/user/workplace' ? '/workplace/user' : p,
});
const result = runtime.normalizePathForPersistence('/home/user/workplace');
expect(result).toBe('/workplace/user');
});
it('falls back to original path when realpathSync throws', () => {
const runtime = createTestRuntime({
realpathSync: () => {
throw new Error('ENOENT');
},
});
const result = runtime.normalizePathForPersistence('/nonexistent/path');
expect(result).toBe('/nonexistent/path');
});
it('passes through when realpathSync is not provided', () => {
const runtime = createTestRuntime({ realpathSync: undefined });
const result = runtime.normalizePathForPersistence('/some/path');
expect(result).toBe('/some/path');
});
});
describe('sanitizeProjects', () => {
it('resolves symlinks in project paths', () => {
const runtime = createTestRuntime({
realpathSync: (p) =>
p === '/home/user/workplace/MyProject'
? '/workplace/user/MyProject'
: p,
});
const projects = [
{ id: 'proj1', path: '/home/user/workplace/MyProject', label: 'MyProject', color: 'primary', addedAt: 1000, lastOpenedAt: 1000 },
];
const result = runtime.sanitizeProjects(projects);
expect(result[0].path).toBe('/workplace/user/MyProject');
});
it('falls back to path.resolve when realpathSync throws', () => {
const runtime = createTestRuntime({
realpathSync: () => { throw new Error('ENOENT'); },
path: { resolve: (p) => '/resolved' + p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' },
});
const projects = [
{ id: 'proj1', path: '/missing/path', label: 'Missing', color: 'primary', addedAt: 1000, lastOpenedAt: 1000 },
];
const result = runtime.sanitizeProjects(projects);
expect(result[0].path).toBe('/resolved/missing/path');
});
it('deduplicates projects that resolve to the same realpath', () => {
const runtime = createTestRuntime({
realpathSync: (p) => p.startsWith('/symlink') ? '/real/project' : p,
path: { resolve: (p) => p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' },
});
const projects = [
{ id: 'proj1', path: '/symlink/a', label: 'A', color: 'primary', addedAt: 1000, lastOpenedAt: 1000 },
{ id: 'proj2', path: '/symlink/b', label: 'B', color: 'keyword', addedAt: 2000, lastOpenedAt: 2000 },
];
const result = runtime.sanitizeProjects(projects);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('proj1');
});
});
describe('normalizeSettingsPaths', () => {
it('resolves symlinks in lastDirectory', () => {
const runtime = createTestRuntime({
realpathSync: (p) =>
p === '/home/user/workplace/LyraRefactoring'
? '/workplace/user/LyraRefactoring'
: p,
});
const result = runtime.normalizeSettingsPaths({
lastDirectory: '/home/user/workplace/LyraRefactoring',
});
expect(result.changed).toBe(true);
expect(result.settings.lastDirectory).toBe('/workplace/user/LyraRefactoring');
});
it('does not flag as changed when path is already canonical', () => {
const runtime = createTestRuntime();
const result = runtime.normalizeSettingsPaths({
lastDirectory: '/real/path',
});
expect(result.changed).toBe(false);
});
});
});