891 lines
44 KiB
TypeScript
891 lines
44 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { spawn } from "node:child_process";
|
|
import { AgentRunError } from "../common/errors.js";
|
|
import { backendCapabilities, backendProfileSpec, backendProfileSpecs, compareBackendProfiles, isBackendProfileSlug } from "../common/backend-profiles.js";
|
|
import { dsflashGoModelCatalogFile, dsflashGoModelCatalogJson, dsflashGoModelSlug, dsflashGoRuntimeModelCatalogPath } from "../common/model-catalogs.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";
|
|
const kubectlLastAppliedAnnotation = "kubectl.kubernetes.io/last-applied-configuration";
|
|
const providerSecretNamePrefix = "agentrun-v01-provider-";
|
|
|
|
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;
|
|
modelCatalogPath?: string;
|
|
}
|
|
|
|
interface RenderedConfig {
|
|
configToml: string;
|
|
configSummary: JsonRecord;
|
|
}
|
|
|
|
export async function listProviderProfiles(options: ProviderProfileOptions = {}): Promise<JsonRecord> {
|
|
const profiles = await listProviderProfileIds(options);
|
|
const items = await Promise.all(profiles.map((profile) => providerProfileStatus(profile, options)));
|
|
return { items, count: items.length, valuesPrinted: false };
|
|
}
|
|
|
|
export async function listBackendCapabilities(options: ProviderProfileOptions = {}): Promise<JsonRecord> {
|
|
const profiles = new Set<BackendProfile>(backendProfileSpecs.map((spec) => spec.profile));
|
|
try {
|
|
for (const profile of await listProviderProfileIds(options)) profiles.add(profile);
|
|
const dynamicProfiles = [...profiles].filter((profile) => !isBuiltinProviderProfile(profile)).sort(compareBackendProfiles);
|
|
return {
|
|
items: backendCapabilities([...profiles]),
|
|
dynamicProfileDiscovery: {
|
|
status: "succeeded",
|
|
dynamicProfiles,
|
|
dynamicProfileCount: dynamicProfiles.length,
|
|
valuesPrinted: false,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
items: backendCapabilities([...profiles]),
|
|
dynamicProfileDiscovery: {
|
|
status: "failed",
|
|
failureKind: error instanceof AgentRunError ? error.failureKind : "infra-failed",
|
|
message: redactText(error instanceof Error ? error.message : String(error)),
|
|
valuesPrinted: false,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function showProviderProfile(profile: string, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
|
|
return providerProfileStatus(validateBackendProfile(profile), options);
|
|
}
|
|
|
|
export async function getProviderProfileConfig(profileValue: string, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
|
|
const profile = validateBackendProfile(profileValue);
|
|
const spec = requiredSpec(profile);
|
|
const namespace = profileNamespace(options);
|
|
const secret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
|
|
if (!secret) throw new AgentRunError("secret-unavailable", `provider profile ${profile} Secret is not configured`, { httpStatus: 404, details: { profile, secretRef: secretRefSummary(profile, namespace) } });
|
|
const data = asOptionalRecord(secret.data);
|
|
const annotations = asOptionalRecord(asOptionalRecord(secret.metadata)?.annotations);
|
|
const configToml = configTomlFromData(data, profile);
|
|
return {
|
|
action: "provider-profile-config-read",
|
|
profile,
|
|
configured: hasRequiredKeys(data, spec.requiredSecretKeys),
|
|
secretRef: secretRefSummary(profile, namespace),
|
|
resourceVersion: stringPath(secret, ["metadata", "resourceVersion"]),
|
|
credentialHashSuffix: hashDataKey(data, "auth.json") ?? stringPath(annotations, [`${credentialAnnotationPrefix}-credential-hash-suffix`]),
|
|
configHashSuffix: shortHash(configToml),
|
|
updatedAt: stringPath(annotations, [`${credentialAnnotationPrefix}-updated-at`]) ?? stringPath(secret, ["metadata", "creationTimestamp"]),
|
|
configToml,
|
|
configTomlPrinted: true,
|
|
credentialValuesPrinted: false,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export async function removeProviderProfile(profileValue: string, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
|
|
const profile = validateBackendProfile(profileValue);
|
|
const spec = requiredSpec(profile);
|
|
const namespace = profileNamespace(options);
|
|
const secret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
|
|
const data = asOptionalRecord(secret?.data);
|
|
const annotations = asOptionalRecord(asOptionalRecord(secret?.metadata)?.annotations);
|
|
if (secret) await kubectlDeleteSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
|
|
return {
|
|
action: "provider-profile-removed",
|
|
mutation: true,
|
|
profile,
|
|
configured: false,
|
|
removed: Boolean(secret),
|
|
...(secret ? {} : { alreadyAbsent: true }),
|
|
...(isBuiltinProviderProfile(profile) ? { builtinCapabilityRetained: true } : {}),
|
|
secretRef: secretRefSummary(profile, namespace),
|
|
deletedResourceVersion: 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: new Date().toISOString(),
|
|
valuesPrinted: false,
|
|
pollActions: [
|
|
providerActionDescriptor({ action: "list-provider-profiles", operation: "list", resourceKind: "provider-profile", resourceName: "*" }),
|
|
providerActionDescriptor({ action: "inspect-provider-profile", operation: "describe", resourceKind: "provider-profile", resourceName: profile, profile }),
|
|
providerActionDescriptor({ action: "set-provider-key", operation: "set-key", resourceKind: "provider-profile", resourceName: profile, profile, inputKind: "credential" }),
|
|
providerActionDescriptor({ action: "set-provider-config", operation: "set-config", resourceKind: "provider-profile", resourceName: profile, profile, inputKind: "config" }),
|
|
],
|
|
};
|
|
}
|
|
|
|
export async function setProviderProfileConfig(profileValue: string, body: unknown, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
|
|
const profile = validateBackendProfile(profileValue);
|
|
const spec = requiredSpec(profile);
|
|
const record = asRecord(body ?? {}, "providerProfileConfig");
|
|
const configToml = configTomlField(record, profile);
|
|
const delegatedBy = delegatedBySummary(record.delegatedBy);
|
|
const namespace = profileNamespace(options);
|
|
const existingSecret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
|
|
const existingData = asOptionalRecord(existingSecret?.data);
|
|
const authJsonData = dataKey(existingData, "auth.json");
|
|
const credentialHashSuffix = hashDataKey(existingData, "auth.json");
|
|
const secretData = providerProfileSecretData({ profile, authJsonData, configToml, existingData });
|
|
const secretManifest: 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,
|
|
...(credentialHashSuffix ? { [`${credentialAnnotationPrefix}-credential-hash-suffix`]: credentialHashSuffix } : {}),
|
|
[`${credentialAnnotationPrefix}-config-hash-suffix`]: shortHash(configToml),
|
|
[`${credentialAnnotationPrefix}-updated-at`]: new Date().toISOString(),
|
|
...(delegatedBy ? { [`${credentialAnnotationPrefix}-delegated-system`]: delegatedBy.system, [`${credentialAnnotationPrefix}-delegated-request-id`]: delegatedBy.requestId ?? "" } : {}),
|
|
},
|
|
},
|
|
type: "Opaque",
|
|
data: secretData,
|
|
};
|
|
const applied = await kubectlUpsertSecret(secretManifest, options.kubectlCommand ?? "kubectl");
|
|
return {
|
|
action: "provider-profile-config-updated",
|
|
mutation: true,
|
|
profile,
|
|
configured: hasRequiredKeys(secretData, spec.requiredSecretKeys),
|
|
secretRef: secretRefSummary(profile, namespace),
|
|
resourceVersion: objectPath(applied, ["metadata", "resourceVersion"]),
|
|
credentialHashSuffix: credentialHashSuffix ?? null,
|
|
configHashSuffix: shortHash(configToml),
|
|
updatedAt: objectPath(applied, ["metadata", "annotations", `${credentialAnnotationPrefix}-updated-at`]) ?? new Date().toISOString(),
|
|
delegatedBy,
|
|
requiresExternalBridgeUpdate: profileUsesMoonBridge(profile, configToml),
|
|
configTomlPrinted: false,
|
|
credentialValuesPrinted: false,
|
|
valuesPrinted: false,
|
|
pollActions: [
|
|
providerActionDescriptor({ action: "inspect-provider-config", operation: "config", resourceKind: "provider-profile", resourceName: profile, profile }),
|
|
providerActionDescriptor({ action: "inspect-provider-profile", operation: "describe", resourceKind: "provider-profile", resourceName: profile, profile }),
|
|
providerActionDescriptor({ action: "set-provider-key", operation: "set-key", resourceKind: "provider-profile", resourceName: profile, profile, inputKind: "credential" }),
|
|
providerActionDescriptor({ action: "validate-provider-profile", operation: "validate", resourceKind: "provider-profile", resourceName: profile, profile, wait: true, timeoutMs: 120_000 }),
|
|
],
|
|
};
|
|
}
|
|
|
|
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 credential = credentialAuthJson(record);
|
|
const delegatedBy = delegatedBySummary(record.delegatedBy);
|
|
const namespace = profileNamespace(options);
|
|
const existingSecret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
|
|
const existingData = asOptionalRecord(existingSecret?.data);
|
|
const renderedConfig = await renderedConfigForWrite(profile, record, options);
|
|
const secretData = providerProfileSecretData({ profile, authJsonData: base64Data(credential.authJson), configToml: renderedConfig.configToml, existingData });
|
|
const secretManifest: 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(credential.authJson),
|
|
[`${credentialAnnotationPrefix}-config-hash-suffix`]: shortHash(renderedConfig.configToml),
|
|
[`${credentialAnnotationPrefix}-updated-at`]: new Date().toISOString(),
|
|
...(delegatedBy ? { [`${credentialAnnotationPrefix}-delegated-system`]: delegatedBy.system, [`${credentialAnnotationPrefix}-delegated-request-id`]: delegatedBy.requestId ?? "" } : {}),
|
|
},
|
|
},
|
|
type: "Opaque",
|
|
data: secretData,
|
|
};
|
|
const applied = await kubectlUpsertSecret(secretManifest, options.kubectlCommand ?? "kubectl");
|
|
return {
|
|
action: "provider-profile-credential-updated",
|
|
mutation: true,
|
|
profile,
|
|
configured: hasRequiredKeys(secretData, spec.requiredSecretKeys),
|
|
secretRef: secretRefSummary(profile, namespace),
|
|
resourceVersion: objectPath(applied, ["metadata", "resourceVersion"]),
|
|
credentialHashSuffix: shortHash(credential.authJson),
|
|
configHashSuffix: shortHash(renderedConfig.configToml),
|
|
updatedAt: objectPath(applied, ["metadata", "annotations", `${credentialAnnotationPrefix}-updated-at`]) ?? new Date().toISOString(),
|
|
credentialSource: credential.source,
|
|
configSummary: renderedConfig.configSummary,
|
|
delegatedBy,
|
|
requiresExternalBridgeUpdate: profileUsesMoonBridge(profile, renderedConfig.configToml),
|
|
valuesPrinted: false,
|
|
pollActions: [
|
|
providerActionDescriptor({ action: "inspect-provider-profile", operation: "describe", resourceKind: "provider-profile", resourceName: profile, profile }),
|
|
providerActionDescriptor({ action: "validate-provider-profile", operation: "validate", resourceKind: "provider-profile", resourceName: profile, profile, wait: true, timeoutMs: 120_000 }),
|
|
],
|
|
};
|
|
}
|
|
|
|
function providerActionDescriptor(input: { action: string; operation: string; resourceKind: string; resourceName: string; profile?: string | null; inputKind?: string | null; wait?: boolean; timeoutMs?: number | null }): JsonRecord {
|
|
return {
|
|
action: input.action,
|
|
operation: input.operation,
|
|
resourceKind: input.resourceKind,
|
|
resourceName: input.resourceName,
|
|
profile: input.profile ?? null,
|
|
...(input.inputKind ? { inputKind: input.inputKind } : {}),
|
|
...(input.wait === true ? { wait: true } : {}),
|
|
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
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: "/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<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,
|
|
};
|
|
}
|
|
|
|
async function listProviderProfileIds(options: ProviderProfileOptions): Promise<BackendProfile[]> {
|
|
const profiles = new Set<BackendProfile>(backendProfileSpecs.map((spec) => spec.profile));
|
|
const namespace = profileNamespace(options);
|
|
const secrets = await kubectlListSecrets(namespace, options.kubectlCommand ?? "kubectl");
|
|
for (const item of secrets) {
|
|
const secretProfile = providerProfileFromSecret(item);
|
|
if (secretProfile) profiles.add(secretProfile);
|
|
}
|
|
return [...profiles].sort(compareProviderProfiles);
|
|
}
|
|
|
|
function compareProviderProfiles(left: string, right: string): number {
|
|
return compareBackendProfiles(left, right);
|
|
}
|
|
|
|
function isBuiltinProviderProfile(profile: BackendProfile): boolean {
|
|
return backendProfileSpecs.some((item) => item.profile === profile);
|
|
}
|
|
|
|
function providerProfileFromSecret(secret: JsonRecord): BackendProfile | null {
|
|
const metadata = asOptionalRecord(secret.metadata);
|
|
const labels = asOptionalRecord(metadata?.labels);
|
|
const annotatedProfile = stringPath(asOptionalRecord(metadata?.annotations), [`${credentialAnnotationPrefix}-profile`]);
|
|
const labeledProfile = stringPath(labels, ["agentrun.pikastech.local/profile"]);
|
|
const namedProfile = profileFromSecretName(stringPath(metadata, ["name"]));
|
|
for (const candidate of [annotatedProfile, labeledProfile, namedProfile]) {
|
|
if (candidate && isBackendProfileSlug(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function profileFromSecretName(name: string | null): string | null {
|
|
if (!name || !name.startsWith(providerSecretNamePrefix)) return null;
|
|
const profile = name.slice(providerSecretNamePrefix.length);
|
|
return profile.length > 0 ? profile : null;
|
|
}
|
|
|
|
function providerProfileSecretData(input: { profile: BackendProfile; authJsonData?: string | null; configToml: string; existingData?: JsonRecord | null }): JsonRecord {
|
|
const data: JsonRecord = { "config.toml": base64Data(input.configToml) };
|
|
if (input.authJsonData) data["auth.json"] = input.authJsonData;
|
|
const modelCatalogData = input.profile === "dsflash-go" ? dataKey(input.existingData ?? null, dsflashGoModelCatalogFile) ?? defaultModelCatalogData(input.profile) : null;
|
|
if (modelCatalogData) data[dsflashGoModelCatalogFile] = modelCatalogData;
|
|
return data;
|
|
}
|
|
|
|
function defaultModelCatalogData(profile: BackendProfile): string | null {
|
|
return profile === "dsflash-go" ? base64Data(dsflashGoModelCatalogJson()) : null;
|
|
}
|
|
|
|
function credentialAuthJson(record: JsonRecord): { authJson: string; source: "auth-json" | "api-key" } {
|
|
const authJson = record.authJson;
|
|
if (typeof authJson === "string" && authJson.trim().length > 0) return { authJson: authJsonField(authJson), source: "auth-json" };
|
|
const apiKey = stringField(record, "apiKey");
|
|
if (apiKey.length < 8) throw new AgentRunError("secret-unavailable", "apiKey is too short", { httpStatus: 400 });
|
|
return { authJson: `${JSON.stringify(authPayload(apiKey))}\n`, source: "api-key" };
|
|
}
|
|
|
|
function authJsonField(value: string): string {
|
|
if (!value.trim()) throw new AgentRunError("schema-invalid", "authJson is required", { httpStatus: 400 });
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(value);
|
|
} catch {
|
|
throw new AgentRunError("schema-invalid", "authJson must be valid JSON", { httpStatus: 400 });
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new AgentRunError("schema-invalid", "authJson must be a JSON object", { httpStatus: 400 });
|
|
return value;
|
|
}
|
|
|
|
function authPayload(apiKey: string): JsonRecord {
|
|
return { OPENAI_API_KEY: apiKey };
|
|
}
|
|
|
|
function renderConfigToml(config: ProfileConfig): string {
|
|
const { contextWindow, autoCompactTokenLimit } = contextWindowSettings(config.model);
|
|
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 = ${contextWindow}`,
|
|
`model_auto_compact_token_limit = ${autoCompactTokenLimit}`,
|
|
...(config.modelCatalogPath ? [`model_catalog_json = ${tomlString(config.modelCatalogPath)}`] : []),
|
|
"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,
|
|
modelCatalogJson: config.modelCatalogPath ?? null,
|
|
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" || profile === "dsflash-go") && configToml.includes("hyueapi.com")) return false;
|
|
if (profile === "deepseek" && !configToml.includes("hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local")) return false;
|
|
if (profile === "dsflash-go") {
|
|
if (!configToml.includes(dsflashGoModelSlug)) return false;
|
|
if (modelCatalogPathFromConfigToml(configToml) !== dsflashGoRuntimeModelCatalogPath) return false;
|
|
const baseUrl = baseUrlFromConfigToml(configToml);
|
|
if (!baseUrl) return false;
|
|
try {
|
|
if (!isAllowedDsflashBridgeUrl(new URL(baseUrl))) return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function configTomlFromData(data: JsonRecord | null, profile: BackendProfile): string {
|
|
const encoded = dataKey(data, "config.toml");
|
|
if (!encoded) throw new AgentRunError("secret-unavailable", `provider profile ${profile} config.toml is not configured`, { httpStatus: 404, details: { profile, key: "config.toml" } });
|
|
try {
|
|
return Buffer.from(encoded, "base64").toString("utf8");
|
|
} catch {
|
|
throw new AgentRunError("schema-invalid", `provider profile ${profile} config.toml is not valid base64`, { httpStatus: 400, details: { profile, key: "config.toml" } });
|
|
}
|
|
}
|
|
|
|
function configTomlField(record: JsonRecord, profile: BackendProfile): string {
|
|
const value = record.configToml;
|
|
if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", "configToml is required", { httpStatus: 400 });
|
|
validateConfigTomlForProfile(profile, value);
|
|
return value;
|
|
}
|
|
|
|
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;
|
|
if (profile === "dsflash-go" && model !== dsflashGoModelSlug) throw new AgentRunError("schema-invalid", `dsflash-go model must be ${dsflashGoModelSlug}`, { httpStatus: 400 });
|
|
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 === "dsflash-go") {
|
|
return {
|
|
model: "deepseek-v4-flash",
|
|
providerName: "opencode",
|
|
baseUrl: "http://hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local:4000/v1",
|
|
envKey: "OPENAI_API_KEY",
|
|
wireApi: "responses",
|
|
displayName: "Moon Bridge OpenCode Zen Go Flash",
|
|
modelCatalogPath: dsflashGoRuntimeModelCatalogPath,
|
|
};
|
|
}
|
|
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 profileUsesMoonBridge(profile: BackendProfile, configToml: string): boolean {
|
|
return profile === "deepseek" || profile === "dsflash-go" || /hwlab-deepseek-proxy\.hwlab-v02\.svc\.cluster\.local|moon|bridge/iu.test(configToml);
|
|
}
|
|
|
|
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" || profile === "dsflash-go") && isHyueHost(url.hostname)) {
|
|
throw new AgentRunError("tenant-policy-denied", `${profile} 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 DeepSeek bridge", { httpStatus: 403 });
|
|
}
|
|
if (profile === "dsflash-go" && !isAllowedDsflashBridgeUrl(url)) {
|
|
throw new AgentRunError("tenant-policy-denied", "dsflash-go profile baseUrl must point to a Moon Bridge service or wrapper-local bridge", { httpStatus: 403 });
|
|
}
|
|
}
|
|
|
|
function validateConfigTomlForProfile(profile: BackendProfile, configToml: string): void {
|
|
if ((profile === "deepseek" || profile === "dsflash-go") && /hyueapi\.com/iu.test(configToml)) {
|
|
throw new AgentRunError("tenant-policy-denied", `${profile} profile must use HWLAB Moon Bridge, not hyueapi.com`, { httpStatus: 403 });
|
|
}
|
|
if (profile === "deepseek" && !configToml.includes("hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local")) {
|
|
throw new AgentRunError("tenant-policy-denied", "deepseek profile config.toml must point to HWLAB v0.2 DeepSeek bridge", { httpStatus: 403 });
|
|
}
|
|
if (profile !== "dsflash-go") return;
|
|
if (!configToml.includes(dsflashGoModelSlug)) throw new AgentRunError("schema-invalid", `dsflash-go config.toml must use model ${dsflashGoModelSlug}`, { httpStatus: 400 });
|
|
if (modelCatalogPathFromConfigToml(configToml) !== dsflashGoRuntimeModelCatalogPath) throw new AgentRunError("schema-invalid", `dsflash-go config.toml must set model_catalog_json to ${dsflashGoRuntimeModelCatalogPath}`, { httpStatus: 400 });
|
|
const baseUrl = baseUrlFromConfigToml(configToml);
|
|
if (!baseUrl) throw new AgentRunError("schema-invalid", "dsflash-go config.toml must include model_providers base_url", { httpStatus: 400 });
|
|
validateBaseUrl(profile, baseUrl);
|
|
}
|
|
|
|
function isHyueHost(hostname: string): boolean {
|
|
return hostname === "hyueapi.com" || hostname.endsWith(".hyueapi.com");
|
|
}
|
|
|
|
function isAllowedDsflashBridgeUrl(url: URL): boolean {
|
|
if (url.hostname === "127.0.0.1" || url.hostname === "localhost") return true;
|
|
if (url.hostname === "hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local") return true;
|
|
return url.hostname.endsWith(".svc.cluster.local") && /moon|bridge|deepseek|opencode/iu.test(url.hostname);
|
|
}
|
|
|
|
function baseUrlFromConfigToml(configToml: string): string | null {
|
|
const match = configToml.match(/^\s*base_url\s*=\s*"([^"]+)"\s*$/mu);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function modelCatalogPathFromConfigToml(configToml: string): string | null {
|
|
const match = configToml.match(/^\s*model_catalog_json\s*=\s*"([^"]+)"\s*$/mu);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function contextWindowSettings(model: string): { contextWindow: number; autoCompactTokenLimit: number } {
|
|
if (model === "deepseek-v4-pro" || model === "deepseek-v4-flash") {
|
|
return { contextWindow: 1_000_000, autoCompactTokenLimit: 900_000 };
|
|
}
|
|
return { contextWindow: 200_000, autoCompactTokenLimit: 180_000 };
|
|
}
|
|
|
|
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 {
|
|
const bootRepoUrl = optionalString(options.runnerJobDefaults?.bootRepoUrl ?? process.env.AGENTRUN_BOOT_REPO_URL);
|
|
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 ?? "",
|
|
...(bootRepoUrl ? { bootRepoUrl } : {}),
|
|
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} must be a lowercase slug`, { 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 kubectlListSecrets(namespace: string, kubectlCommand: string): Promise<JsonRecord[]> {
|
|
const result = await runKubectl(kubectlCommand, ["get", "secrets", "-n", namespace, "-o", "json"]);
|
|
if (result.code !== 0) {
|
|
throw new AgentRunError("infra-failed", `kubectl get secrets ${namespace} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) });
|
|
}
|
|
const parsed = parseKubectlObject(result.stdout, "secret-list");
|
|
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
|
return items.filter((item): item is JsonRecord => typeof item === "object" && item !== null && !Array.isArray(item));
|
|
}
|
|
|
|
async function kubectlUpsertSecret(manifest: JsonRecord, kubectlCommand: string): Promise<JsonRecord> {
|
|
const name = stringPath(manifest, ["metadata", "name"]) ?? "<unknown>";
|
|
const namespace = stringPath(manifest, ["metadata", "namespace"]) ?? defaultNamespace;
|
|
const stdin = `${JSON.stringify(manifest)}\n`;
|
|
const replace = await runKubectl(kubectlCommand, ["replace", "-f", "-", "-o", "json"], stdin);
|
|
if (replace.code === 0) return await kubectlRemoveLastAppliedAnnotation(kubectlCommand, name, namespace);
|
|
if (isKubectlNotFoundFailure(replace)) {
|
|
const created = await runKubectl(kubectlCommand, ["create", "-f", "-", "-o", "json"], stdin);
|
|
if (created.code === 0) return parseKubectlObject(created.stdout, "provider profile secret create", { redactSecretData: true });
|
|
throw new AgentRunError("infra-failed", `kubectl create provider profile secret ${namespace}/${name} failed with code ${created.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(created.stderr.slice(-2000)), stdout: redactText(created.stdout.slice(-1000)) }) });
|
|
}
|
|
throw new AgentRunError("infra-failed", `kubectl replace provider profile secret ${namespace}/${name} failed with code ${replace.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(replace.stderr.slice(-2000)), stdout: redactText(replace.stdout.slice(-1000)) }) });
|
|
}
|
|
|
|
async function kubectlDeleteSecret(name: string, namespace: string, kubectlCommand: string): Promise<void> {
|
|
const result = await runKubectl(kubectlCommand, ["delete", "secret", name, "-n", namespace, "--ignore-not-found=true"]);
|
|
if (result.code !== 0) {
|
|
throw new AgentRunError("infra-failed", `kubectl delete 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)) }) });
|
|
}
|
|
}
|
|
|
|
function isKubectlNotFoundFailure(result: { stdout: string; stderr: string }): boolean {
|
|
return /notfound|not found|not-found/iu.test(`${result.stderr}\n${result.stdout}`);
|
|
}
|
|
|
|
async function kubectlRemoveLastAppliedAnnotation(kubectlCommand: string, name: string, namespace: string): Promise<JsonRecord> {
|
|
const patch = { metadata: { annotations: { [kubectlLastAppliedAnnotation]: null } } };
|
|
const result = await runKubectl(kubectlCommand, ["patch", "secret", name, "-n", namespace, "--type", "merge", "-p", JSON.stringify(patch), "-o", "json"]);
|
|
if (result.code !== 0) {
|
|
throw new AgentRunError("infra-failed", `kubectl remove provider profile secret last-applied annotation ${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, "provider profile secret annotation cleanup", { 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 dataKey(data: JsonRecord | null, key: string): string | null {
|
|
const value = data?.[key];
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
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);
|
|
}
|