Merge pull request #923 from pikasTech/fix/issue917-provider-admin

feat: v0.2 管理页委托配置 Provider API Key
This commit is contained in:
Lyon
2026-06-05 17:18:06 +08:00
committed by GitHub
11 changed files with 1188 additions and 3 deletions
@@ -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<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
}
function close(server: any) {
return new Promise<void>((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve())));
}
@@ -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();
}
+6
View File
@@ -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;
+66
View File
@@ -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[] = [];
+218
View File
@@ -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);
+5 -1
View File
@@ -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<RouteId, string> = {
workspace: "工作台",
performance: "性能监控",
skills: "Skills",
management: "管理",
access: "授权管理",
gate: "内部复核",
help: "使用说明",
@@ -188,6 +190,7 @@ export function App(): ReactElement {
{route === "workspace" ? <ConversationPanel messages={store.state.messages} loading={store.state.loading} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} onCopySession={() => 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" ? <DeferredRouteView><PerformanceView /></DeferredRouteView> : null}
{route === "skills" ? <DeferredRouteView><SkillsView /></DeferredRouteView> : null}
{route === "management" ? <DeferredRouteView><ManagementView /></DeferredRouteView> : null}
{route === "access" ? <DeferredRouteView><AccessView /></DeferredRouteView> : null}
{route === "gate" ? <DeferredRouteView><GateView /></DeferredRouteView> : null}
{route === "help" ? <HelpView help={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";
@@ -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: "使用说明" },
@@ -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<ProviderProfile, string>;
validations: Partial<Record<ProviderProfile, ProviderProfileValidation>>;
error: string | null;
notice: string | null;
}
const profileOrder: ProviderProfile[] = ["deepseek", "codex-api", "minimax-m3"];
const profileLabels: Record<ProviderProfile, string> = {
deepseek: "DeepSeek",
"codex-api": "Codex API",
"minimax-m3": "MiniMax-M3"
};
const initialInputs: Record<ProviderProfile, string> = { 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<ManagementState>(initialState);
async function load(): Promise<void> {
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<void> {
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<void> {
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 (
<section className="view management-view" id="management" data-view="management" aria-labelledby="management-title">
<div className="workspace-panel management-panel">
<div className="panel-title-row">
<div><p className="eyebrow"></p><h2 id="management-title"></h2></div>
<div className="panel-title-actions"><StateTag id="management-status" tone={statusTone}>{statusLabel}</StateTag><button className="command-button secondary" id="management-refresh" type="button" disabled={state.loading} onClick={() => void load()}>{state.loading ? "刷新中" : "刷新"}</button></div>
</div>
{state.error ? <p className="management-error" role="alert">{state.error}</p> : null}
{state.notice ? <p className="management-notice" role="status">{state.notice}</p> : null}
<div className="management-grid" aria-live="polite">
{visibleProfiles.map((profile) => <ProfilePanel key={profile.profile} profile={profile} inputValue={state.inputs[profile.profile] ?? ""} validation={state.validations[profile.profile] ?? profile.lastValidation ?? null} saving={state.savingProfile === profile.profile} validating={state.validatingProfile === profile.profile} busy={Boolean(state.savingProfile || state.validatingProfile)} onInput={setInput} onSave={(item) => void saveKey(item)} onValidate={(item) => void validateProfile(item)} />)}
</div>
</div>
</section>
);
}
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 (
<article className="management-profile" data-profile={profile.profile} aria-labelledby={`management-profile-${profile.profile}`}>
<header className="management-profile-head">
<div><p className="eyebrow">{profile.backendKind ?? profile.profile}</p><h3 id={`management-profile-${profile.profile}`}>{profileLabels[profile.profile]}</h3></div>
<StateTag tone={tone}>{profile.configured ? "已配置" : "未配置"}</StateTag>
</header>
<dl className="management-meta-grid">
<div><dt>Secret</dt><dd className="mono wrap">{profile.secretRef?.namespace ?? "-"}/{profile.secretRef?.name ?? "-"}</dd></div>
<div><dt>keys</dt><dd className="mono wrap">{profile.secretRef?.keys?.join(", ") ?? "-"}</dd></div>
<div><dt>key hash</dt><dd className="mono">{shortToken(profile.credentialHashSuffix, 16)}</dd></div>
<div><dt>config hash</dt><dd className="mono">{shortToken(profile.configHashSuffix, 16)}</dd></div>
<div><dt>resource</dt><dd className="mono">{shortToken(profile.resourceVersion, 14)}</dd></div>
<div><dt>updated</dt><dd>{formatTimestamp(profile.updatedAt)}</dd></div>
</dl>
{bridge ? <div className="management-bridge" aria-label="DeepSeek bridge"><strong>{bridge.route}</strong><span className="mono wrap">{bridge.serviceUrl}{bridge.responsesPath ?? ""}</span></div> : null}
<div className="management-key-row">
<label><span>API Key</span><input type="password" value={inputValue} autoComplete="new-password" placeholder="sk-..." disabled={disabled || saving || validating} onChange={(event) => onInput(profile.profile, event.currentTarget.value)} /></label>
<button className="command-button" type="button" disabled={disabled || saving || validating || inputValue.trim().length < 8} onClick={() => onSave(profile.profile)}>{saving ? "保存中" : "保存"}</button>
<button className="command-button secondary" type="button" disabled={disabled || saving || validating || profile.configured !== true} onClick={() => onValidate(profile.profile)}>{validating ? "验证中" : "验证"}</button>
</div>
<section className="management-validation" aria-label={`${profileLabels[profile.profile]} validation`}>
<div className="management-section-head"><h4></h4><StateTag tone={validationTone}>{validation?.status ?? "未运行"}</StateTag></div>
<dl className="management-validation-grid">
<div><dt>validation</dt><dd className="mono wrap">{validation?.validationId ?? "-"}</dd></div>
<div><dt>run</dt><dd className="mono wrap">{validation?.runId ?? "-"}</dd></div>
<div><dt>command</dt><dd className="mono wrap">{validation?.commandId ?? "-"}</dd></div>
<div><dt>job</dt><dd className="mono wrap">{validationJob}</dd></div>
<div><dt>terminal</dt><dd>{validation?.result?.terminalStatus ?? validation?.result?.status ?? "-"}</dd></div>
<div><dt>failure</dt><dd>{validation?.result?.failureKind ?? "-"}</dd></div>
</dl>
</section>
</article>
);
}
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<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
@@ -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<ApiResult<AdminAccessMutationResponse>> => 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<ApiResult<AdminAccessMutationResponse>> => 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<ApiResult<AdminAccessMutationResponse>> => 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<ApiResult<ProviderProfilesResponse>> => fetchJson("/v1/admin/provider-profiles", { timeoutMs: 12000, timeoutName: "provider profiles" }),
setProviderProfileKey: (profile: ProviderProfile, apiKey: string): Promise<ApiResult<ProviderProfilesResponse>> => 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<ApiResult<ProviderProfilesResponse>> => 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<ApiResult<ProviderProfilesResponse & ProviderProfileValidation>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`, { timeoutMs: 12000, timeoutName: "provider profile validation" }),
workspace: (projectId: string): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }),
updateWorkspace: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }),
selectConversation: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }),
+29 -1
View File
@@ -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; }
+87 -1
View File
@@ -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<string, boolean>;
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;