checkpoint work: run codex multiple times (#11)

This commit is contained in:
Michael Bolin
2025-10-03 16:46:05 -07:00
committed by GitHub
parent d49a23a33c
commit 1c44f3d736
6 changed files with 338 additions and 168 deletions
+20 -19
View File
@@ -31,24 +31,24 @@ 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`. | `""` |
| `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. | `""` |
| `sandbox` | No | Sandbox mode for Codex. One of `workspace-write` (default), `read-only` or `danger-full-access`. | `""` |
| `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" |
| Name | Description | Default |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| `openai-api-key` | Secret used to start the Responses API proxy. Required when starting the proxy (key-only or key+prompt). Store it in `secrets`. | `""` |
| `prompt` | Inline prompt text. Provide this or `prompt-file`. | `""` |
| `prompt-file` | Path (relative to the repository root) of a file that contains the prompt. Provide this or `prompt`. | `""` |
| `output-file` | File where the final Codex message is written. Leave empty to skip writing a file. | `""` |
| `working-directory` | Directory passed to `codex exec --cd`. Defaults to the repository root. | `""` |
| `sandbox` | Sandbox mode for Codex. One of `workspace-write` (default), `read-only` or `danger-full-access`. | `""` |
| `codex-version` | Version of `@openai/codex` to install. | `0.42.0-alpha.3` |
| `codex-args` | Extra arguments forwarded to `codex exec`. Accepts JSON arrays (`["--flag", "value"]`) or shell-style strings. | `""` |
| `output-schema` | Inline schema contents written to a temp file and passed to `codex exec --output-schema`. Mutually exclusive with `output-schema-file`. | `""` |
| `output-schema-file` | Schema file forwarded to `codex exec --output-schema`. Leave empty to skip passing the option. | `""` |
| `model` | Model the agent should use. Leave empty to let Codex pick its default. | `""` |
| `codex-home` | Directory to use as the Codex CLI home (config/cache). Uses the CLI default when empty. | `""` |
| `safety-strategy` | Controls how the action restricts Codex privileges. See [Safety strategy](#safety-strategy). | `drop-sudo` |
| `codex-user` | Username to run Codex as when `safety-strategy` is `unprivileged-user`. | `""` |
| `require-repo-write` | Whether to require the triggering actor to have write access to the repository before running. | "true" |
| `allow-bots` | Allow runs triggered by GitHub Apps/bot accounts to bypass the write-access check. | "false" |
## Safety Strategy
@@ -86,7 +86,8 @@ Replace `steps.codex` with the `id` assigned to your action step.
- Run this action after `actions/checkout@v5` so Codex has access to your repository contents.
- If you want Codex to have access to a narrow set of privileged functionality, consider running a local MCP server that can perform these actions and configure Codex to use it.
- If you need more control over the CLI invocation, pass flags through `codex-args` or create a `config.toml` in `codex-home`.
- Once `openai/codex-action` is run once with `openai-api-key`, you can also call `codex` from subsequent scripts in your job. (You can omit `prompt` and `prompt-file` from the action in this case.)
## License
This project is licensed under the [Apache License 2.0](./LICENSE)
This project is licensed under the [Apache License 2.0](./LICENSE).
+38 -17
View File
@@ -16,7 +16,8 @@ inputs:
default: ""
openai-api-key:
description: "OpenAI API key used by Codex."
required: true
required: false
default: ""
working-directory:
description: "Working directory that Codex should use. Defaults to the repository root."
required: false
@@ -127,36 +128,62 @@ runs:
shell: bash
run: npm install -g "@openai/codex-responses-api-proxy@${{ inputs['codex-version'] }}"
- name: Resolve Codex home
id: resolve_home
shell: bash
run: |
node "${{ github.action_path }}/dist/main.js" resolve-codex-home "${{ inputs['codex-home'] }}"
- name: Determine server info path
id: derive_server_info
shell: bash
run: |
server_info_file="${{ steps.resolve_home.outputs.codex-home }}/${{ github.run_id }}.json"
echo "server_info_file=$server_info_file" >> "$GITHUB_OUTPUT"
- name: Start Responses API proxy
id: start_proxy
if: ${{ inputs['openai-api-key'] != '' }}
env:
OPENAI_API_KEY: ${{ inputs['openai-api-key'] }}
shell: bash
run: |
tmpfile=$(mktemp)
server_info_file="${{ steps.derive_server_info.outputs.server_info_file }}"
if [ -s "$server_info_file" ]; then
echo "Responses API proxy already appears to be running (found $server_info_file)."
exit 0
fi
(
printenv OPENAI_API_KEY | codex-responses-api-proxy --http-shutdown --server-info "$tmpfile"
printenv OPENAI_API_KEY | codex-responses-api-proxy --http-shutdown --server-info "$server_info_file"
) &
for _ in {1..10}; do
if [ -s "$tmpfile" ]; then
if [ -s "$server_info_file" ]; then
break
fi
sleep 1
done
if [ ! -s "$tmpfile" ]; then
if [ ! -s "$server_info_file" ]; then
echo "responses-api-proxy did not write server info" >&2
exit 1
fi
echo "server_info_file=$tmpfile" >> "$GITHUB_OUTPUT"
# This step has an output named `port`.
- name: Read server info
id: read_server_info
if: ${{ inputs['openai-api-key'] != '' || inputs.prompt != '' || inputs['prompt-file'] != '' }}
shell: bash
run: node "${{ github.action_path }}/dist/main.js" read-server-info "${{ steps.start_proxy.outputs.server_info_file }}"
run: node "${{ github.action_path }}/dist/main.js" read-server-info "${{ steps.derive_server_info.outputs.server_info_file }}"
- name: Write Codex proxy config
if: ${{ inputs['openai-api-key'] != '' }}
shell: bash
run: |
node "${{ github.action_path }}/dist/main.js" write-proxy-config \
--codex-home "${{ steps.resolve_home.outputs.codex-home }}" \
--port "${{ steps.read_server_info.outputs.port }}"
- name: Drop sudo privilege, if appropriate
if: ${{ inputs['safety-strategy'] == 'drop-sudo' }}
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' }}
shell: bash
run: |
case "${RUNNER_OS}" in
@@ -173,7 +200,7 @@ runs:
esac
- name: Verify sudo privilege removed
if: ${{ inputs['safety-strategy'] == 'drop-sudo' }}
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' }}
shell: bash
run: |
if sudo -n true 2>/dev/null; then
@@ -184,11 +211,12 @@ runs:
- name: Run codex exec
id: run_codex
if: ${{ inputs.prompt != '' || inputs['prompt-file'] != '' }}
env:
CODEX_PROMPT: ${{ inputs.prompt }}
CODEX_PROMPT_FILE: ${{ inputs['prompt-file'] }}
CODEX_OUTPUT_FILE: ${{ inputs['output-file'] }}
CODEX_HOME: ${{ inputs['codex-home'] }}
CODEX_HOME: ${{ steps.resolve_home.outputs.codex-home }}
CODEX_WORKING_DIRECTORY: ${{ inputs['working-directory'] || github.workspace }}
CODEX_SANDBOX: ${{ inputs.sandbox }}
CODEX_ARGS: ${{ inputs['codex-args'] }}
@@ -206,7 +234,6 @@ runs:
--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-schema "$CODEX_OUTPUT_SCHEMA" \
--output-schema-file "$CODEX_OUTPUT_SCHEMA_FILE" \
@@ -214,9 +241,3 @@ runs:
--model "$CODEX_MODEL" \
--safety-strategy "$CODEX_SAFETY_STRATEGY" \
--codex-user "$CODEX_USER"
- name: Shutdown codex webserver
env:
CODEX_SERVER_PORT: ${{ steps.read_server_info.outputs.port }}
shell: bash
run: curl --fail --silent --show-error "http://127.0.0.1:${CODEX_SERVER_PORT}/shutdown"
+178 -114
View File
File diff suppressed because one or more lines are too long
+64 -8
View File
@@ -1,4 +1,7 @@
import { Command, Option } from "commander";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import pkg from "../package.json" assert { type: "json" };
import { readServerInfo } from "./readServerInfo";
@@ -12,6 +15,7 @@ import {
import { dropSudo } from "./dropSudo";
import { ensureActorHasWriteAccess } from "./checkActorPermissions";
import parseArgsStringToArgv from "string-argv";
import { writeProxyConfig } from "./writeProxyConfig";
export async function main() {
const program = new Command();
@@ -29,6 +33,37 @@ export async function main() {
await readServerInfo(serverInfoFile);
});
program
.command("resolve-codex-home")
.description(
"Resolve the Codex home directory with precedence: input, env, default (~/.codex)"
)
.argument(
"[inputCodexHome]",
"Optional codex-home input value (may be empty)"
)
.action(async (inputCodexHome?: string) => {
const resolved = await resolveCodexHome(
emptyAsNull(inputCodexHome ?? "")
);
// Ensure directory exists for downstream steps that will write files here.
await fs.mkdir(resolved, { recursive: true });
const { setOutput } = await import("@actions/core");
setOutput("codex-home", resolved);
console.log(`Resolved Codex home: ${resolved}`);
});
program
.command("write-proxy-config")
.description(
"Write the OpenAI Proxy model provider config into CODEX_HOME/config.toml"
)
.requiredOption("--codex-home <DIRECTORY>", "Path to Codex home directory")
.requiredOption("--port <port>", "Proxy server port", parseIntStrict)
.action(async (options: { codexHome: string; port: number }) => {
await writeProxyConfig(options.codexHome, options.port);
});
program
.command("drop-sudo")
.description("Drops sudo privileges for the configured user.")
@@ -62,11 +97,6 @@ export async function main() {
"Path to the Codex CLI home directory (where config files are stored)."
)
.requiredOption("--cd <DIRECTORY>", "Working directory for Codex")
.requiredOption(
"--proxy-port <port>",
"Port of the Responses API Proxy",
parseIntStrict
)
.requiredOption(
"--extra-args <args>",
"Additional args to pass through to `codex exec` as JSON array or shell string.",
@@ -103,7 +133,6 @@ export async function main() {
promptFile: string;
codexHome: string;
cd: string;
proxyPort: number;
extraArgs: Array<string>;
outputFile: string;
outputSchemaFile: string;
@@ -119,7 +148,6 @@ export async function main() {
outputFile,
codexHome,
cd,
proxyPort,
extraArgs,
outputSchema,
outputSchemaFile,
@@ -131,6 +159,12 @@ export async function main() {
const normalizedPrompt = emptyAsNull(prompt);
const normalizedPromptFile = emptyAsNull(promptFile);
if (normalizedPrompt != null && normalizedPromptFile != null) {
throw new Error(
"Only one of `prompt` or `prompt-file` may be specified."
);
}
let promptSource: PromptSource;
if (normalizedPrompt != null) {
promptSource = { type: "inline", content: normalizedPrompt };
@@ -173,7 +207,6 @@ export async function main() {
prompt: promptSource,
codexHome: emptyAsNull(codexHome),
cd,
proxyPort,
extraArgs,
explicitOutputFile: emptyAsNull(outputFile),
outputSchema: outputSchemaSource,
@@ -279,3 +312,26 @@ function parseBoolean(value: string): boolean {
}
main();
async function resolveCodexHome(
inputCodexHome: string | null
): Promise<string> {
if (inputCodexHome != null) {
return expandTilde(inputCodexHome);
}
const envHome = emptyAsNull(process.env.CODEX_HOME ?? "");
if (envHome != null) {
return envHome;
}
return path.join(os.homedir(), ".codex");
}
function expandTilde(p: string): string {
if (p === "~") {
return os.homedir();
}
if (p.startsWith("~/") || p.startsWith("~\\")) {
return path.join(os.homedir(), p.slice(2));
}
return p;
}
-10
View File
@@ -38,7 +38,6 @@ export async function runCodexExec({
prompt,
codexHome,
cd,
proxyPort,
extraArgs,
explicitOutputFile,
outputSchema,
@@ -50,7 +49,6 @@ export async function runCodexExec({
prompt: PromptSource;
codexHome: string | null;
cd: string;
proxyPort: number;
extraArgs: Array<string>;
explicitOutputFile: string | null;
outputSchema: OutputSchemaSource | null;
@@ -94,20 +92,12 @@ export async function runCodexExec({
command.push("sudo", "-u", codexUser, "--");
}
const providerBaseUrl = `http://127.0.0.1:${proxyPort}/v1`;
const providerId = "openai-proxy";
const providerConfig = `model_providers.${providerId}={ name = "OpenAI Proxy", base_url = "${providerBaseUrl}", wire_api = "responses" }`;
command.push(
"codex",
"exec",
"--skip-git-repo-check",
"--cd",
cd,
"--config",
providerConfig,
"--config",
`model_provider="${providerId}"`,
"--output-last-message",
outputFile.file
);
+38
View File
@@ -0,0 +1,38 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
const MODEL_PROVIDER = "openai-proxy";
export async function writeProxyConfig(
codexHome: string,
port: number
): Promise<void> {
const configPath = path.join(codexHome, "config.toml");
let existing = "";
try {
existing = await fs.readFile(configPath, "utf8");
} catch {
existing = "";
}
const header = `# Added by codex-action.
model_provider = "${MODEL_PROVIDER}"
`;
const table = `
# Added by codex-action.
[model_providers.${MODEL_PROVIDER}]
name = "OpenAI Proxy"
base_url = "http://127.0.0.1:${port}/v1"
wire_api = "responses"
`;
// Prepend model_provider at the very top.
let output = `${header}${existing}${table}`;
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(configPath, output, "utf8");
}