@neuralnomads/codenomad-dev 0.10.3-dev-20260213-ba418a85 → 0.10.3-dev-20260213-e9f281a6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/assets/{main-CSlDZj4f.js → main-crtt5pqm.js} +82 -80
- package/public/index.html +1 -1
- package/public/sw.js +1 -1
- package/public/ui-version.json +1 -1
- package/dist/integrations/github/bot-signature.js +0 -11
- package/dist/integrations/github/git-ops.js +0 -133
- package/dist/integrations/github/github-types.js +0 -1
- package/dist/integrations/github/job-runner.js +0 -608
- package/dist/integrations/github/octokit.js +0 -58
- package/dist/integrations/github/sanitize-webhook.js +0 -42
- package/dist/integrations/github/webhook-verify.js +0 -21
- package/dist/integrations/github/workspace-context.js +0 -10
- package/dist/integrations/github/worktree-context.js +0 -15
- package/dist/opencode/request-context.js +0 -39
- package/dist/opencode/worktree-directory.js +0 -42
- package/dist/opencode-config-template/README.md +0 -32
- package/dist/opencode-config-template/opencode.jsonc +0 -3
- package/dist/opencode-config-template/plugin/codenomad.ts +0 -40
- package/dist/opencode-config-template/plugin/lib/background-process.ts +0 -160
- package/dist/opencode-config-template/plugin/lib/client.ts +0 -165
- package/dist/server/routes/github-plugin.js +0 -215
- package/dist/server/routes/github-webhook.js +0 -32
- package/scripts/copy-auth-pages.mjs +0 -22
- package/scripts/copy-opencode-config.mjs +0 -61
- package/scripts/copy-ui-dist.mjs +0 -21
- package/src/api-types.ts +0 -326
- package/src/auth/auth-store.ts +0 -175
- package/src/auth/http-auth.ts +0 -38
- package/src/auth/manager.ts +0 -163
- package/src/auth/password-hash.ts +0 -49
- package/src/auth/session-manager.ts +0 -23
- package/src/auth/token-manager.ts +0 -32
- package/src/background-processes/manager.ts +0 -519
- package/src/bin.ts +0 -29
- package/src/config/binaries.ts +0 -192
- package/src/config/location.ts +0 -78
- package/src/config/schema.ts +0 -104
- package/src/config/store.ts +0 -244
- package/src/events/bus.ts +0 -45
- package/src/filesystem/__tests__/search-cache.test.ts +0 -61
- package/src/filesystem/browser.ts +0 -353
- package/src/filesystem/search-cache.ts +0 -66
- package/src/filesystem/search.ts +0 -184
- package/src/index.ts +0 -540
- package/src/launcher.ts +0 -177
- package/src/loader.ts +0 -21
- package/src/logger.ts +0 -133
- package/src/opencode-config.ts +0 -31
- package/src/plugins/channel.ts +0 -55
- package/src/plugins/handlers.ts +0 -36
- package/src/releases/dev-release-monitor.ts +0 -118
- package/src/releases/release-monitor.ts +0 -149
- package/src/server/http-server.ts +0 -693
- package/src/server/network-addresses.ts +0 -75
- package/src/server/routes/auth-pages/login.html +0 -134
- package/src/server/routes/auth-pages/token.html +0 -93
- package/src/server/routes/auth.ts +0 -164
- package/src/server/routes/background-processes.ts +0 -85
- package/src/server/routes/config.ts +0 -76
- package/src/server/routes/events.ts +0 -61
- package/src/server/routes/filesystem.ts +0 -54
- package/src/server/routes/meta.ts +0 -58
- package/src/server/routes/plugin.ts +0 -75
- package/src/server/routes/storage.ts +0 -66
- package/src/server/routes/workspaces.ts +0 -113
- package/src/server/routes/worktrees.ts +0 -195
- package/src/server/tls.ts +0 -283
- package/src/storage/instance-store.ts +0 -64
- package/src/ui/__tests__/remote-ui.test.ts +0 -58
- package/src/ui/remote-ui.ts +0 -571
- package/src/workspaces/git-worktrees.ts +0 -241
- package/src/workspaces/instance-events.ts +0 -226
- package/src/workspaces/manager.ts +0 -493
- package/src/workspaces/opencode-auth.ts +0 -22
- package/src/workspaces/runtime.ts +0 -428
- package/src/workspaces/worktree-map.ts +0 -129
- package/tsconfig.json +0 -17
|
@@ -1,693 +0,0 @@
|
|
|
1
|
-
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
|
2
|
-
import cors from "@fastify/cors"
|
|
3
|
-
import fastifyStatic from "@fastify/static"
|
|
4
|
-
import replyFrom from "@fastify/reply-from"
|
|
5
|
-
import fs from "fs"
|
|
6
|
-
import path from "path"
|
|
7
|
-
import { fetch } from "undici"
|
|
8
|
-
import type { Logger } from "../logger"
|
|
9
|
-
import { WorkspaceManager } from "../workspaces/manager"
|
|
10
|
-
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
|
11
|
-
|
|
12
|
-
import { ConfigStore } from "../config/store"
|
|
13
|
-
import { BinaryRegistry } from "../config/binaries"
|
|
14
|
-
import { FileSystemBrowser } from "../filesystem/browser"
|
|
15
|
-
import { EventBus } from "../events/bus"
|
|
16
|
-
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
|
17
|
-
import { registerConfigRoutes } from "./routes/config"
|
|
18
|
-
import { registerFilesystemRoutes } from "./routes/filesystem"
|
|
19
|
-
import { registerMetaRoutes } from "./routes/meta"
|
|
20
|
-
import { registerEventRoutes } from "./routes/events"
|
|
21
|
-
import { registerStorageRoutes } from "./routes/storage"
|
|
22
|
-
import { registerPluginRoutes } from "./routes/plugin"
|
|
23
|
-
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
|
24
|
-
import { registerWorktreeRoutes } from "./routes/worktrees"
|
|
25
|
-
import { ServerMeta } from "../api-types"
|
|
26
|
-
import { InstanceStore } from "../storage/instance-store"
|
|
27
|
-
import { BackgroundProcessManager } from "../background-processes/manager"
|
|
28
|
-
import type { AuthManager } from "../auth/manager"
|
|
29
|
-
import { registerAuthRoutes } from "./routes/auth"
|
|
30
|
-
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
|
31
|
-
|
|
32
|
-
interface HttpServerDeps {
|
|
33
|
-
bindHost: string
|
|
34
|
-
bindPort: number
|
|
35
|
-
/** When bindPort is 0, try this first. */
|
|
36
|
-
defaultPort: number
|
|
37
|
-
protocol: "http" | "https"
|
|
38
|
-
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
|
39
|
-
workspaceManager: WorkspaceManager
|
|
40
|
-
configStore: ConfigStore
|
|
41
|
-
binaryRegistry: BinaryRegistry
|
|
42
|
-
fileSystemBrowser: FileSystemBrowser
|
|
43
|
-
eventBus: EventBus
|
|
44
|
-
serverMeta: ServerMeta
|
|
45
|
-
instanceStore: InstanceStore
|
|
46
|
-
authManager: AuthManager
|
|
47
|
-
uiStaticDir: string
|
|
48
|
-
uiDevServerUrl?: string
|
|
49
|
-
logger: Logger
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
interface HttpServerStartResult {
|
|
53
|
-
port: number
|
|
54
|
-
url: string
|
|
55
|
-
displayHost: string
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function createHttpServer(deps: HttpServerDeps) {
|
|
59
|
-
// Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
|
|
60
|
-
// We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
|
|
61
|
-
const app = Fastify(
|
|
62
|
-
({
|
|
63
|
-
logger: false,
|
|
64
|
-
...(deps.protocol === "https" && deps.httpsOptions ? { https: deps.httpsOptions } : {}),
|
|
65
|
-
} as unknown) as any,
|
|
66
|
-
) as unknown as FastifyInstance
|
|
67
|
-
const proxyLogger = deps.logger.child({ component: "proxy" })
|
|
68
|
-
const apiLogger = deps.logger.child({ component: "http" })
|
|
69
|
-
const sseLogger = deps.logger.child({ component: "sse" })
|
|
70
|
-
|
|
71
|
-
const sseClients = new Set<() => void>()
|
|
72
|
-
const registerSseClient = (cleanup: () => void) => {
|
|
73
|
-
sseClients.add(cleanup)
|
|
74
|
-
return () => sseClients.delete(cleanup)
|
|
75
|
-
}
|
|
76
|
-
const closeSseClients = () => {
|
|
77
|
-
for (const cleanup of Array.from(sseClients)) {
|
|
78
|
-
cleanup()
|
|
79
|
-
}
|
|
80
|
-
sseClients.clear()
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
app.addHook("onRequest", (request, _reply, done) => {
|
|
84
|
-
;(request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = {
|
|
85
|
-
start: process.hrtime.bigint(),
|
|
86
|
-
}
|
|
87
|
-
done()
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
app.addHook("onResponse", (request, reply, done) => {
|
|
91
|
-
const meta = (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta
|
|
92
|
-
const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined
|
|
93
|
-
const base = {
|
|
94
|
-
method: request.method,
|
|
95
|
-
url: request.url,
|
|
96
|
-
status: reply.statusCode,
|
|
97
|
-
durationMs,
|
|
98
|
-
}
|
|
99
|
-
apiLogger.debug(base, "HTTP request completed")
|
|
100
|
-
if (apiLogger.isLevelEnabled("trace")) {
|
|
101
|
-
apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload")
|
|
102
|
-
}
|
|
103
|
-
done()
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
|
|
107
|
-
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
|
108
|
-
|
|
109
|
-
const getSelfOrigins = (): Set<string> => {
|
|
110
|
-
const origins = new Set<string>()
|
|
111
|
-
const candidates: Array<string | undefined> = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl]
|
|
112
|
-
for (const candidate of candidates) {
|
|
113
|
-
if (!candidate) continue
|
|
114
|
-
try {
|
|
115
|
-
origins.add(new URL(candidate).origin)
|
|
116
|
-
} catch {
|
|
117
|
-
// ignore
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
for (const addr of deps.serverMeta.addresses ?? []) {
|
|
121
|
-
try {
|
|
122
|
-
origins.add(new URL(addr.remoteUrl).origin)
|
|
123
|
-
} catch {
|
|
124
|
-
// ignore
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return origins
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
app.register(cors, {
|
|
131
|
-
origin: (origin, cb) => {
|
|
132
|
-
if (!origin) {
|
|
133
|
-
cb(null, true)
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const selfOrigins = getSelfOrigins()
|
|
138
|
-
if (selfOrigins.has(origin)) {
|
|
139
|
-
cb(null, true)
|
|
140
|
-
return
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (allowedDevOrigins.has(origin)) {
|
|
144
|
-
cb(null, true)
|
|
145
|
-
return
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
|
|
149
|
-
if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) {
|
|
150
|
-
cb(null, true)
|
|
151
|
-
return
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
cb(null, false)
|
|
156
|
-
},
|
|
157
|
-
credentials: true,
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
app.register(replyFrom, {
|
|
161
|
-
contentTypesToEncode: [],
|
|
162
|
-
undici: {
|
|
163
|
-
connections: 16,
|
|
164
|
-
pipelining: 1,
|
|
165
|
-
bodyTimeout: 0,
|
|
166
|
-
headersTimeout: 0,
|
|
167
|
-
},
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
const backgroundProcessManager = new BackgroundProcessManager({
|
|
171
|
-
workspaceManager: deps.workspaceManager,
|
|
172
|
-
eventBus: deps.eventBus,
|
|
173
|
-
logger: deps.logger.child({ component: "background-processes" }),
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
registerAuthRoutes(app, { authManager: deps.authManager })
|
|
177
|
-
|
|
178
|
-
app.addHook("preHandler", (request, reply, done) => {
|
|
179
|
-
const rawUrl = request.raw.url ?? request.url
|
|
180
|
-
const pathname = (rawUrl.split("?")[0] ?? "").trim()
|
|
181
|
-
|
|
182
|
-
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"])
|
|
183
|
-
const publicPagePaths = new Set(["/login"])
|
|
184
|
-
if (deps.authManager.isTokenBootstrapEnabled()) {
|
|
185
|
-
publicPagePaths.add("/auth/token")
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
|
|
189
|
-
done()
|
|
190
|
-
return
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const session = deps.authManager.getSessionFromRequest(request)
|
|
194
|
-
|
|
195
|
-
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
|
196
|
-
if (requiresAuthForApi && !session) {
|
|
197
|
-
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
|
198
|
-
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
|
199
|
-
if (pluginMatch) {
|
|
200
|
-
const workspaceId = pluginMatch[1]
|
|
201
|
-
const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
|
202
|
-
const provided = Array.isArray(request.headers.authorization)
|
|
203
|
-
? request.headers.authorization[0]
|
|
204
|
-
: request.headers.authorization
|
|
205
|
-
|
|
206
|
-
if (expected && provided && provided === expected) {
|
|
207
|
-
done()
|
|
208
|
-
return
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
sendUnauthorized(request, reply)
|
|
213
|
-
return
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (!session && wantsHtml(request)) {
|
|
217
|
-
reply.redirect("/login")
|
|
218
|
-
return
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
done()
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
app.get("/", async (request, reply) => {
|
|
225
|
-
const session = deps.authManager.getSessionFromRequest(request)
|
|
226
|
-
if (!session) {
|
|
227
|
-
reply.redirect("/login")
|
|
228
|
-
return
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (deps.uiDevServerUrl) {
|
|
232
|
-
await proxyToDevServer(request, reply, deps.uiDevServerUrl)
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const uiDir = deps.uiStaticDir
|
|
237
|
-
const indexPath = path.join(uiDir, "index.html")
|
|
238
|
-
if (uiDir && fs.existsSync(indexPath)) {
|
|
239
|
-
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
|
240
|
-
return
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
reply.code(404).send({ message: "UI bundle missing" })
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
|
247
|
-
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
|
248
|
-
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
|
249
|
-
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
|
250
|
-
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
|
251
|
-
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
|
252
|
-
registerStorageRoutes(app, {
|
|
253
|
-
instanceStore: deps.instanceStore,
|
|
254
|
-
eventBus: deps.eventBus,
|
|
255
|
-
workspaceManager: deps.workspaceManager,
|
|
256
|
-
})
|
|
257
|
-
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
|
258
|
-
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
|
259
|
-
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (deps.uiDevServerUrl) {
|
|
263
|
-
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
|
|
264
|
-
} else {
|
|
265
|
-
setupStaticUi(app, deps.uiStaticDir, deps.authManager)
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
return {
|
|
269
|
-
instance: app,
|
|
270
|
-
start: async (): Promise<HttpServerStartResult> => {
|
|
271
|
-
const attemptListen = async (requestedPort: number) => {
|
|
272
|
-
const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost })
|
|
273
|
-
return { addressInfo, requestedPort }
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const autoPortRequested = deps.bindPort === 0
|
|
277
|
-
const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort
|
|
278
|
-
|
|
279
|
-
const shouldRetryWithEphemeral = (error: unknown) => {
|
|
280
|
-
if (!autoPortRequested) return false
|
|
281
|
-
const err = error as NodeJS.ErrnoException | undefined
|
|
282
|
-
return Boolean(err && err.code === "EADDRINUSE")
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
let listenResult
|
|
286
|
-
|
|
287
|
-
try {
|
|
288
|
-
listenResult = await attemptListen(primaryPort)
|
|
289
|
-
} catch (error) {
|
|
290
|
-
if (!shouldRetryWithEphemeral(error)) {
|
|
291
|
-
throw error
|
|
292
|
-
}
|
|
293
|
-
deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")
|
|
294
|
-
listenResult = await attemptListen(0)
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
let actualPort = listenResult.requestedPort
|
|
298
|
-
|
|
299
|
-
if (typeof listenResult.addressInfo === "string") {
|
|
300
|
-
try {
|
|
301
|
-
const parsed = new URL(listenResult.addressInfo)
|
|
302
|
-
actualPort = Number(parsed.port) || listenResult.requestedPort
|
|
303
|
-
} catch {
|
|
304
|
-
actualPort = listenResult.requestedPort
|
|
305
|
-
}
|
|
306
|
-
} else {
|
|
307
|
-
const address = app.server.address()
|
|
308
|
-
if (typeof address === "object" && address) {
|
|
309
|
-
actualPort = address.port
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost
|
|
314
|
-
const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}`
|
|
315
|
-
|
|
316
|
-
deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening")
|
|
317
|
-
|
|
318
|
-
return { port: actualPort, url: serverUrl, displayHost }
|
|
319
|
-
},
|
|
320
|
-
stop: () => {
|
|
321
|
-
closeSseClients()
|
|
322
|
-
return app.close()
|
|
323
|
-
},
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
interface InstanceProxyDeps {
|
|
328
|
-
workspaceManager: WorkspaceManager
|
|
329
|
-
logger: Logger
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
|
333
|
-
app.register(async (instance) => {
|
|
334
|
-
instance.removeAllContentTypeParsers()
|
|
335
|
-
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
|
336
|
-
|
|
337
|
-
const proxyBaseHandler = async (
|
|
338
|
-
request: FastifyRequest<{ Params: { id: string; slug: string } }>,
|
|
339
|
-
reply: FastifyReply,
|
|
340
|
-
) => {
|
|
341
|
-
await proxyWorkspaceRequest({
|
|
342
|
-
request,
|
|
343
|
-
reply,
|
|
344
|
-
workspaceManager: deps.workspaceManager,
|
|
345
|
-
worktreeSlug: request.params.slug,
|
|
346
|
-
pathSuffix: "",
|
|
347
|
-
logger: deps.logger,
|
|
348
|
-
})
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const proxyWildcardHandler = async (
|
|
352
|
-
request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>,
|
|
353
|
-
reply: FastifyReply,
|
|
354
|
-
) => {
|
|
355
|
-
await proxyWorkspaceRequest({
|
|
356
|
-
request,
|
|
357
|
-
reply,
|
|
358
|
-
workspaceManager: deps.workspaceManager,
|
|
359
|
-
worktreeSlug: request.params.slug,
|
|
360
|
-
pathSuffix: request.params["*"] ?? "",
|
|
361
|
-
logger: deps.logger,
|
|
362
|
-
})
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
|
|
366
|
-
instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
|
|
367
|
-
})
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
|
371
|
-
|
|
372
|
-
async function proxyWorkspaceRequest(args: {
|
|
373
|
-
request: FastifyRequest
|
|
374
|
-
reply: FastifyReply
|
|
375
|
-
workspaceManager: WorkspaceManager
|
|
376
|
-
logger: Logger
|
|
377
|
-
worktreeSlug: string
|
|
378
|
-
pathSuffix?: string
|
|
379
|
-
}) {
|
|
380
|
-
const { request, reply, workspaceManager, logger, worktreeSlug } = args
|
|
381
|
-
const workspaceId = (request.params as { id: string }).id
|
|
382
|
-
const workspace = workspaceManager.get(workspaceId)
|
|
383
|
-
|
|
384
|
-
const bodyToJson = (body: unknown): unknown => {
|
|
385
|
-
if (body == null) return null
|
|
386
|
-
|
|
387
|
-
const anyBody = body as any
|
|
388
|
-
if (anyBody && typeof anyBody.pipe === "function") {
|
|
389
|
-
// Don't consume streams (would break proxying).
|
|
390
|
-
// Best-effort: if the stream already has buffered chunks, parse those.
|
|
391
|
-
try {
|
|
392
|
-
const buffered = anyBody?._readableState?.buffer
|
|
393
|
-
if (Array.isArray(buffered) && buffered.length > 0) {
|
|
394
|
-
const chunks: Buffer[] = []
|
|
395
|
-
for (const entry of buffered) {
|
|
396
|
-
if (!entry) continue
|
|
397
|
-
if (Buffer.isBuffer(entry)) {
|
|
398
|
-
chunks.push(entry)
|
|
399
|
-
continue
|
|
400
|
-
}
|
|
401
|
-
const data = (entry as any).data
|
|
402
|
-
if (Buffer.isBuffer(data)) {
|
|
403
|
-
chunks.push(data)
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
if (chunks.length > 0) {
|
|
408
|
-
const text = Buffer.concat(chunks).toString("utf-8")
|
|
409
|
-
try {
|
|
410
|
-
return JSON.parse(text)
|
|
411
|
-
} catch {
|
|
412
|
-
return { __raw: text }
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
} catch {
|
|
417
|
-
// fall through
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
return { __stream: true }
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const maybeParse = (input: string): unknown => {
|
|
424
|
-
try {
|
|
425
|
-
return JSON.parse(input)
|
|
426
|
-
} catch {
|
|
427
|
-
return { __raw: input }
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
if (Buffer.isBuffer(body)) {
|
|
432
|
-
return maybeParse(body.toString("utf-8"))
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (typeof body === "string") {
|
|
436
|
-
return maybeParse(body)
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
if (typeof body === "object") {
|
|
440
|
-
return body
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
return body
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (!workspace) {
|
|
447
|
-
reply.code(404).send({ error: "Workspace not found" })
|
|
448
|
-
return
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
const port = workspaceManager.getInstancePort(workspaceId)
|
|
452
|
-
if (!port) {
|
|
453
|
-
reply.code(502).send({ error: "Workspace instance is not ready" })
|
|
454
|
-
return
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
if (!isValidWorktreeSlug(worktreeSlug)) {
|
|
458
|
-
reply.code(400).send({ error: "Invalid worktree slug" })
|
|
459
|
-
return
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const directory = await resolveWorktreeDirectory({
|
|
463
|
-
workspaceId,
|
|
464
|
-
workspacePath: workspace.path,
|
|
465
|
-
worktreeSlug,
|
|
466
|
-
logger,
|
|
467
|
-
})
|
|
468
|
-
|
|
469
|
-
if (!directory) {
|
|
470
|
-
reply.code(404).send({ error: "Worktree not found" })
|
|
471
|
-
return
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
|
475
|
-
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
|
476
|
-
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
|
477
|
-
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
|
478
|
-
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
|
479
|
-
|
|
480
|
-
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
|
481
|
-
if (logger.isLevelEnabled("trace")) {
|
|
482
|
-
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
return reply.from(targetUrl, {
|
|
486
|
-
rewriteRequestHeaders: (_originalRequest, headers) => {
|
|
487
|
-
if (instanceAuthHeader) {
|
|
488
|
-
headers.authorization = instanceAuthHeader
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
|
492
|
-
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
|
493
|
-
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
|
494
|
-
|
|
495
|
-
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
|
|
496
|
-
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
|
|
497
|
-
|
|
498
|
-
if (logger.isLevelEnabled("trace")) {
|
|
499
|
-
const outgoing: Record<string, unknown> = {}
|
|
500
|
-
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
|
501
|
-
outgoing[key] = value
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// Redact sensitive headers.
|
|
505
|
-
for (const key of Object.keys(outgoing)) {
|
|
506
|
-
const lower = key.toLowerCase()
|
|
507
|
-
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
|
|
508
|
-
outgoing[key] = "<redacted>"
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
logger.trace(
|
|
513
|
-
{
|
|
514
|
-
workspaceId,
|
|
515
|
-
method: request.method,
|
|
516
|
-
targetUrl,
|
|
517
|
-
worktreeSlug,
|
|
518
|
-
directory,
|
|
519
|
-
contentType: request.headers["content-type"],
|
|
520
|
-
body: bodyToJson(request.body),
|
|
521
|
-
headers: outgoing,
|
|
522
|
-
},
|
|
523
|
-
"Proxy -> OpenCode request",
|
|
524
|
-
)
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
return headers
|
|
528
|
-
},
|
|
529
|
-
onError: (proxyReply, { error }) => {
|
|
530
|
-
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
|
531
|
-
if (!proxyReply.sent) {
|
|
532
|
-
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
|
|
533
|
-
}
|
|
534
|
-
},
|
|
535
|
-
})
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
|
539
|
-
if (!pathSuffix || pathSuffix === "/") {
|
|
540
|
-
return "/"
|
|
541
|
-
}
|
|
542
|
-
const trimmed = pathSuffix.replace(/^\/+/, "")
|
|
543
|
-
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
type WorktreeCacheEntry = {
|
|
547
|
-
expiresAt: number
|
|
548
|
-
repoRoot: string
|
|
549
|
-
worktrees: Array<{ slug: string; directory: string }>
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
const WORKTREE_CACHE_TTL_MS = 2000
|
|
553
|
-
const worktreeCache = new Map<string, WorktreeCacheEntry>()
|
|
554
|
-
|
|
555
|
-
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
|
|
556
|
-
const cached = worktreeCache.get(params.workspaceId)
|
|
557
|
-
const now = Date.now()
|
|
558
|
-
if (cached && cached.expiresAt > now) {
|
|
559
|
-
return cached
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
|
|
563
|
-
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
|
|
564
|
-
const entry: WorktreeCacheEntry = {
|
|
565
|
-
expiresAt: now + WORKTREE_CACHE_TTL_MS,
|
|
566
|
-
repoRoot,
|
|
567
|
-
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
|
|
568
|
-
}
|
|
569
|
-
worktreeCache.set(params.workspaceId, entry)
|
|
570
|
-
return entry
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
async function resolveWorktreeDirectory(params: {
|
|
574
|
-
workspaceId: string
|
|
575
|
-
workspacePath: string
|
|
576
|
-
worktreeSlug: string
|
|
577
|
-
logger: Logger
|
|
578
|
-
}): Promise<string | null> {
|
|
579
|
-
const { worktreeSlug } = params
|
|
580
|
-
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
|
581
|
-
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
|
|
582
|
-
if (match) {
|
|
583
|
-
return match.directory
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// If the slug is new (e.g., created moments ago), refresh once.
|
|
587
|
-
worktreeCache.delete(params.workspaceId)
|
|
588
|
-
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
|
589
|
-
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
|
593
|
-
if (!uiDir) {
|
|
594
|
-
app.log.warn("UI static directory not provided; API endpoints only")
|
|
595
|
-
return
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
if (!fs.existsSync(uiDir)) {
|
|
599
|
-
app.log.warn({ uiDir }, "UI static directory missing; API endpoints only")
|
|
600
|
-
return
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
app.register(fastifyStatic, {
|
|
604
|
-
root: uiDir,
|
|
605
|
-
prefix: "/",
|
|
606
|
-
decorateReply: false,
|
|
607
|
-
})
|
|
608
|
-
|
|
609
|
-
const indexPath = path.join(uiDir, "index.html")
|
|
610
|
-
|
|
611
|
-
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
|
612
|
-
const url = request.raw.url ?? ""
|
|
613
|
-
if (isApiRequest(url)) {
|
|
614
|
-
reply.code(404).send({ message: "Not Found" })
|
|
615
|
-
return
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
const session = authManager.getSessionFromRequest(request)
|
|
619
|
-
if (!session && wantsHtml(request)) {
|
|
620
|
-
reply.redirect("/login")
|
|
621
|
-
return
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
if (fs.existsSync(indexPath)) {
|
|
625
|
-
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
|
626
|
-
} else {
|
|
627
|
-
reply.code(404).send({ message: "UI bundle missing" })
|
|
628
|
-
}
|
|
629
|
-
})
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
|
|
633
|
-
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
|
|
634
|
-
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
|
635
|
-
const url = request.raw.url ?? ""
|
|
636
|
-
if (isApiRequest(url)) {
|
|
637
|
-
reply.code(404).send({ message: "Not Found" })
|
|
638
|
-
return
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
const session = authManager.getSessionFromRequest(request)
|
|
642
|
-
if (!session && wantsHtml(request)) {
|
|
643
|
-
reply.redirect("/login")
|
|
644
|
-
return
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
void proxyToDevServer(request, reply, upstreamBase)
|
|
648
|
-
})
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
async function proxyToDevServer(request: FastifyRequest, reply: FastifyReply, upstreamBase: string) {
|
|
652
|
-
try {
|
|
653
|
-
const targetUrl = new URL(request.raw.url ?? "/", upstreamBase)
|
|
654
|
-
const response = await fetch(targetUrl, {
|
|
655
|
-
method: request.method,
|
|
656
|
-
headers: buildProxyHeaders(request.headers),
|
|
657
|
-
})
|
|
658
|
-
|
|
659
|
-
response.headers.forEach((value, key) => {
|
|
660
|
-
reply.header(key, value)
|
|
661
|
-
})
|
|
662
|
-
|
|
663
|
-
reply.code(response.status)
|
|
664
|
-
|
|
665
|
-
if (!response.body || request.method === "HEAD") {
|
|
666
|
-
reply.send()
|
|
667
|
-
return
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const buffer = Buffer.from(await response.arrayBuffer())
|
|
671
|
-
reply.send(buffer)
|
|
672
|
-
} catch (error) {
|
|
673
|
-
request.log.error({ err: error }, "Failed to proxy UI request to dev server")
|
|
674
|
-
if (!reply.sent) {
|
|
675
|
-
reply.code(502).send("UI dev server is unavailable")
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
function isApiRequest(rawUrl: string | null | undefined) {
|
|
681
|
-
if (!rawUrl) return false
|
|
682
|
-
const pathname = rawUrl.split("?")[0] ?? ""
|
|
683
|
-
return pathname === "/api" || pathname.startsWith("/api/")
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
|
|
687
|
-
const result: Record<string, string> = {}
|
|
688
|
-
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
689
|
-
if (!value || key.toLowerCase() === "host") continue
|
|
690
|
-
result[key] = Array.isArray(value) ? value.join(",") : value
|
|
691
|
-
}
|
|
692
|
-
return result
|
|
693
|
-
}
|