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; } 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 { 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 { return providerProfileStatus(validateBackendProfile(profile), options); } export async function setProviderProfileCredential(profileValue: string, body: unknown, options: ProviderProfileOptions = {}): Promise { 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 secretPatch: JsonRecord = { metadata: { 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(), "kubectl.kubernetes.io/last-applied-configuration": null, ...(delegatedBy ? { [`${credentialAnnotationPrefix}-delegated-system`]: delegatedBy.system, [`${credentialAnnotationPrefix}-delegated-request-id`]: delegatedBy.requestId ?? "" } : {}), }, }, type: "Opaque", data: { "auth.json": base64Data(rendered.authJson), "config.toml": base64Data(rendered.config.configToml), }, }; const applied = await kubectlPatchSecret(spec.defaultSecretName, namespace, secretPatch, 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 { 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: "/home/agentrun/agentrun-source" }, 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 { 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 { 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 { 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 { 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 { 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 kubectlPatchSecret(name: string, namespace: string, patch: JsonRecord, kubectlCommand: string): Promise { const result = await runKubectl(kubectlCommand, ["patch", "secret", name, "-n", namespace, "--type", "merge", "--patch-file", "/dev/stdin", "-o", "json"], `${JSON.stringify(patch)}\n`); if (result.code !== 0) { throw new AgentRunError("infra-failed", `kubectl patch provider profile 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, "patched 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 base64Data(value: string): string { return Buffer.from(value, "utf8").toString("base64"); } 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); }