openai/codex-action
Publicmirrored fromhttps://github.com/openai/codex-actionAvailable
action.yml
357lines · modecode
| 1 | name: "Codex Exec Action" |
| 2 | description: "Run `codex exec` with a prompt." |
| 3 | author: "OpenAI" |
| 4 | inputs: |
| 5 | prompt: |
| 6 | description: "Prompt to pass to Codex. `prompt` or `prompt-file` must be provided." |
| 7 | required: false |
| 8 | default: "" |
| 9 | prompt-file: |
| 10 | description: "Path to file that contains the prompt to pass to Codex. `prompt` or `prompt-file` must be provided." |
| 11 | required: false |
| 12 | default: "" |
| 13 | output-file: |
| 14 | description: "File where the final Codex message is written. Leave empty to skip writing a file." |
| 15 | required: false |
| 16 | default: "" |
| 17 | openai-api-key: |
| 18 | description: "OpenAI API key used by Codex." |
| 19 | required: false |
| 20 | default: "" |
| 21 | responses-api-endpoint: |
| 22 | description: "Optional Responses API endpoint override, e.g. https://example.openai.azure.com/openai/v1/responses. Defaults to the proxy's built-in endpoint when empty." |
| 23 | required: false |
| 24 | default: "" |
| 25 | working-directory: |
| 26 | description: "Working directory that Codex should use. Defaults to the repository root." |
| 27 | required: false |
| 28 | default: "" |
| 29 | sandbox: |
| 30 | description: | |
| 31 | Sandbox mode for Codex. One of `workspace-write` (default), `read-only` or `danger-full-access`. |
| 32 | required: false |
| 33 | default: "workspace-write" |
| 34 | codex-version: |
| 35 | description: "Version of `@openai/codex` to install." |
| 36 | required: false |
| 37 | default: "" |
| 38 | codex-args: |
| 39 | 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." |
| 40 | required: false |
| 41 | default: "" |
| 42 | output-schema: |
| 43 | description: "Inline schema contents to use with `codex exec --output-schema`." |
| 44 | required: false |
| 45 | default: "" |
| 46 | output-schema-file: |
| 47 | description: "File path to the schema that should be passed to `codex exec --output-schema`." |
| 48 | required: false |
| 49 | default: "" |
| 50 | model: |
| 51 | description: "Model the agent should use." |
| 52 | required: false |
| 53 | default: "" |
| 54 | effort: |
| 55 | description: "Reasoning effort the agent should use." |
| 56 | required: false |
| 57 | default: "" |
| 58 | codex-home: |
| 59 | description: "Directory to use as the Codex home directory. If empty, the default Codex home directory will be used." |
| 60 | required: false |
| 61 | default: "" |
| 62 | safety-strategy: |
| 63 | description: | |
| 64 | Specify one of the following options (on Windows, the only supported option is `unsafe`): |
| 65 | |
| 66 | * `drop-sudo` (default, IRREVERSIBLE) Drop sudo privileges (if any) from |
| 67 | the default user before running Codex, and run Codex as that user. This |
| 68 | is only supported on Linux and macOS runners. This option is |
| 69 | irreversible: if the default user has sudo privileges, they will be |
| 70 | removed permanently for the duration of the job. |
| 71 | * `unprivileged-user` Run Codex as the specified user specified by the |
| 72 | `codex-user` option (the user must already exist). Note the caller is |
| 73 | responsible for ensuring the specified user has the privileges it needs |
| 74 | to perform the requested actions. For example, the copy of the repo |
| 75 | created by `actions/checkout` is not world-readable by default. |
| 76 | * `read-only` Run Codex in a sandbox that can read any file on disk, |
| 77 | but cannot write to disk or access the network. Note Codex will still |
| 78 | run as the default user for this Action, which likely has sudo |
| 79 | privileges, so it could read `openai-api-key` from memory and reveal it |
| 80 | by printing it to the output of the GitHub Action. |
| 81 | * `unsafe` (NOT RECOMMENDED) Do not try to restrict Codex's privileges at all. |
| 82 | This is extremely dangerous, as the default user for this Action likely |
| 83 | has sudo privileges, which means it can read secrets stored in memory |
| 84 | (such as the value of `openai-api-key`) and print them to the output |
| 85 | of the GitHub Action or exfiltrate them in other ways. |
| 86 | required: false |
| 87 | default: "drop-sudo" |
| 88 | codex-user: |
| 89 | description: "If `safety-strategy` is set to `unprivileged-user`, this specifies the UNIX username to run Codex as." |
| 90 | required: false |
| 91 | default: "" |
| 92 | allow-users: |
| 93 | description: "Comma-separated list of GitHub usernames who can run this action, or '*' to allow all users. Note users who have write access to the GitHub repo have access by default and do not need to be listed here." |
| 94 | required: false |
| 95 | default: "" |
| 96 | allow-bots: |
| 97 | description: "Allow runs triggered by trusted GitHub bot accounts (github-actions[bot]) to bypass the write-access check." |
| 98 | required: false |
| 99 | default: "false" |
| 100 | allow-bot-users: |
| 101 | description: "Comma-separated list of GitHub bot usernames that can bypass the write-access check. '*' is not supported; list trusted bots explicitly. Entries may include or omit the trailing [bot] suffix." |
| 102 | required: false |
| 103 | default: "" |
| 104 | outputs: |
| 105 | final-message: |
| 106 | description: "Raw output emitted by `codex exec`." |
| 107 | value: ${{ steps.run_codex.outputs['final-message'] }} |
| 108 | runs: |
| 109 | using: "composite" |
| 110 | steps: |
| 111 | - name: Validate Windows safety strategy |
| 112 | if: ${{ runner.os == 'Windows' }} |
| 113 | shell: bash |
| 114 | env: |
| 115 | SAFETY_STRATEGY: ${{ inputs['safety-strategy'] }} |
| 116 | run: | |
| 117 | if [ "$SAFETY_STRATEGY" != "unsafe" ]; then |
| 118 | echo "On Windows, inputs['safety-strategy'] must be 'unsafe'" >&2 |
| 119 | echo "because no viable sandboxing options are available at this time." >&2 |
| 120 | exit 1 |
| 121 | fi |
| 122 | |
| 123 | - name: Ensure Node.js available |
| 124 | # Pin to a commit hash because some repositories require it: |
| 125 | # https://github.com/openai/codex-action/issues/43 |
| 126 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 |
| 127 | with: |
| 128 | node-version: "20" |
| 129 | |
| 130 | - name: Check repository write access |
| 131 | env: |
| 132 | ACTION_PATH: ${{ github.action_path }} |
| 133 | GITHUB_TOKEN: ${{ github.token }} |
| 134 | ALLOW_BOTS: ${{ inputs['allow-bots'] }} |
| 135 | ALLOW_BOT_USERS: ${{ inputs['allow-bot-users'] }} |
| 136 | ALLOW_USERS: ${{ inputs['allow-users'] }} |
| 137 | shell: bash |
| 138 | run: | |
| 139 | node "$ACTION_PATH/dist/main.js" check-write-access \ |
| 140 | --allow-bots "$ALLOW_BOTS" \ |
| 141 | --allow-bot-users "$ALLOW_BOT_USERS" \ |
| 142 | --allow-users "$ALLOW_USERS" |
| 143 | |
| 144 | - name: Install Codex CLI |
| 145 | shell: bash |
| 146 | env: |
| 147 | CODEX_VERSION: ${{ inputs['codex-version'] }} |
| 148 | run: npm install -g "@openai/codex@${CODEX_VERSION}" |
| 149 | |
| 150 | - name: Install Codex Responses API proxy |
| 151 | shell: bash |
| 152 | env: |
| 153 | CODEX_VERSION: ${{ inputs['codex-version'] }} |
| 154 | run: npm install -g "@openai/codex-responses-api-proxy@${CODEX_VERSION}" |
| 155 | |
| 156 | - name: Resolve Codex home |
| 157 | id: resolve_home |
| 158 | shell: bash |
| 159 | env: |
| 160 | ACTION_PATH: ${{ github.action_path }} |
| 161 | CODEX_HOME_OVERRIDE: ${{ inputs['codex-home'] }} |
| 162 | SAFETY_STRATEGY: ${{ inputs['safety-strategy'] }} |
| 163 | CODEX_USER: ${{ inputs['codex-user'] }} |
| 164 | CODEX_RUN_ID: ${{ github.run_id }} |
| 165 | run: | |
| 166 | node "$ACTION_PATH/dist/main.js" resolve-codex-home \ |
| 167 | --codex-home-override "$CODEX_HOME_OVERRIDE" \ |
| 168 | --safety-strategy "$SAFETY_STRATEGY" \ |
| 169 | --codex-user "$CODEX_USER" \ |
| 170 | --github-run-id "$CODEX_RUN_ID" |
| 171 | |
| 172 | - name: Determine server info path |
| 173 | id: derive_server_info |
| 174 | shell: bash |
| 175 | env: |
| 176 | CODEX_HOME: ${{ steps.resolve_home.outputs.codex-home }} |
| 177 | CODEX_RUN_ID: ${{ github.run_id }} |
| 178 | run: | |
| 179 | server_info_file="$CODEX_HOME/$CODEX_RUN_ID.json" |
| 180 | echo "server_info_file=$server_info_file" >> "$GITHUB_OUTPUT" |
| 181 | |
| 182 | - name: Check Responses API proxy status |
| 183 | id: start_proxy |
| 184 | if: ${{ inputs['openai-api-key'] != '' }} |
| 185 | shell: bash |
| 186 | env: |
| 187 | SERVER_INFO_FILE: ${{ steps.derive_server_info.outputs.server_info_file }} |
| 188 | run: | |
| 189 | if [ -s "$SERVER_INFO_FILE" ]; then |
| 190 | echo "Responses API proxy already appears to be running (found $SERVER_INFO_FILE)." |
| 191 | echo "server_info_file_exists=true" >> "$GITHUB_OUTPUT" |
| 192 | else |
| 193 | echo "server_info_file_exists=false" >> "$GITHUB_OUTPUT" |
| 194 | fi |
| 195 | |
| 196 | # This is its own step to minimize the runtime logic that has access to the |
| 197 | # API key. Note we use `env -u PROXY_API_KEY` to ensure extra copies of the |
| 198 | # key do not end up in the memory of the `codex-responses-api-proxy` |
| 199 | # process where environment variables are stored. |
| 200 | - name: Start Responses API proxy |
| 201 | if: ${{ inputs['openai-api-key'] != '' && steps.start_proxy.outputs.server_info_file_exists == 'false' }} |
| 202 | env: |
| 203 | SERVER_INFO_FILE: ${{ steps.derive_server_info.outputs.server_info_file }} |
| 204 | PROXY_API_KEY: ${{ inputs['openai-api-key'] }} |
| 205 | UPSTREAM_URL: ${{ inputs['responses-api-endpoint'] }} |
| 206 | shell: bash |
| 207 | run: | |
| 208 | args=( |
| 209 | codex-responses-api-proxy |
| 210 | --http-shutdown |
| 211 | --server-info "$SERVER_INFO_FILE" |
| 212 | ) |
| 213 | |
| 214 | if [ -n "$UPSTREAM_URL" ]; then |
| 215 | args+=(--upstream-url "$UPSTREAM_URL") |
| 216 | fi |
| 217 | |
| 218 | ( |
| 219 | printenv PROXY_API_KEY | env -u PROXY_API_KEY "${args[@]}" |
| 220 | ) & |
| 221 | |
| 222 | - name: Wait for Responses API proxy |
| 223 | if: ${{ inputs['openai-api-key'] != '' && steps.start_proxy.outputs.server_info_file_exists == 'false' }} |
| 224 | shell: bash |
| 225 | env: |
| 226 | SERVER_INFO_FILE: ${{ steps.derive_server_info.outputs.server_info_file }} |
| 227 | run: | |
| 228 | for _ in {1..10}; do |
| 229 | if [ -s "$SERVER_INFO_FILE" ]; then |
| 230 | break |
| 231 | fi |
| 232 | sleep 1 |
| 233 | done |
| 234 | if [ ! -s "$SERVER_INFO_FILE" ]; then |
| 235 | echo "responses-api-proxy did not write server info" >&2 |
| 236 | exit 1 |
| 237 | fi |
| 238 | |
| 239 | if [ "${RUNNER_OS}" != "Windows" ]; then |
| 240 | sudo chmod 444 "$SERVER_INFO_FILE" |
| 241 | sudo chown root "$SERVER_INFO_FILE" |
| 242 | fi |
| 243 | |
| 244 | # This step has an output named `port`. |
| 245 | - name: Read server info |
| 246 | id: read_server_info |
| 247 | if: ${{ inputs['openai-api-key'] != '' || inputs.prompt != '' || inputs['prompt-file'] != '' }} |
| 248 | shell: bash |
| 249 | env: |
| 250 | ACTION_PATH: ${{ github.action_path }} |
| 251 | SERVER_INFO_FILE: ${{ steps.derive_server_info.outputs.server_info_file }} |
| 252 | run: node "$ACTION_PATH/dist/main.js" read-server-info "$SERVER_INFO_FILE" |
| 253 | |
| 254 | - name: Write Codex proxy config |
| 255 | if: ${{ inputs['openai-api-key'] != '' }} |
| 256 | shell: bash |
| 257 | env: |
| 258 | ACTION_PATH: ${{ github.action_path }} |
| 259 | CODEX_HOME: ${{ steps.resolve_home.outputs.codex-home }} |
| 260 | PROXY_PORT: ${{ steps.read_server_info.outputs.port }} |
| 261 | SAFETY_STRATEGY: ${{ inputs['safety-strategy'] }} |
| 262 | run: | |
| 263 | node "$ACTION_PATH/dist/main.js" write-proxy-config \ |
| 264 | --codex-home "$CODEX_HOME" \ |
| 265 | --port "$PROXY_PORT" \ |
| 266 | --safety-strategy "$SAFETY_STRATEGY" |
| 267 | |
| 268 | - name: Enable Linux user namespaces for bubblewrap |
| 269 | if: ${{ runner.os == 'Linux' && runner.environment == 'github-hosted' && (inputs['openai-api-key'] != '' || inputs.prompt != '' || inputs['prompt-file'] != '') }} |
| 270 | shell: bash |
| 271 | run: | |
| 272 | set -euo pipefail |
| 273 | |
| 274 | # Bubblewrap needs unprivileged user namespaces on GitHub-hosted Linux |
| 275 | # runners. This step runs before drop-sudo, then becomes a no-op on |
| 276 | # later codex-action invocations in the same job because the sysctls |
| 277 | # already have the desired values. See issue #75 for the failure mode |
| 278 | # this is working around on newer Ubuntu images. |
| 279 | current_userns="$(sysctl -n kernel.unprivileged_userns_clone 2>/dev/null || true)" |
| 280 | if [ -n "$current_userns" ] && [ "$current_userns" != "1" ]; then |
| 281 | echo "Enabling kernel.unprivileged_userns_clone for bubblewrap." |
| 282 | sudo sysctl -w kernel.unprivileged_userns_clone=1 |
| 283 | fi |
| 284 | |
| 285 | # Ubuntu 24.04+ can additionally block unprivileged user namespaces via |
| 286 | # AppArmor, which causes bubblewrap to fail with |
| 287 | # `loopback: Failed RTM_NEWADDR: Operation not permitted`. |
| 288 | current_apparmor="$(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || true)" |
| 289 | if [ -n "$current_apparmor" ] && [ "$current_apparmor" != "0" ]; then |
| 290 | echo "Disabling kernel.apparmor_restrict_unprivileged_userns for bubblewrap." |
| 291 | sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 |
| 292 | fi |
| 293 | |
| 294 | - name: Drop sudo privilege, if appropriate |
| 295 | if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' }} |
| 296 | shell: bash |
| 297 | env: |
| 298 | ACTION_PATH: ${{ github.action_path }} |
| 299 | run: | |
| 300 | case "${RUNNER_OS}" in |
| 301 | Linux) |
| 302 | node "$ACTION_PATH/dist/main.js" drop-sudo --user runner --group sudo |
| 303 | ;; |
| 304 | macOS) |
| 305 | node "$ACTION_PATH/dist/main.js" drop-sudo --user runner --group admin |
| 306 | ;; |
| 307 | *) |
| 308 | echo "Unsupported OS for drop-sudo: ${RUNNER_OS}" >&2 |
| 309 | exit 1 |
| 310 | ;; |
| 311 | esac |
| 312 | |
| 313 | - name: Verify sudo privilege removed |
| 314 | if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' }} |
| 315 | shell: bash |
| 316 | run: | |
| 317 | if sudo -n true 2>/dev/null; then |
| 318 | echo "Expected sudo to be disabled, but sudo succeeded." >&2 |
| 319 | exit 1 |
| 320 | fi |
| 321 | echo "Confirmed sudo privilege is disabled." |
| 322 | |
| 323 | - name: Run codex exec |
| 324 | id: run_codex |
| 325 | if: ${{ inputs.prompt != '' || inputs['prompt-file'] != '' }} |
| 326 | env: |
| 327 | CODEX_PROMPT: ${{ inputs.prompt }} |
| 328 | CODEX_PROMPT_FILE: ${{ inputs['prompt-file'] }} |
| 329 | CODEX_OUTPUT_FILE: ${{ inputs['output-file'] }} |
| 330 | CODEX_HOME: ${{ steps.resolve_home.outputs.codex-home }} |
| 331 | CODEX_WORKING_DIRECTORY: ${{ inputs['working-directory'] || github.workspace }} |
| 332 | CODEX_SANDBOX: ${{ inputs.sandbox }} |
| 333 | CODEX_ARGS: ${{ inputs['codex-args'] }} |
| 334 | CODEX_OUTPUT_SCHEMA: ${{ inputs['output-schema'] }} |
| 335 | CODEX_OUTPUT_SCHEMA_FILE: ${{ inputs['output-schema-file'] }} |
| 336 | CODEX_MODEL: ${{ inputs.model }} |
| 337 | CODEX_EFFORT: ${{ inputs.effort }} |
| 338 | CODEX_SAFETY_STRATEGY: ${{ inputs['safety-strategy'] }} |
| 339 | CODEX_USER: ${{ inputs['codex-user'] }} |
| 340 | ACTION_PATH: ${{ github.action_path }} |
| 341 | FORCE_COLOR: 1 |
| 342 | shell: bash |
| 343 | run: | |
| 344 | node "$ACTION_PATH/dist/main.js" run-codex-exec \ |
| 345 | --prompt "${CODEX_PROMPT}" \ |
| 346 | --prompt-file "${CODEX_PROMPT_FILE}" \ |
| 347 | --output-file "$CODEX_OUTPUT_FILE" \ |
| 348 | --codex-home "$CODEX_HOME" \ |
| 349 | --cd "$CODEX_WORKING_DIRECTORY" \ |
| 350 | --extra-args "$CODEX_ARGS" \ |
| 351 | --output-schema "$CODEX_OUTPUT_SCHEMA" \ |
| 352 | --output-schema-file "$CODEX_OUTPUT_SCHEMA_FILE" \ |
| 353 | --sandbox "$CODEX_SANDBOX" \ |
| 354 | --model "$CODEX_MODEL" \ |
| 355 | --effort "$CODEX_EFFORT" \ |
| 356 | --safety-strategy "$CODEX_SAFETY_STRATEGY" \ |
| 357 | --codex-user "$CODEX_USER" |
| 358 | |