Skip to content

Commit a9430db

Browse files
authored
Merge pull request #222 from ProverCoderAI/codex/ci-e2e-ssh-checks
feat(web): proxy project browsers over noVNC
2 parents 5df9027 + 57120d9 commit a9430db

42 files changed

Lines changed: 2328 additions & 255 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/api/src/api/contracts.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ export type ProjectPortForwardRequest = {
5757
readonly hostPort?: number | undefined
5858
}
5959

60+
export type ProjectBrowserStatus = "running" | "stopped" | "missing" | "unknown"
61+
62+
export type ProjectBrowserSession = {
63+
readonly projectId: string
64+
readonly projectKey: string
65+
readonly containerName: string
66+
readonly status: ProjectBrowserStatus
67+
readonly noVncPath: string
68+
readonly noVncUrl: string
69+
readonly cdpPath: string
70+
readonly cdpUrl: string
71+
}
72+
6073
export type GithubAuthTokenStatus = {
6174
readonly key: string
6275
readonly label: string

packages/api/src/api/schema.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,19 @@ export const ProjectPortForwardRequestSchema = Schema.Struct({
129129
targetPort: Schema.Number
130130
})
131131

132+
export const ProjectBrowserStatusSchema = Schema.Literal("running", "stopped", "missing", "unknown")
133+
134+
export const ProjectBrowserSessionSchema = Schema.Struct({
135+
cdpPath: Schema.String,
136+
cdpUrl: Schema.String,
137+
containerName: Schema.String,
138+
noVncPath: Schema.String,
139+
noVncUrl: Schema.String,
140+
projectId: Schema.String,
141+
projectKey: Schema.String,
142+
status: ProjectBrowserStatusSchema
143+
})
144+
132145
export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "custom")
133146

134147
export const AgentEnvVarSchema = Schema.Struct({

packages/api/src/http.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ import {
7474
upProject
7575
} from "./services/projects.js"
7676
import { readProjectAuthSnapshot, runProjectAuthFlow } from "./services/project-auth.js"
77+
import { readProjectBrowserSession, proxyProjectBrowser } from "./services/project-browser.js"
78+
import { parseProjectBrowserProxyPath } from "./services/project-browser-core.js"
7779
import {
7880
createProjectPortForward,
7981
deleteProjectPortForward,
@@ -358,9 +360,13 @@ const terminalWebSocketUpgradeResponse = Effect.gen(function*(_) {
358360
)
359361
})
360362

361-
const projectPortProxyResponse = Effect.gen(function*(_) {
363+
const projectProxyResponse = Effect.gen(function*(_) {
362364
const request = yield* _(HttpServerRequest.HttpServerRequest)
363365
const pathname = new URL(request.url, "http://localhost").pathname
366+
const browserTarget = parseProjectBrowserProxyPath(pathname)
367+
if (browserTarget !== null) {
368+
return yield* _(proxyProjectBrowser(request, browserTarget, resolveRequestOrigin(request)))
369+
}
364370
const target = parseProjectPortProxyPath(pathname)
365371
if (target === null) {
366372
return yield* _(Effect.fail(new ApiNotFoundError({ message: `Route not found: ${pathname}` })))
@@ -726,6 +732,15 @@ export const makeRouter = () => {
726732
Effect.catchAll(errorResponse)
727733
)
728734
),
735+
HttpRouter.get(
736+
"/projects/:projectId/browser",
737+
Effect.gen(function*(_) {
738+
const { projectId } = yield* _(projectParams)
739+
const request = yield* _(HttpServerRequest.HttpServerRequest)
740+
const browser = yield* _(readProjectBrowserSession(projectId, resolveRequestOrigin(request)))
741+
return yield* _(jsonResponse({ browser }, 200))
742+
}).pipe(Effect.catchAll(errorResponse))
743+
),
729744
HttpRouter.del(
730745
"/projects/:projectId",
731746
projectParams.pipe(
@@ -955,7 +970,7 @@ export const makeRouter = () => {
955970
),
956971
HttpRouter.all(
957972
"*",
958-
projectPortProxyResponse.pipe(Effect.catchAll(errorResponse))
973+
projectProxyResponse.pipe(Effect.catchAll(errorResponse))
959974
)
960975
)
961976
}

packages/api/src/program.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { makeRouter } from "./http.js"
77
import { initializeAgentState } from "./services/agents.js"
88
import { attachAuthTerminalWebSocketServer } from "./services/auth-terminal-sessions.js"
99
import { startOutboxPolling } from "./services/federation.js"
10+
import { attachProjectBrowserWebSocketServer } from "./services/project-browser.js"
1011
import { attachTerminalWebSocketServer } from "./services/terminal-sessions.js"
1112

1213
const resolvePort = (env: Record<string, string | undefined>): number => {
@@ -46,6 +47,7 @@ export const program = (() => {
4647
const server = createServer()
4748
attachAuthTerminalWebSocketServer(server)
4849
attachTerminalWebSocketServer(server)
50+
attachProjectBrowserWebSocketServer(server)
4951
const serverLayer = NodeHttpServer.layer(() => server, { port })
5052

5153
const pollingInterval = parseInt(process.env["DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS"] ?? "5000", 10)
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { projectShortKey } from "./project-port-proxy-core.js"
2+
3+
export const browserNoVncPort = 6080
4+
export const browserCdpPort = 9223
5+
export const browserVncPort = 5900
6+
7+
export type ProjectBrowserProxyPath =
8+
| {
9+
readonly _tag: "NoVnc"
10+
readonly projectKey: string
11+
readonly upstreamPath: string
12+
}
13+
| {
14+
readonly _tag: "Cdp"
15+
readonly projectKey: string
16+
readonly upstreamPath: string
17+
}
18+
19+
const browserPathPattern = /^\/(?:api\/)?b\/([a-f0-9]{12})(?:\/(.*))?$/u
20+
21+
export const renderProjectBrowserProxyPath = (projectId: string): string =>
22+
`/b/${projectShortKey(projectId)}/`
23+
24+
export const renderProjectBrowserNoVncPath = (projectId: string): string => {
25+
const projectKey = projectShortKey(projectId)
26+
const params = new URLSearchParams({
27+
autoconnect: "true",
28+
resize: "remote",
29+
path: `b/${projectKey}/websockify`
30+
})
31+
return `/b/${projectKey}/vnc.html?${params.toString()}`
32+
}
33+
34+
export const renderProjectBrowserCdpPath = (projectId: string): string =>
35+
`/b/${projectShortKey(projectId)}/cdp/json/version`
36+
37+
export const parseProjectBrowserProxyPath = (pathname: string): ProjectBrowserProxyPath | null => {
38+
const match = browserPathPattern.exec(pathname)
39+
if (match === null) {
40+
return null
41+
}
42+
const projectKey = match[1]
43+
const rawPath = match[2] ?? ""
44+
if (projectKey === undefined) {
45+
return null
46+
}
47+
if (rawPath === "cdp" || rawPath.startsWith("cdp/")) {
48+
return {
49+
_tag: "Cdp",
50+
projectKey,
51+
upstreamPath: `/${rawPath.slice("cdp".length).replace(/^\/?/u, "")}`
52+
}
53+
}
54+
return {
55+
_tag: "NoVnc",
56+
projectKey,
57+
upstreamPath: `/${rawPath}`
58+
}
59+
}
60+
61+
export const renderExternalUrl = (origin: string, path: string): string => {
62+
const trimmed = origin.endsWith("/") ? origin.slice(0, -1) : origin
63+
return `${trimmed}${path}`
64+
}
65+
66+
export const rewriteCdpWebSocketUrl = (
67+
value: string,
68+
externalOrigin: string,
69+
projectId: string
70+
): string => {
71+
const match = /^wss?:\/\/[^/]+\/(.+)$/u.exec(value)
72+
const upstreamPath = match?.[1]
73+
if (upstreamPath === undefined || upstreamPath.length === 0) {
74+
return value
75+
}
76+
const external = new URL(externalOrigin)
77+
external.protocol = external.protocol === "https:" ? "wss:" : "ws:"
78+
external.pathname = `/b/${projectShortKey(projectId)}/cdp/${upstreamPath}`
79+
external.search = ""
80+
external.hash = ""
81+
return external.toString()
82+
}
83+
84+
export const rewriteCdpVersionPayload = (
85+
payload: string,
86+
externalOrigin: string,
87+
projectId: string
88+
): string => {
89+
let decoded: unknown
90+
try {
91+
decoded = JSON.parse(payload)
92+
} catch {
93+
return payload
94+
}
95+
if (typeof decoded !== "object" || decoded === null || !("webSocketDebuggerUrl" in decoded)) {
96+
return payload
97+
}
98+
const current = decoded.webSocketDebuggerUrl
99+
if (typeof current !== "string") {
100+
return payload
101+
}
102+
return JSON.stringify({
103+
...decoded,
104+
webSocketDebuggerUrl: rewriteCdpWebSocketUrl(current, externalOrigin, projectId)
105+
})
106+
}

0 commit comments

Comments
 (0)