From 127a1f628d32d73b07f0a6973f1018632dbbade5 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 18 Feb 2026 09:43:30 +0000 Subject: [PATCH] feat(server,ui): allow OpenCode directory override via proxy path --- packages/server/src/server/http-server.ts | 142 ++++++++++++++++++++-- packages/ui/src/lib/sdk-manager.ts | 16 +-- packages/ui/src/stores/worktrees.ts | 28 +++++ 3 files changed, 168 insertions(+), 18 deletions(-) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 3fc7106e..dd36f882 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -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//session/create +// +// The server will decode -> 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 "/" diff --git a/packages/ui/src/lib/sdk-manager.ts b/packages/ui/src/lib/sdk-manager.ts index 5f525eda..2e4ee3c1 100644 --- a/packages/ui/src/lib/sdk-manager.ts +++ b/packages/ui/src/lib/sdk-manager.ts @@ -4,12 +4,12 @@ import { CODENOMAD_API_BASE } from "./api-client" class SDKManager { private clients = new Map() - 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 { diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index cc541ddc..4337d32c 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -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,