Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions backend/src/routes/opencode-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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<string, unknown> }).location = { ...location, directory }
return JSON.stringify(parsed)
}
Expand Down Expand Up @@ -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),
)
})

Expand Down
81 changes: 80 additions & 1 deletion backend/test/routes/opencode-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
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 () => {
Expand Down Expand Up @@ -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<string, string>)['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<string, string>)['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()
Expand Down