import { createHash } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { access, readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { JsonRecord } from "../../src/common/types.js"; import { AgentRunError } from "../../src/common/errors.js"; import { backendProfileSpec, isBackendProfile } from "../../src/common/backend-profiles.js"; export interface CodexSecretRenderOptions { profile?: string; codexHome?: string; authFile?: string; configFile?: string; modelCatalogFile?: string; namespace?: string; secretName?: string; dryRun?: boolean; } interface SecretFileSummary extends JsonRecord { key: string; source: string; bytes: number; sha256: string; contentHash: string; } interface SecretSourceFile { key: string; path: string; validate: (content: string, file: string) => unknown; } const defaultNamespace = "agentrun-v01"; const credentialKeyPattern = /(?:api[_-]?key|token|password|secret|credential|authorization|auth)/iu; export async function renderCodexProviderSecretPlan(options: CodexSecretRenderOptions = {}): Promise { if (options.dryRun === false) { throw new AgentRunError("schema-invalid", "Codex provider Secret rendering only supports --dry-run in v0.1", { httpStatus: 2 }); } const profile = nonEmpty(options.profile, "codex"); if (!isBackendProfile(profile)) throw new AgentRunError("schema-invalid", `profile ${profile} is not supported in v0.1`, { httpStatus: 2 }); const spec = backendProfileSpec(profile); const namespace = nonEmpty(options.namespace, defaultNamespace); const secretName = nonEmpty(options.secretName, spec?.defaultSecretName ?? "agentrun-v01-provider-codex"); const codexHome = resolvePath(nonEmpty(options.codexHome, path.join(os.homedir(), ".codex"))); const secretKeys = [...(spec?.requiredSecretKeys ?? ["auth.json", "config.toml"])] as string[]; const sources = secretKeys.map((key): SecretSourceFile => sourceForSecretKey(key, codexHome, options)); const files: SecretFileSummary[] = []; const hash = createHash("sha256"); for (const source of sources) { const content = await readSecretInput(source); const bytes = Buffer.byteLength(content, "utf8"); if (bytes === 0) throw new AgentRunError("secret-unavailable", `${source.key} is empty`, { httpStatus: 2, details: { key: source.key, path: source.path } }); source.validate(content, source.path); const sha256 = sha256Hex(content); hash.update(source.key); hash.update("\0"); hash.update(content, "utf8"); hash.update("\0"); files.push({ key: source.key, source: source.path, bytes, sha256, contentHash: `sha256:${sha256}` }); } const manifestSummary: JsonRecord = { apiVersion: "v1", kind: "Secret", metadata: { namespace, name: secretName }, type: "Opaque", dataKeys: [...secretKeys], dataRedacted: true, }; return { mode: "dry-run", writeAttempted: false, namespace, secretName, profile, backendKind: spec?.backendKind ?? "codex-app-server-stdio", keys: [...secretKeys], totalBytes: files.reduce((sum, file) => sum + file.bytes, 0), sha256: hash.digest("hex"), files, manifestSummary, apply: { attempted: false, command: `kubectl create secret generic ${secretName} -n ${namespace} ${secretKeys.map((key) => `--from-file=${key}=`).join(" ")} --dry-run=client -o yaml | kubectl apply -f -`, note: "本命令只展示形状;v0.1 工具不会执行 kubectl apply,也不会输出 Secret data。", }, redaction: { secretValuesPrinted: false, manifestDataPrinted: false, configTomlPrinted: false, authJsonPrinted: false, }, }; } function sourceForSecretKey(key: string, codexHome: string, options: CodexSecretRenderOptions): SecretSourceFile { if (key === "auth.json") return { key, path: resolvePath(options.authFile ?? path.join(codexHome, key)), validate: validateAuthJson }; if (key === "config.toml") return { key, path: resolvePath(options.configFile ?? path.join(codexHome, key)), validate: validateConfigToml }; if (key === "model-catalog.json") return { key, path: resolvePath(options.modelCatalogFile ?? path.join(codexHome, key)), validate: validateModelCatalogJson }; return { key, path: resolvePath(path.join(codexHome, key)), validate: validateNonEmptyFile(key) }; } async function readSecretInput(source: SecretSourceFile): Promise { try { await access(source.path, fsConstants.R_OK); return await readFile(source.path, "utf8"); } catch (error) { if (isNodeError(error, "ENOENT")) { throw new AgentRunError("secret-unavailable", `${source.key} is missing`, { httpStatus: 2, details: { key: source.key, path: source.path } }); } if (isNodeError(error, "EACCES") || isNodeError(error, "EPERM")) { throw new AgentRunError("secret-unavailable", `${source.key} is not readable`, { httpStatus: 2, details: { key: source.key, path: source.path } }); } throw error; } } function validateAuthJson(content: string, file: string): unknown { let parsed: unknown; try { parsed = JSON.parse(content); } catch { throw new AgentRunError("schema-invalid", "auth.json is not valid JSON", { httpStatus: 2, details: { key: "auth.json", path: file } }); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new AgentRunError("schema-invalid", "auth.json must contain a JSON object", { httpStatus: 2, details: { key: "auth.json", path: file } }); } const emptyField = findEmptyCredentialField(parsed); if (emptyField) { throw new AgentRunError("secret-unavailable", "auth.json contains an empty credential field", { httpStatus: 2, details: { key: "auth.json", path: file, field: emptyField } }); } if (!hasNonEmptyCredentialField(parsed)) { throw new AgentRunError("secret-unavailable", "auth.json does not contain any non-empty credential field", { httpStatus: 2, details: { key: "auth.json", path: file } }); } return parsed; } function validateConfigToml(content: string, file: string): unknown { const parser = (globalThis as typeof globalThis & { Bun?: { TOML?: { parse?: (value: string) => unknown } } }).Bun?.TOML?.parse; if (typeof parser !== "function") { throw new AgentRunError("infra-failed", "Bun TOML parser is unavailable", { httpStatus: 1, details: { key: "config.toml", path: file } }); } let parsed: unknown; try { parsed = parser(content); } catch { throw new AgentRunError("schema-invalid", "config.toml is not valid TOML", { httpStatus: 2, details: { key: "config.toml", path: file } }); } const emptyField = findEmptyCredentialField(parsed); if (emptyField) { throw new AgentRunError("secret-unavailable", "config.toml contains an empty credential field", { httpStatus: 2, details: { key: "config.toml", path: file, field: emptyField } }); } return parsed; } function validateModelCatalogJson(content: string, file: string): unknown { let parsed: unknown; try { parsed = JSON.parse(content); } catch { throw new AgentRunError("schema-invalid", "model-catalog.json is not valid JSON", { httpStatus: 2, details: { key: "model-catalog.json", path: file } }); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new AgentRunError("schema-invalid", "model-catalog.json must contain a JSON object", { httpStatus: 2, details: { key: "model-catalog.json", path: file } }); } const models = (parsed as { models?: unknown }).models; if (!Array.isArray(models) || models.length === 0) { throw new AgentRunError("schema-invalid", "model-catalog.json must contain a non-empty models array", { httpStatus: 2, details: { key: "model-catalog.json", path: file } }); } for (const [index, model] of models.entries()) { const item = typeof model === "object" && model !== null && !Array.isArray(model) ? model as JsonRecord : null; if (!item) throw new AgentRunError("schema-invalid", `model-catalog.json models[${index}] must be an object`, { httpStatus: 2, details: { key: "model-catalog.json", path: file } }); if (typeof item.slug !== "string" || item.slug.trim().length === 0) throw new AgentRunError("schema-invalid", `model-catalog.json models[${index}].slug is required`, { httpStatus: 2, details: { key: "model-catalog.json", path: file } }); if (typeof item.context_window !== "number" || !Number.isFinite(item.context_window) || item.context_window <= 0) throw new AgentRunError("schema-invalid", `model-catalog.json models[${index}].context_window must be a positive number`, { httpStatus: 2, details: { key: "model-catalog.json", path: file } }); } return parsed; } function validateNonEmptyFile(key: string): (content: string, file: string) => unknown { return (content: string, file: string) => { if (content.length === 0) throw new AgentRunError("secret-unavailable", `${key} is empty`, { httpStatus: 2, details: { key, path: file } }); return content; }; } function hasNonEmptyCredentialField(value: unknown): boolean { if (Array.isArray(value)) return value.some((item) => hasNonEmptyCredentialField(item)); if (typeof value !== "object" || value === null) return false; return Object.entries(value).some(([key, entry]) => { if (credentialKeyPattern.test(key) && typeof entry === "string" && entry.trim().length > 0) return true; return hasNonEmptyCredentialField(entry); }); } function findEmptyCredentialField(value: unknown, trail: string[] = []): string | null { if (Array.isArray(value)) { for (let index = 0; index < value.length; index += 1) { const found = findEmptyCredentialField(value[index], [...trail, String(index)]); if (found) return found; } return null; } if (typeof value !== "object" || value === null) return null; for (const [key, entry] of Object.entries(value)) { const nextTrail = [...trail, key]; if (credentialKeyPattern.test(key) && (entry === null || (typeof entry === "string" && entry.trim().length === 0))) return nextTrail.join("."); const found = findEmptyCredentialField(entry, nextTrail); if (found) return found; } return null; } function nonEmpty(value: string | undefined, fallback: string): string { return typeof value === "string" && value.length > 0 ? value : fallback; } function resolvePath(file: string): string { if (file === "~") return os.homedir(); if (file.startsWith("~/")) return path.join(os.homedir(), file.slice(2)); return path.resolve(file); } function sha256Hex(content: string): string { return createHash("sha256").update(content, "utf8").digest("hex"); } function isNodeError(error: unknown, code: string): boolean { return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === code; }