feat: add support for inline output_schema option (#7)

This commit is contained in:
Michael Bolin
2025-10-03 09:01:54 -07:00
committed by GitHub
parent 7304b0a763
commit bc00fb04d1
5 changed files with 244 additions and 84 deletions
+17 -16
View File
@@ -31,22 +31,23 @@ Provide either `prompt` or `prompt_file`; the other may be left empty. The actio
## Inputs
| Name | Required | Description | Default |
| -------------------- | ------------- | -------------------------------------------------------------------------------------------------------------- | ---------------- |
| `openai_api_key` | Yes | Secret used to authenticate the helper proxy with OpenAI. Store it in `secrets` and never hardcode it. | — |
| `prompt` | Conditionally | Inline prompt text. Provide this or `prompt_file`. | `""` |
| `prompt_file` | Conditionally | Path (relative to the repository root) of a file that contains the prompt. Provide this or `prompt`. | `""` |
| `working_directory` | No | Directory passed to `codex exec --cd`. Defaults to the repository root. | `""` |
| `codex_version` | No | Version of `@openai/codex` to install. | `0.42.0-alpha.3` |
| `codex_args` | No | Extra arguments forwarded to `codex exec`. Accepts JSON arrays (`["--flag", "value"]`) or shell-style strings. | `""` |
| `output_file` | No | File where the final Codex message is written. Leave empty to skip writing a file. | `""` |
| `output_schema_file` | No | Schema file forwarded to `codex exec --output-schema`. Leave empty to skip passing the option. | `""` |
| `model` | No | Model the agent should use. Leave empty to let Codex pick its default. | `""` |
| `codex_home` | No | Directory to use as the Codex CLI home (config/cache). Uses the CLI default when empty. | `""` |
| `safety_strategy` | No | Controls how the action restricts Codex privileges. See [Safety strategy](#safety-strategy). | `drop_sudo` |
| `codex_user` | No | Username to run Codex as when `safety_strategy` is `unprivileged_user`. | `""` |
| `require_repo_write` | No | Whether to require the triggering actor to have write access to the repository before running. | "true" |
| `allow_bots` | No | Allow runs triggered by GitHub Apps/bot accounts to bypass the write-access check. | "false" |
| Name | Required | Description | Default |
| -------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| `openai_api_key` | Yes | Secret used to authenticate the helper proxy with OpenAI. Store it in `secrets` and never hardcode it. | — |
| `prompt` | Conditionally | Inline prompt text. Provide this or `prompt_file`. | `""` |
| `prompt_file` | Conditionally | Path (relative to the repository root) of a file that contains the prompt. Provide this or `prompt`. | `""` |
| `output_file` | No | File where the final Codex message is written. Leave empty to skip writing a file. | `""` |
| `working_directory` | No | Directory passed to `codex exec --cd`. Defaults to the repository root. | `""` |
| `codex_version` | No | Version of `@openai/codex` to install. | `0.42.0-alpha.3` |
| `codex_args` | No | Extra arguments forwarded to `codex exec`. Accepts JSON arrays (`["--flag", "value"]`) or shell-style strings. | `""` |
| `output_schema` | No | Inline schema contents written to a temp file and passed to `codex exec --output-schema`. Mutually exclusive with `output_schema_file`. | `""` |
| `output_schema_file` | No | Schema file forwarded to `codex exec --output-schema`. Leave empty to skip passing the option. | `""` |
| `model` | No | Model the agent should use. Leave empty to let Codex pick its default. | `""` |
| `codex_home` | No | Directory to use as the Codex CLI home (config/cache). Uses the CLI default when empty. | `""` |
| `safety_strategy` | No | Controls how the action restricts Codex privileges. See [Safety strategy](#safety-strategy). | `drop_sudo` |
| `codex_user` | No | Username to run Codex as when `safety_strategy` is `unprivileged_user`. | `""` |
| `require_repo_write` | No | Whether to require the triggering actor to have write access to the repository before running. | "true" |
| `allow_bots` | No | Allow runs triggered by GitHub Apps/bot accounts to bypass the write-access check. | "false" |
## Safety Strategy
+10 -4
View File
@@ -10,6 +10,10 @@ inputs:
description: "Path to file that contains the prompt to pass to `codex exec`. `prompt` or `prompt_file` must be provided."
required: false
default: ""
output_file:
description: "Path to a JSON Schema file describing the model's final response shape."
required: false
default: ""
openai_api_key:
description: "OpenAI API key used by the Codex CLI."
required: true
@@ -25,8 +29,8 @@ inputs:
description: "Additional args to pass through to `codex exec`. If this value starts with `[`, it will be parsed as a JSON array; otherwise, it will be parsed as a shell-like string."
required: false
default: ""
output_file:
description: "Path to a JSON Schema file describing the model's final response shape."
output_schema:
description: "Inline schema contents to use with `codex exec --output-schema`."
required: false
default: ""
output_schema_file:
@@ -178,10 +182,11 @@ runs:
env:
CODEX_PROMPT: ${{ inputs.prompt }}
CODEX_PROMPT_FILE: ${{ inputs.prompt_file }}
CODEX_OUTPUT_FILE: ${{ inputs.output_file }}
CODEX_HOME: ${{ inputs.codex_home }}
CODEX_WORKING_DIRECTORY: ${{ inputs.working_directory || github.workspace }}
CODEX_ARGS: ${{ inputs.codex_args }}
CODEX_OUTPUT_FILE: ${{ inputs.output_file }}
CODEX_OUTPUT_SCHEMA: ${{ inputs.output_schema }}
CODEX_OUTPUT_SCHEMA_FILE: ${{ inputs.output_schema_file }}
CODEX_MODEL: ${{ inputs.model }}
CODEX_SAFETY_STRATEGY: ${{ inputs.safety_strategy }}
@@ -191,11 +196,12 @@ runs:
node "${{ github.action_path }}/dist/main.js" run-codex-exec \
--prompt "${CODEX_PROMPT}" \
--prompt-file "${CODEX_PROMPT_FILE}" \
--output-file "$CODEX_OUTPUT_FILE" \
--codex-home "$CODEX_HOME" \
--cd "$CODEX_WORKING_DIRECTORY" \
--proxy-port "${{ steps.read_server_info.outputs.port }}" \
--extra-args "$CODEX_ARGS" \
--output-file "$CODEX_OUTPUT_FILE" \
--output-schema "$CODEX_OUTPUT_SCHEMA" \
--output-schema-file "$CODEX_OUTPUT_SCHEMA_FILE" \
--model "$CODEX_MODEL" \
--safety-strategy "$CODEX_SAFETY_STRATEGY" \
+85 -30
View File
File diff suppressed because one or more lines are too long
+40 -4
View File
@@ -2,7 +2,12 @@ import { Command, Option } from "commander";
import pkg from "../package.json" assert { type: "json" };
import { readServerInfo } from "./readServerInfo";
import { PromptSource, runCodexExec, SafetyStrategy } from "./runCodexExec";
import {
OutputSchemaSource,
PromptSource,
runCodexExec,
SafetyStrategy,
} from "./runCodexExec";
import { dropSudo } from "./dropSudo";
import { ensureActorHasWriteAccess } from "./checkActorPermissions";
import parseArgsStringToArgv from "string-argv";
@@ -74,6 +79,10 @@ export async function main() {
"--output-schema-file <FILE>",
"Path to a schema file to pass to `codex exec --output-schema`."
)
.requiredOption(
"--output-schema <SCHEMA>",
"Inline schema contents to pass to `codex exec --output-schema`."
)
.requiredOption("--model <model>", "Model the agent should use")
.requiredOption(
"--safety-strategy <strategy>",
@@ -93,6 +102,7 @@ export async function main() {
extraArgs: Array<string>;
outputFile: string;
outputSchemaFile: string;
outputSchema: string;
model: string;
safetyStrategy: string;
codexUser: string;
@@ -100,11 +110,12 @@ export async function main() {
const {
prompt,
promptFile,
outputFile,
codexHome,
cd,
proxyPort,
extraArgs,
outputFile,
outputSchema,
outputSchemaFile,
model,
safetyStrategy,
@@ -115,7 +126,7 @@ export async function main() {
const normalizedPromptFile = emptyAsNull(promptFile);
let promptSource: PromptSource;
if (normalizedPrompt != null) {
promptSource = { type: "text", content: normalizedPrompt };
promptSource = { type: "inline", content: normalizedPrompt };
} else if (normalizedPromptFile != null) {
promptSource = { type: "file", path: normalizedPromptFile };
} else {
@@ -126,6 +137,31 @@ export async function main() {
// Custom option processing to coerces to null does not work with
// Commander.js's requiredOption, so we have to post-process here.
const normalizedOutputSchemaFile = emptyAsNull(outputSchemaFile);
const normalizedOutputSchema = emptyAsNull(outputSchema);
if (
normalizedOutputSchemaFile != null &&
normalizedOutputSchema != null
) {
throw new Error(
"Only one of `output_schema` or `output_schema_file` may be specified."
);
}
let outputSchemaSource: OutputSchemaSource | null = null;
if (normalizedOutputSchema != null) {
outputSchemaSource = {
type: "inline",
content: normalizedOutputSchema,
};
} else if (normalizedOutputSchemaFile != null) {
outputSchemaSource = {
type: "file",
path: normalizedOutputSchemaFile,
};
}
await runCodexExec({
prompt: promptSource,
codexHome: emptyAsNull(codexHome),
@@ -133,7 +169,7 @@ export async function main() {
proxyPort,
extraArgs,
explicitOutputFile: emptyAsNull(outputFile),
outputSchemaFile: emptyAsNull(outputSchemaFile),
outputSchema: outputSchemaSource,
model: emptyAsNull(model),
safetyStrategy: toSafetyStrategy(safetyStrategy),
codexUser: emptyAsNull(codexUser),
+92 -30
View File
@@ -1,11 +1,11 @@
import { spawn } from "child_process";
import { mkdtemp, readFile, rm } from "fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "fs/promises";
import path from "path";
import { setOutput } from "@actions/core";
export type PromptSource =
| {
type: "text";
type: "inline";
content: string;
}
| {
@@ -19,6 +19,16 @@ export type SafetyStrategy =
| "unprivileged_user"
| "unsafe";
export type OutputSchemaSource =
| {
type: "file";
path: string;
}
| {
type: "inline";
content: string;
};
export async function runCodexExec({
prompt,
codexHome,
@@ -26,7 +36,7 @@ export async function runCodexExec({
proxyPort,
extraArgs,
explicitOutputFile,
outputSchemaFile,
outputSchema,
model,
safetyStrategy,
codexUser,
@@ -37,14 +47,14 @@ export async function runCodexExec({
proxyPort: number;
extraArgs: Array<string>;
explicitOutputFile: string | null;
outputSchemaFile: string | null;
outputSchema: OutputSchemaSource | null;
model: string | null;
safetyStrategy: SafetyStrategy;
codexUser: string | null;
}): Promise<void> {
let input: string;
switch (prompt.type) {
case "text":
case "inline":
input = prompt.content;
break;
case "file":
@@ -59,6 +69,8 @@ export async function runCodexExec({
outputFile = await createTempOutputFile();
}
const resolvedOutputSchema = await resolveOutputSchema(outputSchema);
const command: Array<string> = [];
if (safetyStrategy === "unprivileged_user") {
@@ -89,8 +101,8 @@ export async function runCodexExec({
outputFile.file
);
if (outputSchemaFile != null) {
command.push("--output-schema", outputSchemaFile);
if (resolvedOutputSchema != null) {
command.push("--output-schema", resolvedOutputSchema.file);
}
if (model != null) {
@@ -119,30 +131,34 @@ export async function runCodexExec({
.map((a) => JSON.stringify(a))
.join(" ")}`
);
return new Promise((resolve, reject) => {
const child = spawn(program, command, {
env,
stdio: ["pipe", "inherit", "inherit"],
try {
await new Promise((resolve, reject) => {
const child = spawn(program, command, {
env,
stdio: ["pipe", "inherit", "inherit"],
});
child.stdin.write(input);
child.stdin.end();
child.on("error", reject);
child.on("close", async (code) => {
if (code !== 0) {
reject(new Error(`${program} exited with code ${code}`));
return;
}
try {
await finalizeExecution(outputFile);
resolve(undefined);
} catch (err) {
reject(err);
}
});
});
child.stdin.write(input);
child.stdin.end();
child.on("error", reject);
child.on("close", async (code) => {
if (code !== 0) {
reject(new Error(`${program} exited with code ${code}`));
return;
}
try {
await finalizeExecution(outputFile);
resolve();
} catch (err) {
reject(err);
}
});
});
} finally {
await cleanupOutputSchema(resolvedOutputSchema);
}
}
async function finalizeExecution(outputFile: OutputFile): Promise<void> {
@@ -164,6 +180,17 @@ type OutputFile =
file: string;
};
type ResolvedOutputSchema =
| {
type: "explicit";
file: string;
}
| {
type: "temp";
file: string;
dir: string;
};
async function createTempOutputFile(): Promise<OutputFile> {
const dir = await mkdtemp("codex-exec-");
return { type: "temp", file: path.join(dir, "output.md") };
@@ -182,3 +209,38 @@ async function cleanupTempOutput(outputFile: OutputFile): Promise<void> {
}
}
}
async function resolveOutputSchema(
schema: OutputSchemaSource | null
): Promise<ResolvedOutputSchema | null> {
if (schema == null) {
return null;
}
switch (schema.type) {
case "file":
return { type: "explicit", file: schema.path };
case "inline": {
const dir = await mkdtemp("codex-output-schema-");
const file = path.join(dir, "schema.json");
await writeFile(file, schema.content);
return { type: "temp", file, dir };
}
}
}
async function cleanupOutputSchema(
schema: ResolvedOutputSchema | null
): Promise<void> {
if (schema == null) {
return;
}
switch (schema.type) {
case "explicit":
return;
case "temp":
await rm(schema.dir, { recursive: true, force: true });
return;
}
}