feat: render agentrun codex config from yaml
This commit is contained in:
@@ -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<Record<string, boolean>>;
|
||||
readonly projects: readonly AgentRunCodexProjectSpec[];
|
||||
readonly tuiModelAvailabilityNux: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
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<string, unkn
|
||||
},
|
||||
secrets: spec.secrets.map((secret) => ({
|
||||
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<string, unknown>, path: string): AgentRun
|
||||
|
||||
function parseLaneSecret(input: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<Record<string, boolean>> {
|
||||
if (value === undefined || value === null) return {};
|
||||
const raw = asRecord(value, path);
|
||||
const result: Record<string, boolean> = {};
|
||||
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<Record<string, number>> {
|
||||
if (value === undefined || value === null) return {};
|
||||
const raw = asRecord(value, path);
|
||||
const result: Record<string, number> = {};
|
||||
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<string, unknown>, path: string): string | null {
|
||||
const value = input.providerCredential;
|
||||
if (value === undefined || value === null) return null;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user