diff --git a/backend/src/routes/opencode-proxy.ts b/backend/src/routes/opencode-proxy.ts index 6b9aa16b1..04ad88878 100644 --- a/backend/src/routes/opencode-proxy.ts +++ b/backend/src/routes/opencode-proxy.ts @@ -11,6 +11,8 @@ import { withDefaultOpenCodeDirectory, } from '../services/opencode/upstream' import { getRepoById } from '../db/queries' +import { getWorkspacePath } from '@opencode-manager/shared/config/env' +import { isPathWithinRoot } from '../services/sandbox/command' import { buildProxyResponseHeaders, filterProxyHeaders } from '../utils/proxy-headers' interface ProxyRequestParts { @@ -24,23 +26,43 @@ type ProxyRewrite = (parts: ProxyRequestParts) => void type ProxyBodyRewrite = (bodyText: string) => string | undefined +type RepoDirectoryResolver = (directory: string | null | undefined) => string + function isJsonContentType(contentType: string | undefined): boolean { return (contentType ?? '').toLowerCase().includes('application/json') } -function rewriteRepoLocation(parts: ProxyRequestParts, directory: string): void { +function decodeDirectoryHeader(value: string | undefined): string | undefined { + if (!value) return undefined + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +/** + * Keeps a directory the Manager's OpenCode server owns (repos, OpenCode and schedule + * worktrees, all under the workspace) and maps anything else, such as the attaching + * client's local cwd, to the bound repo. + */ +function createRepoDirectoryResolver(repoPath: string): RepoDirectoryResolver { + return (directory) => (directory && isPathWithinRoot(getWorkspacePath(), directory) ? directory : repoPath) +} + +function rewriteRepoLocation(parts: ProxyRequestParts, directory: string, resolveDirectory: RepoDirectoryResolver): void { parts.headers[OPENCODE_DIRECTORY_HEADER] = encodeURIComponent(directory) if (parts.searchParams.has('location[directory]')) { parts.searchParams.set('location[directory]', directory) } - if (parts.method === 'GET' && parts.path === '/api/session') { - parts.searchParams.set('directory', directory) + if (parts.method === 'GET' && parts.path === '/api/session' && parts.searchParams.has('directory')) { + parts.searchParams.set('directory', resolveDirectory(parts.searchParams.get('directory'))) } } -function rewriteRepoLocationBody(bodyText: string, directory: string): string | undefined { +function rewriteRepoLocationBody(bodyText: string, resolveDirectory: RepoDirectoryResolver): string | undefined { let parsed: unknown try { parsed = JSON.parse(bodyText) @@ -53,6 +75,8 @@ function rewriteRepoLocationBody(bodyText: string, directory: string): string | const location = (parsed as { location?: unknown }).location if (!location || typeof location !== 'object' || Array.isArray(location)) return undefined + const requested = (location as { directory?: unknown }).directory + const directory = resolveDirectory(typeof requested === 'string' ? requested : undefined) ;(parsed as { location: Record }).location = { ...location, directory } return JSON.stringify(parsed) } @@ -141,12 +165,16 @@ export function createOpenCodeProxyRoutes(db: Database, settingsService: Setting const url = new URL(c.req.url) const pathSuffix = url.pathname.replace(/^\/api\/opencode-proxy\/repos\/[^/]+/, '') || '/' + const resolveDirectory = createRepoDirectoryResolver(repo.fullPath) + const directory = resolveDirectory( + url.searchParams.get('location[directory]') || decodeDirectoryHeader(c.req.header(OPENCODE_DIRECTORY_HEADER)), + ) return forwardToOpenCode( c, pathSuffix, - (parts) => rewriteRepoLocation(parts, repo.fullPath), - (bodyText) => rewriteRepoLocationBody(bodyText, repo.fullPath), + (parts) => rewriteRepoLocation(parts, directory, resolveDirectory), + (bodyText) => rewriteRepoLocationBody(bodyText, resolveDirectory), ) }) diff --git a/backend/test/routes/opencode-proxy.test.ts b/backend/test/routes/opencode-proxy.test.ts index 891f7decc..9e9d2d201 100644 --- a/backend/test/routes/opencode-proxy.test.ts +++ b/backend/test/routes/opencode-proxy.test.ts @@ -817,7 +817,7 @@ describe('opencode-proxy repo-scoped mount', () => { const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] const fetchHeaders = fetchCall[1].headers as Record expect(fetchHeaders['x-opencode-directory']).toBe(encodeURIComponent('/srv/repos/my-repo')) - expect(fetchCall[0]).toBe('http://127.0.0.1:5551/api/session?limit=1&directory=%2Fsrv%2Frepos%2Fmy-repo') + expect(fetchCall[0]).toBe('http://127.0.0.1:5551/api/session?limit=1') }) it('rewrites the location[directory] query value', async () => { @@ -924,6 +924,85 @@ describe('opencode-proxy repo-scoped mount', () => { const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] expect(new TextDecoder().decode(fetchCall[1].body as ArrayBuffer)).toBe('{not json') }) + + describe('workspace directories', () => { + const worktreePath = join(getWorkspacePath(), '.opencode', 'state', 'opencode', 'worktree', 'abc123', 'feature') + const otherRepoPath = join(getWorkspacePath(), 'repos', 'other-repo') + + beforeEach(() => { + getRepoByIdMock.mockReturnValue(readyRepo) + }) + + it('keeps a workspace x-opencode-directory', async () => { + const upstreamFetch = upstreamOk() + + const res = await app.request('/api/opencode-proxy/repos/7/api/agent', { + headers: { + Authorization: 'Bearer test-internal-token', + 'x-opencode-directory': encodeURIComponent(worktreePath), + }, + }) + + expect(res.status).toBe(200) + const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] + expect((fetchCall[1].headers as Record)['x-opencode-directory']).toBe(encodeURIComponent(worktreePath)) + }) + + it('keeps another project location[directory] from the workspace', async () => { + const upstreamFetch = upstreamOk() + + const res = await app.request( + `/api/opencode-proxy/repos/7/api/agent?location%5Bdirectory%5D=${encodeURIComponent(otherRepoPath)}`, + { headers: { Authorization: 'Bearer test-internal-token' } } + ) + + expect(res.status).toBe(200) + const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] + expect(new URL(fetchCall[0]).searchParams.get('location[directory]')).toBe(otherRepoPath) + expect((fetchCall[1].headers as Record)['x-opencode-directory']).toBe(encodeURIComponent(otherRepoPath)) + }) + + it('keeps a workspace body location.directory', async () => { + const upstreamFetch = upstreamOk() + + const res = await app.request('/api/opencode-proxy/repos/7/api/session', { + method: 'POST', + headers: { + Authorization: 'Bearer test-internal-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ title: 'x', location: { directory: worktreePath } }), + }) + + expect(res.status).toBe(200) + const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] + expect(JSON.parse(fetchCall[1].body as string)).toEqual({ title: 'x', location: { directory: worktreePath } }) + }) + + it('keeps a workspace directory filter on the session list', async () => { + const upstreamFetch = upstreamOk() + + const res = await app.request(`/api/opencode-proxy/repos/7/api/session?directory=${encodeURIComponent(worktreePath)}`, { + headers: { Authorization: 'Bearer test-internal-token' }, + }) + + expect(res.status).toBe(200) + const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] + expect(new URL(fetchCall[0]).searchParams.get('directory')).toBe(worktreePath) + }) + + it('does not add a directory filter to an unfiltered session list', async () => { + const upstreamFetch = upstreamOk() + + const res = await app.request('/api/opencode-proxy/repos/7/api/session?limit=50&parentID=null', { + headers: { Authorization: 'Bearer test-internal-token' }, + }) + + expect(res.status).toBe(200) + const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] + expect(new URL(fetchCall[0]).searchParams.has('directory')).toBe(false) + }) + }) }) const SHIPPED_OPENCODE_BIN = resolveOpenCode2Binary()