Skip to content

Commit aaa4f30

Browse files
authored
feat(skiller): support external Skiller web service
* feat(skiller): support external web service * feat(api): connect Skiller Web to docker-git runtime * feat(api): support Skiller Web localhost links * fix(api): avoid DOM RequestInit in Skiller CORS test
1 parent 7aaebec commit aaa4f30

8 files changed

Lines changed: 674 additions & 28 deletions

File tree

packages/api/src/http.ts

Lines changed: 206 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,18 @@ import {
159159
startTerminalSession
160160
} from "./services/terminal-sessions.js"
161161
import {
162+
connectSkillerWeb,
162163
openSkiller,
163164
openSkillerForTerminalSession,
164165
parseSkillerRoute,
165166
proxySkillerTrpc,
167+
readSkillerProjectContext,
166168
serveSkillerApp
167169
} from "./services/skiller.js"
170+
import {
171+
isSkillerWebCorsOriginAllowed,
172+
resolveDockerGitSkillerBackendUrl
173+
} from "./services/skiller-core.js"
168174
import {
169175
commitStateFromRequest,
170176
initStateFromRequest,
@@ -228,6 +234,11 @@ const AuthTerminalSessionParamsSchema = Schema.Struct({
228234
sessionId: Schema.String
229235
})
230236

237+
const SkillerConnectRequestSchema = Schema.Struct({
238+
projectKey: Schema.String,
239+
sessionId: Schema.optional(Schema.String)
240+
})
241+
231242
type ApiError =
232243
| ApiAuthRequiredError
233244
| ApiBadRequestError
@@ -446,6 +457,7 @@ const readCodexAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(CodexAu
446457
const readGrokAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GrokAuthLogoutRequestSchema)
447458
const readCodexAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthLogoutRequestSchema)
448459
const readProjectAuthRequest = () => HttpServerRequest.schemaBodyJson(ProjectAuthRequestSchema)
460+
const readSkillerConnectRequest = () => HttpServerRequest.schemaBodyJson(SkillerConnectRequestSchema)
449461
const readProjectPromptUpdateRequest = () => HttpServerRequest.schemaBodyJson(ProjectPromptUpdateRequestSchema)
450462
const readProjectSkillUpdateRequest = () => HttpServerRequest.schemaBodyJson(ProjectSkillUpdateRequestSchema)
451463
const readActiveProjectTerminalSessionRequest = () =>
@@ -596,6 +608,80 @@ const resolveRequestOrigin = (request: HttpServerRequest.HttpServerRequest): str
596608
return `${proto}://${host}`
597609
}
598610

611+
const resolveSkillerBackendUrl = (request: HttpServerRequest.HttpServerRequest): string =>
612+
resolveDockerGitSkillerBackendUrl(process.env, resolveRequestOrigin(request))
613+
614+
const isPrivateNetworkCorsRequest = (
615+
request: HttpServerRequest.HttpServerRequest
616+
): boolean =>
617+
readHeader(request, "access-control-request-private-network")?.toLowerCase() === "true"
618+
619+
const skillerCorsHeaders = (
620+
request: HttpServerRequest.HttpServerRequest
621+
): Record<string, string> => {
622+
const origin = readHeader(request, "origin")
623+
if (origin === undefined || !isSkillerWebCorsOriginAllowed(origin, process.env)) {
624+
return {}
625+
}
626+
const privateNetworkHeaders = isPrivateNetworkCorsRequest(request)
627+
? { "access-control-allow-private-network": "true" }
628+
: {}
629+
return {
630+
...privateNetworkHeaders,
631+
"access-control-allow-credentials": "true",
632+
"access-control-allow-headers": readHeader(request, "access-control-request-headers") ??
633+
"content-type,trpc-accept,x-trpc-source",
634+
"access-control-allow-methods": "GET,POST,OPTIONS",
635+
"access-control-allow-origin": origin,
636+
"access-control-max-age": "600",
637+
"access-control-expose-headers": "content-type",
638+
vary: "origin, access-control-request-private-network"
639+
}
640+
}
641+
642+
const withSkillerCors = (
643+
request: HttpServerRequest.HttpServerRequest,
644+
response: HttpServerResponse.HttpServerResponse
645+
): HttpServerResponse.HttpServerResponse => {
646+
const headers = skillerCorsHeaders(request)
647+
return Object.keys(headers).length === 0 ? response : HttpServerResponse.setHeaders(response, headers)
648+
}
649+
650+
const skillerJsonResponse = (
651+
request: HttpServerRequest.HttpServerRequest,
652+
data: unknown,
653+
status: number
654+
) =>
655+
jsonResponse(data, status).pipe(
656+
Effect.map((response) => withSkillerCors(request, response))
657+
)
658+
659+
const skillerErrorResponse = (
660+
request: HttpServerRequest.HttpServerRequest,
661+
error: unknown
662+
) =>
663+
errorResponse(error).pipe(
664+
Effect.map((response) => withSkillerCors(request, response))
665+
)
666+
667+
const isSkillerCorsPath = (pathname: string): boolean => {
668+
const normalized = pathname.startsWith("/api/") ? pathname.slice("/api".length) : pathname
669+
return normalized === "/skiller/connect" ||
670+
/^\/projects\/by-key\/[^/]+(?:\/terminal-sessions\/[^/]+)?\/skiller\/context$/u.test(normalized) ||
671+
parseSkillerRoute(pathname) !== null
672+
}
673+
674+
const skillerCorsPreflightResponse = (
675+
request: HttpServerRequest.HttpServerRequest
676+
) => {
677+
const origin = readHeader(request, "origin")
678+
const allowed = origin === undefined || isSkillerWebCorsOriginAllowed(origin, process.env)
679+
return Effect.succeed(HttpServerResponse.empty({
680+
headers: allowed ? skillerCorsHeaders(request) : noStoreHeaders,
681+
status: allowed ? 204 : 403
682+
}))
683+
}
684+
599685
const resolveFederationContext = (
600686
request: HttpServerRequest.HttpServerRequest,
601687
requestedDomain?: string | undefined
@@ -771,11 +857,25 @@ const terminalWebSocketUpgradeResponse = Effect.gen(function*(_) {
771857
const projectProxyResponse = Effect.gen(function*(_) {
772858
const request = yield* _(HttpServerRequest.HttpServerRequest)
773859
const pathname = new URL(request.url, "http://localhost").pathname
860+
if (request.method === "OPTIONS" && isSkillerCorsPath(pathname)) {
861+
return yield* _(skillerCorsPreflightResponse(request))
862+
}
774863
const skillerRoute = parseSkillerRoute(pathname)
775864
if (skillerRoute !== null) {
776-
return skillerRoute._tag === "App"
777-
? yield* _(serveSkillerApp(skillerRoute))
778-
: yield* _(proxySkillerTrpc(request, skillerRoute))
865+
if (skillerRoute._tag === "App") {
866+
return yield* _(
867+
serveSkillerApp(skillerRoute).pipe(
868+
Effect.map((response) => withSkillerCors(request, response)),
869+
Effect.catchAll((error) => skillerErrorResponse(request, error))
870+
)
871+
)
872+
}
873+
return yield* _(
874+
proxySkillerTrpc(request, skillerRoute).pipe(
875+
Effect.map((response) => withSkillerCors(request, response)),
876+
Effect.catchAll((error) => skillerErrorResponse(request, error))
877+
)
878+
)
779879
}
780880
const browserTarget = parseProjectBrowserProxyPath(pathname)
781881
if (browserTarget !== null) {
@@ -800,6 +900,38 @@ const projectProxyResponse = Effect.gen(function*(_) {
800900
return yield* _(proxyProjectPortForward(request, target))
801901
})
802902

903+
const normalizedOptionalString = (value: string | undefined): string | undefined => {
904+
const trimmed = value?.trim()
905+
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed
906+
}
907+
908+
const skillerConnectInfoResponse = (
909+
request: HttpServerRequest.HttpServerRequest
910+
) =>
911+
listProjects().pipe(
912+
Effect.flatMap((projects) => skillerJsonResponse(request, { ok: true, projects }, 200)),
913+
Effect.catchAll((error) => skillerErrorResponse(request, error))
914+
)
915+
916+
const skillerConnectResponse = (
917+
request: HttpServerRequest.HttpServerRequest
918+
) =>
919+
Effect.gen(function*(_) {
920+
const body = yield* _(readSkillerConnectRequest())
921+
const projectKey = body.projectKey.trim()
922+
if (projectKey.length === 0) {
923+
return yield* _(Effect.fail(new ApiBadRequestError({ message: "projectKey is required." })))
924+
}
925+
const connection = yield* _(connectSkillerWeb(
926+
projectKey,
927+
normalizedOptionalString(body.sessionId),
928+
resolveSkillerBackendUrl(request)
929+
))
930+
return yield* _(skillerJsonResponse(request, { ok: true, ...connection }, 202))
931+
}).pipe(
932+
Effect.catchAll((error) => skillerErrorResponse(request, error))
933+
)
934+
803935
export const makeRouter = () => {
804936
const withCoreRoutes = HttpRouter.empty.pipe(
805937
HttpRouter.get(
@@ -810,28 +942,85 @@ export const makeRouter = () => {
810942
return yield* _(jsonResponse({ ok: true, revision: controllerRevision, cwd, projectsRoot }, 200))
811943
}).pipe(Effect.catchAll(errorResponse))
812944
),
945+
HttpRouter.get(
946+
"/skiller/connect",
947+
Effect.gen(function*(_) {
948+
const request = yield* _(HttpServerRequest.HttpServerRequest)
949+
return yield* _(skillerConnectInfoResponse(request))
950+
})
951+
),
952+
HttpRouter.get(
953+
"/api/skiller/connect",
954+
Effect.gen(function*(_) {
955+
const request = yield* _(HttpServerRequest.HttpServerRequest)
956+
return yield* _(skillerConnectInfoResponse(request))
957+
})
958+
),
959+
HttpRouter.post(
960+
"/skiller/connect",
961+
Effect.gen(function*(_) {
962+
const request = yield* _(HttpServerRequest.HttpServerRequest)
963+
return yield* _(skillerConnectResponse(request))
964+
})
965+
),
966+
HttpRouter.post(
967+
"/api/skiller/connect",
968+
Effect.gen(function*(_) {
969+
const request = yield* _(HttpServerRequest.HttpServerRequest)
970+
return yield* _(skillerConnectResponse(request))
971+
})
972+
),
813973
HttpRouter.post(
814974
"/skiller/open",
815-
openSkiller().pipe(
816-
Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)),
817-
Effect.catchAll(errorResponse)
818-
)
975+
Effect.gen(function*(_) {
976+
const request = yield* _(HttpServerRequest.HttpServerRequest)
977+
const launch = yield* _(openSkiller(undefined, undefined, resolveSkillerBackendUrl(request)))
978+
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
979+
}).pipe(Effect.catchAll(errorResponse))
819980
),
820981
HttpRouter.post(
821982
"/projects/by-key/:projectKey/skiller/open",
822-
projectKeyParams.pipe(
823-
Effect.flatMap(({ projectKey }) => openSkiller(projectKey)),
824-
Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)),
825-
Effect.catchAll(errorResponse)
826-
)
983+
Effect.gen(function*(_) {
984+
const request = yield* _(HttpServerRequest.HttpServerRequest)
985+
const { projectKey } = yield* _(projectKeyParams)
986+
const launch = yield* _(openSkiller(projectKey, undefined, resolveSkillerBackendUrl(request)))
987+
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
988+
}).pipe(Effect.catchAll(errorResponse))
827989
),
828990
HttpRouter.post(
829991
"/projects/by-key/:projectKey/terminal-sessions/:sessionId/skiller/open",
830-
terminalSessionByProjectKeyParams.pipe(
831-
Effect.flatMap(({ projectKey, sessionId }) => openSkillerForTerminalSession(projectKey, sessionId)),
832-
Effect.flatMap((launch) => jsonResponse({ ok: true, ...launch }, 202)),
833-
Effect.catchAll(errorResponse)
834-
)
992+
Effect.gen(function*(_) {
993+
const request = yield* _(HttpServerRequest.HttpServerRequest)
994+
const { projectKey, sessionId } = yield* _(terminalSessionByProjectKeyParams)
995+
const launch = yield* _(openSkillerForTerminalSession(projectKey, sessionId, resolveSkillerBackendUrl(request)))
996+
return yield* _(jsonResponse({ ok: true, ...launch }, 202))
997+
}).pipe(Effect.catchAll(errorResponse))
998+
),
999+
HttpRouter.get(
1000+
"/projects/by-key/:projectKey/skiller/context",
1001+
Effect.gen(function*(_) {
1002+
const request = yield* _(HttpServerRequest.HttpServerRequest)
1003+
return yield* _(
1004+
projectKeyParams.pipe(
1005+
Effect.flatMap(({ projectKey }) => readSkillerProjectContext(projectKey, null)),
1006+
Effect.flatMap((context) => skillerJsonResponse(request, { ok: true, ...context }, 200)),
1007+
Effect.catchAll((error) => skillerErrorResponse(request, error))
1008+
)
1009+
)
1010+
})
1011+
),
1012+
HttpRouter.get(
1013+
"/projects/by-key/:projectKey/terminal-sessions/:sessionId/skiller/context",
1014+
Effect.gen(function*(_) {
1015+
const request = yield* _(HttpServerRequest.HttpServerRequest)
1016+
return yield* _(
1017+
terminalSessionByProjectKeyParams.pipe(
1018+
Effect.flatMap(({ projectKey, sessionId }) => readSkillerProjectContext(projectKey, sessionId)),
1019+
Effect.flatMap((context) => skillerJsonResponse(request, { ok: true, ...context }, 200)),
1020+
Effect.catchAll((error) => skillerErrorResponse(request, error))
1021+
)
1022+
)
1023+
})
8351024
),
8361025
HttpRouter.get(
8371026
"/cloudflare-tunnels/panel",

packages/api/src/services/skiller-core.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,110 @@ export type SkillerBrowserScope = {
3535
readonly sessionId: string | null
3636
}
3737

38+
export type ConfiguredSkillerWebUrl =
39+
| { readonly _tag: "Disabled" }
40+
| { readonly _tag: "Enabled"; readonly baseUrl: string }
41+
| { readonly _tag: "Invalid"; readonly message: string }
42+
43+
export type ExternalSkillerLaunchUrlInput = {
44+
readonly backendUrl: string
45+
readonly projectKey: string | undefined
46+
readonly sessionId: string | undefined
47+
readonly skillerWebUrl: string
48+
}
49+
50+
const trimTrailingSlashes = (value: string): string =>
51+
value.replace(/\/+$/u, "")
52+
53+
const configuredOrigin = (raw: string): string | null => {
54+
const value = raw.trim()
55+
if (!URL.canParse(value)) {
56+
return null
57+
}
58+
const parsed = new URL(value)
59+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
60+
return null
61+
}
62+
return parsed.origin
63+
}
64+
65+
const uniqueOrigins = (origins: ReadonlyArray<string>): ReadonlyArray<string> =>
66+
[...new Set(origins)]
67+
68+
const defaultSkillerWebCorsOrigins = [
69+
"https://skiller-web-henna.vercel.app",
70+
"http://localhost:5180",
71+
"http://127.0.0.1:5180"
72+
] as const
73+
74+
export const resolveConfiguredSkillerWebUrl = (
75+
env: Record<string, string | undefined>
76+
): ConfiguredSkillerWebUrl => {
77+
const raw = env["DOCKER_GIT_SKILLER_WEB_URL"]?.trim()
78+
if (raw === undefined || raw.length === 0) {
79+
return { _tag: "Disabled" }
80+
}
81+
if (!URL.canParse(raw)) {
82+
return { _tag: "Invalid", message: `Invalid DOCKER_GIT_SKILLER_WEB_URL: ${raw}` }
83+
}
84+
const parsed = new URL(raw)
85+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
86+
return { _tag: "Invalid", message: "DOCKER_GIT_SKILLER_WEB_URL must use http or https." }
87+
}
88+
parsed.hash = ""
89+
parsed.search = ""
90+
return { _tag: "Enabled", baseUrl: trimTrailingSlashes(parsed.toString()) }
91+
}
92+
93+
export const resolveDockerGitSkillerBackendUrl = (
94+
env: Record<string, string | undefined>,
95+
requestOrigin: string
96+
): string => {
97+
const configured = [
98+
env["DOCKER_GIT_SKILLER_BACKEND_URL"],
99+
env["DOCKER_GIT_API_PUBLIC_URL"]
100+
]
101+
.map((value) => value?.trim())
102+
.find((value) => value !== undefined && value.length > 0)
103+
return configured ?? requestOrigin
104+
}
105+
106+
export const configuredSkillerWebCorsOrigins = (
107+
env: Record<string, string | undefined>
108+
): ReadonlyArray<string> => {
109+
const configuredWeb = resolveConfiguredSkillerWebUrl(env)
110+
const fromWeb = configuredWeb._tag === "Enabled"
111+
? [configuredOrigin(configuredWeb.baseUrl)].filter((origin): origin is string => origin !== null)
112+
: []
113+
const fromAllowed = (env["DOCKER_GIT_SKILLER_ALLOWED_ORIGINS"] ?? "")
114+
.split(",")
115+
.map(configuredOrigin)
116+
.filter((origin): origin is string => origin !== null)
117+
return uniqueOrigins([
118+
...fromWeb,
119+
...fromAllowed,
120+
...defaultSkillerWebCorsOrigins
121+
])
122+
}
123+
124+
export const isSkillerWebCorsOriginAllowed = (
125+
origin: string | undefined,
126+
env: Record<string, string | undefined>
127+
): boolean =>
128+
origin !== undefined && configuredSkillerWebCorsOrigins(env).includes(origin)
129+
130+
export const externalSkillerLaunchUrl = (input: ExternalSkillerLaunchUrlInput): string => {
131+
const url = new URL(`${trimTrailingSlashes(input.skillerWebUrl)}/launch`)
132+
url.searchParams.set("backendUrl", input.backendUrl)
133+
if (input.projectKey !== undefined) {
134+
url.searchParams.set("projectKey", input.projectKey)
135+
}
136+
if (input.sessionId !== undefined) {
137+
url.searchParams.set("sessionId", input.sessionId)
138+
}
139+
return url.toString()
140+
}
141+
38142
export const parseDockerMountLines = (output: string): ReadonlyArray<DockerContainerMount> =>
39143
output
40144
.split(/\r?\n/u)

0 commit comments

Comments
 (0)