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"; export interface CodexSecretRenderOptions { codexHome?: string; authFile?: string; configFile?: string; namespace?: string; secretName?: string; dryRun?: boolean; } interface SecretFileSummary extends JsonRecord { key: "auth.json" | "config.toml"; source: string; bytes: number; sha256: string; contentHash: string; } interface SecretSourceFile { key: "auth.json" | "config.toml"; path: string; validate: (content: string, file: string) => unknown; } const defaultNamespace = "agentrun-v01"; const defaultSecretName = "agentrun-v01-provider-codex"; const secretKeys = ["auth.json", "config.toml"] as const; 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 namespace = nonEmpty(options.namespace, defaultNamespace); const secretName = nonEmpty(options.secretName, defaultSecretName); const codexHome = resolvePath(nonEmpty(options.codexHome, path.join(os.homedir(), ".codex"))); const sources: SecretSourceFile[] = [ { key: "auth.json", path: resolvePath(options.authFile ?? path.join(codexHome, "auth.json")), validate: validateAuthJson }, { key: "config.toml", path: resolvePath(options.configFile ?? path.join(codexHome, "config.toml")), validate: validateConfigToml }, ]; 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, 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} --from-file=auth.json= --from-file=config.toml= --dry-run=client -o yaml | kubectl apply -f -`, note: "本命令只展示形状;v0.1 工具不会执行 kubectl apply,也不会输出 Secret data。", }, redaction: { secretValuesPrinted: false, manifestDataPrinted: false, configTomlPrinted: false, authJsonPrinted: false, }, }; } 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 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; }