feat(server,ui): allow OpenCode directory override via proxy path
This commit is contained in:
@@ -367,6 +367,21 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
|
||||
|
||||
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: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
@@ -457,19 +472,43 @@ async function proxyWorkspaceRequest(args: {
|
||||
return
|
||||
}
|
||||
|
||||
const directory = await resolveWorktreeDirectory({
|
||||
workspaceId,
|
||||
workspacePath: workspace.path,
|
||||
worktreeSlug,
|
||||
logger,
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
reply.code(404).send({ error: "Worktree not found" })
|
||||
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
|
||||
try {
|
||||
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||
reply.code(400).send({ error: message })
|
||||
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 search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
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) {
|
||||
if (!pathSuffix || pathSuffix === "/") {
|
||||
return "/"
|
||||
|
||||
@@ -4,12 +4,12 @@ import { CODENOMAD_API_BASE } from "./api-client"
|
||||
class SDKManager {
|
||||
private clients = new Map<string, OpencodeClient>()
|
||||
|
||||
private key(instanceId: string, worktreeSlug: string): string {
|
||||
return `${instanceId}:${worktreeSlug || "root"}`
|
||||
private key(instanceId: string, proxyPath: string): string {
|
||||
return `${instanceId}:${normalizeProxyPath(proxyPath)}`
|
||||
}
|
||||
|
||||
createClient(instanceId: string, proxyPath: string, worktreeSlug = "root"): OpencodeClient {
|
||||
const key = this.key(instanceId, worktreeSlug)
|
||||
createClient(instanceId: string, proxyPath: string, _worktreeSlug = "root"): OpencodeClient {
|
||||
const key = this.key(instanceId, proxyPath)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
@@ -23,12 +23,12 @@ class SDKManager {
|
||||
return client
|
||||
}
|
||||
|
||||
getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null {
|
||||
return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null
|
||||
getClient(instanceId: string, proxyPath: string): OpencodeClient | null {
|
||||
return this.clients.get(this.key(instanceId, proxyPath)) ?? null
|
||||
}
|
||||
|
||||
destroyClient(instanceId: string, worktreeSlug = "root"): void {
|
||||
this.clients.delete(this.key(instanceId, worktreeSlug))
|
||||
destroyClient(instanceId: string, proxyPath: string): void {
|
||||
this.clients.delete(this.key(instanceId, proxyPath))
|
||||
}
|
||||
|
||||
destroyClientsForInstance(instanceId: string): void {
|
||||
|
||||
@@ -329,12 +329,38 @@ function buildWorktreeProxyPath(instanceId: string, slug: string): string {
|
||||
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 {
|
||||
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
|
||||
const proxyPath = buildWorktreeProxyPath(instanceId, 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 {
|
||||
return getOrCreateWorktreeClient(instanceId, "root")
|
||||
}
|
||||
@@ -359,7 +385,9 @@ export {
|
||||
removeParentSessionMapping,
|
||||
getWorktreeSlugForDirectory,
|
||||
buildWorktreeProxyPath,
|
||||
buildWorktreeProxyPathWithDirectoryOverride,
|
||||
getOrCreateWorktreeClient,
|
||||
getOrCreateWorktreeClientWithDirectoryOverride,
|
||||
getRootClient,
|
||||
createWorktree,
|
||||
deleteWorktree,
|
||||
|
||||
Reference in New Issue
Block a user