Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 206 additions & 17 deletions packages/api/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,18 @@ import {
startTerminalSession
} from "./services/terminal-sessions.js"
import {
connectSkillerWeb,
openSkiller,
openSkillerForTerminalSession,
parseSkillerRoute,
proxySkillerTrpc,
readSkillerProjectContext,
serveSkillerApp
} from "./services/skiller.js"
import {
isSkillerWebCorsOriginAllowed,
resolveDockerGitSkillerBackendUrl
} from "./services/skiller-core.js"
import {
commitStateFromRequest,
initStateFromRequest,
Expand Down Expand Up @@ -228,6 +234,11 @@ const AuthTerminalSessionParamsSchema = Schema.Struct({
sessionId: Schema.String
})

const SkillerConnectRequestSchema = Schema.Struct({
projectKey: Schema.String,
sessionId: Schema.optional(Schema.String)
})

type ApiError =
| ApiAuthRequiredError
| ApiBadRequestError
Expand Down Expand Up @@ -446,6 +457,7 @@ const readCodexAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(CodexAu
const readGrokAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GrokAuthLogoutRequestSchema)
const readCodexAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthLogoutRequestSchema)
const readProjectAuthRequest = () => HttpServerRequest.schemaBodyJson(ProjectAuthRequestSchema)
const readSkillerConnectRequest = () => HttpServerRequest.schemaBodyJson(SkillerConnectRequestSchema)
const readProjectPromptUpdateRequest = () => HttpServerRequest.schemaBodyJson(ProjectPromptUpdateRequestSchema)
const readProjectSkillUpdateRequest = () => HttpServerRequest.schemaBodyJson(ProjectSkillUpdateRequestSchema)
const readActiveProjectTerminalSessionRequest = () =>
Expand Down Expand Up @@ -596,6 +608,80 @@ const resolveRequestOrigin = (request: HttpServerRequest.HttpServerRequest): str
return `${proto}://${host}`
}

const resolveSkillerBackendUrl = (request: HttpServerRequest.HttpServerRequest): string =>
resolveDockerGitSkillerBackendUrl(process.env, resolveRequestOrigin(request))

const isPrivateNetworkCorsRequest = (
request: HttpServerRequest.HttpServerRequest
): boolean =>
readHeader(request, "access-control-request-private-network")?.toLowerCase() === "true"

const skillerCorsHeaders = (
request: HttpServerRequest.HttpServerRequest
): Record<string, string> => {
const origin = readHeader(request, "origin")
if (origin === undefined || !isSkillerWebCorsOriginAllowed(origin, process.env)) {
return {}
}
const privateNetworkHeaders = isPrivateNetworkCorsRequest(request)
? { "access-control-allow-private-network": "true" }
: {}
return {
...privateNetworkHeaders,
"access-control-allow-credentials": "true",
"access-control-allow-headers": readHeader(request, "access-control-request-headers") ??
"content-type,trpc-accept,x-trpc-source",
"access-control-allow-methods": "GET,POST,OPTIONS",
"access-control-allow-origin": origin,
"access-control-max-age": "600",
"access-control-expose-headers": "content-type",
vary: "origin, access-control-request-private-network"
}
}

const withSkillerCors = (
request: HttpServerRequest.HttpServerRequest,
response: HttpServerResponse.HttpServerResponse
): HttpServerResponse.HttpServerResponse => {
const headers = skillerCorsHeaders(request)
return Object.keys(headers).length === 0 ? response : HttpServerResponse.setHeaders(response, headers)
}

const skillerJsonResponse = (
request: HttpServerRequest.HttpServerRequest,
data: unknown,
status: number
) =>
jsonResponse(data, status).pipe(
Effect.map((response) => withSkillerCors(request, response))
)

const skillerErrorResponse = (
request: HttpServerRequest.HttpServerRequest,
error: unknown
) =>
errorResponse(error).pipe(
Effect.map((response) => withSkillerCors(request, response))
)

const isSkillerCorsPath = (pathname: string): boolean => {
const normalized = pathname.startsWith("/api/") ? pathname.slice("/api".length) : pathname
return normalized === "/skiller/connect" ||
/^\/projects\/by-key\/[^/]+(?:\/terminal-sessions\/[^/]+)?\/skiller\/context$/u.test(normalized) ||
parseSkillerRoute(pathname) !== null
}

const skillerCorsPreflightResponse = (
request: HttpServerRequest.HttpServerRequest
) => {
const origin = readHeader(request, "origin")
const allowed = origin === undefined || isSkillerWebCorsOriginAllowed(origin, process.env)
return Effect.succeed(HttpServerResponse.empty({
headers: allowed ? skillerCorsHeaders(request) : noStoreHeaders,
status: allowed ? 204 : 403
}))
}

const resolveFederationContext = (
request: HttpServerRequest.HttpServerRequest,
requestedDomain?: string | undefined
Expand Down Expand Up @@ -771,11 +857,25 @@ const terminalWebSocketUpgradeResponse = Effect.gen(function*(_) {
const projectProxyResponse = Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const pathname = new URL(request.url, "http://localhost").pathname
if (request.method === "OPTIONS" && isSkillerCorsPath(pathname)) {
return yield* _(skillerCorsPreflightResponse(request))
}
const skillerRoute = parseSkillerRoute(pathname)
if (skillerRoute !== null) {
return skillerRoute._tag === "App"
? yield* _(serveSkillerApp(skillerRoute))
: yield* _(proxySkillerTrpc(request, skillerRoute))
if (skillerRoute._tag === "App") {
return yield* _(
serveSkillerApp(skillerRoute).pipe(
Effect.map((response) => withSkillerCors(request, response)),
Effect.catchAll((error) => skillerErrorResponse(request, error))
)
)
}
return yield* _(
proxySkillerTrpc(request, skillerRoute).pipe(
Effect.map((response) => withSkillerCors(request, response)),
Effect.catchAll((error) => skillerErrorResponse(request, error))
)
)
}
const browserTarget = parseProjectBrowserProxyPath(pathname)
if (browserTarget !== null) {
Expand All @@ -800,6 +900,38 @@ const projectProxyResponse = Effect.gen(function*(_) {
return yield* _(proxyProjectPortForward(request, target))
})

const normalizedOptionalString = (value: string | undefined): string | undefined => {
const trimmed = value?.trim()
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed
}

const skillerConnectInfoResponse = (
request: HttpServerRequest.HttpServerRequest
) =>
listProjects().pipe(
Effect.flatMap((projects) => skillerJsonResponse(request, { ok: true, projects }, 200)),
Effect.catchAll((error) => skillerErrorResponse(request, error))
)

const skillerConnectResponse = (
request: HttpServerRequest.HttpServerRequest
) =>
Effect.gen(function*(_) {
const body = yield* _(readSkillerConnectRequest())
const projectKey = body.projectKey.trim()
if (projectKey.length === 0) {
return yield* _(Effect.fail(new ApiBadRequestError({ message: "projectKey is required." })))
}
const connection = yield* _(connectSkillerWeb(
projectKey,
normalizedOptionalString(body.sessionId),
resolveSkillerBackendUrl(request)
))
return yield* _(skillerJsonResponse(request, { ok: true, ...connection }, 202))
}).pipe(
Effect.catchAll((error) => skillerErrorResponse(request, error))
)

export const makeRouter = () => {
const withCoreRoutes = HttpRouter.empty.pipe(
HttpRouter.get(
Expand All @@ -810,28 +942,85 @@ export const makeRouter = () => {
return yield* _(jsonResponse({ ok: true, revision: controllerRevision, cwd, projectsRoot }, 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.get(
"/skiller/connect",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
return yield* _(skillerConnectInfoResponse(request))
})
),
HttpRouter.get(
"/api/skiller/connect",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
return yield* _(skillerConnectInfoResponse(request))
})
),
HttpRouter.post(
"/skiller/connect",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
return yield* _(skillerConnectResponse(request))
})
),
HttpRouter.post(
"/api/skiller/connect",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
return yield* _(skillerConnectResponse(request))
})
),
HttpRouter.post(
"/skiller/open",
openSkiller().pipe(
Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)),
Effect.catchAll(errorResponse)
)
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const launch = yield* _(openSkiller(undefined, undefined, resolveSkillerBackendUrl(request)))
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.post(
"/projects/by-key/:projectKey/skiller/open",
projectKeyParams.pipe(
Effect.flatMap(({ projectKey }) => openSkiller(projectKey)),
Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)),
Effect.catchAll(errorResponse)
)
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const { projectKey } = yield* _(projectKeyParams)
const launch = yield* _(openSkiller(projectKey, undefined, resolveSkillerBackendUrl(request)))
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.post(
"/projects/by-key/:projectKey/terminal-sessions/:sessionId/skiller/open",
terminalSessionByProjectKeyParams.pipe(
Effect.flatMap(({ projectKey, sessionId }) => openSkillerForTerminalSession(projectKey, sessionId)),
Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)),
Effect.catchAll(errorResponse)
)
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const { projectKey, sessionId } = yield* _(terminalSessionByProjectKeyParams)
const launch = yield* _(openSkillerForTerminalSession(projectKey, sessionId, resolveSkillerBackendUrl(request)))
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.get(
"/projects/by-key/:projectKey/skiller/context",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
return yield* _(
projectKeyParams.pipe(
Effect.flatMap(({ projectKey }) => readSkillerProjectContext(projectKey, null)),
Effect.flatMap((context) => skillerJsonResponse(request, { ok: true, ...context }, 200)),
Effect.catchAll((error) => skillerErrorResponse(request, error))
)
)
})
),
HttpRouter.get(
"/projects/by-key/:projectKey/terminal-sessions/:sessionId/skiller/context",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
return yield* _(
terminalSessionByProjectKeyParams.pipe(
Effect.flatMap(({ projectKey, sessionId }) => readSkillerProjectContext(projectKey, sessionId)),
Effect.flatMap((context) => skillerJsonResponse(request, { ok: true, ...context }, 200)),
Effect.catchAll((error) => skillerErrorResponse(request, error))
)
)
})
),
HttpRouter.get(
"/cloudflare-tunnels/panel",
Expand Down
104 changes: 104 additions & 0 deletions packages/api/src/services/skiller-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,110 @@ export type SkillerBrowserScope = {
readonly sessionId: string | null
}

export type ConfiguredSkillerWebUrl =
| { readonly _tag: "Disabled" }
| { readonly _tag: "Enabled"; readonly baseUrl: string }
| { readonly _tag: "Invalid"; readonly message: string }

export type ExternalSkillerLaunchUrlInput = {
readonly backendUrl: string
readonly projectKey: string | undefined
readonly sessionId: string | undefined
readonly skillerWebUrl: string
}

const trimTrailingSlashes = (value: string): string =>
value.replace(/\/+$/u, "")

const configuredOrigin = (raw: string): string | null => {
const value = raw.trim()
if (!URL.canParse(value)) {
return null
}
const parsed = new URL(value)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return null
}
return parsed.origin
}

const uniqueOrigins = (origins: ReadonlyArray<string>): ReadonlyArray<string> =>
[...new Set(origins)]

const defaultSkillerWebCorsOrigins = [
"https://skiller-web-henna.vercel.app",
"http://localhost:5180",
"http://127.0.0.1:5180"
] as const

export const resolveConfiguredSkillerWebUrl = (
env: Record<string, string | undefined>
): ConfiguredSkillerWebUrl => {
const raw = env["DOCKER_GIT_SKILLER_WEB_URL"]?.trim()
if (raw === undefined || raw.length === 0) {
return { _tag: "Disabled" }
}
if (!URL.canParse(raw)) {
return { _tag: "Invalid", message: `Invalid DOCKER_GIT_SKILLER_WEB_URL: ${raw}` }
}
const parsed = new URL(raw)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return { _tag: "Invalid", message: "DOCKER_GIT_SKILLER_WEB_URL must use http or https." }
}
parsed.hash = ""
parsed.search = ""
return { _tag: "Enabled", baseUrl: trimTrailingSlashes(parsed.toString()) }
}

export const resolveDockerGitSkillerBackendUrl = (
env: Record<string, string | undefined>,
requestOrigin: string
): string => {
const configured = [
env["DOCKER_GIT_SKILLER_BACKEND_URL"],
env["DOCKER_GIT_API_PUBLIC_URL"]
]
.map((value) => value?.trim())
.find((value) => value !== undefined && value.length > 0)
return configured ?? requestOrigin
}

export const configuredSkillerWebCorsOrigins = (
env: Record<string, string | undefined>
): ReadonlyArray<string> => {
const configuredWeb = resolveConfiguredSkillerWebUrl(env)
const fromWeb = configuredWeb._tag === "Enabled"
? [configuredOrigin(configuredWeb.baseUrl)].filter((origin): origin is string => origin !== null)
: []
const fromAllowed = (env["DOCKER_GIT_SKILLER_ALLOWED_ORIGINS"] ?? "")
.split(",")
.map(configuredOrigin)
.filter((origin): origin is string => origin !== null)
return uniqueOrigins([
...fromWeb,
...fromAllowed,
...defaultSkillerWebCorsOrigins
])
}

export const isSkillerWebCorsOriginAllowed = (
origin: string | undefined,
env: Record<string, string | undefined>
): boolean =>
origin !== undefined && configuredSkillerWebCorsOrigins(env).includes(origin)

export const externalSkillerLaunchUrl = (input: ExternalSkillerLaunchUrlInput): string => {
const url = new URL(`${trimTrailingSlashes(input.skillerWebUrl)}/launch`)
url.searchParams.set("backendUrl", input.backendUrl)
if (input.projectKey !== undefined) {
url.searchParams.set("projectKey", input.projectKey)
}
if (input.sessionId !== undefined) {
url.searchParams.set("sessionId", input.sessionId)
}
return url.toString()
}

export const parseDockerMountLines = (output: string): ReadonlyArray<DockerContainerMount> =>
output
.split(/\r?\n/u)
Expand Down
Loading
Loading