Files
pikasTech-agentrun/scripts/src/secret-render.ts
T
2026-06-08 23:31:33 +08:00

236 lines
11 KiB
TypeScript

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<JsonRecord> {
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}=<redacted>`).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<string> {
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;
}