diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index fed36cd..a9b8a4f 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -39,6 +39,10 @@ async function dispatch(args: ParsedArgs): Promise { 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 { return renderCodexProviderSecretPlan(options); } +async function setProviderProfileKey(args: ParsedArgs, profileValue: string): Promise { + 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 { + 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 { 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 [--json-file ] [--idempotency-key ] [--image ] [--namespace ]", "queue refresh ", "secrets codex render --dry-run [--profile codex|deepseek|minimax-m3] [--codex-home ] [--namespace agentrun-v01] [--secret-name ]", + "provider-profiles list", + "provider-profiles show ", + "provider-profiles set-key --key-stdin [--model ] [--base-url ]", + "provider-profiles validate [--wait] [--timeout-ms ]", "backends list", "server start [--port ] [--host ] [--foreground]", "server status [--port ]", diff --git a/src/mgr/client.ts b/src/mgr/client.ts index b0f3dd2..6905c0b 100644 --- a/src/mgr/client.ts +++ b/src/mgr/client.ts @@ -12,6 +12,10 @@ export class ManagerClient { return this.request("POST", path, body); } + async put(path: string, body: JsonValue): Promise { + return this.request("PUT", path, body); + } + async patch(path: string, body: JsonValue): Promise { return this.request("PATCH", path, body); } diff --git a/src/mgr/provider-profiles.ts b/src/mgr/provider-profiles.ts new file mode 100644 index 0000000..6f84664 --- /dev/null +++ b/src/mgr/provider-profiles.ts @@ -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; +} + +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 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 { + 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 { + 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 kubectlApplySecret(manifest: JsonRecord, kubectlCommand: string): Promise { + 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); +} diff --git a/src/mgr/server.ts b/src/mgr/server.ts index 51e2f48..bd762a5 100644 --- a/src/mgr/server.ts +++ b/src/mgr/server.ts @@ -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; sessionPvcDefaults?: NonNullable }): Promise { +async function route({ method, url, body, store, sourceCommit, runnerJobDefaults, sessionPvcDefaults, providerProfileDefaults }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable; sessionPvcDefaults?: NonNullable; providerProfileDefaults?: NonNullable }): Promise { 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"); diff --git a/src/selftest/cases/45-provider-profile-management.ts b/src/selftest/cases/45-provider-profile-management.ts new file mode 100644 index 0000000..c9fb167 --- /dev/null +++ b/src/selftest/cases/45-provider-profile-management.ts @@ -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((resolve) => server.server.close(() => resolve())); + } +}; + +export default selfTest;