diff --git a/internal/workbench/http.ts b/internal/workbench/http.ts index fe369c58..e6fce546 100644 --- a/internal/workbench/http.ts +++ b/internal/workbench/http.ts @@ -8,12 +8,15 @@ export function createWorkbenchHttpApp(options: { dispatch: (command: WorkbenchC if (url.pathname === "/health/live") return json(200, { ok: true, serviceId: "hwlab-workbench-api", status: "live" }); if (url.pathname === "/health/ready") return resultResponse(await options.dispatch({ operation: "health" })); if (options.snapshot) { - if (url.pathname === "/auth/session" || url.pathname === "/auth/bootstrap") return json(200, { ok: true, authenticated: true, actor: { id: "usr_native", role: "user" }, mode: "native-test" }); - if (url.pathname === "/auth/login" && request.method === "POST") return Response.json({ ok: true, authenticated: true, actor: { id: "usr_native", role: "user" }, mode: "native-test" }, { status: 200, headers: { "set-cookie": "hwlab_session=native-test; Path=/; HttpOnly; SameSite=Lax", "cache-control": "no-store" } }); + if (url.pathname === "/auth/session" || url.pathname === "/auth/bootstrap") return json(200, nativeAuthSession()); + if (url.pathname === "/auth/login" && request.method === "POST") return Response.json(nativeAuthSession(), { status: 200, headers: { "set-cookie": "hwlab_session=native-test; Path=/; HttpOnly; SameSite=Lax", "cache-control": "no-store" } }); if (url.pathname === "/v1/users/me") return json(200, { ok: true, actor: { id: "usr_native", role: "user" }, authMethod: "native-test" }); if (url.pathname === "/v1/access/status") return json(200, { ok: true, status: "active", actor: { id: "usr_native", role: "user" }, nav: ["workbench.code"] }); if (url.pathname === "/v1/provider-profiles") return json(200, { ok: true, items: [{ profile: "native-test", backendProfile: "native-test", configured: true }], count: 1, mode: "native-test" }); if (url.pathname === "/v1/dashboard/summary") return json(200, { ok: true, status: "ready", mode: "native-test" }); + if (url.pathname === "/v1/caserun/cases") return json(200, { ok: true, status: "unavailable", cases: [], count: 0, mode: "native-test" }); + if (url.pathname === "/v1/hwpod/specs") return json(200, { ok: true, status: "unavailable", specs: [], mode: "native-test" }); + if (url.pathname === "/v1/hwpod-node-ops") return json(200, { ok: true, status: "unavailable", events: [], groups: [], mode: "native-test" }); if (url.pathname === "/v1/workbench/events" && request.method === "GET") return nativeEventStream(); if (url.pathname === "/v1/workbench/sessions" && request.method === "GET") return nativeSessionList(options, url); const sessionMatch = /^\/v1\/workbench\/sessions\/([^/]+)(?:\/(messages))?$/u.exec(url.pathname); @@ -47,6 +50,17 @@ export function createWorkbenchHttpApp(options: { dispatch: (command: WorkbenchC }; } +function nativeAuthSession() { + return { + ok: true, + authenticated: true, + actor: { id: "usr_native", role: "user" }, + access: { nav: { profileId: "workbench-native", allowedIds: ["workbench.code"], valuesRedacted: true } }, + mode: "native-test", + valuesRedacted: true + }; +} + function requireAuthorization(request: Request, options: { snapshot?: unknown; authorization?: string }) { if (options.snapshot) return; const expected = text(options.authorization); diff --git a/internal/workbench/workbench.test.ts b/internal/workbench/workbench.test.ts index f46ed86e..7e5e8b9b 100644 --- a/internal/workbench/workbench.test.ts +++ b/internal/workbench/workbench.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import type { WorkbenchApplication, WorkbenchTemporalGateway } from "./contracts.ts"; import { createWorkbenchDispatcher } from "./dispatcher.ts"; import { createCloudWorkbenchApplication } from "./cloud-application.ts"; +import { createWorkbenchHttpApp } from "./http.ts"; describe("Workbench dispatcher", () => { test("submits through Temporal without synthesizing terminal state", async () => { @@ -58,6 +59,37 @@ describe("Workbench Cloud application adapter", () => { }); }); +describe("Workbench native HTTP adapter", () => { + test("authorizes only the Workbench navigation entry", async () => { + const app = createWorkbenchHttpApp({ + async dispatch() { return { ok: true }; }, + async snapshot() { return { sessions: {}, turns: {} }; } + }); + + for (const path of ["/auth/session", "/auth/bootstrap"]) { + const response = await app.fetch(new Request(`http://native.test${path}`)); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + authenticated: true, + access: { nav: { profileId: "workbench-native", allowedIds: ["workbench.code"] } } + }); + } + }); + + test("serves read-only empty states for Workbench auxiliary panels", async () => { + const app = createWorkbenchHttpApp({ + async dispatch() { return { ok: true }; }, + async snapshot() { return { sessions: {}, turns: {} }; } + }); + + for (const path of ["/v1/caserun/cases", "/v1/hwpod/specs?probe=1", "/v1/hwpod-node-ops"]) { + const response = await app.fetch(new Request(`http://native.test${path}`)); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true, status: "unavailable", mode: "native-test" }); + } + }); +}); + function stubApplication(): WorkbenchApplication { return { async health() { return {}; }, diff --git a/tools/src/workbench-cli.ts b/tools/src/workbench-cli.ts index 98b3e2e4..baa88114 100644 --- a/tools/src/workbench-cli.ts +++ b/tools/src/workbench-cli.ts @@ -31,7 +31,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_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"], + nativeServiceConfig: ["WORKBENCH_API_BIND_HOST", "WORKBENCH_API_PROBE_HOST", "WORKBENCH_API_PUBLIC_HOST", "WORKBENCH_API_PORT", "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"], apiConfig: ["WORKBENCH_API_URL", "HWLAB_API_KEY"] }; } diff --git a/tools/src/workbench-native-service.ts b/tools/src/workbench-native-service.ts index b715471a..f16a7130 100644 --- a/tools/src/workbench-native-service.ts +++ b/tools/src/workbench-native-service.ts @@ -71,7 +71,7 @@ function serviceCommand(service: ServiceName, env: Record) { if (service === "api") return { ...process.env, ...env, WORKBENCH_MODE: env.WORKBENCH_MODE ?? "native-test", WORKBENCH_API_HOST: requiredEnv(env, "WORKBENCH_API_BIND_HOST"), WORKBENCH_API_PORT: requiredEnv(env, "WORKBENCH_API_PORT") }; - if (service === "web") return { ...process.env, ...env, WORKBENCH_WEB_HOST: requiredEnv(env, "WORKBENCH_WEB_BIND_HOST"), WORKBENCH_WEB_PORT: requiredEnv(env, "WORKBENCH_WEB_PORT"), WORKBENCH_NATIVE_API_URL: requiredEnv(env, "WORKBENCH_NATIVE_API_URL") }; + if (service === "web") return { ...process.env, ...env, WORKBENCH_WEB_HOST: requiredEnv(env, "WORKBENCH_WEB_BIND_HOST"), WORKBENCH_WEB_PORT: requiredEnv(env, "WORKBENCH_WEB_PORT"), WORKBENCH_NATIVE_API_URL: requiredEnv(env, "WORKBENCH_NATIVE_API_URL"), WORKBENCH_WEB_RUNTIME_CONFIG: requiredEnv(env, "WORKBENCH_WEB_RUNTIME_CONFIG") }; return { ...process.env, ...env, WORKBENCH_MODE: "temporal", WORKBENCH_WORKER_HEALTH_HOST: requiredEnv(env, "WORKBENCH_WORKER_BIND_HOST"), WORKBENCH_WORKER_HEALTH_PORT: requiredEnv(env, "WORKBENCH_WORKER_HEALTH_PORT") }; } function validService(value: string): ServiceName { if (value === "api" || value === "worker" || value === "web") return value; throw codedError("invalid_service", "service must be api, worker, or web"); } diff --git a/web/hwlab-cloud-web/scripts/workbench-native-vite-config.test.ts b/web/hwlab-cloud-web/scripts/workbench-native-vite-config.test.ts new file mode 100644 index 00000000..fce65748 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/workbench-native-vite-config.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; + +import { parseWorkbenchNativeRuntimeConfig } from "../vite.config"; + +const validConfig = { + displayTime: { timeZone: "Asia/Shanghai", locale: "zh-CN", label: "北京时间" }, + workbench: { + realtimeFeatures: { liveKafkaSse: false, kafkaRefreshReplay: false, projectionRealtime: true }, + debugCapabilities: { isolatedKafka: false, rawHwlabEventWindow: { enabled: false, maxEntries: 1, maxRetainedBytes: 1 } }, + traceTimeline: { autoExpandRunning: false, autoCollapseTerminal: false } + } +}; + +describe("Workbench native Vite runtime config", () => { + test("accepts the YAML-rendered runtime contract", () => { + expect(parseWorkbenchNativeRuntimeConfig(JSON.stringify(validConfig))).toEqual(validConfig); + }); + + test("fails closed when required runtime capabilities are missing", () => { + expect(() => parseWorkbenchNativeRuntimeConfig(JSON.stringify({ displayTime: validConfig.displayTime, workbench: {} }))).toThrow("workbench.realtimeFeatures"); + }); +}); diff --git a/web/hwlab-cloud-web/vite.config.ts b/web/hwlab-cloud-web/vite.config.ts index 2d7eb6da..b266b208 100644 --- a/web/hwlab-cloud-web/vite.config.ts +++ b/web/hwlab-cloud-web/vite.config.ts @@ -2,11 +2,12 @@ import { Agent } from "node:http"; import { fileURLToPath, URL } from "node:url"; import vue from "@vitejs/plugin-vue"; -import { defineConfig, loadEnv } from "vite"; +import { defineConfig, loadEnv, type Plugin } from "vite"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); const nativeApiUrl = String(process.env.WORKBENCH_NATIVE_API_URL ?? env.WORKBENCH_NATIVE_API_URL ?? "").trim(); + const nativeRuntimeConfig = String(process.env.WORKBENCH_WEB_RUNTIME_CONFIG ?? env.WORKBENCH_WEB_RUNTIME_CONFIG ?? "").trim(); const nativeCaseRunEnabled = (process.env.HWLAB_CASERUN_NATIVE_TEST ?? env.HWLAB_CASERUN_NATIVE_TEST) === "1"; const nativeCaseRunTarget = process.env.HWLAB_CASERUN_NATIVE_URL ?? env.HWLAB_CASERUN_NATIVE_URL ?? "http://127.0.0.1:4316"; const nativeCaseRunAgent = new Agent({ keepAlive: true }); @@ -14,9 +15,10 @@ export default defineConfig(({ mode }) => { if (nativeApiUrl && nativeCaseRunEnabled) { throw new Error("Workbench and CaseRun native modes cannot share one Vite server"); } + if (nativeApiUrl && !nativeRuntimeConfig) throw new Error("WORKBENCH_WEB_RUNTIME_CONFIG is required in Workbench native mode"); return { - plugins: [vue()], + plugins: [...(nativeApiUrl ? [nativeRuntimeConfigPlugin(nativeRuntimeConfig)] : []), vue()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) @@ -64,3 +66,48 @@ export default defineConfig(({ mode }) => { } }; }); + +function nativeRuntimeConfigPlugin(serialized: string): Plugin { + const config = parseWorkbenchNativeRuntimeConfig(serialized); + const inlineJson = JSON.stringify(config).replace(/ { + let value: unknown; + try { value = JSON.parse(serialized); } + catch { throw new Error("WORKBENCH_WEB_RUNTIME_CONFIG must be valid JSON"); } + const config = record(value, "WORKBENCH_WEB_RUNTIME_CONFIG"); + const displayTime = record(config.displayTime, "WORKBENCH_WEB_RUNTIME_CONFIG.displayTime"); + requiredString(displayTime.timeZone, "displayTime.timeZone"); + requiredString(displayTime.locale, "displayTime.locale"); + requiredString(displayTime.label, "displayTime.label"); + const workbench = record(config.workbench, "WORKBENCH_WEB_RUNTIME_CONFIG.workbench"); + const realtime = record(workbench.realtimeFeatures, "workbench.realtimeFeatures"); + requiredBoolean(realtime.liveKafkaSse, "workbench.realtimeFeatures.liveKafkaSse"); + requiredBoolean(realtime.kafkaRefreshReplay, "workbench.realtimeFeatures.kafkaRefreshReplay"); + requiredBoolean(realtime.projectionRealtime, "workbench.realtimeFeatures.projectionRealtime"); + const debug = record(workbench.debugCapabilities, "workbench.debugCapabilities"); + requiredBoolean(debug.isolatedKafka, "workbench.debugCapabilities.isolatedKafka"); + const rawWindow = record(debug.rawHwlabEventWindow, "workbench.debugCapabilities.rawHwlabEventWindow"); + requiredBoolean(rawWindow.enabled, "workbench.debugCapabilities.rawHwlabEventWindow.enabled"); + requiredPositiveInteger(rawWindow.maxEntries, "workbench.debugCapabilities.rawHwlabEventWindow.maxEntries"); + requiredPositiveInteger(rawWindow.maxRetainedBytes, "workbench.debugCapabilities.rawHwlabEventWindow.maxRetainedBytes"); + const traceTimeline = record(workbench.traceTimeline, "workbench.traceTimeline"); + requiredBoolean(traceTimeline.autoExpandRunning, "workbench.traceTimeline.autoExpandRunning"); + requiredBoolean(traceTimeline.autoCollapseTerminal, "workbench.traceTimeline.autoCollapseTerminal"); + return config; +} + +function record(value: unknown, path: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as Record; +} +function requiredString(value: unknown, path: string): void { if (typeof value !== "string" || !value.trim()) throw new Error(`${path} must be a non-empty string`); } +function requiredBoolean(value: unknown, path: string): void { if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`); } +function requiredPositiveInteger(value: unknown, path: string): void { if (!Number.isInteger(value) || Number(value) <= 0) throw new Error(`${path} must be a positive integer`); }