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
+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);
};