Drop the canonical-containment 403 guard and the extra realpath(base) the read routes (stat/read/raw/serve) had gained. Every workspace resolution returns insideWorkspace: true and outside-file grants use base = dirname(canonicalPath), so the guard could never fire; the flag had no remaining reader and is gone with it. The read routes are back to the single realpath(resolved.resolved) they had before. Move the lexical-base fallback out of the inline header parsing in routes.js. x-opencode-directory decoding belongs to project-directory-runtime, so resolveProjectDirectory now also returns requestedDirectory, the pre-realpath candidate that validated. resolveWorkspacePathFromContext retries against it when the canonical base rejects a path, which keeps files under a symlinked project root addressable without a second copy of the header/query parsing.
155 lines
6.4 KiB
JavaScript
155 lines
6.4 KiB
JavaScript
import { createRealpathCache } from '../path-realpath-cache.js';
|
|
|
|
// Browser transport percent-encodes directory hints and marks them explicitly.
|
|
// Only marked values are decoded so literal percent sequences from direct API
|
|
// clients are preserved.
|
|
const safeDecodeMarkedURIComponent = (value, encoding) => {
|
|
if (encoding !== 'uri') return value;
|
|
try { return decodeURIComponent(value); } catch { return value; }
|
|
};
|
|
|
|
export const createProjectDirectoryRuntime = (dependencies) => {
|
|
const {
|
|
fsPromises,
|
|
path,
|
|
normalizeDirectoryPath,
|
|
readSettingsFromDiskMigrated,
|
|
getReadSettingsFromDiskMigrated,
|
|
sanitizeProjects,
|
|
} = dependencies;
|
|
const realpathCache = createRealpathCache({
|
|
realpath: fsPromises.realpath.bind(fsPromises),
|
|
});
|
|
|
|
const resolveDirectoryCandidate = (value) => {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
const normalized = normalizeDirectoryPath(trimmed);
|
|
return path.resolve(normalized);
|
|
};
|
|
|
|
const validateDirectoryPath = async (candidate) => {
|
|
const resolved = resolveDirectoryCandidate(candidate);
|
|
if (!resolved) {
|
|
return { ok: false, error: 'Directory parameter is required' };
|
|
}
|
|
try {
|
|
const stats = await fsPromises.stat(resolved);
|
|
if (!stats.isDirectory()) {
|
|
return { ok: false, error: 'Specified path is not a directory' };
|
|
}
|
|
const realPath = await realpathCache.resolve(resolved);
|
|
// `requestedDirectory` is the pre-realpath candidate the caller asked
|
|
// for. Callers that address files in the user-visible path space (the
|
|
// file tree, the read-family FS routes) need it when the project root
|
|
// is itself a symlink and the canonical `directory` no longer contains
|
|
// the paths the client sends.
|
|
return { ok: true, directory: realPath, requestedDirectory: resolved };
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return { ok: false, error: 'Directory not found' };
|
|
}
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|
return { ok: false, error: 'Access to directory denied' };
|
|
}
|
|
return { ok: false, error: 'Failed to validate directory' };
|
|
}
|
|
};
|
|
|
|
const resolveProjectDirectory = async (req) => {
|
|
const rawHeaderDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
|
const headerEncoding = typeof req.get === 'function' ? req.get('x-opencode-directory-encoding') : null;
|
|
const headerDirectory = rawHeaderDirectory ? safeDecodeMarkedURIComponent(rawHeaderDirectory, headerEncoding) : null;
|
|
const queryDirectory = Array.isArray(req.query?.directory)
|
|
? req.query.directory[0]
|
|
: req.query?.directory;
|
|
const requested = [headerDirectory, queryDirectory].filter(Boolean);
|
|
|
|
if (requested.length > 0) {
|
|
let lastError = null;
|
|
for (const candidate of requested) {
|
|
const validated = await validateDirectoryPath(candidate);
|
|
if (validated.ok) {
|
|
return { directory: validated.directory, requestedDirectory: validated.requestedDirectory, error: null };
|
|
}
|
|
lastError = validated.error;
|
|
}
|
|
return { directory: null, requestedDirectory: null, error: lastError };
|
|
}
|
|
|
|
const readSettings = typeof getReadSettingsFromDiskMigrated === 'function'
|
|
? getReadSettingsFromDiskMigrated()
|
|
: readSettingsFromDiskMigrated;
|
|
const settings = await readSettings();
|
|
|
|
// `lastDirectory` reflects the directory the UI is currently browsing —
|
|
// useDirectoryStore.setDirectory() persists it on every navigation.
|
|
// Prefer it over activeProjectId, because the user may have navigated
|
|
// away from the project that was last "clicked" in the sidebar (e.g. via
|
|
// `go to parent`, directory picker, or a deep link), leaving
|
|
// activeProjectId stale. Fetches scoped to the stale project would 400
|
|
// with "Path is outside of active workspace".
|
|
if (typeof settings.lastDirectory === 'string' && settings.lastDirectory.trim()) {
|
|
const validated = await validateDirectoryPath(settings.lastDirectory);
|
|
if (validated.ok) {
|
|
return { directory: validated.directory, requestedDirectory: validated.requestedDirectory, error: null };
|
|
}
|
|
}
|
|
|
|
const projects = sanitizeProjects(settings.projects) || [];
|
|
if (projects.length === 0) {
|
|
return { directory: null, requestedDirectory: null, error: 'Directory parameter or active project is required' };
|
|
}
|
|
|
|
const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
|
|
const active = projects.find((project) => project.id === activeId) || projects[0];
|
|
if (!active || !active.path) {
|
|
return { directory: null, requestedDirectory: null, error: 'Directory parameter or active project is required' };
|
|
}
|
|
|
|
const validated = await validateDirectoryPath(active.path);
|
|
if (!validated.ok) {
|
|
return { directory: null, requestedDirectory: null, error: validated.error };
|
|
}
|
|
|
|
return { directory: validated.directory, requestedDirectory: validated.requestedDirectory, error: null };
|
|
};
|
|
|
|
const resolveOptionalProjectDirectory = async (req) => {
|
|
const rawHeaderDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
|
const headerEncoding = typeof req.get === 'function' ? req.get('x-opencode-directory-encoding') : null;
|
|
const headerDirectory = rawHeaderDirectory ? safeDecodeMarkedURIComponent(rawHeaderDirectory, headerEncoding) : null;
|
|
const queryDirectory = Array.isArray(req.query?.directory)
|
|
? req.query.directory[0]
|
|
: req.query?.directory;
|
|
const requested = [headerDirectory, queryDirectory].filter(Boolean);
|
|
|
|
if (requested.length === 0) {
|
|
return { directory: null, requestedDirectory: null, error: null };
|
|
}
|
|
|
|
let lastError = null;
|
|
for (const candidate of requested) {
|
|
const validated = await validateDirectoryPath(candidate);
|
|
if (validated.ok) {
|
|
return { directory: validated.directory, requestedDirectory: validated.requestedDirectory, error: null };
|
|
}
|
|
lastError = validated.error;
|
|
}
|
|
return { directory: null, requestedDirectory: null, error: lastError };
|
|
};
|
|
|
|
return {
|
|
resolveDirectoryCandidate,
|
|
validateDirectoryPath,
|
|
resolveProjectDirectory,
|
|
resolveOptionalProjectDirectory,
|
|
};
|
|
};
|