diff --git a/internal/cloud/provider-profile-management.test.ts b/internal/cloud/provider-profile-management.test.ts new file mode 100644 index 00000000..cd30d16d --- /dev/null +++ b/internal/cloud/provider-profile-management.test.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { createServer as createHttpServer } from "node:http"; +import { test } from "bun:test"; + +import { createCloudApiServer } from "./server.ts"; + +const adminActor = { id: "usr_admin", username: "admin", displayName: "Admin", role: "admin", status: "active" }; +const userActor = { id: "usr_user", username: "user", displayName: "User", role: "user", status: "active" }; + +test("provider profile management requires HWLAB admin auth before AgentRun delegation", async () => { + const calls: unknown[] = []; + const agentRunServer = await startAgentRunServer(calls); + const { port: agentRunPort } = agentRunServer.address() as { port: number }; + const server = createCloudApiServer({ + env: testEnv(agentRunPort), + accessController: fakeAccessController({ actor: userActor }) + }); + await listen(server); + const { port } = server.address() as { port: number }; + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/admin/provider-profiles`, { headers: { cookie: "hwlab_session=user" } }); + const body = await response.json(); + assert.equal(response.status, 403); + assert.equal(body.error.code, "admin_required"); + assert.equal(calls.length, 0); + } finally { + await close(server); + await close(agentRunServer); + } +}); + +test("provider profile list delegates to AgentRun and rewrites codex profile for HWLAB UI", async () => { + const calls: Array<{ method?: string; path?: string; body?: unknown }> = []; + const agentRunServer = await startAgentRunServer(calls); + const { port: agentRunPort } = agentRunServer.address() as { port: number }; + const server = createCloudApiServer({ + env: testEnv(agentRunPort), + accessController: fakeAccessController({ actor: adminActor }) + }); + await listen(server); + const { port } = server.address() as { port: number }; + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/admin/provider-profiles`, { headers: { cookie: "hwlab_session=admin" } }); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.ok, true); + assert.equal(body.contractVersion, "provider-profile-management-v1"); + assert.equal(body.delegation.agentRunHost, "127.0.0.1"); + assert.deepEqual(body.items.map((item: any) => item.profile), ["deepseek", "codex-api", "minimax-m3"]); + assert.equal(body.items[0].bridge.route, "HWLAB Moon Bridge -> DeepSeek official upstream"); + assert.equal(JSON.stringify(body).includes("auth.json"), true); + assert.equal(JSON.stringify(body).includes("sk-test"), false); + assert.equal(calls[0].method, "GET"); + assert.equal(calls[0].path, "/api/v1/provider-profiles"); + } finally { + await close(server); + await close(agentRunServer); + } +}); + +test("provider profile set-key delegates through HWLAB without echoing API key", async () => { + const calls: Array<{ method?: string; path?: string; body?: any }> = []; + const agentRunServer = await startAgentRunServer(calls); + const { port: agentRunPort } = agentRunServer.address() as { port: number }; + const server = createCloudApiServer({ + env: testEnv(agentRunPort), + accessController: fakeAccessController({ actor: adminActor, authMethod: "api-key" }) + }); + await listen(server); + const { port } = server.address() as { port: number }; + const key = "sk-test-provider-secret-123456"; + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/admin/provider-profiles/deepseek/credential`, { + method: "PUT", + headers: { "content-type": "application/json", authorization: "Bearer hwlab-test" }, + body: JSON.stringify({ apiKey: key }) + }); + const body = await response.json(); + const text = JSON.stringify(body); + assert.equal(response.status, 200); + assert.equal(body.ok, true); + assert.equal(body.status, "updated"); + assert.equal(body.profile, "deepseek"); + assert.equal(body.data.requiresExternalBridgeUpdate, true); + assert.equal(text.includes(key), false); + assert.equal(text.includes("Bearer hwlab-test"), false); + assert.equal(calls[0].method, "PUT"); + assert.equal(calls[0].path, "/api/v1/provider-profiles/deepseek/credential"); + assert.equal(calls[0].body.apiKey, key); + assert.equal(calls[0].body.delegatedBy.system, "hwlab-v02"); + assert.equal(calls[0].body.delegatedBy.userId, "usr_admin"); + assert.equal(calls[0].body.delegatedBy.authMethod, "api-key"); + } finally { + await close(server); + await close(agentRunServer); + } +}); + +function testEnv(port: number) { + return { + HWLAB_CODE_AGENT_AGENTRUN_MGR_URL: `http://127.0.0.1:${port}`, + HWLAB_PROVIDER_PROFILE_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_ACCESS_CONTROL_REQUIRED: "1" + }; +} + +function fakeAccessController({ actor, authMethod = "web-session" }: { actor: any; authMethod?: string }) { + return { + required: true, + async authenticate(request: any, { required = true } = {}) { + const hasAuth = Boolean(request.headers?.cookie || request.headers?.authorization); + if (!hasAuth && required) return { ok: false, status: 401, error: { code: "auth_required", message: "Authentication is required" } }; + return { ok: true, actor, session: { id: "ses_test", userId: actor.id }, authMethod }; + }, + async handleAuthRoute() { throw new Error("not used"); }, + async handleUserRoute() { throw new Error("not used"); }, + async handleAccessStatusRoute() { throw new Error("not used"); }, + async handleSetupRoute() { throw new Error("not used"); }, + async handleAdminRoute() { throw new Error("provider profile route should bypass generic admin route"); }, + async handleDevicePodRoute() { throw new Error("not used"); }, + configureCodeAgentWorkspaceContext() {} + }; +} + +async function startAgentRunServer(calls: Array<{ method?: string; path?: string; body?: unknown }>) { + const server = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + const chunks = []; + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null; + calls.push({ method: request.method, path: url.pathname, body }); + response.writeHead(200, { "content-type": "application/json" }); + if (request.method === "GET" && url.pathname === "/api/v1/provider-profiles") { + response.end(`${JSON.stringify({ ok: true, traceId: "trc_profiles", data: { items: [profile("deepseek"), profile("codex"), profile("minimax-m3")], count: 3, valuesPrinted: false } })}\n`); + return; + } + if (request.method === "PUT" && url.pathname === "/api/v1/provider-profiles/deepseek/credential") { + response.end(`${JSON.stringify({ ok: true, traceId: "trc_set_key", data: { action: "provider-profile-credential-updated", mutation: true, profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "def67890", updatedAt: "2026-06-05T08:00:00.000Z", requiresExternalBridgeUpdate: true, valuesPrinted: false } })}\n`); + return; + } + response.end(`${JSON.stringify({ ok: false, error: { code: "unexpected", message: `${request.method} ${url.pathname}` } })}\n`); + }); + await listen(server); + return server; +} + +function profile(name: string) { + return { + profile: name, + backendKind: name, + configured: true, + secretRef: { namespace: "agentrun-v01", name: `agentrun-v01-provider-${name}`, keys: ["auth.json", "config.toml"] }, + credentialHashSuffix: "abc12345", + configHashSuffix: "def67890", + updatedAt: "2026-06-05T08:00:00.000Z", + valuesPrinted: false + }; +} + +function listen(server: any) { + return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +} + +function close(server: any) { + return new Promise((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve()))); +} diff --git a/internal/cloud/provider-profile-management.ts b/internal/cloud/provider-profile-management.ts new file mode 100644 index 00000000..ed93e8d3 --- /dev/null +++ b/internal/cloud/provider-profile-management.ts @@ -0,0 +1,395 @@ +import { randomUUID } from "node:crypto"; + +const CONTRACT_VERSION = "provider-profile-management-v1"; +const DEFAULT_AGENTRUN_MGR_URL = "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080"; +const DEFAULT_AGENTRUN_HOST = "agentrun-mgr.agentrun-v01.svc.cluster.local"; +const DEFAULT_TIMEOUT_MS = 30000; + +const publicProfiles = new Set(["deepseek", "codex-api", "minimax-m3"]); + +export async function handleProviderProfilesHttp(request, response, url, options = {}) { + try { + const auth = await authenticateAdmin(request, options); + if (!auth.ok) return sendJson(response, numericStatus(auth.status) ?? numericStatus(auth.statusCode) ?? 401, auth); + + const requestId = requestIdFor(request); + const route = parseProviderProfileRoute(url.pathname, request.method); + const managerUrl = resolveAgentRunManagerUrl(options.env ?? process.env); + const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = positiveInteger((options.env ?? process.env).HWLAB_PROVIDER_PROFILE_AGENTRUN_HTTP_TIMEOUT_MS, positiveInteger((options.env ?? process.env).HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)); + + if (route.kind === "list") { + const delegated = await agentRunJson(fetchImpl, managerUrl, "/api/v1/provider-profiles", { method: "GET", requestId, timeoutMs }); + return sendDelegated(response, delegated, auth, route, managerUrl, requestId, normalizeListData(delegated.data)); + } + + if (route.kind === "credential") { + const body = await readJsonObject(request, options.bodyLimitBytes); + const apiKey = text(body.apiKey); + if (apiKey.length < 8) return sendError(response, 400, "api_key_required", "apiKey is required and must not be empty"); + const delegatedBody = { + apiKey, + ...(body.config && typeof body.config === "object" ? { config: body.config } : {}), + delegatedBy: delegatedBy(auth, requestId), + reason: text(body.reason) || "hwlab-provider-management" + }; + const delegated = await agentRunJson(fetchImpl, managerUrl, `/api/v1/provider-profiles/${encodeURIComponent(route.agentRunProfile)}/credential`, { method: "PUT", body: delegatedBody, requestId, timeoutMs }); + return sendDelegated(response, delegated, auth, route, managerUrl, requestId, normalizeProfileData(delegated.data)); + } + + if (route.kind === "validate") { + const body = await readOptionalJsonObject(request, options.bodyLimitBytes); + const delegatedBody = { + ...(text(body.prompt) ? { prompt: text(body.prompt) } : {}), + delegatedBy: delegatedBy(auth, requestId), + reason: text(body.reason) || "hwlab-provider-management" + }; + const delegated = await agentRunJson(fetchImpl, managerUrl, `/api/v1/provider-profiles/${encodeURIComponent(route.agentRunProfile)}/validate`, { method: "POST", body: delegatedBody, requestId, timeoutMs }); + return sendDelegated(response, delegated, auth, route, managerUrl, requestId, normalizeValidationData(delegated.data, route.publicProfile)); + } + + if (route.kind === "validation") { + const delegated = await agentRunJson(fetchImpl, managerUrl, `/api/v1/provider-profiles/${encodeURIComponent(route.agentRunProfile)}/validations/${encodeURIComponent(route.validationId)}`, { method: "GET", requestId, timeoutMs }); + return sendDelegated(response, delegated, auth, route, managerUrl, requestId, normalizeValidationData(delegated.data, route.publicProfile)); + } + + return sendError(response, 404, "not_found", "Provider profile route is not implemented"); + } catch (error) { + return sendError(response, error.httpStatus ?? 500, error.code ?? "provider_profile_management_failed", error.message ?? "Provider profile management failed", error.details); + } +} + +async function authenticateAdmin(request, options) { + const controller = options.accessController; + if (!controller || typeof controller.authenticate !== "function") { + return errorPayload("access_controller_unavailable", "HWLAB access controller is not available", 503); + } + const auth = await controller.authenticate(request, { required: true }); + if (!auth.ok) return auth; + if (auth.actor?.role !== "admin") return errorPayload("admin_required", "Only admin users can manage provider profiles", 403); + return auth; +} + +function parseProviderProfileRoute(pathname, method) { + if (pathname === "/v1/admin/provider-profiles") { + if (method !== "GET") throw routeError(405, "method_not_allowed", "Provider profile list only supports GET"); + return { kind: "list", publicProfile: null, agentRunProfile: null }; + } + const match = pathname.match(/^\/v1\/admin\/provider-profiles\/([^/]+)(?:\/(credential|validate|validations\/([^/]+)))?$/u); + if (!match) throw routeError(404, "not_found", "Provider profile route is not implemented"); + const profile = normalizeProfile(decodeURIComponent(match[1])); + const suffix = match[2] ?? ""; + if (suffix === "credential") { + if (method !== "PUT") throw routeError(405, "method_not_allowed", "Provider profile credential only supports PUT"); + return { kind: "credential", ...profile }; + } + if (suffix === "validate") { + if (method !== "POST") throw routeError(405, "method_not_allowed", "Provider profile validation only supports POST"); + return { kind: "validate", ...profile }; + } + if (suffix.startsWith("validations/")) { + if (method !== "GET") throw routeError(405, "method_not_allowed", "Provider profile validation lookup only supports GET"); + return { kind: "validation", ...profile, validationId: decodeURIComponent(match[3] ?? "") }; + } + throw routeError(404, "not_found", "Provider profile route is not implemented"); +} + +function normalizeProfile(value) { + const input = text(value).toLowerCase(); + const publicProfile = input === "codex" ? "codex-api" : input; + if (!publicProfiles.has(publicProfile)) throw routeError(400, "invalid_provider_profile", "Provider profile is not supported", { profile: input, supportedProfiles: Array.from(publicProfiles) }); + return { publicProfile, agentRunProfile: publicProfile === "codex-api" ? "codex" : publicProfile }; +} + +async function agentRunJson(fetchImpl, managerUrl, path, { method = "GET", body, requestId, timeoutMs }) { + const startedAt = Date.now(); + const targetUrl = `${managerUrl}${path}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(targetUrl, { + method, + headers: { + accept: "application/json", + "content-type": "application/json", + "x-request-id": requestId, + "x-source-service-id": "hwlab-cloud-api", + "x-hwlab-delegation": "provider-profile-management-v1" + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal + }); + const textBody = await response.text(); + const payload = parseJson(textBody); + const ok = response.ok && payload?.ok !== false; + return { + ok, + httpStatus: response.status, + data: payload?.data ?? payload, + payload, + traceId: text(payload?.traceId), + elapsedMs: Date.now() - startedAt, + method, + path + }; + } catch (error) { + const timedOut = controller.signal.aborted || error?.name === "AbortError"; + return { + ok: false, + httpStatus: 0, + data: null, + payload: { ok: false, error: { code: timedOut ? "agentrun_timeout" : "agentrun_request_failed", message: error?.message ?? String(error) } }, + traceId: "", + elapsedMs: Date.now() - startedAt, + method, + path, + transportError: { code: timedOut ? "agentrun_timeout" : "agentrun_request_failed", message: error?.message ?? String(error), valuesPrinted: false } + }; + } finally { + clearTimeout(timer); + } +} + +function sendDelegated(response, delegated, auth, route, managerUrl, requestId, data) { + const status = delegated.ok ? (route.kind === "credential" ? 200 : delegated.httpStatus || 200) : delegated.httpStatus || 502; + const base = { + ok: delegated.ok, + status: delegated.ok ? (route.kind === "credential" ? "updated" : "ok") : "failed", + contractVersion: CONTRACT_VERSION, + actor: actorSummary(auth.actor), + profile: route.publicProfile ?? undefined, + delegation: delegationSummary(managerUrl, delegated, requestId), + valuesPrinted: false + }; + if (delegated.ok) return sendJson(response, status, { ...base, ...listAliases(data), data }); + return sendJson(response, status, { + ...base, + error: delegatedError(delegated), + data: null + }); +} + +function normalizeListData(data) { + const sourceItems = Array.isArray(data?.items) ? data.items : []; + const items = sourceItems.map(normalizeProfileData); + return { ...redactObject(data), items, count: items.length, valuesPrinted: false }; +} + +function normalizeProfileData(item) { + if (!item || typeof item !== "object") return item; + const copy = redactObject(item); + if (copy.profile === "codex") { + copy.backendProfile = "codex"; + copy.profile = "codex-api"; + } + if (copy.profile === "deepseek") copy.bridge = deepseekBridgeSummary(); + copy.valuesPrinted = false; + return copy; +} + +function normalizeValidationData(data, fallbackProfile) { + if (!data || typeof data !== "object") return data; + const copy = normalizeProfileData(data); + if (!copy.profile) copy.profile = fallbackProfile; + if (copy.result && typeof copy.result === "object") copy.result = redactObject(copy.result); + if (copy.runnerJobs && Array.isArray(copy.runnerJobs)) copy.runnerJobs = copy.runnerJobs.map(redactObject); + return copy; +} + +function listAliases(data) { + return Array.isArray(data?.items) ? { items: data.items, count: data.count ?? data.items.length } : {}; +} + +function delegatedError(delegated) { + const body = delegated.payload && typeof delegated.payload === "object" ? delegated.payload : {}; + const nested = body.error && typeof body.error === "object" ? body.error : {}; + return { + code: text(nested.code ?? body.code) || "agentrun_delegation_failed", + message: text(nested.message ?? body.message) || `AgentRun provider profile delegation failed with HTTP ${delegated.httpStatus}`, + failureKind: text(nested.failureKind ?? body.failureKind) || undefined, + agentRun: { + httpStatus: delegated.httpStatus, + traceId: delegated.traceId || undefined, + route: { method: delegated.method, path: delegated.path }, + elapsedMs: delegated.elapsedMs, + transportError: delegated.transportError ? redactObject(delegated.transportError) : undefined, + valuesPrinted: false + }, + valuesPrinted: false + }; +} + +function delegatedBy(auth, requestId) { + return { + system: "hwlab-v02", + userId: text(auth.actor?.id), + username: text(auth.actor?.username), + authMethod: text(auth.authMethod) || (auth.apiKey ? "api-key" : "web-session"), + requestId, + valuesPrinted: false + }; +} + +function actorSummary(actor) { + return actor ? { + id: text(actor.id), + username: text(actor.username), + displayName: text(actor.displayName), + role: text(actor.role), + status: text(actor.status), + valuesPrinted: false + } : null; +} + +function delegationSummary(managerUrl, delegated, requestId) { + const url = new URL(managerUrl); + return { + provider: "agentrun-v01", + requestId, + agentRunBaseUrl: redactUrl(managerUrl), + agentRunHost: url.hostname, + traceId: delegated.traceId || undefined, + httpStatus: delegated.httpStatus, + elapsedMs: delegated.elapsedMs, + valuesPrinted: false + }; +} + +function deepseekBridgeSummary() { + return { + kind: "moon-bridge", + route: "HWLAB Moon Bridge -> DeepSeek official upstream", + serviceUrl: "http://hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local:4000/v1", + responsesPath: "/v1/responses", + forbiddenHosts: ["hyueapi.com"], + valuesPrinted: false + }; +} + +function resolveAgentRunManagerUrl(env) { + const raw = text(env.AGENTRUN_MGR_URL) || text(env.HWLAB_CODE_AGENT_AGENTRUN_MGR_URL) || text(env.AGENTRUN_MANAGER_URL) || DEFAULT_AGENTRUN_MGR_URL; + let parsed; + try { + parsed = new URL(raw); + } catch { + throw routeError(503, "agentrun_manager_url_invalid", "AgentRun manager URL is invalid", { managerUrl: redactUrl(raw) }); + } + const allowLocal = truthy(env.HWLAB_PROVIDER_PROFILE_AGENTRUN_ALLOW_NON_K3S_URL) || truthy(env.HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL); + const localAllowed = allowLocal && ["127.0.0.1", "localhost"].includes(parsed.hostname); + if (parsed.hostname !== DEFAULT_AGENTRUN_HOST && !localAllowed) { + throw routeError(503, "agentrun_manager_url_invalid", `AgentRun manager URL must use internal k3s Service DNS ${DEFAULT_AGENTRUN_MGR_URL}`, { managerUrl: redactUrl(raw) }); + } + return raw.replace(/\/+$/u, ""); +} + +async function readOptionalJsonObject(request, limitBytes) { + const body = await readBody(request, limitBytes); + if (!body.trim()) return {}; + return parseJsonObject(body); +} + +async function readJsonObject(request, limitBytes) { + return parseJsonObject(await readBody(request, limitBytes)); +} + +async function readBody(request, limitBytes = 1024 * 1024) { + const chunks = []; + let total = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.length; + if (total > limitBytes) throw routeError(413, "body_too_large", "Request body is too large"); + chunks.push(buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function parseJsonObject(body) { + const parsed = parseJson(body); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw routeError(400, "invalid_json_body", "Request body must be a JSON object"); + return parsed; +} + +function parseJson(value) { + try { + return value ? JSON.parse(value) : null; + } catch (error) { + throw routeError(400, "parse_error", "Invalid JSON body", { reason: error?.message ?? String(error) }); + } +} + +function redactObject(value, key = "") { + if (Array.isArray(value)) return value.map((item) => redactObject(item, key)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, redactObject(entryValue, entryKey)])); + } + if (typeof value === "string" && (secretField(key) || looksSecret(value))) return "[redacted]"; + return value; +} + +function secretField(key) { + const lower = key.toLowerCase(); + if (["apikey", "api_key", "authorization", "authjson", "auth.json", "configtoml", "config.toml", "stringdata"].includes(lower)) return true; + if (lower === "secretref" || lower === "secretname" || lower === "secretkey" || lower === "keys") return false; + return lower.includes("password") || lower.endsWith("token"); +} + +function looksSecret(value) { + return /^sk-[A-Za-z0-9_-]{10,}/u.test(value) || /^Bearer\s+\S+/u.test(value); +} + +function requestIdFor(request) { + return getHeader(request, "x-request-id") || `req_provider_${randomUUID().replace(/-/gu, "")}`; +} + +function getHeader(request, name) { + const value = request.headers?.[name.toLowerCase()]; + if (Array.isArray(value)) return text(value[0]); + return text(value); +} + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} + +function numericStatus(value) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 100 && parsed <= 599 ? parsed : null; +} + +function truthy(value) { + return ["1", "true", "yes", "on"].includes(text(value).toLowerCase()); +} + +function redactUrl(value) { + try { + const url = new URL(value); + if (url.username) url.username = "redacted"; + if (url.password) url.password = "redacted"; + return url.toString().replace(/\/$/u, ""); + } catch { + return "[invalid-url]"; + } +} + +function routeError(httpStatus, code, message, details = {}) { + return Object.assign(new Error(message), { httpStatus, code, details }); +} + +function errorPayload(code, message, status, details = {}) { + return { ok: false, status: "failed", contractVersion: CONTRACT_VERSION, error: { code, message, ...details, valuesPrinted: false }, valuesPrinted: false, statusCode: status }; +} + +function sendError(response, statusCode, code, message, details = {}) { + return sendJson(response, statusCode, { ok: false, status: "failed", contractVersion: CONTRACT_VERSION, error: { code, message, ...redactObject(details), valuesPrinted: false }, valuesPrinted: false }); +} + +function sendJson(response, statusCode, payload) { + response.writeHead(statusCode, { "content-type": "application/json" }); + response.end(`${JSON.stringify(redactObject(payload))}\n`); +} + +function text(value) { + return String(value ?? "").trim(); +} diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index c708ba8c..878ab9d7 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -63,6 +63,7 @@ import { } from "./server-code-agent-http.ts"; import { handleM3IoControlHttp } from "./server-m3-http.ts"; import { handleSkillsHttp } from "./server-skills-http.ts"; +import { handleProviderProfilesHttp } from "./provider-profile-management.ts"; import { configureSkillRuntime, ensureCodexSkillsAggregationSync @@ -417,6 +418,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/admin/provider-profiles" || url.pathname.startsWith("/v1/admin/provider-profiles/")) { + await handleProviderProfilesHttp(request, response, url, options); + return; + } + if (url.pathname.startsWith("/v1/admin/")) { await options.accessController.handleAdminRoute(request, response, url); return; diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index f84c3954..60fa17d0 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -105,6 +105,72 @@ test("hwlab-cli client access uses Cloud Web admin access routes", async () => { assert.equal(user.payload.body.user.username, "alice"); }); +test("hwlab-cli client provider-profiles uses Cloud Web admin routes", async () => { + const calls: any[] = []; + const result = await runHwlabCli(["client", "provider-profiles", "list", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-admin"], { + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ + ok: true, + contractVersion: "provider-profile-management-v1", + delegation: { provider: "agentrun-v01", agentRunHost: "agentrun-mgr.agentrun-v01.svc.cluster.local", valuesPrinted: false }, + items: [{ profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", bridge: { route: "HWLAB Moon Bridge -> DeepSeek official upstream", serviceUrl: "http://hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local:4000/v1", responsesPath: "/v1/responses" }, valuesPrinted: false }] + }), { status: 200 }); + } + }); + + assert.equal(result.exitCode, 0); + assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles"); + assert.equal(calls[0].init.method, "GET"); + assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-admin"); + assert.equal(result.payload.route.path, "/v1/admin/provider-profiles"); + assert.equal(result.payload.body.items[0].profile, "deepseek"); + assert.equal(result.payload.body.items[0].bridge.route, "HWLAB Moon Bridge -> DeepSeek official upstream"); + assert.equal(JSON.stringify(result.payload).includes("sk-"), false); +}); + +test("hwlab-cli client provider-profiles set-key reads stdin and never prints key", async () => { + const calls: any[] = []; + const secret = "sk-test-provider-secret-123456"; + const result = await runHwlabCli(["client", "provider-profiles", "set-key", "deepseek", "--key-stdin", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-admin"], { + stdinText: secret, + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); + return new Response(JSON.stringify({ ok: true, status: "updated", profile: "deepseek", data: { profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "def67890", requiresExternalBridgeUpdate: true, valuesPrinted: false }, valuesPrinted: false }), { status: 200 }); + } + }); + + assert.equal(result.exitCode, 0); + assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles/deepseek/credential"); + assert.equal(calls[0].init.method, "PUT"); + assert.equal(calls[0].body.apiKey, secret); + assert.equal(result.payload.body.profileStatus.profile, "deepseek"); + assert.equal(result.payload.body.profileStatus.requiresExternalBridgeUpdate, true); + assert.equal(JSON.stringify(result.payload).includes(secret), false); +}); + +test("hwlab-cli client provider-profiles validate can wait by polling validation result", async () => { + const calls: any[] = []; + const result = await runHwlabCli(["client", "provider-profiles", "validate", "deepseek", "--wait", "--timeout-ms", "5000", "--poll-interval-ms", "10", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-admin"], { + sleep: async () => undefined, + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }); + if (String(url).endsWith("/v1/admin/provider-profiles/deepseek/validate")) { + return new Response(JSON.stringify({ ok: true, data: { validationId: "val_0123456789abcdef0123456789abcdef", profile: "deepseek", runId: "run_1", commandId: "cmd_1", status: "running", valuesPrinted: false } }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: true, data: { validationId: "val_0123456789abcdef0123456789abcdef", profile: "deepseek", runId: "run_1", commandId: "cmd_1", status: "completed", result: { terminalStatus: "completed", agentRun: { jobName: "agentrun-v01-runner-test" } }, valuesPrinted: false } }), { status: 200 }); + } + }); + + assert.equal(result.exitCode, 0); + assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles/deepseek/validate"); + assert.equal(calls[1].url, "http://web.test/v1/admin/provider-profiles/deepseek/validations/val_0123456789abcdef0123456789abcdef"); + assert.equal(result.payload.waited, true); + assert.equal(result.payload.timedOut, false); + assert.equal(result.payload.body.validation.terminalStatus, "completed"); + assert.equal(result.payload.body.validation.jobName, "agentrun-v01-runner-test"); +}); + test("hwlab-cli client request reports auth username from the selected session", async () => { const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-profile-user-")); const calls: any[] = []; diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 7fd4fa39..42e877c3 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -69,6 +69,7 @@ async function clientCommand(context: any) { } if (group === "auth") return authCommand(next); if (group === "access") return accessCommand(next); + if (group === "provider-profiles" || group === "provider-profile" || group === "providers") return providerProfilesCommand(next); if (group === "device-pods" || group === "device-pod") return devicePodsCommand(next); if (group === "runtime") return runtimeCommand(next); if (group === "gateway") return gatewayCommand(next); @@ -84,6 +85,7 @@ async function clientCommand(context: any) { function clientSubcommandHelp(group: string) { if (group === "auth") return authHelp(); if (group === "access") return accessHelp(); + if (group === "provider-profiles" || group === "provider-profile" || group === "providers") return providerProfilesHelp(); if (group === "device-pods" || group === "device-pod") return devicePodsHelp(); if (group === "runtime") return runtimeHelp(); if (group === "gateway") return gatewayHelp(); @@ -125,6 +127,9 @@ function help() { "hwlab-cli client access tools grant USER_ID hwpod", "hwlab-cli client access tools revoke USER_ID hwpod", "hwlab-cli client access check --user USER_ID --relation viewer --object device_pod:POD_ID", + "hwlab-cli client provider-profiles list", + "hwlab-cli client provider-profiles set-key deepseek --key-stdin", + "hwlab-cli client provider-profiles validate deepseek --wait --timeout-ms 120000", "hwlab-cli client device-pods list", "hwlab-cli client device-pods status device-pod-71-freq", "hwlab-cli client runtime routes", @@ -313,6 +318,129 @@ function accessHelp() { }); } +async function providerProfilesCommand(context: any) { + const subcommand = context.rest[0] || "list"; + if (wantsHelp(context)) return providerProfilesHelp(); + if (subcommand === "list") { + const pathName = "/v1/admin/provider-profiles"; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + return responsePayload("client.provider-profiles.list", response, context, { route: route("GET", pathName), body: providerProfileBodyForCli(response.body, context.parsed) }); + } + if (subcommand === "set-key" || subcommand === "set" || subcommand === "credential") { + const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]); + if (context.parsed.keyStdin !== true) throw cliError("key_stdin_required", "set-key requires --key-stdin so the API key is not passed in argv", { command: "provider-profiles set-key PROFILE --key-stdin" }); + const apiKey = await providerProfileApiKeyFromStdin(context); + const pathName = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/credential`; + const response = await requestJson({ ...context, method: "PUT", path: pathName, body: { apiKey }, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS }); + return responsePayload("client.provider-profiles.set-key", response, context, { route: route("PUT", pathName), profile, body: providerProfileBodyForCli(response.body, context.parsed), valuesPrinted: false }); + } + if (subcommand === "validate") { + return providerProfilesValidate(context); + } + if (subcommand === "validation" || subcommand === "result") { + const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]); + const validationId = requiredText(context.parsed.validationId ?? context.rest[2], "validationId"); + const pathName = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + return responsePayload("client.provider-profiles.validation", response, context, { route: route("GET", pathName), profile, validationId, body: providerProfileBodyForCli(response.body, context.parsed) }); + } + throw cliError("unsupported_provider_profiles_command", `unsupported provider-profiles command: ${subcommand}`, { subcommand }); +} + +async function providerProfilesValidate(context: any) { + const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]); + const pathName = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validate`; + const response = await requestJson({ ...context, method: "POST", path: pathName, body: {}, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS }); + const body = providerProfileBodyForCli(response.body, context.parsed); + const validationId = text(response.body?.data?.validationId ?? response.body?.validationId); + if (context.parsed.wait !== true || !responseSucceeded(response) || !validationId) { + return responsePayload("client.provider-profiles.validate", response, context, { route: route("POST", pathName), profile, validationId: validationId || undefined, waited: false, body, waitPolicy: providerProfileWaitPolicy(profile, validationId) }); + } + + const timeoutMs = Math.min(numberOption(context.parsed.timeoutMs) ?? 120000, 120000); + const pollIntervalMs = Math.max(numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS, 500); + const deadline = Date.now() + timeoutMs; + let polls = 0; + let lastResponse = response; + let lastBody = body; + while (Date.now() < deadline) { + const status = validationTerminalStatus(lastResponse.body); + if (status.terminal) { + return responsePayload("client.provider-profiles.validate", lastResponse, context, { route: route("GET", `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`), profile, validationId, waited: true, timedOut: false, polls, body: lastBody, waitPolicy: providerProfileWaitPolicy(profile, validationId) }); + } + await context.sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now()))); + polls += 1; + const resultPath = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`; + lastResponse = await requestJson({ ...context, method: "GET", path: resultPath, timeoutMs: Math.min(DEFAULT_TIMEOUT_MS, pollIntervalMs + 2000) }); + lastBody = providerProfileBodyForCli(lastResponse.body, context.parsed); + } + return ok("client.provider-profiles.validate", { + baseUrl: response.runtimeEndpoint?.baseUrl ?? baseUrl(context.parsed, context.env, context.endpointKind ?? "web"), + route: route("GET", `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`), + profile, + validationId, + waited: true, + timedOut: true, + polls, + timeoutMsRequested: numberOption(context.parsed.timeoutMs) ?? 120000, + timeoutMsEffective: timeoutMs, + body: lastBody, + waitPolicy: providerProfileWaitPolicy(profile, validationId) + }, "timeout"); +} + +function providerProfilesHelp() { + return ok("client.provider-profiles.help", { + serviceRuntime: false, + imagePublished: false, + sameOrigin: true, + valuesPrinted: false, + commands: [ + "list", + "set-key PROFILE --key-stdin", + "validate PROFILE [--wait] [--timeout-ms N] [--poll-interval-ms N]", + "validation PROFILE VALIDATION_ID" + ], + routes: { + list: route("GET", "/v1/admin/provider-profiles"), + setKey: route("PUT", "/v1/admin/provider-profiles/:profile/credential"), + validate: route("POST", "/v1/admin/provider-profiles/:profile/validate"), + validation: route("GET", "/v1/admin/provider-profiles/:profile/validations/:validationId") + } + }); +} + +async function providerProfileApiKeyFromStdin(context: any) { + const apiKey = context.stdinText !== undefined ? String(context.stdinText).trimEnd() : await readStdin(); + if (apiKey.trim().length < 8) throw cliError("api_key_too_short", "provider profile API key from stdin is too short", { minLength: 8 }); + return apiKey.trim(); +} + +function normalizeProviderProfile(value: unknown) { + const profile = text(value).toLowerCase(); + const normalized = profile === "codex" ? "codex-api" : profile; + if (["deepseek", "codex-api", "minimax-m3"].includes(normalized)) return normalized; + throw cliError("invalid_provider_profile", "provider profile must be deepseek, codex-api, or minimax-m3", { profile }); +} + +function validationTerminalStatus(body: any) { + const status = text(body?.data?.result?.terminalStatus ?? body?.data?.status ?? body?.result?.terminalStatus ?? body?.status).toLowerCase(); + return { status, terminal: ["completed", "failed", "canceled", "cancelled", "timeout", "blocked"].includes(status) }; +} + +function providerProfileWaitPolicy(profile: string, validationId: string) { + return pruneUndefined({ + webEquivalent: true, + shortConnection: true, + remotePassthroughSafe: true, + defaultWait: false, + profile, + validationId: text(validationId) || undefined, + maxTimeoutMs: 120000, + nextCommands: text(validationId) ? [`hwlab-cli client provider-profiles validation ${profile} ${validationId}`, `hwlab-cli client provider-profiles validate ${profile} --wait --timeout-ms 120000`] : [`hwlab-cli client provider-profiles validate ${profile} --wait --timeout-ms 120000`] + }); +} + function normalizeDevicePodRelation(value: unknown) { const relation = text(value).replace(/-/gu, "_"); if (["viewer", "operator", "profile_editor", "job_submitter"].includes(relation)) return relation; @@ -2932,6 +3060,96 @@ function adminAccessTupleForCli(value: any) { }); } +function providerProfileBodyForCli(body: any, parsed: ParsedArgs) { + if (parsed.full === true) return redactSecretLike(body); + const source = body?.data && typeof body.data === "object" ? body.data : body; + const items = Array.isArray(body?.items) ? body.items : Array.isArray(source?.items) ? source.items : []; + return pruneUndefined({ + ok: body?.ok, + status: text(body?.status) || text(source?.status) || undefined, + contractVersion: text(body?.contractVersion) || text(source?.contractVersion) || undefined, + actor: compactApiBody(body?.actor ?? source?.actor), + profile: text(body?.profile ?? source?.profile) || undefined, + delegation: providerProfileDelegationForCli(body?.delegation ?? source?.delegation), + items: items.length > 0 ? items.map(providerProfileStatusForCli) : undefined, + count: items.length || body?.count || source?.count, + profileStatus: source?.profile ? providerProfileStatusForCli(source) : undefined, + validation: providerProfileValidationForCli(source?.validationId ? source : body?.validationId ? body : null), + error: body?.error ? compactApiBody(body.error) : undefined, + valuesPrinted: false, + fullBodyAvailable: true + }); +} + +function providerProfileStatusForCli(value: any) { + return pruneUndefined({ + profile: text(value?.profile), + backendProfile: text(value?.backendProfile), + backendKind: text(value?.backendKind), + configured: value?.configured === true, + failureKind: text(value?.failureKind) || undefined, + secretRef: providerSecretRefForCli(value?.secretRef), + resourceVersion: text(value?.resourceVersion) || undefined, + credentialHashSuffix: text(value?.credentialHashSuffix) || undefined, + configHashSuffix: text(value?.configHashSuffix) || undefined, + updatedAt: text(value?.updatedAt) || undefined, + keyPresence: value?.keyPresence, + bridge: providerBridgeForCli(value?.bridge), + lastValidation: providerProfileValidationForCli(value?.lastValidation), + requiresExternalBridgeUpdate: value?.requiresExternalBridgeUpdate === true ? true : undefined, + valuesPrinted: false + }); +} + +function providerProfileValidationForCli(value: any) { + if (!value || typeof value !== "object") return undefined; + const result = value.result && typeof value.result === "object" ? value.result : {}; + const agentRun = result.agentRun && typeof result.agentRun === "object" ? result.agentRun : {}; + const runnerJobs = Array.isArray(value.runnerJobs) ? value.runnerJobs : []; + return pruneUndefined({ + validationId: text(value.validationId) || undefined, + profile: text(value.profile) || undefined, + runId: text(value.runId) || text(agentRun.runId) || undefined, + commandId: text(value.commandId) || text(agentRun.commandId) || undefined, + jobName: text(runnerJobs[0]?.jobName) || text(agentRun.jobName) || undefined, + traceId: text(result.traceId ?? agentRun.traceId) || undefined, + status: text(value.status) || text(result.status) || undefined, + terminalStatus: text(result.terminalStatus) || undefined, + failureKind: text(result.failureKind) || undefined, + bridge: providerBridgeForCli(value.bridge), + valuesPrinted: false + }); +} + +function providerSecretRefForCli(value: any) { + if (!value || typeof value !== "object") return undefined; + return pruneUndefined({ namespace: text(value.namespace), name: text(value.name), keys: Array.isArray(value.keys) ? value.keys.map(text).filter(Boolean) : undefined, mountPath: text(value.mountPath) || undefined }); +} + +function providerBridgeForCli(value: any) { + if (!value || typeof value !== "object") return undefined; + return pruneUndefined({ kind: text(value.kind), route: text(value.route), serviceUrl: text(value.serviceUrl), responsesPath: text(value.responsesPath), forbiddenHosts: Array.isArray(value.forbiddenHosts) ? value.forbiddenHosts.map(text).filter(Boolean) : undefined, valuesPrinted: false }); +} + +function providerProfileDelegationForCli(value: any) { + if (!value || typeof value !== "object") return undefined; + return pruneUndefined({ provider: text(value.provider), requestId: text(value.requestId), agentRunBaseUrl: text(value.agentRunBaseUrl), agentRunHost: text(value.agentRunHost), traceId: text(value.traceId), httpStatus: value.httpStatus, elapsedMs: value.elapsedMs, valuesPrinted: false }); +} + +function redactSecretLike(value: any): any { + if (Array.isArray(value)) return value.map(redactSecretLike); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, secretLikeField(key) ? "[redacted]" : redactSecretLike(item)])); + } + if (typeof value === "string" && /^sk-[A-Za-z0-9_-]{10,}/u.test(value)) return "[redacted]"; + return value; +} + +function secretLikeField(key: string) { + const lower = key.toLowerCase(); + return ["apikey", "api_key", "authorization", "auth.json", "config.toml", "authjson", "configtoml"].includes(lower) || lower.includes("password") || lower.endsWith("token"); +} + function traceBodyForCli(body: any, parsed: ParsedArgs) { if (text(parsed.render) === "web") return webTraceRenderBody(body, parsed); return responseBodyForCli(body, parsed); diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index a775a1b7..0dc4bb5b 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -20,6 +20,7 @@ import { firstNonEmptyString } from "./utils"; const AccessView = lazy(() => import("./components/access/AccessView").then((module) => ({ default: module.AccessView }))); const GateView = lazy(() => import("./components/gate/GateView").then((module) => ({ default: module.GateView }))); +const ManagementView = lazy(() => import("./components/management/ManagementView").then((module) => ({ default: module.ManagementView }))); const PerformanceView = lazy(() => import("./components/performance/PerformanceView").then((module) => ({ default: module.PerformanceView }))); const SettingsView = lazy(() => import("./components/settings/SettingsView").then((module) => ({ default: module.SettingsView }))); const SkillsView = lazy(() => import("./components/skills/SkillsView").then((module) => ({ default: module.SkillsView }))); @@ -37,6 +38,7 @@ const routeLabels: Record = { workspace: "工作台", performance: "性能监控", skills: "Skills", + management: "管理", access: "授权管理", gate: "内部复核", help: "使用说明", @@ -188,6 +190,7 @@ export function App(): ReactElement { {route === "workspace" ? copyTextToClipboard(firstNonEmptyString(store.composer.sessionId, store.state.workspace?.selectedAgentSessionId, store.state.workspace?.workspace?.selectedAgentSessionId, store.activeConversationId) ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null} {route === "performance" ? : null} {route === "skills" ? : null} + {route === "management" ? : null} {route === "access" ? : null} {route === "gate" ? : null} {route === "help" ? : null} @@ -300,11 +303,12 @@ function HelpView({ help }: { help: string }): ReactElement { function routeFromLocation(): RouteId { const hash = window.location.hash.replace(/^#\/?/u, ""); - if (hash === "performance" || hash === "skills" || hash === "access" || hash === "gate" || hash === "help" || hash === "settings" || hash === "workspace") return hash; + if (hash === "performance" || hash === "skills" || hash === "management" || hash === "access" || hash === "gate" || hash === "help" || hash === "settings" || hash === "workspace") return hash; const path = window.location.pathname.replace(/\/+$/u, "") || "/"; if (path === "/help") return "help"; if (path === "/performance" || path === "/ops/performance") return "performance"; if (path === "/skills") return "skills"; + if (path === "/management" || path === "/admin/provider-profiles") return "management"; if (path === "/access" || path === "/admin/access") return "access"; if (path === "/gate" || path === "/diagnostics/gate") return "gate"; return "workspace"; diff --git a/web/hwlab-cloud-web/src/components/layout/ActivityRail.tsx b/web/hwlab-cloud-web/src/components/layout/ActivityRail.tsx index c96f8bbf..5b902c01 100644 --- a/web/hwlab-cloud-web/src/components/layout/ActivityRail.tsx +++ b/web/hwlab-cloud-web/src/components/layout/ActivityRail.tsx @@ -11,6 +11,7 @@ const routes: Array<{ id: RouteId; label: string; title: string }> = [ { id: "workspace", label: "工作台", title: "工作台" }, { id: "performance", label: "性能", title: "性能监控" }, { id: "skills", label: "Skills", title: "Skills" }, + { id: "management", label: "管理", title: "管理" }, { id: "access", label: "授权", title: "授权管理" }, { id: "gate", label: "内部复核", title: "内部复核" }, { id: "help", label: "使用说明", title: "使用说明" }, diff --git a/web/hwlab-cloud-web/src/components/management/ManagementView.tsx b/web/hwlab-cloud-web/src/components/management/ManagementView.tsx new file mode 100644 index 00000000..773a64ba --- /dev/null +++ b/web/hwlab-cloud-web/src/components/management/ManagementView.tsx @@ -0,0 +1,205 @@ +import type { ReactElement } from "react"; +import { useEffect, useMemo, useState } from "react"; + +import { api } from "../../services/api/client"; +import type { ProviderProfile, ProviderProfileStatusItem, ProviderProfileValidation, Tone } from "../../types/domain"; +import { formatTimestamp, shortToken, toneClass } from "../../utils"; +import { StateTag } from "../shared/StateTag"; + +interface ManagementState { + loading: boolean; + savingProfile: ProviderProfile | null; + validatingProfile: ProviderProfile | null; + profiles: ProviderProfileStatusItem[]; + inputs: Record; + validations: Partial>; + error: string | null; + notice: string | null; +} + +const profileOrder: ProviderProfile[] = ["deepseek", "codex-api", "minimax-m3"]; +const profileLabels: Record = { + deepseek: "DeepSeek", + "codex-api": "Codex API", + "minimax-m3": "MiniMax-M3" +}; + +const initialInputs: Record = { deepseek: "", "codex-api": "", "minimax-m3": "" }; +const initialState: ManagementState = { loading: false, savingProfile: null, validatingProfile: null, profiles: [], inputs: initialInputs, validations: {}, error: null, notice: null }; + +export function ManagementView(): ReactElement { + const [state, setState] = useState(initialState); + + async function load(): Promise { + setState((current) => ({ ...current, loading: true, error: null, notice: null })); + const response = await api.providerProfiles(); + if (!response.ok) { + setState((current) => ({ ...current, loading: false, error: response.error ?? "Provider profile 状态不可用" })); + return; + } + setState((current) => ({ ...current, loading: false, profiles: orderedProfiles(profileItems(response.data)), error: null })); + } + + async function saveKey(profile: ProviderProfile): Promise { + const apiKey = state.inputs[profile].trim(); + if (apiKey.length < 8 || state.savingProfile || state.validatingProfile) return; + setState((current) => ({ ...current, savingProfile: profile, error: null, notice: null })); + const response = await api.setProviderProfileKey(profile, apiKey); + if (!response.ok) { + setState((current) => ({ ...current, savingProfile: null, error: response.error ?? `${profileLabels[profile]} 保存失败` })); + return; + } + const updated = profileItem(response.data); + setState((current) => ({ + ...current, + savingProfile: null, + inputs: { ...current.inputs, [profile]: "" }, + profiles: updated ? mergeProfile(current.profiles, updated) : current.profiles, + notice: `${profileLabels[profile]} 已保存`, + error: null + })); + if (!updated) void load(); + } + + async function validateProfile(profile: ProviderProfile): Promise { + if (state.savingProfile || state.validatingProfile) return; + setState((current) => ({ ...current, validatingProfile: profile, error: null, notice: null })); + const submitted = await api.validateProviderProfile(profile); + if (!submitted.ok) { + setState((current) => ({ ...current, validatingProfile: null, error: submitted.error ?? `${profileLabels[profile]} 验证失败` })); + return; + } + const first = validationPayload(submitted.data, profile); + setValidation(profile, first); + if (!first.validationId) { + setState((current) => ({ ...current, validatingProfile: null, notice: `${profileLabels[profile]} 验证已提交` })); + return; + } + let latest = first; + for (let attempt = 0; attempt < 45 && !terminalValidation(latest); attempt += 1) { + await sleep(2000); + const polled = await api.providerProfileValidation(profile, first.validationId); + if (!polled.ok) { + setState((current) => ({ ...current, validatingProfile: null, error: polled.error ?? `${profileLabels[profile]} 验证查询失败` })); + return; + } + latest = validationPayload(polled.data, profile); + setValidation(profile, latest); + } + setState((current) => ({ ...current, validatingProfile: null, notice: `${profileLabels[profile]} 验证 ${latest.status ?? "已更新"}` })); + } + + function setValidation(profile: ProviderProfile, validation: ProviderProfileValidation): void { + setState((current) => ({ ...current, validations: { ...current.validations, [profile]: validation } })); + } + + function setInput(profile: ProviderProfile, value: string): void { + setState((current) => ({ ...current, inputs: { ...current.inputs, [profile]: value } })); + } + + useEffect(() => { void load(); }, []); + + const statusTone = state.error ? "blocked" : state.loading ? "pending" : "source"; + const statusLabel = state.error ? "阻塞" : state.loading ? "加载中" : `${state.profiles.length} profiles`; + const visibleProfiles = useMemo(() => orderedProfiles(state.profiles), [state.profiles]); + + return ( +
+
+
+

管理员

管理

+
{statusLabel}
+
+ {state.error ?

{state.error}

: null} + {state.notice ?

{state.notice}

: null} +
+ {visibleProfiles.map((profile) => void saveKey(item)} onValidate={(item) => void validateProfile(item)} />)} +
+
+
+ ); +} + +function ProfilePanel({ profile, inputValue, validation, saving, validating, busy, onInput, onSave, onValidate }: { profile: ProviderProfileStatusItem; inputValue: string; validation: ProviderProfileValidation | null; saving: boolean; validating: boolean; busy: boolean; onInput(profile: ProviderProfile, value: string): void; onSave(profile: ProviderProfile): void; onValidate(profile: ProviderProfile): void }): ReactElement { + const tone = profile.configured ? "ok" : "blocked"; + const validationTone = validationToneFor(validation); + const bridge = profile.profile === "deepseek" ? profile.bridge ?? { route: "HWLAB Moon Bridge -> DeepSeek official upstream", serviceUrl: "http://hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local:4000/v1", responsesPath: "/v1/responses" } : null; + const validationJob = validation?.runnerJobs?.[0]?.jobName ?? validation?.result?.agentRun?.jobName ?? "-"; + const disabled = busy && !saving && !validating; + return ( +
+
+

{profile.backendKind ?? profile.profile}

{profileLabels[profile.profile]}

+ {profile.configured ? "已配置" : "未配置"} +
+
+
Secret
{profile.secretRef?.namespace ?? "-"}/{profile.secretRef?.name ?? "-"}
+
keys
{profile.secretRef?.keys?.join(", ") ?? "-"}
+
key hash
{shortToken(profile.credentialHashSuffix, 16)}
+
config hash
{shortToken(profile.configHashSuffix, 16)}
+
resource
{shortToken(profile.resourceVersion, 14)}
+
updated
{formatTimestamp(profile.updatedAt)}
+
+ {bridge ?
{bridge.route}{bridge.serviceUrl}{bridge.responsesPath ?? ""}
: null} +
+ + + +
+
+

验证

{validation?.status ?? "未运行"}
+
+
validation
{validation?.validationId ?? "-"}
+
run
{validation?.runId ?? "-"}
+
command
{validation?.commandId ?? "-"}
+
job
{validationJob}
+
terminal
{validation?.result?.terminalStatus ?? validation?.result?.status ?? "-"}
+
failure
{validation?.result?.failureKind ?? "-"}
+
+
+
+ ); +} + +function profileItems(payload: unknown): ProviderProfileStatusItem[] { + const record = payload && typeof payload === "object" ? payload as { items?: ProviderProfileStatusItem[]; data?: { items?: ProviderProfileStatusItem[] } } : {}; + return Array.isArray(record.items) ? record.items : Array.isArray(record.data?.items) ? record.data.items : []; +} + +function profileItem(payload: unknown): ProviderProfileStatusItem | null { + const record = payload && typeof payload === "object" ? payload as { data?: ProviderProfileStatusItem; profile?: ProviderProfile } : null; + if (!record) return null; + if (record.data && typeof record.data === "object" && "profile" in record.data) return record.data; + return "profile" in record ? record as ProviderProfileStatusItem : null; +} + +function validationPayload(payload: unknown, profile: ProviderProfile): ProviderProfileValidation { + const record = payload && typeof payload === "object" ? payload as { data?: ProviderProfileValidation; validationId?: string } : {}; + const validation = record.data && typeof record.data === "object" ? record.data : record as ProviderProfileValidation; + return { ...validation, profile: validation.profile ?? profile }; +} + +function orderedProfiles(items: ProviderProfileStatusItem[]): ProviderProfileStatusItem[] { + const byProfile = new Map(items.map((item) => [item.profile, item])); + return profileOrder.map((profile) => byProfile.get(profile) ?? { profile, configured: false, valuesPrinted: false }); +} + +function mergeProfile(items: ProviderProfileStatusItem[], updated: ProviderProfileStatusItem): ProviderProfileStatusItem[] { + const next = new Map(orderedProfiles(items).map((item) => [item.profile, item])); + next.set(updated.profile, { ...next.get(updated.profile), ...updated }); + return orderedProfiles(Array.from(next.values())); +} + +function validationToneFor(validation: ProviderProfileValidation | null): Tone { + if (!validation) return "source"; + return toneClass(validation.result?.terminalStatus ?? validation.status); +} + +function terminalValidation(validation: ProviderProfileValidation): boolean { + const status = String(validation.result?.terminalStatus ?? validation.status ?? "").toLowerCase(); + return ["completed", "failed", "canceled", "cancelled", "timeout", "blocked"].includes(status); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} diff --git a/web/hwlab-cloud-web/src/services/api/client.ts b/web/hwlab-cloud-web/src/services/api/client.ts index 1e4c18ec..d4ff229b 100644 --- a/web/hwlab-cloud-web/src/services/api/client.ts +++ b/web/hwlab-cloud-web/src/services/api/client.ts @@ -16,6 +16,9 @@ import type { DevicePodStatusResponse, LiveBuildsPayload, LiveProbePayload, + ProviderProfile, + ProviderProfileValidation, + ProviderProfilesResponse, WebPerformanceSummaryResponse, SkillsResponse, SkillFileResponse, @@ -227,6 +230,10 @@ export const api = { updateAdminAccessUser: (userId: string, payload: { role?: AccessUserRole; status?: AccessUserStatus }): Promise> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 12000, timeoutName: "admin access user update" }), setAdminAccessDevicePod: (userId: string, devicePodId: string, relation: AccessDevicePodRelation, allowed: boolean): Promise> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}/device-pods/${encodeURIComponent(devicePodId)}/${encodeURIComponent(relation)}`, { method: allowed ? "PUT" : "DELETE", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "admin access device-pod" }), setAdminAccessTool: (userId: string, toolId: AccessToolId, allowed: boolean): Promise> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}/tools/${encodeURIComponent(toolId)}/can-use`, { method: allowed ? "PUT" : "DELETE", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "admin access tool" }), + providerProfiles: (): Promise> => fetchJson("/v1/admin/provider-profiles", { timeoutMs: 12000, timeoutName: "provider profiles" }), + setProviderProfileKey: (profile: ProviderProfile, apiKey: string): Promise> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/credential`, { method: "PUT", body: JSON.stringify({ apiKey }), timeoutMs: 30000, timeoutName: "provider profile key update" }), + validateProviderProfile: (profile: ProviderProfile): Promise> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validate`, { method: "POST", body: JSON.stringify({}), timeoutMs: 30000, timeoutName: "provider profile validation submit" }), + providerProfileValidation: (profile: ProviderProfile, validationId: string): Promise> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`, { timeoutMs: 12000, timeoutName: "provider profile validation" }), workspace: (projectId: string): Promise> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }), updateWorkspace: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }), selectConversation: (workspaceId: string, payload: Record): Promise> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }), diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 3d926061..eae0ed06 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -313,7 +313,7 @@ button:disabled { cursor: not-allowed; opacity: 0.55; } .device-detail-dialog { max-width: min(720px, 92vw); border: 1px solid var(--line); border-radius: 8px; padding: 0; } .device-detail-dialog form { display: grid; gap: 10px; padding: 16px; } -.skills-panel, .settings-panel, .help-panel, .access-panel { padding: 14px; } +.skills-panel, .settings-panel, .help-panel, .access-panel, .management-panel { padding: 14px; } .settings-content { display: grid; align-content: start; gap: 14px; } .settings-group { display: grid; grid-template-columns: repeat(3, minmax(150px, 220px)); gap: 10px; align-items: end; } .skills-layout { min-height: 0; display: grid; grid-template-columns: 280px minmax(220px, 1fr) minmax(260px, 1.2fr); gap: 12px; } @@ -369,6 +369,30 @@ button:disabled { cursor: not-allowed; opacity: 0.55; } .access-switch input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; } .access-switch input:disabled + span { opacity: 0.55; } .access-switch.busy span { background: #fff3d8; border-color: #e4bd68; } +.management-view { display: grid; gap: 12px; } +.management-panel { min-height: calc(100dvh - 72px); display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); gap: 12px; overflow: hidden; } +.management-error, .management-notice { margin: 0; border-radius: 6px; padding: 8px 10px; font-size: 13px; font-weight: 800; } +.management-error { border: 1px solid #e7a1a1; background: #fff3f3; color: var(--bad); } +.management-notice { border: 1px solid #b8d6c4; background: #f1fbf5; color: #245a38; } +.management-grid { min-width: 0; min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(260px, 1fr)); gap: 12px; overflow: auto; align-items: start; } +.management-profile { min-width: 0; display: grid; align-content: start; gap: 10px; border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; } +.management-profile-head, .management-section-head { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.management-profile-head h3, .management-section-head h4 { margin: 0; font-size: 16px; } +.management-profile-head > div { min-width: 0; } +.management-meta-grid, .management-validation-grid { display: grid; gap: 6px; margin: 0; } +.management-meta-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.management-meta-grid div, .management-validation-grid div { min-width: 0; display: grid; gap: 2px; border: 1px solid var(--line); border-radius: 6px; background: #fbfcf8; padding: 7px 8px; } +.management-meta-grid dt, .management-validation-grid dt, .management-key-row label span { color: var(--muted); font-size: 11px; font-weight: 900; text-transform: uppercase; } +.management-meta-grid dd, .management-validation-grid dd { margin: 0; font-size: 12px; overflow-wrap: anywhere; } +.management-bridge { min-width: 0; display: grid; gap: 4px; border: 1px solid #a8cfc1; border-radius: 8px; background: #eff8f4; padding: 9px 10px; } +.management-bridge strong { font-size: 13px; } +.management-bridge span { color: var(--muted); font-size: 12px; } +.management-key-row { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; align-items: end; } +.management-key-row label { min-width: 0; display: grid; gap: 4px; } +.management-key-row input { min-width: 0; width: 100%; border: 1px solid var(--line); border-radius: 6px; background: #fff; color: var(--ink); padding: 9px 10px; font: inherit; } +.management-key-row input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } +.management-validation { min-width: 0; display: grid; gap: 8px; border-top: 1px solid var(--line); padding-top: 10px; } +.management-validation-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .performance-view { display: grid; gap: 12px; align-content: start; background: #eef3ef; } .performance-header { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; } .performance-header h2, .performance-panel h3 { margin: 0; } @@ -446,6 +470,10 @@ button:disabled { cursor: not-allowed; opacity: 0.55; } .performance-header { align-items: flex-start; flex-direction: column; } .performance-kpi-grid, .performance-grid { grid-template-columns: minmax(0, 1fr); } .performance-table { min-width: 560px; } + .management-panel { min-height: auto; overflow: visible; } + .management-grid { grid-template-columns: minmax(0, 1fr); overflow: visible; } + .management-key-row { grid-template-columns: minmax(0, 1fr); } + .management-meta-grid, .management-validation-grid { grid-template-columns: minmax(0, 1fr); } .access-panel { min-height: auto; overflow: visible; } .access-layout { grid-template-columns: minmax(0, 1fr); overflow: visible; } .access-user-list { max-height: none; } diff --git a/web/hwlab-cloud-web/src/types/domain.ts b/web/hwlab-cloud-web/src/types/domain.ts index c98f54fe..7e5909f0 100644 --- a/web/hwlab-cloud-web/src/types/domain.ts +++ b/web/hwlab-cloud-web/src/types/domain.ts @@ -2,7 +2,7 @@ import type { LiveBuildsPayload } from "./live-builds"; export type { LiveBuildBuild, LiveBuildCommit, LiveBuildImage, LiveBuildRecord, LiveBuildsPayload } from "./live-builds"; export type Tone = "ok" | "source" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run"; -export type RouteId = "workspace" | "performance" | "skills" | "access" | "gate" | "help" | "settings"; +export type RouteId = "workspace" | "performance" | "skills" | "management" | "access" | "gate" | "help" | "settings"; export type AuthState = "checking" | "login" | "authenticated"; export type ProviderProfile = "deepseek" | "codex-api" | "minimax-m3"; @@ -267,6 +267,92 @@ export interface AgentRunProvenance { [key: string]: unknown; } +export interface ProviderProfileSecretRef { + namespace?: string; + name?: string; + keys?: string[]; + mountPath?: string; +} + +export interface ProviderProfileBridgeSummary { + kind?: string; + route?: string; + serviceUrl?: string; + responsesPath?: string; + forbiddenHosts?: string[]; + valuesPrinted?: boolean; +} + +export interface ProviderProfileStatusItem { + profile: ProviderProfile; + backendProfile?: string; + backendKind?: string; + configured?: boolean; + failureKind?: string | null; + secretRef?: ProviderProfileSecretRef; + resourceVersion?: string | null; + credentialHashSuffix?: string | null; + configHashSuffix?: string | null; + updatedAt?: string | null; + keyPresence?: Record; + lastValidation?: ProviderProfileValidation | null; + bridge?: ProviderProfileBridgeSummary; + valuesPrinted?: boolean; + [key: string]: unknown; +} + +export interface ProviderProfileDelegationSummary { + provider?: string; + requestId?: string; + agentRunBaseUrl?: string; + agentRunHost?: string; + traceId?: string; + httpStatus?: number; + elapsedMs?: number; + valuesPrinted?: boolean; +} + +export interface ProviderProfilesResponse { + ok?: boolean; + status?: string; + contractVersion?: string; + actor?: AuthUser | null; + profile?: ProviderProfile; + items?: ProviderProfileStatusItem[]; + count?: number; + data?: ProviderProfileStatusItem | ProviderProfilesData | ProviderProfileValidation | null; + delegation?: ProviderProfileDelegationSummary; + error?: { code?: string; message?: string; failureKind?: string; [key: string]: unknown }; + valuesPrinted?: boolean; +} + +export interface ProviderProfilesData { + items?: ProviderProfileStatusItem[]; + count?: number; + valuesPrinted?: boolean; +} + +export interface ProviderProfileValidation { + validationId?: string; + profile?: ProviderProfile; + runId?: string; + commandId?: string; + status?: string; + result?: { + status?: string; + terminalStatus?: string; + failureKind?: string | null; + assistantText?: string; + agentRun?: AgentRunProvenance; + [key: string]: unknown; + }; + runnerJobs?: Array<{ jobName?: string; status?: string; terminalStatus?: string; [key: string]: unknown }>; + lastSeq?: number; + valuesPrinted?: boolean; + bridge?: ProviderProfileBridgeSummary; + [key: string]: unknown; +} + export interface AgentChatResultResponse { status?: string; traceId?: string;