Merge pull request #88 from pikasTech/feat/issue28-provider-profile-management
feat: 增加 provider profile 管理 API
This commit is contained in:
@@ -39,6 +39,10 @@ async function dispatch(args: ParsedArgs): Promise<JsonValue> {
|
||||
if (group === "server" && command === "logs") return serverLogs(args);
|
||||
if (group === "server" && command === "stop") return stopServer(args);
|
||||
if (group === "backends" && command === "list") return client(args).get("/api/v1/backends");
|
||||
if (group === "provider-profiles" && command === "list") return client(args).get("/api/v1/provider-profiles");
|
||||
if (group === "provider-profiles" && command === "show" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}`);
|
||||
if (group === "provider-profiles" && command === "set-key" && id) return setProviderProfileKey(args, id);
|
||||
if (group === "provider-profiles" && command === "validate" && id) return validateProviderProfileCli(args, id);
|
||||
if (group === "secrets" && command === "codex" && id === "render") return renderCodexSecret(args);
|
||||
if (group === "sessions" && command === "ps") return listSessions(args);
|
||||
if (group === "sessions" && command === "create") return sessionCreate(args, id ?? null);
|
||||
@@ -391,6 +395,47 @@ async function renderCodexSecret(args: ParsedArgs): Promise<JsonRecord> {
|
||||
return renderCodexProviderSecretPlan(options);
|
||||
}
|
||||
|
||||
async function setProviderProfileKey(args: ParsedArgs, profileValue: string): Promise<JsonRecord> {
|
||||
const profile = normalizeProfile(profileValue);
|
||||
if (args.flags.get("key-stdin") !== true) throw new AgentRunError("schema-invalid", "provider-profiles set-key requires --key-stdin", { httpStatus: 2 });
|
||||
const apiKey = (await readStdinText()).trim();
|
||||
if (apiKey.length === 0) throw new AgentRunError("schema-invalid", "stdin api key is empty", { httpStatus: 2 });
|
||||
const body: JsonRecord = {
|
||||
apiKey,
|
||||
reason: optionalFlag(args, "reason") ?? "operator-cli",
|
||||
delegatedBy: {
|
||||
system: optionalFlag(args, "delegated-system") ?? "operator-cli",
|
||||
userId: optionalFlag(args, "delegated-user-id") ?? null,
|
||||
username: optionalFlag(args, "delegated-username") ?? null,
|
||||
requestId: optionalFlag(args, "delegated-request-id") ?? null,
|
||||
},
|
||||
};
|
||||
const config: JsonRecord = {};
|
||||
copyOptionalFlag(args, config, "model");
|
||||
copyOptionalFlag(args, config, "base-url", "baseUrl");
|
||||
copyOptionalFlag(args, config, "provider-name", "providerName");
|
||||
copyOptionalFlag(args, config, "env-key", "envKey");
|
||||
if (Object.keys(config).length > 0) body.config = config;
|
||||
return await client(args).put(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/credential`, body) as JsonRecord;
|
||||
}
|
||||
|
||||
async function validateProviderProfileCli(args: ParsedArgs, profileValue: string): Promise<JsonRecord> {
|
||||
const profile = normalizeProfile(profileValue);
|
||||
const started = await client(args).post(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/validate`, {}) as JsonRecord;
|
||||
if (args.flags.get("wait") !== true) return started;
|
||||
const validationId = typeof started.validationId === "string" ? started.validationId : "";
|
||||
if (!validationId) throw new AgentRunError("infra-failed", "provider profile validate response omitted validationId", { httpStatus: 1, details: started });
|
||||
const timeoutMs = Math.max(1, Number(optionalFlag(args, "timeout-ms") ?? 60_000));
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let latest: JsonRecord = started;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(2_000);
|
||||
latest = await client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`) as JsonRecord;
|
||||
if (latest.status === "completed" || latest.status === "failed" || latest.status === "cancelled") return { action: "provider-profile-validation", initial: started, latest, valuesPrinted: false };
|
||||
}
|
||||
return { action: "provider-profile-validation", initial: started, latest, timedOut: true, timeoutMs, valuesPrinted: false };
|
||||
}
|
||||
|
||||
async function startServer(args: ParsedArgs): Promise<JsonRecord> {
|
||||
if (args.flags.get("foreground") === true) return startServerForeground(args);
|
||||
const port = Number(flag(args, "port", "8080"));
|
||||
@@ -723,6 +768,10 @@ function help(): JsonRecord {
|
||||
"queue dispatch <taskId> [--json-file <dispatch.json>] [--idempotency-key <key>] [--image <image>] [--namespace <namespace>]",
|
||||
"queue refresh <taskId>",
|
||||
"secrets codex render --dry-run [--profile codex|deepseek|minimax-m3] [--codex-home <dir>] [--namespace agentrun-v01] [--secret-name <name>]",
|
||||
"provider-profiles list",
|
||||
"provider-profiles show <profile>",
|
||||
"provider-profiles set-key <profile> --key-stdin [--model <model>] [--base-url <url>]",
|
||||
"provider-profiles validate <profile> [--wait] [--timeout-ms <ms>]",
|
||||
"backends list",
|
||||
"server start [--port <port>] [--host <host>] [--foreground]",
|
||||
"server status [--port <port>]",
|
||||
|
||||
@@ -12,6 +12,10 @@ export class ManagerClient {
|
||||
return this.request("POST", path, body);
|
||||
}
|
||||
|
||||
async put(path: string, body: JsonValue): Promise<JsonValue> {
|
||||
return this.request("PUT", path, body);
|
||||
}
|
||||
|
||||
async patch(path: string, body: JsonValue): Promise<JsonValue> {
|
||||
return this.request("PATCH", path, body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { AgentRunError } from "../common/errors.js";
|
||||
import { backendProfileSpec, backendProfileSpecs } from "../common/backend-profiles.js";
|
||||
import type { AgentRunStore } from "./store.js";
|
||||
import type { BackendProfile, ExecutionPolicy, JsonRecord, JsonValue } from "../common/types.js";
|
||||
import { asRecord, validateBackendProfile } from "../common/validation.js";
|
||||
import { redactJson, redactText } from "../common/redaction.js";
|
||||
import { createKubernetesRunnerJob, type RunnerJobDefaults } from "./kubernetes-runner-job.js";
|
||||
import { buildRunResult } from "./result.js";
|
||||
import { runnerJobStatusSummary } from "./runner-job-status.js";
|
||||
|
||||
const defaultNamespace = "agentrun-v01";
|
||||
const credentialAnnotationPrefix = "agentrun.pikastech.local/provider-profile";
|
||||
|
||||
export interface ProviderProfileOptions {
|
||||
namespace?: string;
|
||||
kubectlCommand?: string;
|
||||
}
|
||||
|
||||
export interface ProviderProfileValidationOptions extends ProviderProfileOptions {
|
||||
store: AgentRunStore;
|
||||
sourceCommit: string;
|
||||
runnerJobDefaults?: Partial<RunnerJobDefaults>;
|
||||
}
|
||||
|
||||
interface ProfileConfig {
|
||||
model: string;
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
envKey: string;
|
||||
wireApi: "responses";
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface RenderedConfig {
|
||||
configToml: string;
|
||||
configSummary: JsonRecord;
|
||||
}
|
||||
|
||||
export async function listProviderProfiles(options: ProviderProfileOptions = {}): Promise<JsonRecord> {
|
||||
const items = await Promise.all(backendProfileSpecs.map((spec) => providerProfileStatus(spec.profile, options)));
|
||||
return { items, count: items.length, valuesPrinted: false };
|
||||
}
|
||||
|
||||
export async function showProviderProfile(profile: string, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
|
||||
return providerProfileStatus(validateBackendProfile(profile), options);
|
||||
}
|
||||
|
||||
export async function setProviderProfileCredential(profileValue: string, body: unknown, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
|
||||
const profile = validateBackendProfile(profileValue);
|
||||
const spec = requiredSpec(profile);
|
||||
const record = asRecord(body ?? {}, "providerProfileCredential");
|
||||
const apiKey = stringField(record, "apiKey");
|
||||
if (apiKey.length < 8) throw new AgentRunError("secret-unavailable", "apiKey is too short", { httpStatus: 400 });
|
||||
const delegatedBy = delegatedBySummary(record.delegatedBy);
|
||||
const rendered = renderCredential(apiKey, await renderedConfigForWrite(profile, record, options));
|
||||
const namespace = profileNamespace(options);
|
||||
const manifest: JsonRecord = {
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {
|
||||
name: spec.defaultSecretName,
|
||||
namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/part-of": "agentrun",
|
||||
"agentrun.pikastech.local/profile": profile,
|
||||
},
|
||||
annotations: {
|
||||
[`${credentialAnnotationPrefix}-profile`]: profile,
|
||||
[`${credentialAnnotationPrefix}-credential-hash-suffix`]: shortHash(rendered.authJson),
|
||||
[`${credentialAnnotationPrefix}-config-hash-suffix`]: shortHash(rendered.config.configToml),
|
||||
[`${credentialAnnotationPrefix}-updated-at`]: new Date().toISOString(),
|
||||
...(delegatedBy ? { [`${credentialAnnotationPrefix}-delegated-system`]: delegatedBy.system, [`${credentialAnnotationPrefix}-delegated-request-id`]: delegatedBy.requestId ?? "" } : {}),
|
||||
},
|
||||
},
|
||||
type: "Opaque",
|
||||
stringData: {
|
||||
"auth.json": rendered.authJson,
|
||||
"config.toml": rendered.config.configToml,
|
||||
},
|
||||
};
|
||||
const applied = await kubectlApplySecret(manifest, options.kubectlCommand ?? "kubectl");
|
||||
return {
|
||||
action: "provider-profile-credential-updated",
|
||||
mutation: true,
|
||||
profile,
|
||||
configured: true,
|
||||
secretRef: secretRefSummary(profile, namespace),
|
||||
resourceVersion: objectPath(applied, ["metadata", "resourceVersion"]),
|
||||
credentialHashSuffix: shortHash(rendered.authJson),
|
||||
configHashSuffix: shortHash(rendered.config.configToml),
|
||||
updatedAt: objectPath(applied, ["metadata", "annotations", `${credentialAnnotationPrefix}-updated-at`]) ?? new Date().toISOString(),
|
||||
configSummary: rendered.config.configSummary,
|
||||
delegatedBy,
|
||||
requiresExternalBridgeUpdate: profile === "deepseek",
|
||||
valuesPrinted: false,
|
||||
pollCommands: {
|
||||
show: `./scripts/agentrun provider-profiles show ${profile}`,
|
||||
validate: `./scripts/agentrun provider-profiles validate ${profile} --wait --timeout-ms 120000`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateProviderProfile(profileValue: string, body: unknown, options: ProviderProfileValidationOptions): Promise<JsonRecord> {
|
||||
const profile = validateBackendProfile(profileValue);
|
||||
const namespace = profileNamespace(options);
|
||||
const runnerDefaults = runnerDefaultsForValidation(options, namespace);
|
||||
const record = body === null ? {} : asRecord(body ?? {}, "providerProfileValidation");
|
||||
const validationId = `val_${randomUUID().replace(/-/gu, "")}`;
|
||||
const prompt = typeof record.prompt === "string" && record.prompt.trim().length > 0 ? record.prompt.trim() : `只回复 AGENTRUN_PROVIDER_PROFILE_OK_${profile.replace(/[^a-z0-9]/gu, "_").toUpperCase()}`;
|
||||
const run = await options.store.createRun({
|
||||
tenantId: "hwlab",
|
||||
projectId: "pikasTech/HWLAB",
|
||||
workspaceRef: { kind: "opaque", path: "provider-profile-validation" },
|
||||
providerId: "G14",
|
||||
backendProfile: profile,
|
||||
executionPolicy: validationExecutionPolicy(profile, namespace),
|
||||
traceSink: null,
|
||||
sessionRef: { sessionId: `sess_${validationId}`, metadata: { purpose: "provider-profile-validation", validationId, profile } },
|
||||
resourceBundleRef: null,
|
||||
});
|
||||
const command = await options.store.createCommand(run.id, { type: "turn", payload: { prompt }, idempotencyKey: validationId });
|
||||
const runnerJob = await createKubernetesRunnerJob({
|
||||
store: options.store,
|
||||
runId: run.id,
|
||||
input: {
|
||||
commandId: command.id,
|
||||
idempotencyKey: validationId,
|
||||
attemptId: `attempt_${validationId.slice(4, 16)}`,
|
||||
},
|
||||
defaults: runnerDefaults,
|
||||
});
|
||||
return validationResponse({ profile, validationId, runId: run.id, commandId: command.id, runnerJob, status: "running" });
|
||||
}
|
||||
|
||||
export async function getProviderProfileValidation(profileValue: string, validationId: string, options: { store: AgentRunStore }): Promise<JsonRecord> {
|
||||
const profile = validateBackendProfile(profileValue);
|
||||
if (!/^val_[a-f0-9]{32}$/u.test(validationId)) throw new AgentRunError("schema-invalid", `validationId ${validationId} is not valid`, { httpStatus: 400 });
|
||||
const session = await options.store.getSession(`sess_${validationId}`);
|
||||
const runId = session?.lastRunId ?? session?.activeRunId;
|
||||
const commandId = session?.lastCommandId ?? session?.activeCommandId;
|
||||
if (!runId || !commandId) throw new AgentRunError("schema-invalid", `validation ${validationId} was not found`, { httpStatus: 404 });
|
||||
const [result, jobs, events] = await Promise.all([
|
||||
buildRunResult(options.store, runId, commandId),
|
||||
options.store.listRunnerJobs(runId, commandId),
|
||||
options.store.listEvents(runId, 0, 500),
|
||||
]);
|
||||
const status = validationStatus(result as JsonRecord);
|
||||
return {
|
||||
validationId,
|
||||
profile,
|
||||
runId,
|
||||
commandId,
|
||||
status,
|
||||
result: result as JsonRecord,
|
||||
runnerJobs: jobs.map((job) => runnerJobStatusSummary(job, events)),
|
||||
lastSeq: events.at(-1)?.seq ?? 0,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function providerProfileStatus(profile: BackendProfile, options: ProviderProfileOptions): Promise<JsonRecord> {
|
||||
const spec = requiredSpec(profile);
|
||||
const namespace = profileNamespace(options);
|
||||
const secretRef = secretRefSummary(profile, namespace);
|
||||
const secret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
|
||||
if (!secret) {
|
||||
return {
|
||||
profile,
|
||||
backendKind: spec.backendKind,
|
||||
configured: false,
|
||||
failureKind: "secret-unavailable",
|
||||
secretRef,
|
||||
resourceVersion: null,
|
||||
credentialHashSuffix: null,
|
||||
configHashSuffix: null,
|
||||
updatedAt: null,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const data = asOptionalRecord(secret.data);
|
||||
const annotations = asOptionalRecord(asOptionalRecord(secret.metadata)?.annotations);
|
||||
return {
|
||||
profile,
|
||||
backendKind: spec.backendKind,
|
||||
configured: hasRequiredKeys(data, spec.requiredSecretKeys),
|
||||
failureKind: hasRequiredKeys(data, spec.requiredSecretKeys) ? null : "secret-unavailable",
|
||||
secretRef,
|
||||
resourceVersion: stringPath(secret, ["metadata", "resourceVersion"]),
|
||||
credentialHashSuffix: hashDataKey(data, "auth.json") ?? stringPath(annotations, [`${credentialAnnotationPrefix}-credential-hash-suffix`]),
|
||||
configHashSuffix: hashDataKey(data, "config.toml") ?? stringPath(annotations, [`${credentialAnnotationPrefix}-config-hash-suffix`]),
|
||||
updatedAt: stringPath(annotations, [`${credentialAnnotationPrefix}-updated-at`]) ?? stringPath(secret, ["metadata", "creationTimestamp"]),
|
||||
keyPresence: Object.fromEntries(spec.requiredSecretKeys.map((key) => [key, typeof data?.[key] === "string"])) as JsonRecord,
|
||||
lastValidation: null,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function renderCredential(apiKey: string, config: RenderedConfig): { authJson: string; config: RenderedConfig } {
|
||||
const authJson = `${JSON.stringify(authPayload(apiKey))}\n`;
|
||||
return {
|
||||
authJson,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
function authPayload(apiKey: string): JsonRecord {
|
||||
return { OPENAI_API_KEY: apiKey };
|
||||
}
|
||||
|
||||
function renderConfigToml(config: ProfileConfig): string {
|
||||
return [
|
||||
`model_provider = ${tomlString(config.providerName)}`,
|
||||
`model = ${tomlString(config.model)}`,
|
||||
`review_model = ${tomlString(config.model)}`,
|
||||
"disable_response_storage = true",
|
||||
"network_access = \"enabled\"",
|
||||
"model_context_window = 128000",
|
||||
"model_auto_compact_token_limit = 110000",
|
||||
"approvals_reviewer = \"user\"",
|
||||
"",
|
||||
`[model_providers.${config.providerName}]`,
|
||||
`name = ${tomlString(config.displayName)}`,
|
||||
`base_url = ${tomlString(config.baseUrl)}`,
|
||||
`wire_api = ${tomlString(config.wireApi)}`,
|
||||
"requires_openai_auth = true",
|
||||
"",
|
||||
"[projects.\"/root\"]",
|
||||
"trust_level = \"trusted\"",
|
||||
"",
|
||||
"[projects.\"/home/agentrun/workspaces\"]",
|
||||
"trust_level = \"trusted\"",
|
||||
"",
|
||||
"[tui.model_availability_nux]",
|
||||
`${tomlString(config.model)} = 4`,
|
||||
"",
|
||||
"[notice]",
|
||||
"hide_full_access_warning = true",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function renderedConfigForWrite(profile: BackendProfile, record: JsonRecord, options: ProviderProfileOptions): Promise<RenderedConfig> {
|
||||
if (record.config !== undefined && record.config !== null) return renderedConfigFromProfileConfig(configField(record.config, profile), "request-config");
|
||||
const existing = await existingConfigToml(profile, options);
|
||||
if (existing && existingConfigAllowed(profile, existing)) {
|
||||
return { configToml: existing, configSummary: { source: "existing-secret", preserved: true, valuesPrinted: false } };
|
||||
}
|
||||
return renderedConfigFromProfileConfig(defaultConfig(profile), "profile-default");
|
||||
}
|
||||
|
||||
function renderedConfigFromProfileConfig(config: ProfileConfig, source: string): RenderedConfig {
|
||||
return {
|
||||
configToml: renderConfigToml(config),
|
||||
configSummary: {
|
||||
source,
|
||||
model: config.model,
|
||||
providerName: config.providerName,
|
||||
baseUrl: config.baseUrl,
|
||||
authField: config.envKey,
|
||||
wireApi: config.wireApi,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function existingConfigToml(profile: BackendProfile, options: ProviderProfileOptions): Promise<string | null> {
|
||||
const spec = requiredSpec(profile);
|
||||
const namespace = profileNamespace(options);
|
||||
const secret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
|
||||
const data = asOptionalRecord(secret?.data);
|
||||
const value = data?.["config.toml"];
|
||||
if (typeof value !== "string" || value.length === 0) return null;
|
||||
try {
|
||||
return Buffer.from(value, "base64").toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function existingConfigAllowed(profile: BackendProfile, configToml: string): boolean {
|
||||
if (configToml.trim().length === 0) return false;
|
||||
if (profile === "deepseek" && configToml.includes("hyueapi.com")) return false;
|
||||
if (profile === "deepseek" && !configToml.includes("hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function configField(value: unknown, profile: BackendProfile): ProfileConfig {
|
||||
const defaults = defaultConfig(profile);
|
||||
if (value === undefined || value === null) return defaults;
|
||||
const record = asRecord(value, "config");
|
||||
const model = optionalString(record.model) ?? defaults.model;
|
||||
const baseUrl = optionalString(record.baseUrl) ?? defaults.baseUrl;
|
||||
const providerName = optionalString(record.providerName) ?? defaults.providerName;
|
||||
const envKey = optionalString(record.envKey) ?? defaults.envKey;
|
||||
validateBaseUrl(profile, baseUrl);
|
||||
if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(envKey)) throw new AgentRunError("schema-invalid", "config.envKey must be an uppercase env name", { httpStatus: 400 });
|
||||
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/u.test(providerName)) throw new AgentRunError("schema-invalid", "config.providerName must be a provider identifier", { httpStatus: 400 });
|
||||
return { ...defaults, model, baseUrl, providerName, envKey };
|
||||
}
|
||||
|
||||
function defaultConfig(profile: BackendProfile): ProfileConfig {
|
||||
if (profile === "deepseek") {
|
||||
return {
|
||||
model: "deepseek-chat",
|
||||
providerName: "OpenAI",
|
||||
baseUrl: "http://hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local:4000/v1",
|
||||
envKey: "OPENAI_API_KEY",
|
||||
wireApi: "responses",
|
||||
displayName: "OpenAI",
|
||||
};
|
||||
}
|
||||
if (profile === "minimax-m3") {
|
||||
return {
|
||||
model: "MiniMax-M3",
|
||||
providerName: "minimax",
|
||||
baseUrl: "https://api.minimaxi.com/v1",
|
||||
envKey: "OPENAI_API_KEY",
|
||||
wireApi: "responses",
|
||||
displayName: "MiniMax",
|
||||
};
|
||||
}
|
||||
return {
|
||||
model: "gpt-5-codex",
|
||||
providerName: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
envKey: "OPENAI_API_KEY",
|
||||
wireApi: "responses",
|
||||
displayName: "OpenAI",
|
||||
};
|
||||
}
|
||||
|
||||
function validateBaseUrl(profile: BackendProfile, value: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new AgentRunError("schema-invalid", "config.baseUrl must be a valid URL", { httpStatus: 400 });
|
||||
}
|
||||
if (profile === "deepseek" && url.hostname === "hyueapi.com") {
|
||||
throw new AgentRunError("tenant-policy-denied", "deepseek profile must use HWLAB Moon Bridge, not hyueapi.com", { httpStatus: 403 });
|
||||
}
|
||||
if (profile === "deepseek" && url.hostname !== "hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local") {
|
||||
throw new AgentRunError("tenant-policy-denied", "deepseek profile baseUrl must point to HWLAB v0.2 Moon Bridge", { httpStatus: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
function validationExecutionPolicy(profile: BackendProfile, namespace: string): ExecutionPolicy {
|
||||
const spec = requiredSpec(profile);
|
||||
return {
|
||||
sandbox: "workspace-write",
|
||||
approval: "never",
|
||||
timeoutMs: 120_000,
|
||||
network: "enabled",
|
||||
secretScope: {
|
||||
allowCredentialEcho: false,
|
||||
providerCredentials: [{ profile, secretRef: { namespace, name: spec.defaultSecretName, keys: [...spec.requiredSecretKeys] } }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runnerDefaultsForValidation(options: ProviderProfileValidationOptions, namespace: string): RunnerJobDefaults {
|
||||
return {
|
||||
namespace,
|
||||
managerUrl: options.runnerJobDefaults?.managerUrl ?? process.env.AGENTRUN_INTERNAL_MGR_URL ?? `http://agentrun-mgr.${namespace}.svc.cluster.local:8080`,
|
||||
image: options.runnerJobDefaults?.image ?? process.env.AGENTRUN_RUNNER_IMAGE ?? "",
|
||||
sourceCommit: options.runnerJobDefaults?.sourceCommit ?? options.sourceCommit,
|
||||
serviceAccountName: options.runnerJobDefaults?.serviceAccountName ?? process.env.AGENTRUN_RUNNER_SERVICE_ACCOUNT ?? "agentrun-v01-runner",
|
||||
kubectlCommand: options.runnerJobDefaults?.kubectlCommand ?? options.kubectlCommand ?? "kubectl",
|
||||
};
|
||||
}
|
||||
|
||||
function validationResponse(input: { profile: BackendProfile; validationId: string; runId: string; commandId: string; runnerJob: JsonRecord; status: string }): JsonRecord {
|
||||
const jobIdentity = asOptionalRecord(input.runnerJob.jobIdentity);
|
||||
const jobName = typeof jobIdentity?.name === "string" ? jobIdentity.name : typeof input.runnerJob.jobName === "string" ? input.runnerJob.jobName : null;
|
||||
return {
|
||||
action: "provider-profile-validation-started",
|
||||
validationId: input.validationId,
|
||||
profile: input.profile,
|
||||
runId: input.runId,
|
||||
commandId: input.commandId,
|
||||
jobName,
|
||||
status: input.status,
|
||||
pollUrl: `/api/v1/provider-profiles/${encodeURIComponent(input.profile)}/validations/${encodeURIComponent(input.validationId)}`,
|
||||
runnerJob: input.runnerJob,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function validationStatus(result: JsonRecord): string {
|
||||
const status = typeof result.status === "string" ? result.status : null;
|
||||
const terminalStatus = typeof result.terminalStatus === "string" ? result.terminalStatus : null;
|
||||
if (terminalStatus === "completed" || status === "completed") return "completed";
|
||||
if (terminalStatus === "cancelled" || status === "cancelled") return "cancelled";
|
||||
if (terminalStatus === "failed" || terminalStatus === "blocked" || status === "failed" || status === "blocked") return "failed";
|
||||
return "running";
|
||||
}
|
||||
|
||||
function secretRefSummary(profile: BackendProfile, namespace: string): JsonRecord {
|
||||
const spec = requiredSpec(profile);
|
||||
return { namespace, name: spec.defaultSecretName, keys: [...spec.requiredSecretKeys], valuesPrinted: false };
|
||||
}
|
||||
|
||||
function requiredSpec(profile: BackendProfile) {
|
||||
const spec = backendProfileSpec(profile);
|
||||
if (!spec) throw new AgentRunError("schema-invalid", `backendProfile ${profile} is not supported in v0.1`, { httpStatus: 400 });
|
||||
return spec;
|
||||
}
|
||||
|
||||
function profileNamespace(options: ProviderProfileOptions): string {
|
||||
return options.namespace ?? process.env.AGENTRUN_RUNTIME_NAMESPACE ?? defaultNamespace;
|
||||
}
|
||||
|
||||
async function kubectlGetSecret(name: string, namespace: string, kubectlCommand: string): Promise<JsonRecord | null> {
|
||||
const result = await runKubectl(kubectlCommand, ["get", "secret", name, "-n", namespace, "-o", "json"]);
|
||||
if (result.code !== 0) {
|
||||
const failureText = `${result.stderr}\n${result.stdout}`;
|
||||
if (/notfound|not found|not-found/iu.test(failureText)) return null;
|
||||
throw new AgentRunError("infra-failed", `kubectl get secret ${namespace}/${name} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) });
|
||||
}
|
||||
return parseKubectlObject(result.stdout, "secret");
|
||||
}
|
||||
|
||||
async function kubectlApplySecret(manifest: JsonRecord, kubectlCommand: string): Promise<JsonRecord> {
|
||||
const result = await runKubectl(kubectlCommand, ["apply", "-f", "-", "-o", "json"], `${JSON.stringify(manifest)}\n`);
|
||||
if (result.code !== 0) {
|
||||
throw new AgentRunError("infra-failed", `kubectl apply provider profile secret failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) });
|
||||
}
|
||||
return parseKubectlObject(result.stdout, "applied secret", { redactSecretData: true });
|
||||
}
|
||||
|
||||
async function runKubectl(kubectlCommand: string, args: string[], stdin?: string): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> {
|
||||
const child = spawn(kubectlCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
||||
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
||||
child.stdin.end(stdin ?? "");
|
||||
const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", (code, signal) => resolve({ code, signal }));
|
||||
}).catch((error: unknown) => {
|
||||
throw new AgentRunError("infra-failed", `failed to start kubectl: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 503 });
|
||||
});
|
||||
return { ...result, stdout, stderr };
|
||||
}
|
||||
|
||||
function parseKubectlObject(stdout: string, label: string, options: { redactSecretData?: boolean } = {}): JsonRecord {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as unknown;
|
||||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return options.redactSecretData ? redactSecretObject(parsed as JsonRecord) : parsed as JsonRecord;
|
||||
} catch (error) {
|
||||
throw new AgentRunError("infra-failed", `kubectl returned invalid JSON for ${label}: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 502, details: { stdoutPreview: label.includes("secret") ? "REDACTED" : redactText(stdout.slice(0, 1000)) } });
|
||||
}
|
||||
throw new AgentRunError("infra-failed", `kubectl returned non-object JSON for ${label}`, { httpStatus: 502 });
|
||||
}
|
||||
|
||||
function redactSecretObject(object: JsonRecord): JsonRecord {
|
||||
const copy = JSON.parse(JSON.stringify(object)) as JsonRecord;
|
||||
if (copy.data) copy.data = "REDACTED";
|
||||
if (copy.stringData) copy.stringData = "REDACTED";
|
||||
return copy;
|
||||
}
|
||||
|
||||
function hasRequiredKeys(data: JsonRecord | null, keys: readonly string[]): boolean {
|
||||
return keys.every((key) => typeof data?.[key] === "string" && String(data[key]).length > 0);
|
||||
}
|
||||
|
||||
function hashDataKey(data: JsonRecord | null, key: string): string | null {
|
||||
const value = data?.[key];
|
||||
if (typeof value !== "string" || value.length === 0) return null;
|
||||
try {
|
||||
return shortHash(Buffer.from(value, "base64").toString("utf8"));
|
||||
} catch {
|
||||
return shortHash(value);
|
||||
}
|
||||
}
|
||||
|
||||
function shortHash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function stringField(record: JsonRecord, key: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${key} is required`, { httpStatus: 400 });
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function objectPath(record: JsonRecord, path: string[]): JsonValue | null {
|
||||
let current: unknown = record;
|
||||
for (const key of path) {
|
||||
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
|
||||
current = (current as JsonRecord)[key];
|
||||
}
|
||||
return current as JsonValue;
|
||||
}
|
||||
|
||||
function stringPath(record: JsonRecord | null | undefined, path: string[]): string | null {
|
||||
if (!record) return null;
|
||||
const value = objectPath(record, path);
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function asOptionalRecord(value: unknown): JsonRecord | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : null;
|
||||
}
|
||||
|
||||
function delegatedBySummary(value: unknown): JsonRecord | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const record = asRecord(value, "delegatedBy");
|
||||
const system = optionalString(record.system) ?? "operator-cli";
|
||||
return {
|
||||
system,
|
||||
userId: optionalString(record.userId) ?? null,
|
||||
username: optionalString(record.username) ?? null,
|
||||
requestId: optionalString(record.requestId) ?? null,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function tomlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
+14
-2
@@ -13,6 +13,7 @@ import { runnerJobStatusSummary } from "./runner-job-status.js";
|
||||
import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessionPvcSummary, runSessionStorageGc } from "./session-pvc.js";
|
||||
import type { SessionPvcSummary } from "./session-pvc.js";
|
||||
import type { SessionPvcOptions } from "./session-pvc.js";
|
||||
import { getProviderProfileValidation, listProviderProfiles, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js";
|
||||
|
||||
function pvcOptions(defaults: { kubectlCommand?: string } | undefined): SessionPvcOptions {
|
||||
return defaults?.kubectlCommand ? { kubectlCommand: defaults.kubectlCommand } : {};
|
||||
@@ -42,6 +43,7 @@ export interface ManagerServerOptions {
|
||||
kubectlCommand?: string;
|
||||
};
|
||||
sessionPvcOptions?: { kubectlHandler?: import("./session-pvc.js").KubectlHandler; kubectlCommand?: string; storageClassName?: string; size?: string };
|
||||
providerProfileOptions?: { namespace?: string; kubectlCommand?: string };
|
||||
}
|
||||
|
||||
export interface StartedManagerServer {
|
||||
@@ -55,12 +57,13 @@ export async function startManagerServer(options: ManagerServerOptions = {}): Pr
|
||||
const sourceCommit = options.sourceCommit ?? process.env.AGENTRUN_SOURCE_COMMIT ?? "unknown";
|
||||
const runnerJobDefaults = options.runnerJobDefaults;
|
||||
const sessionPvcDefaults = options.sessionPvcOptions;
|
||||
const providerProfileDefaults = options.providerProfileOptions;
|
||||
const server = createServer(async (req, res) => {
|
||||
const traceId = `trc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
const method = req.method ?? "GET";
|
||||
const url = new URL(req.url ?? "/", "http://agentrun.local");
|
||||
const data = await route({ method, url, body: await readBody(req), store, sourceCommit, ...(runnerJobDefaults ? { runnerJobDefaults } : {}), ...(sessionPvcDefaults ? { sessionPvcDefaults } : {}) });
|
||||
const data = await route({ method, url, body: await readBody(req), store, sourceCommit, ...(runnerJobDefaults ? { runnerJobDefaults } : {}), ...(sessionPvcDefaults ? { sessionPvcDefaults } : {}), ...(providerProfileDefaults ? { providerProfileDefaults } : {}) });
|
||||
writeJson(res, 200, { ok: true, data, traceId });
|
||||
} catch (error) {
|
||||
const agentError = normalizeError(error);
|
||||
@@ -81,7 +84,7 @@ async function readBody(req: import("node:http").IncomingMessage): Promise<unkno
|
||||
return JSON.parse(text) as unknown;
|
||||
}
|
||||
|
||||
async function route({ method, url, body, store, sourceCommit, runnerJobDefaults, sessionPvcDefaults }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable<ManagerServerOptions["runnerJobDefaults"]>; sessionPvcDefaults?: NonNullable<ManagerServerOptions["sessionPvcOptions"]> }): Promise<JsonValue> {
|
||||
async function route({ method, url, body, store, sourceCommit, runnerJobDefaults, sessionPvcDefaults, providerProfileDefaults }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable<ManagerServerOptions["runnerJobDefaults"]>; sessionPvcDefaults?: NonNullable<ManagerServerOptions["sessionPvcOptions"]>; providerProfileDefaults?: NonNullable<ManagerServerOptions["providerProfileOptions"]> }): Promise<JsonValue> {
|
||||
const path = url.pathname;
|
||||
if (method === "GET" && (path === "/health" || path === "/health/live" || path === "/health/readiness")) {
|
||||
const database = await store.health();
|
||||
@@ -89,6 +92,15 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
|
||||
return { serviceId: "agentrun-mgr", live: true, ready, database, sourceCommit, secretRefs: { databaseUrl: database.adapter === "postgres" ? "redacted" : "not-used", valuesPrinted: false } };
|
||||
}
|
||||
if (method === "GET" && path === "/api/v1/backends") return { items: await store.backends() as unknown as JsonValue };
|
||||
if (method === "GET" && path === "/api/v1/provider-profiles") return await listProviderProfiles(providerProfileDefaults) as JsonValue;
|
||||
const providerProfileMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)$/u);
|
||||
if (method === "GET" && providerProfileMatch) return await showProviderProfile(providerProfileMatch[1] ?? "", providerProfileDefaults) as JsonValue;
|
||||
const providerCredentialMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)\/credential$/u);
|
||||
if (method === "PUT" && providerCredentialMatch) return await setProviderProfileCredential(providerCredentialMatch[1] ?? "", body, providerProfileDefaults) as JsonValue;
|
||||
const providerValidationCreateMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)\/validate$/u);
|
||||
if (method === "POST" && providerValidationCreateMatch) return await validateProviderProfile(providerValidationCreateMatch[1] ?? "", body, { store, sourceCommit, ...(providerProfileDefaults ?? {}), ...(runnerJobDefaults ? { runnerJobDefaults } : {}) }) as JsonValue;
|
||||
const providerValidationShowMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)\/validations\/([^/]+)$/u);
|
||||
if (method === "GET" && providerValidationShowMatch) return await getProviderProfileValidation(providerValidationShowMatch[1] ?? "", providerValidationShowMatch[2] ?? "", { store }) as JsonValue;
|
||||
if (method === "GET" && path === "/api/v1/sessions") {
|
||||
const input: ListSessionsInput = { limit: integerQuery(url, "limit", 50) };
|
||||
const state = url.searchParams.get("state");
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chmod, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { startManagerServer } from "../../mgr/server.js";
|
||||
import { MemoryAgentRunStore } from "../../mgr/store.js";
|
||||
import { ManagerClient } from "../../mgr/client.js";
|
||||
import type { JsonRecord } from "../../common/types.js";
|
||||
import { assertNoSecretLeak, type SelfTestCase } from "../harness.js";
|
||||
|
||||
const secretText = "sk-selftest-provider-profile-secret";
|
||||
|
||||
const selfTest: SelfTestCase = async (context) => {
|
||||
const fakeKubectl = path.join(context.tmp, "fake-provider-kubectl.js");
|
||||
const appliedManifestPath = path.join(context.tmp, "provider-secret-apply.json");
|
||||
const createdJobPath = path.join(context.tmp, "provider-validation-job.json");
|
||||
await writeFile(fakeKubectl, `#!/usr/bin/env bun
|
||||
const args = Bun.argv.slice(2);
|
||||
const readStdin = async () => {
|
||||
const chunks = [];
|
||||
for await (const chunk of Bun.stdin.stream()) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
};
|
||||
if (args[0] === "get" && args[1] === "secret") {
|
||||
console.log(JSON.stringify({ apiVersion: "v1", kind: "Secret", metadata: { name: args[2], namespace: "agentrun-v01", resourceVersion: "rv-selftest", creationTimestamp: "2026-06-05T00:00:00.000Z" }, data: { "auth.json": Buffer.from(JSON.stringify({ token: "redacted-fixture" })).toString("base64"), "config.toml": Buffer.from("model = \\\"fixture\\\"\\n").toString("base64") } }));
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "apply") {
|
||||
const text = await readStdin();
|
||||
await Bun.write(${JSON.stringify(appliedManifestPath)}, text);
|
||||
const manifest = JSON.parse(text);
|
||||
const annotations = manifest.metadata?.annotations ?? {};
|
||||
console.log(JSON.stringify({ apiVersion: "v1", kind: "Secret", metadata: { name: manifest.metadata.name, namespace: manifest.metadata.namespace, resourceVersion: "rv-applied", annotations } }));
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "create") {
|
||||
const text = await readStdin();
|
||||
await Bun.write(${JSON.stringify(createdJobPath)}, text);
|
||||
const manifest = JSON.parse(text);
|
||||
console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kind, metadata: { uid: "job-provider-validation", resourceVersion: "rv-job", name: manifest.metadata.name, namespace: manifest.metadata.namespace } }));
|
||||
process.exit(0);
|
||||
}
|
||||
console.error("unsupported fake kubectl args: " + JSON.stringify(args));
|
||||
process.exit(1);
|
||||
`);
|
||||
await chmod(fakeKubectl, 0o755);
|
||||
const store = new MemoryAgentRunStore();
|
||||
const server = await startManagerServer({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
sourceCommit: "self-test",
|
||||
store,
|
||||
providerProfileOptions: { namespace: "agentrun-v01", kubectlCommand: fakeKubectl },
|
||||
runnerJobDefaults: {
|
||||
namespace: "agentrun-v01",
|
||||
managerUrl: "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080",
|
||||
image: "127.0.0.1:5000/agentrun/agentrun-mgr@sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
kubectlCommand: fakeKubectl,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const client = new ManagerClient(server.baseUrl);
|
||||
const list = await client.get("/api/v1/provider-profiles") as JsonRecord;
|
||||
assert.equal(list.count, 3);
|
||||
assert.equal(JSON.stringify(list).includes("auth.json"), true);
|
||||
assert.equal(JSON.stringify(list).includes("redacted-fixture"), false);
|
||||
|
||||
const updated = await client.put("/api/v1/provider-profiles/deepseek/credential", {
|
||||
apiKey: secretText,
|
||||
delegatedBy: { system: "hwlab-v02", userId: "u1", username: "tester", requestId: "req-selftest" },
|
||||
reason: "self-test",
|
||||
}) as JsonRecord;
|
||||
assert.equal(updated.profile, "deepseek");
|
||||
assert.equal(updated.resourceVersion, "rv-applied");
|
||||
assert.equal(updated.requiresExternalBridgeUpdate, true);
|
||||
assert.equal(JSON.stringify(updated).includes(secretText), false);
|
||||
assertNoSecretLeak(updated);
|
||||
const manifest = JSON.parse(await readFile(appliedManifestPath, "utf8")) as JsonRecord;
|
||||
const stringData = manifest.stringData as JsonRecord;
|
||||
assert.equal(String(stringData["auth.json"]).includes(secretText), true);
|
||||
assert.equal(String(stringData["auth.json"]).includes("OPENAI_API_KEY"), true);
|
||||
assert.equal(String(stringData["config.toml"]).includes("hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local"), true);
|
||||
assert.equal(String(stringData["config.toml"]).includes("hyueapi.com"), false);
|
||||
|
||||
await assert.rejects(
|
||||
() => client.put("/api/v1/provider-profiles/deepseek/credential", { apiKey: secretText, config: { baseUrl: "https://hyueapi.com/v1" } }),
|
||||
(error) => error instanceof Error && error.message.includes("not hyueapi.com"),
|
||||
);
|
||||
|
||||
const validation = await client.post("/api/v1/provider-profiles/deepseek/validate", {}) as JsonRecord;
|
||||
assert.equal(validation.profile, "deepseek");
|
||||
assert.equal(validation.status, "running");
|
||||
assert.equal(typeof validation.validationId, "string");
|
||||
const validationId = String(validation.validationId);
|
||||
const jobManifest = JSON.parse(await readFile(createdJobPath, "utf8")) as JsonRecord;
|
||||
assert.equal(JSON.stringify(jobManifest).includes("agentrun-v01-provider-deepseek"), true);
|
||||
assert.equal(JSON.stringify(jobManifest).includes(secretText), false);
|
||||
assertNoSecretLeak(validation);
|
||||
|
||||
await client.post(`/api/v1/runs/${encodeURIComponent(String(validation.runId))}/events`, { type: "assistant_message", payload: { commandId: validation.commandId, text: "AGENTRUN_PROVIDER_PROFILE_OK_DEEPSEEK", final: true, replyAuthority: true } });
|
||||
await client.patch(`/api/v1/commands/${encodeURIComponent(String(validation.commandId))}/status`, { terminalStatus: "completed", failureKind: null, failureMessage: null });
|
||||
const finalValidation = await client.get(`/api/v1/provider-profiles/deepseek/validations/${encodeURIComponent(validationId)}`) as JsonRecord;
|
||||
assert.equal(finalValidation.status, "completed");
|
||||
assert.equal(JSON.stringify(finalValidation).includes(secretText), false);
|
||||
assertNoSecretLeak(finalValidation);
|
||||
return { name: "provider-profile-management", tests: ["provider-profiles-list-redacted", "provider-profile-set-key-redacted", "provider-profile-deepseek-moon-bridge", "provider-profile-validation-runner-job"] };
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||
}
|
||||
};
|
||||
|
||||
export default selfTest;
|
||||
Reference in New Issue
Block a user