feat(server,ui): allow OpenCode directory override via proxy path

This commit is contained in:
Shantur Rathore
2026-02-18 09:43:30 +00:00
parent 859312ba3b
commit 127a1f628d
3 changed files with 168 additions and 18 deletions

View File

@@ -367,6 +367,21 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
const INSTANCE_PROXY_HOST = "127.0.0.1" const INSTANCE_PROXY_HOST = "127.0.0.1"
// Special-case OpenCode directory override.
//
// UI clients may need to scope certain requests to an arbitrary directory that is not
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
// injecting per-request headers, we encode an override into the *path* and strip it
// before proxying to the instance.
//
// Example proxied request path:
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
//
// The server will decode <base64url> -> absolute directory, validate it, then set
// x-opencode-directory accordingly and forward the request to /session/create.
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
async function proxyWorkspaceRequest(args: { async function proxyWorkspaceRequest(args: {
request: FastifyRequest request: FastifyRequest
reply: FastifyReply reply: FastifyReply
@@ -457,19 +472,43 @@ async function proxyWorkspaceRequest(args: {
return return
} }
const directory = await resolveWorktreeDirectory({ let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
workspaceId, try {
workspacePath: workspace.path, extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
worktreeSlug, } catch (error) {
logger, const message = error instanceof Error ? error.message : "Invalid directory override"
}) reply.code(400).send({ error: message })
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return return
} }
let directory: string | null = null
let forwardedSuffix = extracted.forwardedSuffix
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix) if (extracted.overrideDirectory) {
try {
directory = validateAndNormalizeOverrideDirectory({
overrideDirectory: extracted.overrideDirectory,
workspaceRoot: workspace.path,
})
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid directory override"
reply.code(400).send({ error: message })
return
}
} else {
directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return
}
}
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
const queryIndex = (request.raw.url ?? "").indexOf("?") const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
@@ -533,6 +572,89 @@ async function proxyWorkspaceRequest(args: {
}) })
} }
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
overrideDirectory: string | null
forwardedSuffix: string | undefined
} {
if (!pathSuffix) {
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
}
// Fastify wildcard param does not include a leading slash.
const trimmed = pathSuffix.replace(/^\/+/, "")
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
}
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
const slashIndex = rest.indexOf("/")
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
if (!encoded) {
throw new Error("Missing directory override")
}
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
throw new Error("Directory override too large")
}
let overrideDirectory = ""
try {
overrideDirectory = decodeBase64Url(encoded)
} catch {
throw new Error("Invalid directory override")
}
const forwardedSuffix = remaining
return { overrideDirectory, forwardedSuffix }
}
function decodeBase64Url(input: string): string {
// base64url -> base64
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
const base64 = `${normalized}${padding}`
return Buffer.from(base64, "base64").toString("utf-8")
}
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
const raw = params.overrideDirectory.trim()
if (!raw) {
throw new Error("Override directory is empty")
}
if (!path.isAbsolute(raw)) {
throw new Error("Override directory must be an absolute path")
}
if (!fs.existsSync(raw)) {
throw new Error(`Override directory does not exist: ${raw}`)
}
const stats = fs.statSync(raw)
if (!stats.isDirectory()) {
throw new Error(`Override path is not a directory: ${raw}`)
}
const normalizedOverride = fs.realpathSync(raw)
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
if (!isSubpath(normalizedOverride, normalizedRoot)) {
throw new Error("Override directory must be within the workspace root")
}
return normalizedOverride
}
function isSubpath(candidate: string, root: string): boolean {
const rel = path.relative(root, candidate)
if (rel === "") return true
if (rel === "..") return false
if (rel.startsWith(`..${path.sep}`)) return false
if (path.isAbsolute(rel)) return false
return true
}
function normalizeInstanceSuffix(pathSuffix: string | undefined) { function normalizeInstanceSuffix(pathSuffix: string | undefined) {
if (!pathSuffix || pathSuffix === "/") { if (!pathSuffix || pathSuffix === "/") {
return "/" return "/"

View File

@@ -4,12 +4,12 @@ import { CODENOMAD_API_BASE } from "./api-client"
class SDKManager { class SDKManager {
private clients = new Map<string, OpencodeClient>() private clients = new Map<string, OpencodeClient>()
private key(instanceId: string, worktreeSlug: string): string { private key(instanceId: string, proxyPath: string): string {
return `${instanceId}:${worktreeSlug || "root"}` return `${instanceId}:${normalizeProxyPath(proxyPath)}`
} }
createClient(instanceId: string, proxyPath: string, worktreeSlug = "root"): OpencodeClient { createClient(instanceId: string, proxyPath: string, _worktreeSlug = "root"): OpencodeClient {
const key = this.key(instanceId, worktreeSlug) const key = this.key(instanceId, proxyPath)
const existing = this.clients.get(key) const existing = this.clients.get(key)
if (existing) { if (existing) {
return existing return existing
@@ -23,12 +23,12 @@ class SDKManager {
return client return client
} }
getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null { getClient(instanceId: string, proxyPath: string): OpencodeClient | null {
return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null return this.clients.get(this.key(instanceId, proxyPath)) ?? null
} }
destroyClient(instanceId: string, worktreeSlug = "root"): void { destroyClient(instanceId: string, proxyPath: string): void {
this.clients.delete(this.key(instanceId, worktreeSlug)) this.clients.delete(this.key(instanceId, proxyPath))
} }
destroyClientsForInstance(instanceId: string): void { destroyClientsForInstance(instanceId: string): void {

View File

@@ -329,12 +329,38 @@ function buildWorktreeProxyPath(instanceId: string, slug: string): string {
return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance` return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance`
} }
function encodeBase64UrlUtf8(input: string): string {
const bytes = new TextEncoder().encode(input)
// Convert bytes -> base64 (btoa expects a binary string)
let binary = ""
const chunkSize = 0x8000
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize)
binary += String.fromCharCode(...chunk)
}
const base64 = btoa(binary)
// base64 -> base64url (strip padding)
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "")
}
function buildWorktreeProxyPathWithDirectoryOverride(instanceId: string, slug: string, directory: string): string {
const base = buildWorktreeProxyPath(instanceId, slug)
const encoded = encodeBase64UrlUtf8(directory)
return `${base}/__dir/${encoded}`
}
function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient { function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient {
const normalized = normalizeWorktreeSlug(instanceId, slug || "root") const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
const proxyPath = buildWorktreeProxyPath(instanceId, normalized) const proxyPath = buildWorktreeProxyPath(instanceId, normalized)
return sdkManager.createClient(instanceId, proxyPath, normalized) return sdkManager.createClient(instanceId, proxyPath, normalized)
} }
function getOrCreateWorktreeClientWithDirectoryOverride(instanceId: string, slug: string, directory: string): OpencodeClient {
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
const proxyPath = buildWorktreeProxyPathWithDirectoryOverride(instanceId, normalized, directory)
return sdkManager.createClient(instanceId, proxyPath, normalized)
}
function getRootClient(instanceId: string): OpencodeClient { function getRootClient(instanceId: string): OpencodeClient {
return getOrCreateWorktreeClient(instanceId, "root") return getOrCreateWorktreeClient(instanceId, "root")
} }
@@ -359,7 +385,9 @@ export {
removeParentSessionMapping, removeParentSessionMapping,
getWorktreeSlugForDirectory, getWorktreeSlugForDirectory,
buildWorktreeProxyPath, buildWorktreeProxyPath,
buildWorktreeProxyPathWithDirectoryOverride,
getOrCreateWorktreeClient, getOrCreateWorktreeClient,
getOrCreateWorktreeClientWithDirectoryOverride,
getRootClient, getRootClient,
createWorktree, createWorktree,
deleteWorktree, deleteWorktree,