feat: 增加 Codex Secret dry-run 工具
This commit is contained in:
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
|
||||
import { startManagerServer } from "../../src/mgr/server.js";
|
||||
import { ManagerClient } from "../../src/mgr/client.js";
|
||||
import { runOnce } from "../../src/runner/run-once.js";
|
||||
import { renderCodexProviderSecretPlan } from "./secret-render.js";
|
||||
import type { JsonRecord, JsonValue } from "../../src/common/types.js";
|
||||
import { AgentRunError, errorToJson } from "../../src/common/errors.js";
|
||||
import type { RunnerOnceOptions } from "../../src/runner/run-once.js";
|
||||
@@ -28,6 +29,7 @@ async function dispatch(args: ParsedArgs): Promise<JsonValue> {
|
||||
if (group === "server" && command === "start") return startServer(args);
|
||||
if (group === "server" && command === "status") return client(args).get("/health/readiness");
|
||||
if (group === "backends" && command === "list") return client(args).get("/api/v1/backends");
|
||||
if (group === "secrets" && command === "codex" && id === "render") return renderCodexSecret(args);
|
||||
if (group === "runs" && command === "create") return client(args).post("/api/v1/runs", await jsonFile(args));
|
||||
if (group === "runs" && command === "show" && id) return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}`);
|
||||
if (group === "runs" && command === "events" && id) return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}/events?afterSeq=${flag(args, "after-seq", "0")}&limit=${flag(args, "limit", "100")}`);
|
||||
@@ -61,6 +63,24 @@ async function dispatch(args: ParsedArgs): Promise<JsonValue> {
|
||||
throw new AgentRunError("schema-invalid", `unsupported command: ${args.positional.join(" ")}`, { httpStatus: 2 });
|
||||
}
|
||||
|
||||
async function renderCodexSecret(args: ParsedArgs): Promise<JsonRecord> {
|
||||
if (args.flags.get("dry-run") !== true) {
|
||||
throw new AgentRunError("schema-invalid", "secrets codex render requires --dry-run", { httpStatus: 2 });
|
||||
}
|
||||
const options: Parameters<typeof renderCodexProviderSecretPlan>[0] = { dryRun: true };
|
||||
const codexHome = optionalFlag(args, "codex-home");
|
||||
const authFile = optionalFlag(args, "auth-file");
|
||||
const configFile = optionalFlag(args, "config-file");
|
||||
const namespace = optionalFlag(args, "namespace");
|
||||
const secretName = optionalFlag(args, "secret-name");
|
||||
if (codexHome) options.codexHome = codexHome;
|
||||
if (authFile) options.authFile = authFile;
|
||||
if (configFile) options.configFile = configFile;
|
||||
if (namespace) options.namespace = namespace;
|
||||
if (secretName) options.secretName = secretName;
|
||||
return renderCodexProviderSecretPlan(options);
|
||||
}
|
||||
|
||||
async function startServer(args: ParsedArgs): Promise<JsonRecord> {
|
||||
const port = Number(flag(args, "port", "8080"));
|
||||
const host = flag(args, "host", "0.0.0.0");
|
||||
@@ -123,6 +143,7 @@ function help(): JsonRecord {
|
||||
"commands create <runId> --type turn --json-file <payload.json>",
|
||||
"commands show <commandId> --run-id <runId>",
|
||||
"runner start --run-id <runId>",
|
||||
"secrets codex render --dry-run [--codex-home <dir>] [--namespace agentrun-v01] [--secret-name agentrun-v01-provider-codex]",
|
||||
"backends list",
|
||||
"server start|status",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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<JsonRecord> {
|
||||
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=<redacted> --from-file=config.toml=<redacted> --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<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 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;
|
||||
}
|
||||
Reference in New Issue
Block a user