diff --git a/tools/src/caserun-native-service.ts b/tools/src/caserun-native-service.ts index 8ee980a5..d53eb7d2 100644 --- a/tools/src/caserun-native-service.ts +++ b/tools/src/caserun-native-service.ts @@ -142,9 +142,11 @@ function serviceEnv(service: ServiceName, env: Record) { const port = service === "api" ? requiredEnv(env, "CASERUN_API_PORT") : requiredEnv(env, "CASERUN_WEB_PORT"); - const host = requiredEnv(env, "CASERUN_PUBLIC_HOST"); - return { host, port: Number(port), url: `http://${host}:${Number(port)}${service === "api" ? "/health" : "/caserun"}` }; + const publicBaseUrl = fixedHttpsOrigin(requiredEnv(env, "CASERUN_PUBLIC_BASE_URL"), "CASERUN_PUBLIC_BASE_URL"); + validPort(port); + return { url: service === "api" ? publicBaseUrl : `${publicBaseUrl}/caserun` }; } +function validPort(value: string) { const port = Number(value); if (!Number.isInteger(port) || port < 1 || port > 65535) throw codedError("invalid_service_port", `invalid CaseRun service port: ${value}`); return port; } function validService(value: string): ServiceName { if (value === "api" || value === "web") return value; throw codedError("invalid_service", "CaseRun service must be api or web"); } async function readState(file: string) { return JSON.parse(await readFile(file, "utf8").catch(() => "null")); } function alive(pid: number) { try { process.kill(pid, 0); return true; } catch { return false; } } @@ -154,4 +156,13 @@ async function waitForExit(pid: number, timeoutMs: number) { return !alive(pid); } function requiredEnv(env: Record, key: string) { const value = String(env[key] ?? "").trim(); if (!value) throw codedError("native_exposure_config_required", `${key} is required from YAML-first native CaseRun config`); return value; } +function fixedHttpsOrigin(value: string, key: string) { + try { + const parsed = new URL(value); + if (parsed.protocol === "https:" && parsed.pathname === "/" && !parsed.search && !parsed.hash && !parsed.port) return parsed.origin; + } catch { + // Report one stable configuration error below. + } + throw codedError("native_exposure_config_invalid", `${key} must be a pathless HTTPS origin`); +} function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); } diff --git a/tools/src/workbench-cli.ts b/tools/src/workbench-cli.ts index 6b8c680a..1c3df883 100644 --- a/tools/src/workbench-cli.ts +++ b/tools/src/workbench-cli.ts @@ -33,7 +33,7 @@ function help() { ], transportContract: "--over-api only changes transport; --overapi is unsupported", localConfig: ["WORKBENCH_MODE", "WORKBENCH_NATIVE_STATE_FILE"], - nativeServiceConfig: ["WORKBENCH_API_BIND_HOST", "WORKBENCH_API_PROBE_HOST", "WORKBENCH_API_PUBLIC_HOST", "WORKBENCH_API_PORT", "WORKBENCH_API_IDLE_TIMEOUT_SECONDS", "WORKBENCH_WORKER_BIND_HOST", "WORKBENCH_WORKER_PROBE_HOST", "WORKBENCH_WORKER_HEALTH_PORT", "WORKBENCH_WEB_BIND_HOST", "WORKBENCH_WEB_PROBE_HOST", "WORKBENCH_WEB_PUBLIC_HOST", "WORKBENCH_WEB_PORT", "WORKBENCH_NATIVE_API_URL", "WORKBENCH_WEB_RUNTIME_CONFIG"], + nativeServiceConfig: ["WORKBENCH_PUBLIC_BASE_URL", "WORKBENCH_API_BIND_HOST", "WORKBENCH_API_PROBE_HOST", "WORKBENCH_API_PORT", "WORKBENCH_API_IDLE_TIMEOUT_SECONDS", "WORKBENCH_WORKER_BIND_HOST", "WORKBENCH_WORKER_PROBE_HOST", "WORKBENCH_WORKER_HEALTH_PORT", "WORKBENCH_WEB_BIND_HOST", "WORKBENCH_WEB_PROBE_HOST", "WORKBENCH_WEB_PORT", "WORKBENCH_NATIVE_API_URL", "WORKBENCH_WEB_RUNTIME_CONFIG"], apiConfig: ["WORKBENCH_API_URL", "HWLAB_API_KEY"] }; } diff --git a/tools/src/workbench-native-service.ts b/tools/src/workbench-native-service.ts index 462746cb..462c70da 100644 --- a/tools/src/workbench-native-service.ts +++ b/tools/src/workbench-native-service.ts @@ -87,18 +87,27 @@ function serviceEndpoints(service: ServiceName, env: Record 65535) throw codedError("invalid_service_port", `invalid Workbench service port: ${value}`); return port; } function requiredEnv(env: Record, key: string) { const value = String(env[key] ?? "").trim(); if (!value) throw codedError("native_exposure_config_required", `${key} is required from the owning YAML native development exposure`); return value; } +function fixedHttpsOrigin(value: string, key: string) { + try { + const parsed = new URL(value); + if (parsed.protocol === "https:" && parsed.pathname === "/" && !parsed.search && !parsed.hash && !parsed.port) return parsed.origin; + } catch { + // Report one stable configuration error below. + } + throw codedError("native_exposure_config_invalid", `${key} must be a pathless HTTPS origin`); +} async function probeHealth(url: string) { try { const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); return { ok: response.ok, status: response.status, url }; } catch (error) { return { ok: false, status: null, url, error: error instanceof Error ? error.message : String(error) }; }