Merge pull request #923 from pikasTech/fix/issue917-provider-admin
feat: v0.2 管理页委托配置 Provider API Key
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user