diff --git a/packages/web/server/lib/opencode/project-directory-runtime.js b/packages/web/server/lib/opencode/project-directory-runtime.js index b6c90347..2e289752 100644 --- a/packages/web/server/lib/opencode/project-directory-runtime.js +++ b/packages/web/server/lib/opencode/project-directory-runtime.js @@ -64,14 +64,18 @@ export const createProjectDirectoryRuntime = (dependencies) => { const queryDirectory = Array.isArray(req.query?.directory) ? req.query.directory[0] : req.query?.directory; - const requested = headerDirectory || queryDirectory || null; + const requested = [headerDirectory, queryDirectory].filter(Boolean); - if (requested) { - const validated = await validateDirectoryPath(requested); - if (!validated.ok) { - return { directory: null, error: validated.error }; + if (requested.length > 0) { + let lastError = null; + for (const candidate of requested) { + const validated = await validateDirectoryPath(candidate); + if (validated.ok) { + return { directory: validated.directory, error: null }; + } + lastError = validated.error; } - return { directory: validated.directory, error: null }; + return { directory: null, error: lastError }; } const readSettings = typeof getReadSettingsFromDiskMigrated === 'function' @@ -119,18 +123,21 @@ export const createProjectDirectoryRuntime = (dependencies) => { const queryDirectory = Array.isArray(req.query?.directory) ? req.query.directory[0] : req.query?.directory; - const requested = headerDirectory || queryDirectory || null; + const requested = [headerDirectory, queryDirectory].filter(Boolean); - if (!requested) { + if (requested.length === 0) { return { directory: null, error: null }; } - const validated = await validateDirectoryPath(requested); - if (!validated.ok) { - return { directory: null, error: validated.error }; + let lastError = null; + for (const candidate of requested) { + const validated = await validateDirectoryPath(candidate); + if (validated.ok) { + return { directory: validated.directory, error: null }; + } + lastError = validated.error; } - - return { directory: validated.directory, error: null }; + return { directory: null, error: lastError }; }; return { diff --git a/packages/web/server/lib/opencode/project-directory-runtime.test.js b/packages/web/server/lib/opencode/project-directory-runtime.test.js index 94b57b1a..2a12a79a 100644 --- a/packages/web/server/lib/opencode/project-directory-runtime.test.js +++ b/packages/web/server/lib/opencode/project-directory-runtime.test.js @@ -180,6 +180,28 @@ describe('project directory runtime', () => { expect(result).toEqual({ directory: rawPath, error: null }); }); + it('falls back to query directory when an unmarked encoded header is invalid', async () => { + const validPath = '/home/user/workspace/project'; + const runtime = createTestRuntime({ + fsPromises: { + stat: async (p) => { + if (p === validPath) return { isDirectory: () => true }; + throw { code: 'ENOENT' }; + }, + realpath: async (p) => p, + }, + }); + + const req = { + get: (header) => header === 'x-opencode-directory' ? encodeURIComponent(validPath) : null, + query: { directory: validPath }, + }; + + const result = await runtime.resolveProjectDirectory(req); + + expect(result).toEqual({ directory: validPath, error: null }); + }); + it('resolves symlinks in query directory parameter', async () => { const runtime = createTestRuntime({ fsPromises: { diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js index 73897026..bb5e87aa 100644 --- a/packages/web/server/lib/opencode/proxy.js +++ b/packages/web/server/lib/opencode/proxy.js @@ -31,6 +31,25 @@ export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } }; }; +export const normalizeForwardedDirectoryHeaders = (headers) => { + const rawDirectory = headers?.['x-opencode-directory']; + if (typeof rawDirectory !== 'string') { + return headers; + } + + if (headers['x-opencode-directory-encoding'] !== 'uri') { + return headers; + } + + try { + headers['x-opencode-directory'] = decodeURIComponent(rawDirectory); + } catch { + // Leave malformed values untouched; upstream will reject invalid paths. + } + delete headers['x-opencode-directory-encoding']; + return headers; +}; + export const waitForSseDrain = (res, signal) => new Promise((resolve) => { if (signal?.aborted || res.writableEnded || res.destroyed) { resolve(); @@ -295,7 +314,9 @@ export const registerOpenCodeProxy = (app, deps) => { ? req.originalUrl : (typeof req.url === 'string' ? req.url : ''); const upstreamPath = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl; - const headers = collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()); + const headers = normalizeForwardedDirectoryHeaders( + collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()) + ); headers.accept ??= 'text/event-stream'; headers['cache-control'] ??= 'no-cache'; @@ -414,7 +435,7 @@ export const registerOpenCodeProxy = (app, deps) => { const fetchSessionListPayload = async (upstreamPath, { req = null, timeoutMs = null } = {}) => { const headers = req ? { - ...collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()), + ...normalizeForwardedDirectoryHeaders(collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())), accept: 'application/json', 'accept-encoding': 'identity', } @@ -654,6 +675,18 @@ export const registerOpenCodeProxy = (app, deps) => { proxyReq.setHeader('Authorization', authHeaders.Authorization); } + if (req.headers?.['x-opencode-directory-encoding'] === 'uri') { + const rawDirectory = req.headers['x-opencode-directory']; + if (typeof rawDirectory === 'string') { + try { + proxyReq.setHeader('x-opencode-directory', decodeURIComponent(rawDirectory)); + } catch { + proxyReq.setHeader('x-opencode-directory', rawDirectory); + } + } + proxyReq.removeHeader?.('x-opencode-directory-encoding'); + } + // Defensive: request identity encoding from upstream OpenCode. // This avoids compressed-body/header mismatches in multi-proxy setups. proxyReq.setHeader('accept-encoding', 'identity'); diff --git a/packages/web/server/lib/opencode/proxy.test.js b/packages/web/server/lib/opencode/proxy.test.js index 5829b1e5..91326d71 100644 --- a/packages/web/server/lib/opencode/proxy.test.js +++ b/packages/web/server/lib/opencode/proxy.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createDirectoryQueryCanonicalizer } from './proxy.js'; +import { createDirectoryQueryCanonicalizer, normalizeForwardedDirectoryHeaders } from './proxy.js'; describe('createDirectoryQueryCanonicalizer', () => { it('canonicalizes directory query params and preserves other params', async () => { @@ -70,3 +70,26 @@ describe('createDirectoryQueryCanonicalizer', () => { await expect(canonicalize('/session?foo=1')).resolves.toBe('/session?foo=1'); }); }); + +describe('normalizeForwardedDirectoryHeaders', () => { + it('decodes marked directory headers before forwarding to OpenCode', () => { + const headers = normalizeForwardedDirectoryHeaders({ + 'x-opencode-directory': encodeURIComponent('/Users/example/project'), + 'x-opencode-directory-encoding': 'uri', + }); + + expect(headers).toEqual({ + 'x-opencode-directory': '/Users/example/project', + }); + }); + + it('preserves unmarked percent sequences from direct clients', () => { + const headers = normalizeForwardedDirectoryHeaders({ + 'x-opencode-directory': '/Users/example/project%20literal', + }); + + expect(headers).toEqual({ + 'x-opencode-directory': '/Users/example/project%20literal', + }); + }); +});