Skip to content

Commit 5df9027

Browse files
authored
Merge pull request #221 from ProverCoderAI/codex/ci-e2e-ssh-checks
Add browser SSH and project port proxy flows
2 parents 71abd7a + 09fa65b commit 5df9027

45 files changed

Lines changed: 2597 additions & 517 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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type AgentStatus = "starting" | "running" | "stopping" | "stopped" | "exi
66

77
export type ProjectSummary = {
88
readonly id: string
9+
readonly projectKey: string
910
readonly displayName: string
1011
readonly repoUrl: string
1112
readonly repoRef: string
@@ -33,6 +34,29 @@ export type ProjectDetails = ProjectSummary & {
3334
readonly codexHome: string
3435
}
3536

37+
export type ProjectPortForwardStatus = "running" | "stopped" | "unknown"
38+
39+
export type ProjectPortForward = {
40+
readonly id: string
41+
readonly projectId: string
42+
readonly projectKey: string
43+
readonly targetPort: number
44+
readonly hostPort: number
45+
readonly bindHost: string
46+
readonly publicHost: string
47+
readonly proxyPath: string
48+
readonly url: string
49+
readonly status: ProjectPortForwardStatus
50+
readonly containerName: string
51+
readonly targetContainerName: string
52+
readonly createdAt: string | null
53+
}
54+
55+
export type ProjectPortForwardRequest = {
56+
readonly targetPort: number
57+
readonly hostPort?: number | undefined
58+
}
59+
3660
export type GithubAuthTokenStatus = {
3761
readonly key: string
3862
readonly label: string

packages/api/src/api/schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ export const UpProjectRequestSchema = Schema.Struct({
124124
useManagedAuthorizedKeys: OptionalBoolean
125125
})
126126

127+
export const ProjectPortForwardRequestSchema = Schema.Struct({
128+
hostPort: Schema.optional(Schema.Number),
129+
targetPort: Schema.Number
130+
})
131+
127132
export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "custom")
128133

129134
export const AgentEnvVarSchema = Schema.Struct({
@@ -201,5 +206,6 @@ export type StateCommitRequestInput = Schema.Schema.Type<typeof StateCommitReque
201206
export type StateSyncRequestInput = Schema.Schema.Type<typeof StateSyncRequestSchema>
202207
export type ApplyAllRequestInput = Schema.Schema.Type<typeof ApplyAllRequestSchema>
203208
export type UpProjectRequestInput = Schema.Schema.Type<typeof UpProjectRequestSchema>
209+
export type ProjectPortForwardRequestInput = Schema.Schema.Type<typeof ProjectPortForwardRequestSchema>
204210
export type CreateAgentRequestInput = Schema.Schema.Type<typeof CreateAgentRequestSchema>
205211
export type CreateFollowRequestInput = Schema.Schema.Type<typeof CreateFollowRequestSchema>

packages/api/src/http.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
GithubAuthLoginRequestSchema,
2525
GithubAuthLogoutRequestSchema,
2626
ProjectAuthRequestSchema,
27+
ProjectPortForwardRequestSchema,
2728
StateCommitRequestSchema,
2829
StateInitRequestSchema,
2930
StateSyncRequestSchema,
@@ -73,6 +74,13 @@ import {
7374
upProject
7475
} from "./services/projects.js"
7576
import { readProjectAuthSnapshot, runProjectAuthFlow } from "./services/project-auth.js"
77+
import {
78+
createProjectPortForward,
79+
deleteProjectPortForward,
80+
listProjectPortForwards
81+
} from "./services/project-port-forwards.js"
82+
import { proxyProjectPortForward } from "./services/project-port-proxy.js"
83+
import { parseProjectPortProxyPath } from "./services/project-port-proxy-core.js"
7684
import { createTerminalSession, deleteTerminalSession } from "./services/terminal-sessions.js"
7785
import {
7886
commitStateFromRequest,
@@ -88,6 +96,11 @@ const ProjectParamsSchema = Schema.Struct({
8896
projectId: Schema.String
8997
})
9098

99+
const ProjectPortForwardParamsSchema = Schema.Struct({
100+
projectId: Schema.String,
101+
targetPort: Schema.String
102+
})
103+
91104
const AgentParamsSchema = Schema.Struct({
92105
projectId: Schema.String,
93106
agentId: Schema.String
@@ -169,6 +182,26 @@ const parseQueryInt = (url: string, key: string, fallback: number): number => {
169182
const hasQueryParam = (url: string, key: string): boolean =>
170183
new URL(url, "http://localhost").searchParams.has(key)
171184

185+
const parsePortParam = (value: string): Effect.Effect<number, ApiBadRequestError> => {
186+
const parsed = Number.parseInt(value, 10)
187+
return String(parsed) === value && parsed > 0 && parsed <= 65_535
188+
? Effect.succeed(parsed)
189+
: Effect.fail(new ApiBadRequestError({ message: `Invalid port: ${value}` }))
190+
}
191+
192+
const hostWithoutPort = (host: string): string => {
193+
if (host.startsWith("[")) {
194+
const end = host.indexOf("]")
195+
return end === -1 ? host : host.slice(1, end)
196+
}
197+
return host.split(":")[0] ?? host
198+
}
199+
200+
const resolvePortPublicHost = (request: HttpServerRequest.HttpServerRequest): string | undefined => {
201+
const host = firstCommaValue(readHeader(request, "x-forwarded-host")) ?? readHeader(request, "host")
202+
return host === undefined || host.trim().length === 0 ? undefined : hostWithoutPort(host.trim())
203+
}
204+
172205
const errorResponse = (error: ApiError | unknown) => {
173206
if (ParseResult.isParseError(error)) {
174207
return jsonResponse(
@@ -228,6 +261,7 @@ const errorResponse = (error: ApiError | unknown) => {
228261
}
229262

230263
const projectParams = HttpRouter.schemaParams(ProjectParamsSchema)
264+
const projectPortForwardParams = HttpRouter.schemaParams(ProjectPortForwardParamsSchema)
231265
const agentParams = HttpRouter.schemaParams(AgentParamsSchema)
232266
const terminalSessionParams = HttpRouter.schemaParams(TerminalSessionParamsSchema)
233267
const authTerminalSessionParams = HttpRouter.schemaParams(AuthTerminalSessionParamsSchema)
@@ -242,6 +276,7 @@ const readCodexAuthImportRequest = () => HttpServerRequest.schemaBodyJson(CodexA
242276
const readCodexAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthLoginRequestSchema)
243277
const readCodexAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthLogoutRequestSchema)
244278
const readProjectAuthRequest = () => HttpServerRequest.schemaBodyJson(ProjectAuthRequestSchema)
279+
const readProjectPortForwardRequest = () => HttpServerRequest.schemaBodyJson(ProjectPortForwardRequestSchema)
245280
const readStateInitRequest = () => HttpServerRequest.schemaBodyJson(StateInitRequestSchema)
246281
const readStateCommitRequest = () => HttpServerRequest.schemaBodyJson(StateCommitRequestSchema)
247282
const readStateSyncRequest = () => HttpServerRequest.schemaBodyJson(StateSyncRequestSchema)
@@ -323,6 +358,16 @@ const terminalWebSocketUpgradeResponse = Effect.gen(function*(_) {
323358
)
324359
})
325360

361+
const projectPortProxyResponse = Effect.gen(function*(_) {
362+
const request = yield* _(HttpServerRequest.HttpServerRequest)
363+
const pathname = new URL(request.url, "http://localhost").pathname
364+
const target = parseProjectPortProxyPath(pathname)
365+
if (target === null) {
366+
return yield* _(Effect.fail(new ApiNotFoundError({ message: `Route not found: ${pathname}` })))
367+
}
368+
return yield* _(proxyProjectPortForward(request, target))
369+
})
370+
326371
export const makeRouter = () => {
327372
const withUi = HttpRouter.empty.pipe(
328373
HttpRouter.get("/",
@@ -651,6 +696,36 @@ export const makeRouter = () => {
651696
return yield* _(jsonResponse({ ok: true, snapshot }, 200))
652697
}).pipe(Effect.catchAll(errorResponse))
653698
),
699+
HttpRouter.get(
700+
"/projects/:projectId/ports",
701+
projectParams.pipe(
702+
Effect.flatMap(({ projectId }) => listProjectPortForwards(projectId)),
703+
Effect.flatMap((forwards) => jsonResponse({ forwards }, 200)),
704+
Effect.catchAll(errorResponse)
705+
)
706+
),
707+
HttpRouter.post(
708+
"/projects/:projectId/ports",
709+
Effect.gen(function*(_) {
710+
const { projectId } = yield* _(projectParams)
711+
const request = yield* _(readProjectPortForwardRequest())
712+
const serverRequest = yield* _(HttpServerRequest.HttpServerRequest)
713+
const forward = yield* _(createProjectPortForward(projectId, request, resolvePortPublicHost(serverRequest)))
714+
return yield* _(jsonResponse({ forward }, 201))
715+
}).pipe(Effect.catchAll(errorResponse))
716+
),
717+
HttpRouter.del(
718+
"/projects/:projectId/ports/:targetPort",
719+
projectPortForwardParams.pipe(
720+
Effect.flatMap(({ projectId, targetPort }) =>
721+
parsePortParam(targetPort).pipe(
722+
Effect.flatMap((port) => deleteProjectPortForward(projectId, port))
723+
)
724+
),
725+
Effect.flatMap(() => jsonResponse({ ok: true }, 200)),
726+
Effect.catchAll(errorResponse)
727+
)
728+
),
654729
HttpRouter.del(
655730
"/projects/:projectId",
656731
projectParams.pipe(
@@ -877,6 +952,10 @@ export const makeRouter = () => {
877952
),
878953
Effect.catchAll(errorResponse)
879954
)
955+
),
956+
HttpRouter.all(
957+
"*",
958+
projectPortProxyResponse.pipe(Effect.catchAll(errorResponse))
880959
)
881960
)
882961
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { createHash } from "node:crypto"
2+
3+
import type { ProjectPortForward, ProjectPortForwardStatus } from "../api/contracts.js"
4+
import { projectShortKey, renderForwardProxyPath } from "./project-port-proxy-core.js"
5+
6+
export const portForwardKindLabel = "ai.docker-git.kind"
7+
export const portForwardKindValue = "port-forward"
8+
export const portForwardProjectLabel = "ai.docker-git.project-id"
9+
export const portForwardTargetPortLabel = "ai.docker-git.target-port"
10+
export const portForwardHostPortLabel = "ai.docker-git.host-port"
11+
export const portForwardBindHostLabel = "ai.docker-git.bind-host"
12+
export const portForwardPublicHostLabel = "ai.docker-git.public-host"
13+
export const portForwardTargetContainerLabel = "ai.docker-git.target-container"
14+
15+
const dockerNameMaxLength = 63
16+
const defaultHostPortSearchLimit = 200
17+
18+
export type PortForwardRow = {
19+
readonly id: string
20+
readonly name: string
21+
readonly state: string
22+
readonly createdAt: string
23+
readonly projectId: string
24+
readonly targetPort: string
25+
readonly hostPort: string
26+
readonly bindHost: string
27+
readonly publicHost: string
28+
readonly targetContainerName: string
29+
}
30+
31+
export type PortForwardRequestPorts = {
32+
readonly targetPort: number
33+
readonly hostPort: number | undefined
34+
}
35+
36+
type PortValidationResult =
37+
| { readonly ok: true; readonly port: number }
38+
| { readonly ok: false; readonly message: string }
39+
40+
type PortRequestValidationResult =
41+
| { readonly ok: true; readonly ports: PortForwardRequestPorts }
42+
| { readonly ok: false; readonly message: string }
43+
44+
const sanitizeNameChunk = (value: string): string =>
45+
value
46+
.toLowerCase()
47+
.replace(/[^a-z0-9_.-]+/g, "-")
48+
.replace(/^-+|-+$/g, "")
49+
50+
const shortHash = (value: string): string => createHash("sha256").update(value).digest("hex").slice(0, 12)
51+
52+
const normalizeDockerName = (value: string): string => {
53+
const sanitized = sanitizeNameChunk(value)
54+
const name = sanitized.length === 0 ? "port" : sanitized
55+
return name.length <= dockerNameMaxLength ? name : name.slice(0, dockerNameMaxLength)
56+
}
57+
58+
export const buildPortForwardContainerName = (
59+
projectId: string,
60+
targetPort: number
61+
): string => normalizeDockerName(`dg-port-${shortHash(projectId)}-${targetPort}`)
62+
63+
export const validatePort = (value: number, label: string): PortValidationResult =>
64+
Number.isInteger(value) && value > 0 && value <= 65_535
65+
? { ok: true, port: value }
66+
: { ok: false, message: `${label} must be an integer between 1 and 65535.` }
67+
68+
export const normalizePortForwardRequest = (
69+
targetPort: number,
70+
hostPort: number | undefined
71+
): PortRequestValidationResult => {
72+
const target = validatePort(targetPort, "targetPort")
73+
if (!target.ok) {
74+
return target
75+
}
76+
if (hostPort === undefined) {
77+
return { ok: true, ports: { targetPort: target.port, hostPort: undefined } }
78+
}
79+
const host = validatePort(hostPort, "hostPort")
80+
return host.ok
81+
? { ok: true, ports: { targetPort: target.port, hostPort: host.port } }
82+
: host
83+
}
84+
85+
export const selectHostPort = (
86+
preferredPort: number,
87+
requestedPort: number | undefined,
88+
usedPorts: ReadonlySet<number>,
89+
searchLimit = defaultHostPortSearchLimit
90+
): number | null => {
91+
if (requestedPort !== undefined) {
92+
return usedPorts.has(requestedPort) ? null : requestedPort
93+
}
94+
for (let port = preferredPort; port <= 65_535 && port < preferredPort + searchLimit; port += 1) {
95+
if (!usedPorts.has(port)) {
96+
return port
97+
}
98+
}
99+
return null
100+
}
101+
102+
export const publicHostFromEnv = (fallback = "localhost"): string =>
103+
process.env["DOCKER_GIT_PUBLIC_HOST"]?.trim() || fallback
104+
105+
export const bindHostFromEnv = (): string =>
106+
process.env["DOCKER_GIT_PUBLIC_BIND_HOST"]?.trim() || "0.0.0.0"
107+
108+
export const projectsRootVolumeFromEnv = (): string =>
109+
process.env["DOCKER_GIT_PROJECTS_ROOT_VOLUME"]?.trim() || "docker-git-projects"
110+
111+
export const renderForwardUrl = (publicHost: string, hostPort: number): string =>
112+
`http://${publicHost}:${hostPort}`
113+
114+
export const statusFromDockerState = (state: string): ProjectPortForwardStatus => {
115+
const normalized = state.trim().toLowerCase()
116+
if (normalized === "running") {
117+
return "running"
118+
}
119+
if (normalized === "exited" || normalized === "created" || normalized === "dead" || normalized === "removing") {
120+
return "stopped"
121+
}
122+
return "unknown"
123+
}
124+
125+
const parseRowPort = (value: string): number => {
126+
const parsed = Number.parseInt(value, 10)
127+
return Number.isInteger(parsed) ? parsed : 0
128+
}
129+
130+
export const parsePortForwardRows = (output: string): ReadonlyArray<PortForwardRow> =>
131+
output
132+
.split(/\r?\n/u)
133+
.map((line) => line.trimEnd())
134+
.filter((line) => line.length > 0)
135+
.flatMap((line) => {
136+
const parts = line.split("\t")
137+
if (parts.length < 10) {
138+
return []
139+
}
140+
const [id, name, state, createdAt, projectId, targetPort, hostPort, bindHost, publicHost, targetContainerName] = parts
141+
return id === undefined ||
142+
name === undefined ||
143+
state === undefined ||
144+
createdAt === undefined ||
145+
projectId === undefined ||
146+
targetPort === undefined ||
147+
hostPort === undefined ||
148+
bindHost === undefined ||
149+
publicHost === undefined ||
150+
targetContainerName === undefined
151+
? []
152+
: [{ id, name, state, createdAt, projectId, targetPort, hostPort, bindHost, publicHost, targetContainerName }]
153+
})
154+
155+
export const rowToProjectPortForward = (row: PortForwardRow): ProjectPortForward => {
156+
const hostPort = parseRowPort(row.hostPort)
157+
const publicHost = row.publicHost.trim().length > 0 ? row.publicHost : "localhost"
158+
return {
159+
bindHost: row.bindHost,
160+
containerName: row.name,
161+
createdAt: row.createdAt.trim().length === 0 ? null : row.createdAt,
162+
hostPort,
163+
id: row.id,
164+
projectId: row.projectId,
165+
projectKey: projectShortKey(row.projectId),
166+
proxyPath: renderForwardProxyPath(row.projectId, parseRowPort(row.targetPort)),
167+
publicHost,
168+
status: statusFromDockerState(row.state),
169+
targetContainerName: row.targetContainerName,
170+
targetPort: parseRowPort(row.targetPort),
171+
url: renderForwardUrl(publicHost, hostPort)
172+
}
173+
}
174+
175+
export const rowsToProjectPortForwards = (rows: ReadonlyArray<PortForwardRow>): ReadonlyArray<ProjectPortForward> =>
176+
rows.map(rowToProjectPortForward)
177+
178+
export const buildForwardSshScript = (
179+
targetIp: string,
180+
sshUser: string,
181+
targetPort: number
182+
): string => [
183+
"set -euo pipefail",
184+
"KEY=/tmp/docker-git-dev-ssh-key",
185+
"cp /docker-git-projects/dev_ssh_key \"$KEY\"",
186+
"chmod 600 \"$KEY\"",
187+
"exec ssh -N \\",
188+
" -i \"$KEY\" \\",
189+
" -o ExitOnForwardFailure=yes \\",
190+
" -o ServerAliveInterval=30 \\",
191+
" -o ServerAliveCountMax=3 \\",
192+
" -o StrictHostKeyChecking=no \\",
193+
" -o UserKnownHostsFile=/dev/null \\",
194+
" -o LogLevel=ERROR \\",
195+
` -L 0.0.0.0:${targetPort}:127.0.0.1:${targetPort} \\`,
196+
` -p 22 ${sshUser}@${targetIp}`
197+
].join("\n")

0 commit comments

Comments
 (0)