From f952a99b37f01265aea093276326e2a244f98bc0 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:47:43 -0400 Subject: [PATCH 1/2] fix(ocm): scope the repo proxy to the requested worktree --- backend/src/index.ts | 2 +- backend/src/routes/opencode-proxy.ts | 62 +++++++++++-- backend/test/routes/opencode-proxy.test.ts | 101 ++++++++++++++++++++- 3 files changed, 154 insertions(+), 11 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 4dcedd4ee..4042ae166 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -341,7 +341,7 @@ app.route('/api/health', createHealthRoutes(db, openCodeSupervisor)) app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth)) app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService)) -app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService)) +app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService, gitAuthService, openCodeClient)) const protectedApi = new Hono() protectedApi.use('/*', requireAuth) diff --git a/backend/src/routes/opencode-proxy.ts b/backend/src/routes/opencode-proxy.ts index 6b9aa16b1..ffba981e1 100644 --- a/backend/src/routes/opencode-proxy.ts +++ b/backend/src/routes/opencode-proxy.ts @@ -11,6 +11,11 @@ import { withDefaultOpenCodeDirectory, } from '../services/opencode/upstream' import { getRepoById } from '../db/queries' +import { getSiblingRepos, resolveRepoWorkingDirectory } from '../services/repo' +import type { GitAuthService } from '../services/git-auth' +import type { OpenCodeClient } from '../services/opencode/client' +import type { Repo } from '@opencode-manager/shared/types' +import type { RepoSibling } from '@opencode-manager/shared/utils' import { buildProxyResponseHeaders, filterProxyHeaders } from '../utils/proxy-headers' interface ProxyRequestParts { @@ -22,12 +27,25 @@ interface ProxyRequestParts { type ProxyRewrite = (parts: ProxyRequestParts) => void -type ProxyBodyRewrite = (bodyText: string) => string | undefined +type ProxyBodyRewrite = (bodyText: string) => Promise + +type RepoDirectoryResolver = (directory: string | undefined) => Promise + +const REPO_SIBLINGS_CACHE_TTL_MS = 5_000 function isJsonContentType(contentType: string | undefined): boolean { return (contentType ?? '').toLowerCase().includes('application/json') } +function decodeDirectoryHeader(value: string | undefined): string | undefined { + if (!value) return undefined + try { + return decodeURIComponent(value) + } catch { + return value + } +} + function rewriteRepoLocation(parts: ProxyRequestParts, directory: string): void { parts.headers[OPENCODE_DIRECTORY_HEADER] = encodeURIComponent(directory) @@ -35,12 +53,12 @@ function rewriteRepoLocation(parts: ProxyRequestParts, directory: string): void parts.searchParams.set('location[directory]', directory) } - if (parts.method === 'GET' && parts.path === '/api/session') { + if (parts.method === 'GET' && parts.path === '/api/session' && !parts.searchParams.has('project')) { parts.searchParams.set('directory', directory) } } -function rewriteRepoLocationBody(bodyText: string, directory: string): string | undefined { +async function rewriteRepoLocationBody(bodyText: string, resolveDirectory: RepoDirectoryResolver): Promise { let parsed: unknown try { parsed = JSON.parse(bodyText) @@ -53,15 +71,41 @@ 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 = await resolveDirectory(typeof requested === 'string' ? requested : undefined) ;(parsed as { location: Record }).location = { ...location, directory } return JSON.stringify(parsed) } -export function createOpenCodeProxyRoutes(db: Database, settingsService: SettingsService) { +export function createOpenCodeProxyRoutes( + db: Database, + settingsService: SettingsService, + gitAuthService: GitAuthService, + openCodeClient: OpenCodeClient, +) { const app = new Hono() + const repoSiblingsCache = new Map }>() app.use('/*', createInternalTokenMiddleware(db)) + function loadRepoSiblings(repoId: number): Promise { + const cached = repoSiblingsCache.get(repoId) + if (cached && cached.expiresAt > Date.now()) return cached.siblings + + const siblings = getSiblingRepos(db, repoId, gitAuthService.getGitEnvironment(), openCodeClient, { includeBranch: false }) + .catch(() => { + repoSiblingsCache.delete(repoId) + return [] + }) + repoSiblingsCache.set(repoId, { expiresAt: Date.now() + REPO_SIBLINGS_CACHE_TTL_MS, siblings }) + return siblings + } + + function createRepoDirectoryResolver(repo: Repo): RepoDirectoryResolver { + return async (directory) => + (await resolveRepoWorkingDirectory(repo, directory, () => loadRepoSiblings(repo.id))) ?? repo.fullPath + } + async function forwardToOpenCode( c: Context, pathSuffix: string, @@ -105,7 +149,7 @@ export function createOpenCodeProxyRoutes(db: Database, settingsService: Setting if (rewriteBody && rawBody !== undefined) { const bodyText = rawBody.byteLength > 0 ? new TextDecoder().decode(rawBody) : undefined - const rewrittenBody = bodyText === undefined ? undefined : rewriteBody(bodyText) + const rewrittenBody = bodyText === undefined ? undefined : await rewriteBody(bodyText) requestBody = rewrittenBody === undefined ? rawBody : rewrittenBody } @@ -141,12 +185,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) + const directory = await 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), + (bodyText) => rewriteRepoLocationBody(bodyText, resolveDirectory), ) }) diff --git a/backend/test/routes/opencode-proxy.test.ts b/backend/test/routes/opencode-proxy.test.ts index 891f7decc..f69dd5c28 100644 --- a/backend/test/routes/opencode-proxy.test.ts +++ b/backend/test/routes/opencode-proxy.test.ts @@ -12,6 +12,8 @@ import { buildOpenCodeBasicAuth } from '@opencode-manager/shared/opencode' import { getWorkspacePath } from '@opencode-manager/shared/config/env' import { createOpenCodeProxyRoutes } from '../../src/routes/opencode-proxy' import type { SettingsService } from '../../src/services/settings' +import type { GitAuthService } from '../../src/services/git-auth' +import type { OpenCodeClient } from '../../src/services/opencode/client' import { OpenCodeSupervisor } from '../../src/services/opencode-supervisor' import { resolveOpenCode2Binary, startOpenCodeServe } from '../helpers/opencode-binary' @@ -35,6 +37,13 @@ vi.mock('../../src/db/queries', () => ({ getRepoById: getRepoByIdMock, })) +const getSiblingReposMock = vi.hoisted(() => vi.fn().mockResolvedValue([])) + +vi.mock('../../src/services/repo', async (importOriginal) => ({ + ...await importOriginal(), + getSiblingRepos: getSiblingReposMock, +})) + const upstreamBaseUrl = vi.hoisted(() => ({ value: 'http://127.0.0.1:5551' })) vi.mock('../../src/services/opencode/upstream', async (importOriginal) => ({ @@ -52,6 +61,10 @@ const mockSettingsService = { const mockDb = {} as Database +const mockGitAuthService = { getGitEnvironment: () => ({}) } as unknown as GitAuthService + +const mockOpenCodeClient = {} as OpenCodeClient + function upstreamOk() { const upstreamFetch = vi.fn().mockResolvedValue( new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }) @@ -69,7 +82,7 @@ describe('opencode-proxy routes', () => { isLifecycleInitializedMock.mockReturnValue(true) originalFetch = globalThis.fetch app = new Hono() - app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService)) + app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService, mockGitAuthService, mockOpenCodeClient)) }) afterEach(() => { @@ -771,7 +784,7 @@ describe('opencode-proxy repo-scoped mount', () => { isLifecycleInitializedMock.mockReturnValue(true) originalFetch = globalThis.fetch app = new Hono() - app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService)) + app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService, mockGitAuthService, mockOpenCodeClient)) }) afterEach(() => { @@ -924,6 +937,88 @@ 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('repo worktrees', () => { + const worktreePath = '/srv/worktrees/my-repo-feature' + + beforeEach(() => { + getRepoByIdMock.mockReturnValue(readyRepo) + getSiblingReposMock.mockResolvedValue([readyRepo, { ...readyRepo, id: -1, fullPath: worktreePath, worktreeSource: 'opencode' }]) + }) + + it('keeps a worktree x-opencode-directory and scopes the session list to it', async () => { + const upstreamFetch = upstreamOk() + + const res = await app.request('/api/opencode-proxy/repos/7/api/session?limit=1', { + 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)) + expect(new URL(fetchCall[0]).searchParams.get('directory')).toBe(worktreePath) + }) + + it('keeps a worktree location[directory] query value', async () => { + const upstreamFetch = upstreamOk() + + const res = await app.request( + `/api/opencode-proxy/repos/7/api/agent?location%5Bdirectory%5D=${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('location[directory]')).toBe(worktreePath) + expect((fetchCall[1].headers as Record)['x-opencode-directory']).toBe(encodeURIComponent(worktreePath)) + }) + + it('keeps a worktree 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('does not add a directory filter to a project-scoped session list', async () => { + const upstreamFetch = upstreamOk() + + const res = await app.request('/api/opencode-proxy/repos/7/api/session?project=prj_1&subpath=..%2Fx', { + 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) + }) + + it('loads siblings once across requests within the cache window', async () => { + upstreamOk() + const request = () => app.request('/api/opencode-proxy/repos/7/api/agent', { + headers: { + Authorization: 'Bearer test-internal-token', + 'x-opencode-directory': encodeURIComponent(worktreePath), + }, + }) + + await Promise.all([request(), request(), request()]) + + expect(getSiblingReposMock).toHaveBeenCalledTimes(1) + }) + }) }) const SHIPPED_OPENCODE_BIN = resolveOpenCode2Binary() @@ -1036,7 +1131,7 @@ describe.skipIf(SHIPPED_OPENCODE_BIN === null)('opencode-proxy repo-scoped mount getRepoByIdMock.mockReturnValue({ id: 1, fullPath: repoPath, cloneStatus: 'ready' }) const proxyApp = new Hono() - proxyApp.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService)) + proxyApp.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService, mockGitAuthService, mockOpenCodeClient)) const proxyServer = await new Promise>((resolve) => { const server = serve({ fetch: proxyApp.fetch, port: 0, hostname: '127.0.0.1' }, () => resolve(server)) }) From 4cc978594828733e82693f1781d73abde8793ca9 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:02:56 -0400 Subject: [PATCH 2/2] fix(ocm): pass workspace directories through the repo proxy Keep any directory inside the Manager workspace (repos, OpenCode and schedule worktrees) instead of forcing the bound repo, so /projects and worktree session lists work. Only remap a directory the client sent on the session list, and stop adding a directory filter to lists that carry none. --- backend/src/index.ts | 2 +- backend/src/routes/opencode-proxy.ts | 66 ++++++++-------------- backend/test/routes/opencode-proxy.test.ts | 66 ++++++++-------------- 3 files changed, 49 insertions(+), 85 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 4042ae166..4dcedd4ee 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -341,7 +341,7 @@ app.route('/api/health', createHealthRoutes(db, openCodeSupervisor)) app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth)) app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService)) -app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService, gitAuthService, openCodeClient)) +app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService)) const protectedApi = new Hono() protectedApi.use('/*', requireAuth) diff --git a/backend/src/routes/opencode-proxy.ts b/backend/src/routes/opencode-proxy.ts index ffba981e1..04ad88878 100644 --- a/backend/src/routes/opencode-proxy.ts +++ b/backend/src/routes/opencode-proxy.ts @@ -11,11 +11,8 @@ import { withDefaultOpenCodeDirectory, } from '../services/opencode/upstream' import { getRepoById } from '../db/queries' -import { getSiblingRepos, resolveRepoWorkingDirectory } from '../services/repo' -import type { GitAuthService } from '../services/git-auth' -import type { OpenCodeClient } from '../services/opencode/client' -import type { Repo } from '@opencode-manager/shared/types' -import type { RepoSibling } from '@opencode-manager/shared/utils' +import { getWorkspacePath } from '@opencode-manager/shared/config/env' +import { isPathWithinRoot } from '../services/sandbox/command' import { buildProxyResponseHeaders, filterProxyHeaders } from '../utils/proxy-headers' interface ProxyRequestParts { @@ -27,11 +24,9 @@ interface ProxyRequestParts { type ProxyRewrite = (parts: ProxyRequestParts) => void -type ProxyBodyRewrite = (bodyText: string) => Promise +type ProxyBodyRewrite = (bodyText: string) => string | undefined -type RepoDirectoryResolver = (directory: string | undefined) => Promise - -const REPO_SIBLINGS_CACHE_TTL_MS = 5_000 +type RepoDirectoryResolver = (directory: string | null | undefined) => string function isJsonContentType(contentType: string | undefined): boolean { return (contentType ?? '').toLowerCase().includes('application/json') @@ -46,19 +41,28 @@ function decodeDirectoryHeader(value: string | undefined): string | undefined { } } -function rewriteRepoLocation(parts: ProxyRequestParts, directory: string): void { +/** + * 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.has('project')) { - 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'))) } } -async function rewriteRepoLocationBody(bodyText: string, resolveDirectory: RepoDirectoryResolver): Promise { +function rewriteRepoLocationBody(bodyText: string, resolveDirectory: RepoDirectoryResolver): string | undefined { let parsed: unknown try { parsed = JSON.parse(bodyText) @@ -72,40 +76,16 @@ async function rewriteRepoLocationBody(bodyText: string, resolveDirectory: RepoD if (!location || typeof location !== 'object' || Array.isArray(location)) return undefined const requested = (location as { directory?: unknown }).directory - const directory = await resolveDirectory(typeof requested === 'string' ? requested : undefined) + const directory = resolveDirectory(typeof requested === 'string' ? requested : undefined) ;(parsed as { location: Record }).location = { ...location, directory } return JSON.stringify(parsed) } -export function createOpenCodeProxyRoutes( - db: Database, - settingsService: SettingsService, - gitAuthService: GitAuthService, - openCodeClient: OpenCodeClient, -) { +export function createOpenCodeProxyRoutes(db: Database, settingsService: SettingsService) { const app = new Hono() - const repoSiblingsCache = new Map }>() app.use('/*', createInternalTokenMiddleware(db)) - function loadRepoSiblings(repoId: number): Promise { - const cached = repoSiblingsCache.get(repoId) - if (cached && cached.expiresAt > Date.now()) return cached.siblings - - const siblings = getSiblingRepos(db, repoId, gitAuthService.getGitEnvironment(), openCodeClient, { includeBranch: false }) - .catch(() => { - repoSiblingsCache.delete(repoId) - return [] - }) - repoSiblingsCache.set(repoId, { expiresAt: Date.now() + REPO_SIBLINGS_CACHE_TTL_MS, siblings }) - return siblings - } - - function createRepoDirectoryResolver(repo: Repo): RepoDirectoryResolver { - return async (directory) => - (await resolveRepoWorkingDirectory(repo, directory, () => loadRepoSiblings(repo.id))) ?? repo.fullPath - } - async function forwardToOpenCode( c: Context, pathSuffix: string, @@ -149,7 +129,7 @@ export function createOpenCodeProxyRoutes( if (rewriteBody && rawBody !== undefined) { const bodyText = rawBody.byteLength > 0 ? new TextDecoder().decode(rawBody) : undefined - const rewrittenBody = bodyText === undefined ? undefined : await rewriteBody(bodyText) + const rewrittenBody = bodyText === undefined ? undefined : rewriteBody(bodyText) requestBody = rewrittenBody === undefined ? rawBody : rewrittenBody } @@ -185,15 +165,15 @@ export function createOpenCodeProxyRoutes( const url = new URL(c.req.url) const pathSuffix = url.pathname.replace(/^\/api\/opencode-proxy\/repos\/[^/]+/, '') || '/' - const resolveDirectory = createRepoDirectoryResolver(repo) - const directory = await resolveDirectory( + 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, directory), + (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 f69dd5c28..9e9d2d201 100644 --- a/backend/test/routes/opencode-proxy.test.ts +++ b/backend/test/routes/opencode-proxy.test.ts @@ -12,8 +12,6 @@ import { buildOpenCodeBasicAuth } from '@opencode-manager/shared/opencode' import { getWorkspacePath } from '@opencode-manager/shared/config/env' import { createOpenCodeProxyRoutes } from '../../src/routes/opencode-proxy' import type { SettingsService } from '../../src/services/settings' -import type { GitAuthService } from '../../src/services/git-auth' -import type { OpenCodeClient } from '../../src/services/opencode/client' import { OpenCodeSupervisor } from '../../src/services/opencode-supervisor' import { resolveOpenCode2Binary, startOpenCodeServe } from '../helpers/opencode-binary' @@ -37,13 +35,6 @@ vi.mock('../../src/db/queries', () => ({ getRepoById: getRepoByIdMock, })) -const getSiblingReposMock = vi.hoisted(() => vi.fn().mockResolvedValue([])) - -vi.mock('../../src/services/repo', async (importOriginal) => ({ - ...await importOriginal(), - getSiblingRepos: getSiblingReposMock, -})) - const upstreamBaseUrl = vi.hoisted(() => ({ value: 'http://127.0.0.1:5551' })) vi.mock('../../src/services/opencode/upstream', async (importOriginal) => ({ @@ -61,10 +52,6 @@ const mockSettingsService = { const mockDb = {} as Database -const mockGitAuthService = { getGitEnvironment: () => ({}) } as unknown as GitAuthService - -const mockOpenCodeClient = {} as OpenCodeClient - function upstreamOk() { const upstreamFetch = vi.fn().mockResolvedValue( new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }) @@ -82,7 +69,7 @@ describe('opencode-proxy routes', () => { isLifecycleInitializedMock.mockReturnValue(true) originalFetch = globalThis.fetch app = new Hono() - app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService, mockGitAuthService, mockOpenCodeClient)) + app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService)) }) afterEach(() => { @@ -784,7 +771,7 @@ describe('opencode-proxy repo-scoped mount', () => { isLifecycleInitializedMock.mockReturnValue(true) originalFetch = globalThis.fetch app = new Hono() - app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService, mockGitAuthService, mockOpenCodeClient)) + app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService)) }) afterEach(() => { @@ -830,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 () => { @@ -938,18 +925,18 @@ describe('opencode-proxy repo-scoped mount', () => { expect(new TextDecoder().decode(fetchCall[1].body as ArrayBuffer)).toBe('{not json') }) - describe('repo worktrees', () => { - const worktreePath = '/srv/worktrees/my-repo-feature' + describe('workspace directories', () => { + const worktreePath = join(getWorkspacePath(), '.opencode', 'state', 'opencode', 'worktree', 'abc123', 'feature') + const otherRepoPath = join(getWorkspacePath(), 'repos', 'other-repo') beforeEach(() => { getRepoByIdMock.mockReturnValue(readyRepo) - getSiblingReposMock.mockResolvedValue([readyRepo, { ...readyRepo, id: -1, fullPath: worktreePath, worktreeSource: 'opencode' }]) }) - it('keeps a worktree x-opencode-directory and scopes the session list to it', async () => { + it('keeps a workspace x-opencode-directory', async () => { const upstreamFetch = upstreamOk() - const res = await app.request('/api/opencode-proxy/repos/7/api/session?limit=1', { + const res = await app.request('/api/opencode-proxy/repos/7/api/agent', { headers: { Authorization: 'Bearer test-internal-token', 'x-opencode-directory': encodeURIComponent(worktreePath), @@ -959,24 +946,23 @@ describe('opencode-proxy repo-scoped mount', () => { 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)) - expect(new URL(fetchCall[0]).searchParams.get('directory')).toBe(worktreePath) }) - it('keeps a worktree location[directory] query value', async () => { + 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(worktreePath)}`, + `/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(worktreePath) - expect((fetchCall[1].headers as Record)['x-opencode-directory']).toBe(encodeURIComponent(worktreePath)) + 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 worktree body location.directory', async () => { + it('keeps a workspace body location.directory', async () => { const upstreamFetch = upstreamOk() const res = await app.request('/api/opencode-proxy/repos/7/api/session', { @@ -993,30 +979,28 @@ describe('opencode-proxy repo-scoped mount', () => { expect(JSON.parse(fetchCall[1].body as string)).toEqual({ title: 'x', location: { directory: worktreePath } }) }) - it('does not add a directory filter to a project-scoped session list', async () => { + 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?project=prj_1&subpath=..%2Fx', { + 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.has('directory')).toBe(false) + expect(new URL(fetchCall[0]).searchParams.get('directory')).toBe(worktreePath) }) - it('loads siblings once across requests within the cache window', async () => { - upstreamOk() - const request = () => app.request('/api/opencode-proxy/repos/7/api/agent', { - headers: { - Authorization: 'Bearer test-internal-token', - 'x-opencode-directory': encodeURIComponent(worktreePath), - }, - }) + it('does not add a directory filter to an unfiltered session list', async () => { + const upstreamFetch = upstreamOk() - await Promise.all([request(), request(), request()]) + const res = await app.request('/api/opencode-proxy/repos/7/api/session?limit=50&parentID=null', { + headers: { Authorization: 'Bearer test-internal-token' }, + }) - expect(getSiblingReposMock).toHaveBeenCalledTimes(1) + expect(res.status).toBe(200) + const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] + expect(new URL(fetchCall[0]).searchParams.has('directory')).toBe(false) }) }) }) @@ -1131,7 +1115,7 @@ describe.skipIf(SHIPPED_OPENCODE_BIN === null)('opencode-proxy repo-scoped mount getRepoByIdMock.mockReturnValue({ id: 1, fullPath: repoPath, cloneStatus: 'ready' }) const proxyApp = new Hono() - proxyApp.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService, mockGitAuthService, mockOpenCodeClient)) + proxyApp.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService)) const proxyServer = await new Promise>((resolve) => { const server = serve({ fetch: proxyApp.fetch, port: 0, hostname: '127.0.0.1' }, () => resolve(server)) })