From 71e025f920be6143c85069959eca0033f39e11eb Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 29 May 2026 11:46:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Codex=20Secret=20d?= =?UTF-8?q?ry-run=20=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/spec-v01-cli.md | 2 + .../reference/spec-v01-secret-distribution.md | 22 +- scripts/src/cli.ts | 21 ++ scripts/src/secret-render.ts | 194 ++++++++++++++++++ src/selftest/run.ts | 39 +++- 5 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 scripts/src/secret-render.ts diff --git a/docs/reference/spec-v01-cli.md b/docs/reference/spec-v01-cli.md index dd7b468..d0e3aaf 100644 --- a/docs/reference/spec-v01-cli.md +++ b/docs/reference/spec-v01-cli.md @@ -30,6 +30,7 @@ bun scripts/agentrun-cli.ts runs events --after-seq --limit bun scripts/agentrun-cli.ts commands create --type turn --json-file bun scripts/agentrun-cli.ts commands show bun scripts/agentrun-cli.ts runner start --run-id --backend +bun scripts/agentrun-cli.ts secrets codex render --dry-run [--codex-home ] bun scripts/agentrun-cli.ts backends list bun scripts/agentrun-cli.ts server start|status|stop|logs ``` @@ -41,6 +42,7 @@ bun scripts/agentrun-cli.ts server start|status|stop|logs - 查询类命令返回当前 state、terminal_status、failureKind、event cursor 或 logPath。 - `events` 默认分页且有界,必须支持 `afterSeq` 和 `limit`。 - `server logs` 返回有界日志摘要,并指向完整日志文件或 Kubernetes pod identity。 +- `secrets codex render --dry-run` 返回 Codex provider Secret 创建计划、输入文件 bytes/hash、SecretRef、manifest 摘要和 apply 命令形状;它不得输出 Secret value 或执行 Kubernetes 写操作。 ## 配置与 Secret 边界 diff --git a/docs/reference/spec-v01-secret-distribution.md b/docs/reference/spec-v01-secret-distribution.md index 58d68ce..e54e27e 100644 --- a/docs/reference/spec-v01-secret-distribution.md +++ b/docs/reference/spec-v01-secret-distribution.md @@ -106,6 +106,25 @@ runner/backend Pod Secret 创建和轮换不由 source branch 自动生成;source branch 只声明需要哪个 SecretRef。后续如果接入 External Secrets、Vault、SealedSecrets 或 SOPS,必须新增或更新本 spec,明确 controller、source of truth、rotation 和 redaction 规则。 +## Codex Secret dry-run 工具 + +`v0.1` 提供只读 CLI 工具,用 operator 本地 `~/.codex/auth.json` 与 `~/.codex/config.toml` 构造 Kubernetes Secret 创建计划: + +```bash +bun scripts/agentrun-cli.ts secrets codex render --dry-run +``` + +可选参数: + +- `--codex-home `:覆盖默认 `~/.codex` 输入目录。 +- `--auth-file ` / `--config-file `:分别覆盖输入文件路径。 +- `--namespace `:默认 `agentrun-v01`。 +- `--secret-name `:默认 `agentrun-v01-provider-codex`。 + +输出必须是 JSON,并且只包含 `namespace`、`secretName`、`keys`、每个输入文件的 `bytes`、`sha256`/`contentHash`、整体 hash、redaction 状态、apply 命令形状和 Secret manifest 摘要。输出不得包含 Secret value、`auth.json` 明文、`config.toml` 明文、base64 `data` 字段或可直接恢复 credential 的内容。工具只支持 `--dry-run`;不得执行 `kubectl apply`。 + +失败必须结构化返回 `failureKind`:缺文件、不可读文件或空 credential 归类为 `secret-unavailable`;非法 JSON/TOML 归类为 `schema-invalid`。 + ## 日志与事件 Redaction - event、trace、日志、CLI 输出、health 和 diagnostics 不得打印 Secret 值。 @@ -133,6 +152,7 @@ Secret 创建和轮换不由 source branch 自动生成;source branch 只声 | --- | --- | --- | | Secret 分发规格 | 已定义 | 本文为 v0.1 provider credential 分发权威。 | | Kubernetes SecretRef 注入 | 未实现 | 需要后续 GitOps/runtime 实现。 | +| Codex Secret dry-run 工具 | 已实现 | `bun scripts/agentrun-cli.ts secrets codex render --dry-run` 只输出 Secret 创建计划、hash 和 redacted manifest 摘要,不执行 apply。 | | Codex auth/config file projection | 未实现 | 需要后续 runner/backend adapter 实现,测试来源为 `~/.codex/auth.json` 和 `~/.codex/config.toml`。 | -| redaction 最小规则 | 已定义 | 需要后续代码实现和测试。 | +| redaction 最小规则 | 已定义/部分实现 | Secret dry-run 工具和自测试已覆盖不输出 `auth.json`、`config.toml` 明文;runtime 日志/event redaction 仍需随 runner/backend 补齐。 | | 外部 secret manager | 未采用 | 如需 Vault/ExternalSecrets/SOPS,后续单独更新规格。 | diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index 169838c..fa1d043 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -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 { 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 { throw new AgentRunError("schema-invalid", `unsupported command: ${args.positional.join(" ")}`, { httpStatus: 2 }); } +async function renderCodexSecret(args: ParsedArgs): Promise { + if (args.flags.get("dry-run") !== true) { + throw new AgentRunError("schema-invalid", "secrets codex render requires --dry-run", { httpStatus: 2 }); + } + const options: Parameters[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 { 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 --type turn --json-file ", "commands show --run-id ", "runner start --run-id ", + "secrets codex render --dry-run [--codex-home ] [--namespace agentrun-v01] [--secret-name agentrun-v01-provider-codex]", "backends list", "server start|status", ], diff --git a/scripts/src/secret-render.ts b/scripts/src/secret-render.ts new file mode 100644 index 0000000..e889b38 --- /dev/null +++ b/scripts/src/secret-render.ts @@ -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 { + 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; +} diff --git a/src/selftest/run.ts b/src/selftest/run.ts index 655fd90..3e8ee0d 100644 --- a/src/selftest/run.ts +++ b/src/selftest/run.ts @@ -7,6 +7,8 @@ import { startManagerServer } from "../mgr/server.js"; import { ManagerClient } from "../mgr/client.js"; import { runOnce } from "../runner/run-once.js"; import { redactText } from "../common/redaction.js"; +import { AgentRunError } from "../common/errors.js"; +import { renderCodexProviderSecretPlan } from "../../scripts/src/secret-render.js"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const tmp = await mkdtemp(path.join(os.tmpdir(), "agentrun-selftest-")); @@ -22,6 +24,41 @@ try { assert.equal(redactText("Authorization: Bearer abc123"), "Authorization: Bearer REDACTED"); + const secretPlan = await renderCodexProviderSecretPlan({ codexHome, dryRun: true }); + assert.equal(secretPlan.namespace, "agentrun-v01"); + assert.equal(secretPlan.secretName, "agentrun-v01-provider-codex"); + assert.deepEqual(secretPlan.keys, ["auth.json", "config.toml"]); + assert.equal(secretPlan.writeAttempted, false); + assert.equal(secretPlan.totalBytes, Buffer.byteLength(JSON.stringify({ token: "test-token-material" }), "utf8") + Buffer.byteLength("model = \"gpt-test\"\n", "utf8")); + assert.match(String(secretPlan.sha256), /^[a-f0-9]{64}$/u); + const renderedSecretJson = JSON.stringify(secretPlan); + assert.equal(renderedSecretJson.includes("test-token-material"), false); + assert.equal(renderedSecretJson.includes("gpt-test"), false); + assert.equal(renderedSecretJson.includes("model ="), false); + await assert.rejects( + () => renderCodexProviderSecretPlan({ codexHome: path.join(tmp, "missing-codex-home"), dryRun: true }), + (error) => error instanceof AgentRunError && error.failureKind === "secret-unavailable", + ); + const invalidCodexHome = path.join(tmp, "invalid-codex-home"); + await mkdir(invalidCodexHome, { recursive: true }); + await writeFile(path.join(invalidCodexHome, "auth.json"), "not-json"); + await writeFile(path.join(invalidCodexHome, "config.toml"), "model = \"gpt-test\"\n"); + await assert.rejects( + () => renderCodexProviderSecretPlan({ codexHome: invalidCodexHome, dryRun: true }), + (error) => error instanceof AgentRunError && error.failureKind === "schema-invalid", + ); + await writeFile(path.join(invalidCodexHome, "auth.json"), JSON.stringify({ token: "" })); + await assert.rejects( + () => renderCodexProviderSecretPlan({ codexHome: invalidCodexHome, dryRun: true }), + (error) => error instanceof AgentRunError && error.failureKind === "secret-unavailable", + ); + await writeFile(path.join(invalidCodexHome, "auth.json"), JSON.stringify({ token: "test-token-material" })); + await writeFile(path.join(invalidCodexHome, "config.toml"), "model ="); + await assert.rejects( + () => renderCodexProviderSecretPlan({ codexHome: invalidCodexHome, dryRun: true }), + (error) => error instanceof AgentRunError && error.failureKind === "schema-invalid", + ); + const server = await startManagerServer({ port: 0, host: "127.0.0.1", sourceCommit: "self-test" }); try { const client = new ManagerClient(server.baseUrl); @@ -56,7 +93,7 @@ try { assert.equal(JSON.stringify(events).includes("Bearer test-token"), false); const finalRun = await client.get(`/api/v1/runs/${run.id}`) as { terminalStatus?: string }; assert.equal(finalRun.terminalStatus, "completed"); - console.log(JSON.stringify({ ok: true, tests: ["manager-memory-lifecycle", "codex-stdio-fake-turn", "redaction"], runId: run.id })); + console.log(JSON.stringify({ ok: true, tests: ["manager-memory-lifecycle", "codex-stdio-fake-turn", "redaction", "codex-secret-dry-run"], runId: run.id })); } finally { await new Promise((resolve) => server.server.close(() => resolve())); }