diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index ce7a9e7f..91b5e2c6 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -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, @@ -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 @@ -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 = () => @@ -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 => { + 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 @@ -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) { @@ -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( @@ -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", diff --git a/packages/api/src/services/skiller-core.ts b/packages/api/src/services/skiller-core.ts index 59aadba0..51217fe5 100644 --- a/packages/api/src/services/skiller-core.ts +++ b/packages/api/src/services/skiller-core.ts @@ -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): ReadonlyArray => + [...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 +): 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, + 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 +): ReadonlyArray => { + 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 +): 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 => output .split(/\r?\n/u) diff --git a/packages/api/src/services/skiller.ts b/packages/api/src/services/skiller.ts index a8b49450..6bc820e6 100644 --- a/packages/api/src/services/skiller.ts +++ b/packages/api/src/services/skiller.ts @@ -19,10 +19,13 @@ import * as Stream from "effect/Stream" import { ApiConflictError, ApiInternalError, ApiNotFoundError } from "../api/errors.js" import { containerCodexSkillsPath, + externalSkillerLaunchUrl, parseDockerMountLines, remapContainerPathToMountedHost, + resolveConfiguredSkillerWebUrl, sameSkillerScope, skillerBrowserScopeForContainer, + type SkillerBrowserScope, type SkillerContainerScope } from "./skiller-core.js" import { getProjectItemByKey } from "./projects.js" @@ -31,7 +34,9 @@ import { getProjectTerminalSession } from "./terminal-sessions.js" export type SkillerLaunch = { readonly alreadyRunning: boolean readonly appPath: string + readonly backendUrl: string | null readonly logPath: string + readonly mode: "bundled" | "external" readonly pid: number | null readonly scope: SkillerContainerScope | null readonly startedAtIso: string @@ -39,6 +44,25 @@ export type SkillerLaunch = { readonly trpcPort: number } +export type SkillerProjectContext = { + readonly browserScope: SkillerBrowserScope + readonly scope: SkillerContainerScope +} + +export type SkillerWebConnection = { + readonly alreadyRunning: boolean + readonly browserScope: SkillerBrowserScope | null + readonly eventsBaseUrl: string + readonly logPath: string + readonly pid: number | null + readonly projectKey: string + readonly sessionId: string | null + readonly startedAtIso: string + readonly trpcBasePath: string + readonly trpcBaseUrl: string + readonly trpcPort: number +} + type SkillerProcess = { readonly appPath: string readonly logPath: string @@ -133,7 +157,9 @@ const toLaunch = ( ): SkillerLaunch => ({ alreadyRunning, appPath: sessionId === undefined ? process.appPath : sessionSkillerAppPath(sessionId), + backendUrl: null, logPath: process.logPath, + mode: "bundled", pid: process.process.pid ?? null, scope: process.scope, startedAtIso: process.startedAtIso, @@ -369,6 +395,27 @@ const resolveRequestedSkillerScope = ( Effect.flatMap((project) => resolveSkillerScope(projectKey, project)) ) +export const readSkillerProjectContext = ( + projectKey: string, + sessionId: string | null +): Effect.Effect< + SkillerProjectContext, + ApiConflictError | ApiInternalError | ApiNotFoundError | PlatformError, + ListProjectsContext +> => + getProjectItemByKey(projectKey).pipe( + Effect.flatMap((project) => + sessionId === null + ? Effect.succeed(project) + : getProjectTerminalSession(project.projectDir, sessionId).pipe(Effect.as(project)) + ), + Effect.flatMap((project) => resolveSkillerScope(projectKey, project)), + Effect.map((scope) => ({ + browserScope: skillerBrowserScopeForContainer(scope, sessionId), + scope + })) + ) + const waitForSkillerReady = (trpcPort: number): Effect.Effect => Effect.tryPromise({ catch: (cause) => new ApiInternalError({ @@ -794,18 +841,47 @@ const touchSkillerActivity = ( ) ) -export const openSkiller = ( - projectKey?: string, - sessionId?: string +const externalSkillerLaunch = ( + scope: SkillerContainerScope | null, + projectKey: string | undefined, + sessionId: string | undefined, + backendUrl: string +): Effect.Effect => { + const config = resolveConfiguredSkillerWebUrl(process.env) + if (config._tag === "Disabled") { + return Effect.succeed(null) + } + if (config._tag === "Invalid") { + return Effect.fail(new ApiInternalError({ message: config.message })) + } + const appPath = externalSkillerLaunchUrl({ + backendUrl, + projectKey, + sessionId, + skillerWebUrl: config.baseUrl + }) + return Effect.succeed({ + alreadyRunning: true, + appPath, + backendUrl, + logPath: "", + mode: "external", + pid: null, + scope, + startedAtIso: new Date().toISOString(), + trpcBasePath: `${config.baseUrl}/trpc`, + trpcPort: 0 + }) +} + +const ensureBundledSkillerRuntimeForScope = ( + scope: SkillerContainerScope | null, + sessionId: string | undefined ): Effect.Effect< SkillerLaunch, - ApiConflictError | ApiInternalError | ApiNotFoundError | PlatformError, - ListProjectsContext + ApiConflictError | ApiInternalError | ApiNotFoundError > => Effect.gen(function*(_) { - const scope = yield* _(resolveRequestedSkillerScope(projectKey)) - yield* _(touchSkillerActivity(scope)) - rememberSessionScope(sessionId, scope) if (currentProcess !== null && isRunning(currentProcess.process)) { if (sameSkillerScope(currentProcess.scope, scope)) { yield* _(Effect.try({ @@ -844,6 +920,79 @@ export const openSkiller = ( return sessionId === undefined || currentProcess === null ? launch : toLaunch(currentProcess, false, sessionId) }) +const ensureBundledSkillerRuntime = ( + projectKey: string | undefined, + sessionId: string | undefined +): Effect.Effect< + SkillerLaunch, + ApiConflictError | ApiInternalError | ApiNotFoundError | PlatformError, + ListProjectsContext +> => + Effect.gen(function*(_) { + const scope = yield* _(resolveRequestedSkillerScope(projectKey)) + yield* _(touchSkillerActivity(scope)) + rememberSessionScope(sessionId, scope) + return yield* _(ensureBundledSkillerRuntimeForScope(scope, sessionId)) + }) + +const absoluteBackendUrl = (backendUrl: string, path: string): string => + new URL(path, `${backendUrl.replace(/\/+$/u, "")}/`).toString().replace(/\/$/u, "") + +export const connectSkillerWeb = ( + projectKey: string, + sessionId: string | undefined, + backendUrl = "http://localhost:3334" +): Effect.Effect< + SkillerWebConnection, + ApiConflictError | ApiInternalError | ApiNotFoundError | PlatformError, + ListProjectsContext +> => + getProjectItemByKey(projectKey).pipe( + Effect.flatMap((project) => + sessionId === undefined + ? Effect.succeed(project) + : getProjectTerminalSession(project.projectDir, sessionId).pipe(Effect.as(project)) + ), + Effect.flatMap(() => ensureBundledSkillerRuntime(projectKey, sessionId)), + Effect.map((launch) => { + const trpcBaseUrl = absoluteBackendUrl(backendUrl, launch.trpcBasePath) + const normalizedSessionId = sessionId ?? null + return { + alreadyRunning: launch.alreadyRunning, + browserScope: launch.scope === null ? null : skillerBrowserScopeForContainer(launch.scope, normalizedSessionId), + eventsBaseUrl: `${trpcBaseUrl}/events`, + logPath: launch.logPath, + pid: launch.pid, + projectKey, + sessionId: normalizedSessionId, + startedAtIso: launch.startedAtIso, + trpcBasePath: launch.trpcBasePath, + trpcBaseUrl, + trpcPort: launch.trpcPort + } + }) + ) + +export const openSkiller = ( + projectKey?: string, + sessionId?: string, + backendUrl = "http://localhost:3334" +): Effect.Effect< + SkillerLaunch, + ApiConflictError | ApiInternalError | ApiNotFoundError | PlatformError, + ListProjectsContext +> => + Effect.gen(function*(_) { + const scope = yield* _(resolveRequestedSkillerScope(projectKey)) + yield* _(touchSkillerActivity(scope)) + rememberSessionScope(sessionId, scope) + const externalLaunch = yield* _(externalSkillerLaunch(scope, projectKey, sessionId, backendUrl)) + if (externalLaunch !== null) { + return externalLaunch + } + return yield* _(ensureBundledSkillerRuntimeForScope(scope, sessionId)) + }) + export const hasLiveProjectSkillerSession = (projectId: string): boolean => currentProcess !== null && isRunning(currentProcess.process) && @@ -851,7 +1000,8 @@ export const hasLiveProjectSkillerSession = (projectId: string): boolean => export const openSkillerForTerminalSession = ( projectKey: string, - sessionId: string + sessionId: string, + backendUrl = "http://localhost:3334" ): Effect.Effect< SkillerLaunch, ApiConflictError | ApiInternalError | ApiNotFoundError | PlatformError, @@ -863,12 +1013,12 @@ export const openSkillerForTerminalSession = ( Effect.as(projectKey) ) ), - Effect.flatMap((resolvedProjectKey) => openSkiller(resolvedProjectKey, sessionId)) + Effect.flatMap((resolvedProjectKey) => openSkiller(resolvedProjectKey, sessionId, backendUrl)) ) export const parseSkillerRoute = (pathname: string): SkillerRoute | null => { const normalized = pathname.startsWith("/api/") ? pathname.slice("/api".length) : pathname - const sessionMatch = /^\/ssh\/session\/([^/]+)\/skiller(?:\/(app|trpc)(\/.*)?)?$/u.exec(normalized) + const sessionMatch = /^\/ssh\/session\/([^/]+)\/skiller(?:\/(app|trpc|events)(\/.*)?)?$/u.exec(normalized) if (sessionMatch !== null) { const sessionId = decodeURIComponent(sessionMatch[1] ?? "") const routeKind = sessionMatch[2] ?? "" @@ -879,6 +1029,9 @@ export const parseSkillerRoute = (pathname: string): SkillerRoute | null => { if (routeKind === "trpc") { return { _tag: "Trpc", sessionId, upstreamPath: `/trpc${tail}` } } + if (routeKind === "events") { + return { _tag: "Trpc", sessionId, upstreamPath: `/events${tail}` } + } } if (normalized === "/skiller/app" || normalized === "/skiller/app/") { return { _tag: "App", relativePath: "/", sessionId: null } @@ -889,6 +1042,9 @@ export const parseSkillerRoute = (pathname: string): SkillerRoute | null => { if (normalized === "/skiller/trpc" || normalized.startsWith("/skiller/trpc/")) { return { _tag: "Trpc", sessionId: null, upstreamPath: normalized.slice("/skiller".length) || "/trpc" } } + if (normalized === "/skiller/events" || normalized.startsWith("/skiller/events/")) { + return { _tag: "Trpc", sessionId: null, upstreamPath: normalized.slice("/skiller".length) || "/events" } + } return null } diff --git a/packages/api/tests/skiller-core.test.ts b/packages/api/tests/skiller-core.test.ts index 1ac95252..a01572c2 100644 --- a/packages/api/tests/skiller-core.test.ts +++ b/packages/api/tests/skiller-core.test.ts @@ -1,16 +1,81 @@ import { describe, expect, it } from "@effect/vitest" import { + configuredSkillerWebCorsOrigins, containerCodexSkillsPath, + externalSkillerLaunchUrl, + isSkillerWebCorsOriginAllowed, parseDockerMountLines, remapContainerPathToMountedHost, remapSkillerBrowserContainerPath, remapSkillerBrowserHostPath, + resolveConfiguredSkillerWebUrl, + resolveDockerGitSkillerBackendUrl, sameSkillerScope, skillerBrowserScopeForContainer } from "../src/services/skiller-core.js" describe("skiller container filesystem mapping", () => { + it("resolves external Skiller web URLs from docker-git environment", () => { + expect(resolveConfiguredSkillerWebUrl({})).toEqual({ _tag: "Disabled" }) + expect(resolveConfiguredSkillerWebUrl({ DOCKER_GIT_SKILLER_WEB_URL: " " })).toEqual({ _tag: "Disabled" }) + expect(resolveConfiguredSkillerWebUrl({ + DOCKER_GIT_SKILLER_WEB_URL: "https://skiller.example/app/?ignored=1#hash" + })).toEqual({ + _tag: "Enabled", + baseUrl: "https://skiller.example/app" + }) + expect(resolveConfiguredSkillerWebUrl({ DOCKER_GIT_SKILLER_WEB_URL: "file:///tmp/skiller" })._tag).toBe("Invalid") + }) + + it("builds external Skiller launch URLs with docker-git context parameters", () => { + const launchUrl = new URL(externalSkillerLaunchUrl({ + backendUrl: "https://docker-git.example/api", + projectKey: "project one", + sessionId: "session/1", + skillerWebUrl: "https://skiller.example/ui/" + })) + + expect(launchUrl.origin).toBe("https://skiller.example") + expect(launchUrl.pathname).toBe("/ui/launch") + expect(launchUrl.searchParams.get("backendUrl")).toBe("https://docker-git.example/api") + expect(launchUrl.searchParams.get("projectKey")).toBe("project one") + expect(launchUrl.searchParams.get("sessionId")).toBe("session/1") + }) + + it("prefers explicit Skiller backend URLs before request origin", () => { + expect(resolveDockerGitSkillerBackendUrl({ + DOCKER_GIT_API_PUBLIC_URL: "https://public-api.example", + DOCKER_GIT_SKILLER_BACKEND_URL: "https://skiller-backend.example" + }, "http://localhost:3334")).toBe("https://skiller-backend.example") + + expect(resolveDockerGitSkillerBackendUrl({ + DOCKER_GIT_API_PUBLIC_URL: " https://public-api.example " + }, "http://localhost:3334")).toBe("https://public-api.example") + + expect(resolveDockerGitSkillerBackendUrl({}, "http://localhost:3334")).toBe("http://localhost:3334") + }) + + it("allows Skiller Web CORS only from configured exact origins and local dev", () => { + const env = { + DOCKER_GIT_SKILLER_ALLOWED_ORIGINS: "https://preview.example/app, file:///tmp/ignored", + DOCKER_GIT_SKILLER_WEB_URL: "https://skiller.example/ui" + } + + expect(configuredSkillerWebCorsOrigins(env)).toEqual([ + "https://skiller.example", + "https://preview.example", + "https://skiller-web-henna.vercel.app", + "http://localhost:5180", + "http://127.0.0.1:5180" + ]) + expect(isSkillerWebCorsOriginAllowed("https://skiller.example", env)).toBe(true) + expect(isSkillerWebCorsOriginAllowed("https://preview.example", env)).toBe(true) + expect(isSkillerWebCorsOriginAllowed("https://skiller-web-henna.vercel.app", env)).toBe(true) + expect(isSkillerWebCorsOriginAllowed("https://preview.example.evil", env)).toBe(false) + expect(isSkillerWebCorsOriginAllowed(undefined, env)).toBe(false) + }) + it("maps a project container path through the most specific writable Docker mount", () => { const mounts = parseDockerMountLines([ "/var/lib/docker/volumes/project-home/_data\t/home/dev\ttrue", diff --git a/packages/api/tests/skiller-cors.test.ts b/packages/api/tests/skiller-cors.test.ts new file mode 100644 index 00000000..abce77b6 --- /dev/null +++ b/packages/api/tests/skiller-cors.test.ts @@ -0,0 +1,77 @@ +import * as HttpApp from "@effect/platform/HttpApp" +import * as HttpRouter from "@effect/platform/HttpRouter" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { makeRouter } from "../src/http.js" + +const SKILLER_WEB_PRODUCTION_ORIGIN = "https://skiller-web-henna.vercel.app" + +const apiHandler = HttpApp.toWebHandler( + Effect.provide(Effect.flatten(HttpRouter.toHttpApp(makeRouter())), NodeContext.layer) +) + +type ApiRequestInit = { + readonly headers: Record + readonly method: string +} + +const requestApiRoute = (path: string, init: ApiRequestInit) => + Effect.tryPromise({ + try: () => apiHandler(new Request(`http://127.0.0.1${path}`, init)), + catch: (cause) => new Error(String(cause)) + }) + +const skillerPrivateNetworkPreflight = (path: string) => + requestApiRoute(path, { + method: "OPTIONS", + headers: { + "access-control-request-method": "POST", + "access-control-request-private-network": "true", + origin: SKILLER_WEB_PRODUCTION_ORIGIN + } + }) + +describe("skiller web CORS", () => { + it("allows production Skiller Web private-network preflight requests", () => + Effect.runPromise( + Effect.gen(function*(_) { + const paths = [ + "/skiller/connect", + "/api/skiller/connect", + "/skiller/trpc/list_projects", + "/skiller/events", + "/projects/by-key/project-proof/skiller/context", + "/projects/by-key/project-proof/terminal-sessions/session-proof/skiller/context" + ] as const + + for (const path of paths) { + const response = yield* _(skillerPrivateNetworkPreflight(path)) + + expect(response.status).toBe(204) + expect(response.headers.get("access-control-allow-origin")).toBe(SKILLER_WEB_PRODUCTION_ORIGIN) + expect(response.headers.get("access-control-allow-private-network")).toBe("true") + expect(response.headers.get("vary")).toContain("access-control-request-private-network") + } + }) + )) + + it("rejects private-network preflight requests from unknown origins", () => + Effect.runPromise( + Effect.gen(function*(_) { + const response = yield* _(requestApiRoute("/skiller/connect", { + method: "OPTIONS", + headers: { + "access-control-request-method": "POST", + "access-control-request-private-network": "true", + origin: "https://skiller-web-henna.vercel.app.evil.example" + } + })) + + expect(response.status).toBe(403) + expect(response.headers.get("access-control-allow-private-network")).toBeNull() + expect(response.headers.get("access-control-allow-origin")).toBeNull() + }) + )) +}) diff --git a/packages/api/tests/skiller-routes.test.ts b/packages/api/tests/skiller-routes.test.ts index da7ec13d..feb880ed 100644 --- a/packages/api/tests/skiller-routes.test.ts +++ b/packages/api/tests/skiller-routes.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "@effect/vitest" +import { NodeContext } from "@effect/platform-node" +import { Effect } from "effect" import { + openSkiller, parseSkillerRoute, resolveSkillerBrowserScopeSelection, resolveSkillerRouteScopeSelection, @@ -76,6 +79,33 @@ describe("skiller routes", () => { expect(launch.userName).toBe("dg-skiller-u2147483001") }) + it("returns an external Skiller Web launch when DOCKER_GIT_SKILLER_WEB_URL is configured", async () => { + const previous = process.env["DOCKER_GIT_SKILLER_WEB_URL"] + process.env["DOCKER_GIT_SKILLER_WEB_URL"] = "https://skiller.example/ui" + try { + const launch = await Effect.runPromise( + openSkiller(undefined, undefined, "https://docker-git.example").pipe(Effect.provide(NodeContext.layer)) + ) + const launchUrl = new URL(launch.appPath) + + expect(launch.mode).toBe("external") + expect(launch.alreadyRunning).toBe(true) + expect(launch.backendUrl).toBe("https://docker-git.example") + expect(launch.pid).toBeNull() + expect(launch.trpcPort).toBe(0) + expect(launchUrl.pathname).toBe("/ui/launch") + expect(launchUrl.searchParams.get("backendUrl")).toBe("https://docker-git.example") + expect(launchUrl.searchParams.has("projectKey")).toBe(false) + expect(launchUrl.searchParams.has("sessionId")).toBe(false) + } finally { + if (previous === undefined) { + delete process.env["DOCKER_GIT_SKILLER_WEB_URL"] + } else { + process.env["DOCKER_GIT_SKILLER_WEB_URL"] = previous + } + } + }) + it("fails stalled child processes with a distinct timeout error", () => expect(runProcess( process.execPath, @@ -95,11 +125,21 @@ describe("skiller routes", () => { sessionId: "terminal-proof", upstreamPath: "/trpc/list_projects" }) + expect(parseSkillerRoute("/api/ssh/session/terminal-proof/skiller/events")).toEqual({ + _tag: "Trpc", + sessionId: "terminal-proof", + upstreamPath: "/events" + }) expect(parseSkillerRoute("/api/skiller/app/")).toEqual({ _tag: "App", relativePath: "/", sessionId: null }) + expect(parseSkillerRoute("/api/skiller/events")).toEqual({ + _tag: "Trpc", + sessionId: null, + upstreamPath: "/events" + }) }) it("uses the current project scope for non-session app routes", () => { diff --git a/packages/app/src/web/actions-skiller.ts b/packages/app/src/web/actions-skiller.ts index b528805f..7fd4690d 100644 --- a/packages/app/src/web/actions-skiller.ts +++ b/packages/app/src/web/actions-skiller.ts @@ -5,7 +5,9 @@ import { type PreparedOpenUrl, prepareOpenUrl } from "./open-url.js" export type SkillerLaunch = { readonly alreadyRunning: boolean readonly appPath: string + readonly backendUrl: string | null readonly logPath: string + readonly mode: "bundled" | "external" readonly pid: number | null readonly scope: { readonly containerName: string @@ -30,6 +32,14 @@ export const skillerLaunchMessage = (launch: SkillerLaunch, openedPath: string, export const openPreparedSkillerLaunch = (launch: SkillerLaunch, preparedUrl: PreparedOpenUrl): string => { const openedPath = launch.appPath const opened = preparedUrl.navigate(openedPath) + if (launch.mode === "external") { + const scope = launch.scope === null + ? "" + : ` Container FS: ${launch.scope.containerName}:${launch.scope.containerProjectPath}.` + return opened + ? `Skiller Web opened.${scope} Opened ${openedPath}.` + : `Skiller Web popup was blocked.${scope} Open ${openedPath} manually.` + } return skillerLaunchMessage(launch, openedPath, opened) } diff --git a/packages/app/src/web/api-skiller-schema.ts b/packages/app/src/web/api-skiller-schema.ts index 1e1cf637..ed834156 100644 --- a/packages/app/src/web/api-skiller-schema.ts +++ b/packages/app/src/web/api-skiller-schema.ts @@ -17,7 +17,12 @@ export const SkillerScopeResponseSchema = Schema.Struct({ export const SkillerLaunchResponseSchema = Schema.Struct({ alreadyRunning: Schema.Boolean, appPath: Schema.String, + backendUrl: Schema.NullOr(Schema.String), logPath: Schema.String, + mode: Schema.optionalWith( + Schema.Union(Schema.Literal("bundled"), Schema.Literal("external")), + { default: () => "bundled" } + ), ok: Schema.Boolean, pid: Schema.NullOr(Schema.Number), scope: Schema.NullOr(SkillerScopeResponseSchema),