From b9bc21d847284f1da333f59b1045c638d45ec8cf Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 08:58:34 +0200 Subject: [PATCH] feat: render agentrun codex config from yaml --- .../unidesk-cicd/references/agentrun.md | 2 +- config/agentrun.yaml | 42 +++++++ config/hwlab-node-lanes.yaml | 2 +- docs/reference/agentrun.md | 2 +- scripts/src/agentrun-lanes.ts | 115 +++++++++++++++++- scripts/src/agentrun/secrets.ts | 68 ++++++++++- 6 files changed, 220 insertions(+), 11 deletions(-) diff --git a/.agents/skills/unidesk-cicd/references/agentrun.md b/.agents/skills/unidesk-cicd/references/agentrun.md index 6eea6d63..3938d444 100644 --- a/.agents/skills/unidesk-cicd/references/agentrun.md +++ b/.agents/skills/unidesk-cicd/references/agentrun.md @@ -46,7 +46,7 @@ JD01 v0.2 的 runtime boot repoURL、Argo repoURL 和 CI git read URL 必须使 Runner egress proxy、持久化、idle timeout 和 retention 只从 `config/agentrun.yaml` 的 `deployment.runner.*` 进入部署。验收不能只看 manager Deployment/Pod env;必须用 HWLAB/AgentRun 原入口创建新 turn 或 runner Job,并检查新 runner Job env、session PVC、`AGENTRUN_SOURCE_COMMIT` 和 trace/result 是否复用同一 run 且没有 `reuse-blocked`。 -Provider credential 的 `config.toml` 变更同样走 YAML `sourceRef`、`secret-sync` 和 `restart`;lane config 只声明该 lane 需要的 Codex CLI runtime options。不要复制指挥机全局 `~/.codex/config.toml` 作为长期事实。 +Provider credential 的 `config.toml` 变更同样走 YAML `sourceRef`、`secret-sync` 和 `restart`;lane config 只声明该 lane 需要的 Codex CLI runtime options。模型、provider 和 endpoint 需要由 lane YAML 拥有时,使用 `sourceMode: codex-config` 加 `codexConfig` 渲染 Secret 中的 `config.toml`;不要修改或复制指挥机全局 `~/.codex/config.toml` 作为长期事实。 ## AgentRun v0.1 Compatibility diff --git a/config/agentrun.yaml b/config/agentrun.yaml index 8efbfd79..02c7d8fa 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -1006,6 +1006,48 @@ controlPlane: key: config.toml providerCredential: profile: codex + - id: provider-gpt-pika-auth-json + sourceMode: file + sourceRef: /root/.codex/auth.json + targetRef: + namespace: agentrun-v02 + name: agentrun-v02-provider-gpt-pika + key: auth.json + providerCredential: + profile: gpt.pika + - id: provider-gpt-pika-config + sourceMode: codex-config + sourceRef: config/agentrun.yaml#controlPlane.lanes.nc01-v02.secrets.provider-gpt-pika-config.codexConfig + codexConfig: + modelProvider: OpenAI + model: gpt-5.4-mini + reviewModel: gpt-5.4-mini + modelReasoningEffort: xhigh + disableResponseStorage: true + networkAccess: enabled + windowsWslSetupAcknowledged: true + serviceTier: fast + modelProviders: + OpenAI: + name: OpenAI + baseUrl: https://api.pikapython.com/ + wireApi: responses + requiresOpenaiAuth: true + features: + goals: true + projects: + /root: + trustLevel: trusted + /root/unidesk: + trustLevel: trusted + tuiModelAvailabilityNux: + gpt-5.4-mini: 4 + targetRef: + namespace: agentrun-v02 + name: agentrun-v02-provider-gpt-pika + key: config.toml + providerCredential: + profile: gpt.pika - extends: controlPlane.templates.secrets.githubPrRawToken - extends: controlPlane.templates.secrets.unideskSshToken extends: controlPlane.templates.agentrunV02Lane diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 5e549d23..c391f89b 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -964,7 +964,7 @@ lanes: secretNamespace: agentrun-v02 repoUrlFrom: runtimeGitReadUrl providerIdFrom: runtimeNodeId - defaultProviderProfile: fake-echo + defaultProviderProfile: gpt.pika codexStdioSupervisor: repo-owned publicExposure: extends: templates.hwlabV03.publicExposurePk01 diff --git a/docs/reference/agentrun.md b/docs/reference/agentrun.md index 9dbf84ec..06d116a3 100644 --- a/docs/reference/agentrun.md +++ b/docs/reference/agentrun.md @@ -108,7 +108,7 @@ AgentRun YAML-only lane 发布收口必须以当前 k8s git-mirror snapshot tip YAML-only lane 的 `trigger-current` 会先确保目标 branch 已同步到 k8s git-mirror snapshot,再从 UniDesk YAML 声明的 image build、GitOps branch/path、runtime namespace、Secret、数据库和 manager env 渲染 artifact catalog 与 GitOps desired state。该路径会删除新 lane source branch 中的 `deploy/deploy.json`,因为部署真相已经迁入 UniDesk YAML;旧 `v0.1` branch 中历史文件只作为迁移前遗留产物存在,不能作为新 lane 的事实来源。Secret export 格式或外部数据库连接参数变化时,先用 `platform-db postgres export-secrets --confirm` 物化本地 Secret source,再用 `agentrun control-plane secret-sync --node --lane --confirm` 下发,最后用 `agentrun control-plane restart --node --lane --confirm` 让 manager Deployment 通过 rollout 读取新 Secret;不要手工删除 Pod 或直接 patch Secret。 -Provider credential Secret 的 `auth.json` 和 `config.toml` 也必须按 lane 的 YAML `sourceRef` 下发,不能把指挥机全局 Codex 配置当成所有 lane 的运行真相。HWLAB 通过 D601 `agentrun-v02` 使用 Codex profile 时,`config.toml` 应只携带该 lane 需要的 Codex CLI runtime options,例如 model、reasoning、context window、auto compact、storage 和 network 相关键;除非对应 `auth.json` / API key source 也由同一 lane 明确拥有并已验证,否则不要在 lane config 中覆盖 provider endpoint、`base_url`、`model_provider` 或其他 endpoint 绑定。常见回归有两类:同步到 runner 的 config 缺少 `model_context_window` / `model_auto_compact_token_limit`,导致多轮 tool/webSearch 后报 context-window failure;或者为了补参数误加不匹配的 provider endpoint,导致 provider auth failure。修复必须走 `agentrun control-plane secret-sync --node --lane ` 的 dry-run/confirm,再用 `restart` 生效,并通过 HWLAB `hwlab-cli client agent send|trace|result` 原入口验证;不要从 Kubernetes Secret 反解配置内容或在 issue/trace 中打印 payload。 +Provider credential Secret 的 `auth.json` 和 `config.toml` 也必须按 lane 的 YAML `sourceRef` 下发,不能把指挥机全局 Codex 配置当成所有 lane 的运行真相。lane 的模型、provider、endpoint 和 Codex runtime options 需要由 UniDesk YAML 拥有时,使用 `sourceMode: codex-config` 和同一 Secret spec 下的 `codexConfig` 渲染 `config.toml`;不要通过修改 `/root/.codex/config.toml` 或 `~/.codex/config.toml` 来改变 HWLAB/AgentRun 运行面模型。HWLAB 通过 D601/NC01 `agentrun-v02` 使用 Codex profile 时,`config.toml` 应只携带该 lane 需要的 Codex CLI runtime options,例如 model、reasoning、context window、auto compact、storage 和 network 相关键;除非对应 `auth.json` / API key source 也由同一 lane 明确拥有并已验证,否则不要在 lane config 中覆盖 provider endpoint、`base_url`、`model_provider` 或其他 endpoint 绑定。常见回归有两类:同步到 runner 的 config 缺少 `model_context_window` / `model_auto_compact_token_limit`,导致多轮 tool/webSearch 后报 context-window failure;或者为了补参数误加不匹配的 provider endpoint,导致 provider auth failure。修复必须走 `agentrun control-plane secret-sync --node --lane ` 的 dry-run/confirm,再用 `restart` 生效,并通过 HWLAB `hwlab-cli client agent send|trace|result` 原入口验证;不要从 Kubernetes Secret 反解配置内容或在 issue/trace 中打印 payload。 AgentRun resource/session client policy 也由 `config/agentrun.yaml` 声明。`client.sessionPolicy` 是未显式选择 node/lane 时 `agentrun send session/...` 和相关 session payload 生成的默认 `tenantId`、`projectId`、`providerId`、`backendProfile`、`workspaceRef` 和 execution policy 来源;显式 `--node --lane ` 后,`explain session-policy`、`send session`、resource primitives 和 AipodSpec render 都必须改用目标 lane 的 YAML 事实。lane `secrets[].providerCredential.profile` 声明 provider credential Secret 归属,UniDesk CLI 只按 YAML 聚合 Secret name/key,不再用代码拼接 provider Secret 名称。只读入口 `bun scripts/cli.ts agentrun explain session-policy` 用于查看选中目标 lane、policy 来源、实际 executionPolicy payload 和 provider credential binding 来源;输出只能包含 Secret metadata、key 名和 `valuesPrinted=false`,不得打印 Secret value。 diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts index 5842ed82..0d93352c 100644 --- a/scripts/src/agentrun-lanes.ts +++ b/scripts/src/agentrun-lanes.ts @@ -47,13 +47,42 @@ export interface AgentRunSecretRef { export interface AgentRunLaneSecretSpec { readonly id: string; readonly sourceRef: string; - readonly sourceMode: "env" | "file"; + readonly sourceMode: "env" | "file" | "codex-config"; readonly sourceKey: string | null; readonly sourceFormat: "env" | "raw-token" | null; + readonly codexConfig: AgentRunCodexConfigSpec | null; readonly targetRef: AgentRunSecretRef; readonly providerCredentialProfile: string | null; } +export interface AgentRunCodexConfigSpec { + readonly modelProvider: string; + readonly model: string; + readonly reviewModel: string | null; + readonly modelReasoningEffort: string | null; + readonly disableResponseStorage: boolean | null; + readonly networkAccess: string | null; + readonly windowsWslSetupAcknowledged: boolean | null; + readonly serviceTier: string | null; + readonly modelProviders: readonly AgentRunCodexModelProviderSpec[]; + readonly features: Readonly>; + readonly projects: readonly AgentRunCodexProjectSpec[]; + readonly tuiModelAvailabilityNux: Readonly>; +} + +export interface AgentRunCodexModelProviderSpec { + readonly id: string; + readonly name: string; + readonly baseUrl: string; + readonly wireApi: string; + readonly requiresOpenaiAuth: boolean | null; +} + +export interface AgentRunCodexProjectSpec { + readonly path: string; + readonly trustLevel: string; +} + export interface AgentRunProviderCredentialRef { readonly profile: string; readonly secretRef: { @@ -406,12 +435,22 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record ({ id: secret.id, - sourceRef: secret.sourceRef.startsWith("/") ? secret.sourceRef : `.state/secrets/${secret.sourceRef}`, + sourceRef: secret.sourceMode === "codex-config" ? secret.sourceRef : secret.sourceRef.startsWith("/") ? secret.sourceRef : `.state/secrets/${secret.sourceRef}`, sourceMode: secret.sourceMode, sourceKey: secret.sourceKey, sourceFormat: secret.sourceFormat, targetRef: secret.targetRef, providerCredential: secret.providerCredentialProfile === null ? null : { profile: secret.providerCredentialProfile }, + codexConfig: secret.codexConfig === null ? null : { + modelProvider: secret.codexConfig.modelProvider, + model: secret.codexConfig.model, + reviewModel: secret.codexConfig.reviewModel, + modelReasoningEffort: secret.codexConfig.modelReasoningEffort, + networkAccess: secret.codexConfig.networkAccess, + serviceTier: secret.codexConfig.serviceTier, + modelProviderCount: secret.codexConfig.modelProviders.length, + projectCount: secret.codexConfig.projects.length, + }, valuesPrinted: false, })), providerCredentials: agentRunProviderCredentialRefs(spec).map((credential) => ({ @@ -831,12 +870,14 @@ function parseImageBuild(input: Record, path: string): AgentRun function parseLaneSecret(input: Record, path: string): AgentRunLaneSecretSpec { const sourceMode = optionalStringField(input, "sourceMode", path) ?? "env"; - if (sourceMode !== "env" && sourceMode !== "file") throw new Error(`${path}.sourceMode must be env or file`); + if (sourceMode !== "env" && sourceMode !== "file" && sourceMode !== "codex-config") throw new Error(`${path}.sourceMode must be env, file, or codex-config`); const sourceKey = optionalStringField(input, "sourceKey", path) ?? null; if (sourceMode === "env" && sourceKey === null) throw new Error(`${path}.sourceKey is required when sourceMode is env`); const sourceFormat = optionalStringField(input, "sourceFormat", path) ?? null; if (sourceFormat !== null && sourceFormat !== "env" && sourceFormat !== "raw-token") throw new Error(`${path}.sourceFormat must be env or raw-token`); if (sourceFormat === "raw-token" && sourceMode !== "env") throw new Error(`${path}.sourceFormat raw-token requires sourceMode env`); + if (sourceMode === "codex-config" && sourceKey !== null) throw new Error(`${path}.sourceKey is not supported when sourceMode is codex-config`); + if (sourceMode === "codex-config" && sourceFormat !== null) throw new Error(`${path}.sourceFormat is not supported when sourceMode is codex-config`); const providerCredential = parseProviderCredentialBinding(input, `${path}.providerCredential`); return { id: stringField(input, "id", path), @@ -844,11 +885,79 @@ function parseLaneSecret(input: Record, path: string): AgentRun sourceMode, sourceKey, sourceFormat, + codexConfig: sourceMode === "codex-config" ? parseCodexConfig(recordField(input, "codexConfig", path), `${path}.codexConfig`) : null, targetRef: parseNamespacedSecretRef(recordField(input, "targetRef", path), `${path}.targetRef`), providerCredentialProfile: providerCredential, }; } +function parseCodexConfig(input: Record, path: string): AgentRunCodexConfigSpec { + const providersRaw = recordField(input, "modelProviders", path); + const modelProviders = Object.entries(providersRaw).map(([id, raw]) => { + validateSimpleId(id, `${path}.modelProviders`); + const provider = asRecord(raw, `${path}.modelProviders.${id}`); + return { + id, + name: stringField(provider, "name", `${path}.modelProviders.${id}`), + baseUrl: stringField(provider, "baseUrl", `${path}.modelProviders.${id}`), + wireApi: stringField(provider, "wireApi", `${path}.modelProviders.${id}`), + requiresOpenaiAuth: optionalBooleanField(provider, "requiresOpenaiAuth", `${path}.modelProviders.${id}`), + }; + }); + if (modelProviders.length === 0) throw new Error(`${path}.modelProviders must not be empty`); + const features = booleanRecord(input.features, `${path}.features`); + const projectsRaw = input.projects === undefined || input.projects === null ? {} : asRecord(input.projects, `${path}.projects`); + const projects = Object.entries(projectsRaw).map(([projectPath, raw]) => { + const project = asRecord(raw, `${path}.projects.${projectPath}`); + return { path: projectPath, trustLevel: stringField(project, "trustLevel", `${path}.projects.${projectPath}`) }; + }); + const tuiModelAvailabilityNux = integerRecord(input.tuiModelAvailabilityNux, `${path}.tuiModelAvailabilityNux`); + return { + modelProvider: stringField(input, "modelProvider", path), + model: stringField(input, "model", path), + reviewModel: optionalStringField(input, "reviewModel", path) ?? null, + modelReasoningEffort: optionalStringField(input, "modelReasoningEffort", path) ?? null, + disableResponseStorage: optionalBooleanField(input, "disableResponseStorage", path), + networkAccess: optionalStringField(input, "networkAccess", path) ?? null, + windowsWslSetupAcknowledged: optionalBooleanField(input, "windowsWslSetupAcknowledged", path), + serviceTier: optionalStringField(input, "serviceTier", path) ?? null, + modelProviders, + features, + projects, + tuiModelAvailabilityNux, + }; +} + +function optionalBooleanField(obj: Record, key: string, path: string): boolean | null { + const value = obj[key]; + if (value === undefined || value === null) return null; + if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean when set`); + return value; +} + +function booleanRecord(value: unknown, path: string): Readonly> { + if (value === undefined || value === null) return {}; + const raw = asRecord(value, path); + const result: Record = {}; + for (const [key, item] of Object.entries(raw)) { + validateSimpleId(key, path); + if (typeof item !== "boolean") throw new Error(`${path}.${key} must be a boolean`); + result[key] = item; + } + return result; +} + +function integerRecord(value: unknown, path: string): Readonly> { + if (value === undefined || value === null) return {}; + const raw = asRecord(value, path); + const result: Record = {}; + for (const [key, item] of Object.entries(raw)) { + if (!Number.isInteger(item)) throw new Error(`${path}.${key} must be an integer`); + result[key] = item; + } + return result; +} + function parseProviderCredentialBinding(input: Record, path: string): string | null { const value = input.providerCredential; if (value === undefined || value === null) return null; diff --git a/scripts/src/agentrun/secrets.ts b/scripts/src/agentrun/secrets.ts index e2b1f470..86f86522 100644 --- a/scripts/src/agentrun/secrets.ts +++ b/scripts/src/agentrun/secrets.ts @@ -21,6 +21,7 @@ import { agentRunSourceSnapshotRef, agentRunSourceSnapshotStageRefPrefix, resolveAgentRunLaneTarget, + type AgentRunCodexConfigSpec, type AgentRunCancelLifecycleSpec, type AgentRunLaneSpec, } from "../agentrun-lanes"; @@ -705,9 +706,10 @@ export function yamlLaneGitMirrorStatusScript(spec: AgentRunLaneSpec): string { export type LaneSecretSource = { id: string; sourceRef: string; - sourceMode: "env" | "file"; + sourceMode: "env" | "file" | "codex-config"; sourceKey: string | null; sourceFormat?: "env" | "raw-token" | null; + codexConfig?: AgentRunCodexConfigSpec | null; targetRef: { namespace: string; name: string; key: string }; transform?: "local-postgres-database" | "local-postgres-user" | "local-postgres-database-url"; }; @@ -715,14 +717,19 @@ export type LaneSecretSource = { export function readSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecretSource): { redactedPath: string; value: string; valueBytes: number; fingerprint: string } { const { sourceRef } = source; if (sourceRef.includes("..")) throw new Error(`secret sourceRef must not contain ..: ${sourceRef}`); - const sourcePath = sourceRef.startsWith("/") + const sourcePath = source.sourceMode === "codex-config" ? null : sourceRef.startsWith("/") ? sourceRef : join(resolveSecretSourceRoot(spec), ...sourceRef.split("/")); - if (!existsSync(sourcePath)) throw new Error(`secret source ${sourceRef} is missing`); + if (sourcePath !== null && !existsSync(sourcePath)) throw new Error(`secret source ${sourceRef} is missing`); let value: string; - if (source.sourceMode === "file") { + if (source.sourceMode === "codex-config") { + if (source.codexConfig === undefined || source.codexConfig === null) throw new Error(`secret source ${sourceRef} is missing codexConfig`); + value = renderCodexConfigToml(source.codexConfig); + } else if (source.sourceMode === "file") { + if (sourcePath === null) throw new Error(`secret source ${sourceRef} has no source path`); value = readFileSync(sourcePath, "utf8"); } else { + if (sourcePath === null) throw new Error(`secret source ${sourceRef} has no source path`); if (source.sourceKey === null) throw new Error(`secret source ${sourceRef} is missing sourceKey`); const text = readFileSync(sourcePath, "utf8"); const envValue = source.sourceFormat === "raw-token" ? text.trim() : parseEnvFile(text).get(source.sourceKey); @@ -732,13 +739,63 @@ export function readSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecret if (value.length === 0) throw new Error(`secret source ${sourceRef} is empty`); value = transformSecretSourceValue(spec, source, value); return { - redactedPath: sourceRef.startsWith("/") ? redactAbsoluteSecretPath(sourceRef) : `.state/secrets/${sourceRef}`, + redactedPath: source.sourceMode === "codex-config" ? sourceRef : sourceRef.startsWith("/") ? redactAbsoluteSecretPath(sourceRef) : `.state/secrets/${sourceRef}`, value, valueBytes: Buffer.byteLength(value, "utf8"), fingerprint: sha256Fingerprint(value), }; } +function renderCodexConfigToml(config: AgentRunCodexConfigSpec): string { + const lines = [ + `model_provider = ${tomlString(config.modelProvider)}`, + `model = ${tomlString(config.model)}`, + ...(config.reviewModel === null ? [] : [`review_model = ${tomlString(config.reviewModel)}`]), + ...(config.modelReasoningEffort === null ? [] : [`model_reasoning_effort = ${tomlString(config.modelReasoningEffort)}`]), + ...(config.disableResponseStorage === null ? [] : [`disable_response_storage = ${tomlBoolean(config.disableResponseStorage)}`]), + ...(config.networkAccess === null ? [] : [`network_access = ${tomlString(config.networkAccess)}`]), + ...(config.windowsWslSetupAcknowledged === null ? [] : [`windows_wsl_setup_acknowledged = ${tomlBoolean(config.windowsWslSetupAcknowledged)}`]), + ...(config.serviceTier === null ? [] : [`service_tier = ${tomlString(config.serviceTier)}`]), + "", + ]; + for (const provider of config.modelProviders) { + lines.push( + `[model_providers.${tomlKeySegment(provider.id)}]`, + `name = ${tomlString(provider.name)}`, + `base_url = ${tomlString(provider.baseUrl)}`, + `wire_api = ${tomlString(provider.wireApi)}`, + ...(provider.requiresOpenaiAuth === null ? [] : [`requires_openai_auth = ${tomlBoolean(provider.requiresOpenaiAuth)}`]), + "", + ); + } + if (Object.keys(config.features).length > 0) { + lines.push("[features]"); + for (const [key, value] of Object.entries(config.features)) lines.push(`${tomlKeySegment(key)} = ${tomlBoolean(value)}`); + lines.push(""); + } + for (const project of config.projects) { + lines.push(`[projects.${tomlKeySegment(project.path)}]`, `trust_level = ${tomlString(project.trustLevel)}`, ""); + } + if (Object.keys(config.tuiModelAvailabilityNux).length > 0) { + lines.push("[tui.model_availability_nux]"); + for (const [key, value] of Object.entries(config.tuiModelAvailabilityNux)) lines.push(`${tomlKeySegment(key)} = ${value}`); + lines.push(""); + } + return `${lines.join("\n").trimEnd()}\n`; +} + +function tomlString(value: string): string { + return JSON.stringify(value); +} + +function tomlBoolean(value: boolean): string { + return value ? "true" : "false"; +} + +function tomlKeySegment(value: string): string { + return /^[A-Za-z0-9_-]+$/u.test(value) ? value : tomlString(value); +} + function transformSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecretSource, rawValue: string): string { if (source.transform === undefined) return rawValue; const localPostgres = spec.deployment.localPostgres; @@ -843,6 +900,7 @@ export function collectLaneSecretSources(spec: AgentRunLaneSpec): LaneSecretSour sourceMode: secret.sourceMode, sourceKey: secret.sourceKey, sourceFormat: secret.sourceFormat, + codexConfig: secret.codexConfig, targetRef: secret.targetRef, }); }