Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Run [Codex](https://github.com/openai/codex#codex-exec) from a GitHub Actions workflow while keeping tight control over the privileges available to Codex. This action handles installing the Codex CLI and configuring it with a secure proxy to the [Responses API](https://platform.openai.com/docs/api-reference/responses).

Users must provide their [`OPENAI_API_KEY`](https://platform.openai.com/api-keys) as a [GitHub Actions secret](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets) to use this action.
Provide credentials for the model provider you plan to use. Most workflows will supply [`openai-api-key`](#inputs) with their [`OPENAI_API_KEY`](https://platform.openai.com/api-keys) stored as a [GitHub Actions secret](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets). If you run against [Azure OpenAI](https://learn.microsoft.com/azure/ai-services/openai/), pass the Azure inputs instead (`azure-openai-api-key`, `azure-openai-endpoint`, and `azure-openai-api-version`).

## Example: Create Your Own Pull Request Bot

Expand Down Expand Up @@ -93,6 +93,10 @@ jobs:
| 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`. | `""` |
| `azure-openai-api-key` | Secret used when calling Azure OpenAI directly. Required when running against Azure. Store it in `secrets`. | `""` |
| `azure-openai-endpoint` | Azure OpenAI endpoint base URL (for example: `https://example.openai.azure.com/`). The action will append `/openai` if missing. | `""` |
| `azure-openai-api-version` | Azure OpenAI API version (for example: `2025-04-01-preview`). Required when using the Azure key. | `""` |
| `azure-openai-env-key` | Environment variable name that should hold the Azure OpenAI API key before invoking Codex. | `"AZURE_OPENAI_API_KEY"` |
| `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. | `""` |
Expand All @@ -109,6 +113,18 @@ jobs:
| `allow-users` | List of GitHub usernames who can trigger the action in addition to those who have write access to the repo. | "" |
| `allow-bots` | Allow runs triggered by GitHub Apps/bot accounts to bypass the write-access check. | "false" |

### Using Azure OpenAI

When you supply the Azure inputs, the action skips starting the Responses API proxy and instead writes a Codex CLI configuration that points directly at your Azure endpoint. Provide:

- `azure-openai-api-key`: the key stored in `secrets`.
- `azure-openai-endpoint`: the base URL for your resource (you can use the root such as `https://example.openai.azure.com/`; the action appends `/openai` if needed).
- `azure-openai-api-version`: the REST API version you want to call.

Codex still needs a deployment name, so set the action's `model` input to your Azure deployment ID (for example `gpt-4o-mini`). You can change the environment variable used to inject the key by overriding `azure-openai-env-key`; the default is `AZURE_OPENAI_API_KEY`.

Only one provider can be configured per run—if you pass both OpenAI and Azure secrets the action fails fast so you can fix the workflow configuration.

## Safety Strategy

The `safety-strategy` input determines how much access Codex receives on the runner. Choosing the right option is critical, especially when sensitive secrets (like your OpenAI API key) are present.
Expand Down
93 changes: 82 additions & 11 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ inputs:
description: "OpenAI API key used by Codex."
required: false
default: ""
azure-openai-api-key:
description: "Azure OpenAI API key used by Codex."
required: false
default: ""
azure-openai-endpoint:
description: "Azure OpenAI endpoint base URL (for example: https://example.openai.azure.com/openai)."
required: false
default: ""
azure-openai-api-version:
description: "Azure OpenAI API version (for example: 2025-04-01-preview)."
required: false
default: ""
azure-openai-env-key:
description: "Environment variable name that should hold the Azure OpenAI API key (defaults to AZURE_OPENAI_API_KEY)."
required: false
default: "AZURE_OPENAI_API_KEY"
working-directory:
description: "Working directory that Codex should use. Defaults to the repository root."
required: false
Expand Down Expand Up @@ -145,9 +161,39 @@ runs:
server_info_file="${{ steps.resolve_home.outputs.codex-home }}/${{ github.run_id }}.json"
echo "server_info_file=$server_info_file" >> "$GITHUB_OUTPUT"

- name: Determine model provider
id: determine_provider
shell: bash
run: |
openai_key="${{ inputs['openai-api-key'] }}"
azure_key="${{ inputs['azure-openai-api-key'] }}"
azure_endpoint="${{ inputs['azure-openai-endpoint'] }}"
azure_version="${{ inputs['azure-openai-api-version'] }}"
if [ -n "$openai_key" ] && [ -n "$azure_key" ]; then
echo "Provide only one of openai-api-key or azure-openai-api-key." >&2
exit 1
fi

provider="none"
if [ -n "$openai_key" ]; then
provider="openai-proxy"
elif [ -n "$azure_key" ]; then
provider="azure-openai"
if [ -z "$azure_endpoint" ]; then
echo "azure-openai-endpoint must be provided when using azure-openai-api-key." >&2
exit 1
fi
if [ -z "$azure_version" ]; then
echo "azure-openai-api-version must be provided when using azure-openai-api-key." >&2
exit 1
fi
fi

echo "provider=$provider" >> "$GITHUB_OUTPUT"

- name: Check Responses API proxy status
id: start_proxy
if: ${{ inputs['openai-api-key'] != '' }}
if: ${{ steps.determine_provider.outputs.provider == 'openai-proxy' }}
shell: bash
run: |
server_info_file="${{ steps.derive_server_info.outputs.server_info_file }}"
Expand All @@ -163,7 +209,7 @@ runs:
# key do not end up in the memory of the `codex-responses-api-proxy` process
# where environment variables are stored.
- name: Start Responses API proxy
if: ${{ inputs['openai-api-key'] != '' && steps.start_proxy.outputs.server_info_file_exists == 'false' }}
if: ${{ steps.determine_provider.outputs.provider == 'openai-proxy' && steps.start_proxy.outputs.server_info_file_exists == 'false' }}
env:
OPENAI_API_KEY: ${{ inputs['openai-api-key'] }}
shell: bash
Expand All @@ -173,7 +219,7 @@ runs:
) &

- name: Wait for Responses API proxy
if: ${{ inputs['openai-api-key'] != '' && steps.start_proxy.outputs.server_info_file_exists == 'false' }}
if: ${{ steps.determine_provider.outputs.provider == 'openai-proxy' && steps.start_proxy.outputs.server_info_file_exists == 'false' }}
shell: bash
run: |
server_info_file="${{ steps.derive_server_info.outputs.server_info_file }}"
Expand All @@ -196,21 +242,40 @@ runs:
# 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'] != '' }}
if: ${{ steps.determine_provider.outputs.provider == 'openai-proxy' }}
shell: bash
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'] != '' }}
if: ${{ steps.determine_provider.outputs.provider != 'none' }}
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 }}" \
--safety-strategy "${{ inputs['safety-strategy'] }}"
set -euo pipefail
provider="${{ steps.determine_provider.outputs.provider }}"
case "$provider" in
openai-proxy)
node "${{ github.action_path }}/dist/main.js" write-proxy-config \
--codex-home "${{ steps.resolve_home.outputs.codex-home }}" \
--safety-strategy "${{ inputs['safety-strategy'] }}" \
--provider openai-proxy \
--port "${{ steps.read_server_info.outputs.port }}"
;;
azure-openai)
node "${{ github.action_path }}/dist/main.js" write-proxy-config \
--codex-home "${{ steps.resolve_home.outputs.codex-home }}" \
--safety-strategy "${{ inputs['safety-strategy'] }}" \
--provider azure-openai \
--azure-base-url "${{ inputs['azure-openai-endpoint'] }}" \
--azure-api-version "${{ inputs['azure-openai-api-version'] }}" \
--azure-env-key "${{ inputs['azure-openai-env-key'] }}"
;;
*)
echo "No model provider configured; skipping Codex config write."
;;
esac

- name: Drop sudo privilege, if appropriate
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' }}
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && steps.determine_provider.outputs.provider != 'none' }}
shell: bash
run: |
case "${RUNNER_OS}" in
Expand All @@ -227,7 +292,7 @@ runs:
esac

- name: Verify sudo privilege removed
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' }}
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && steps.determine_provider.outputs.provider != 'none' }}
shell: bash
run: |
if sudo -n true 2>/dev/null; then
Expand All @@ -252,9 +317,15 @@ runs:
CODEX_MODEL: ${{ inputs.model }}
CODEX_SAFETY_STRATEGY: ${{ inputs['safety-strategy'] }}
CODEX_USER: ${{ inputs['codex-user'] }}
CODEX_PROVIDER: ${{ steps.determine_provider.outputs.provider }}
CODEX_AZURE_API_KEY: ${{ inputs['azure-openai-api-key'] }}
CODEX_AZURE_ENV_KEY: ${{ inputs['azure-openai-env-key'] }}
FORCE_COLOR: 1
shell: bash
run: |
if [ "$CODEX_PROVIDER" = "azure-openai" ] && [ -n "$CODEX_AZURE_API_KEY" ]; then
export "$CODEX_AZURE_ENV_KEY"="$CODEX_AZURE_API_KEY"
fi
node "${{ github.action_path }}/dist/main.js" run-codex-exec \
--prompt "${CODEX_PROMPT}" \
--prompt-file "${CODEX_PROMPT_FILE}" \
Expand Down
148 changes: 129 additions & 19 deletions dist/main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ If you have effectively opened up your use of `openai/codex-action` to the world

## Protecting your `OPENAI_API_KEY`

No doubt your `OPENAI_API_KEY` is an important secret that you do not want to share with the world. **Be sure to use either `drop-sudo` or `unprivileged-user` to ensure it stays secret!**
No doubt your `OPENAI_API_KEY` is an important secret that you do not want to share with the world. **Be sure to use either `drop-sudo` or `unprivileged-user` to ensure it stays secret!** The same trade-offs apply if you wire up Azure OpenAI: when you populate `azure-openai-api-key`, the action exports that value to an environment variable (default `AZURE_OPENAI_API_KEY`) immediately before invoking `codex exec`, so the safeguards below remain essential.

To underscore the importance of specifying either `drop-sudo` or `unprivileged-user` as the `safety-strategy` for `openai/codex-action`, we provide [an example](../examples/test-sandbox-protections.yml) of how **the combination of read-only access to the filesystem and `sudo` can be used to expose your `OPENAI_API_KEY`**. This often surprises developers, as many expect the combination of "read-only access" and no network to be a sufficient safeguard, but this is not the case in the presence of passwordless `sudo` (which is the default on GitHub-hosted runners). Notably, Linux's [procfs](https://en.wikipedia.org/wiki/Procfs) makes a considerable amount of information available via file-read operations to a user with appropriate privileges.

Expand Down
111 changes: 107 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { dropSudo } from "./dropSudo";
import { ensureActorHasWriteAccess } from "./checkActorPermissions";
import parseArgsStringToArgv from "string-argv";
import { writeProxyConfig } from "./writeProxyConfig";
import { writeProxyConfig, ProviderConfig } from "./writeProxyConfig";
import { checkOutput } from "./checkOutput";

export async function main() {
Expand Down Expand Up @@ -80,19 +80,50 @@ export async function main() {
"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)
.requiredOption(
"--safety-strategy <strategy>",
"Safety strategy to use. One of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'."
)
.addOption(
new Option(
"--provider <provider>",
"Model provider to configure. One of 'openai-proxy' or 'azure-openai'."
).default("openai-proxy")
)
.option("--port <port>", "Proxy server port", parseIntStrict)
.option(
"--azure-base-url <URL>",
"Azure OpenAI endpoint base URL (for example: https://example.openai.azure.com/openai)"
)
.option(
"--azure-api-version <VERSION>",
"Azure OpenAI API version (for example: 2025-04-01-preview)"
)
.addOption(
new Option(
"--azure-env-key <NAME>",
"Environment variable name that will hold the Azure API key."
).default("AZURE_OPENAI_API_KEY")
)
.action(
async (options: {
codexHome: string;
port: number;
safetyStrategy: string;
provider: string;
port?: number;
azureBaseUrl?: string;
azureApiVersion?: string;
azureEnvKey?: string;
}) => {
const safetyStrategy = toSafetyStrategy(options.safetyStrategy);
await writeProxyConfig(options.codexHome, options.port, safetyStrategy);
const providerConfig = resolveProviderConfig({
provider: options.provider,
port: options.port,
azureBaseUrl: options.azureBaseUrl,
azureApiVersion: options.azureApiVersion,
azureEnvKey: options.azureEnvKey,
});
await writeProxyConfig(options.codexHome, safetyStrategy, providerConfig);
}
);

Expand Down Expand Up @@ -295,6 +326,61 @@ export async function main() {
program.parse();
}

function resolveProviderConfig({
provider,
port,
azureBaseUrl,
azureApiVersion,
azureEnvKey,
}: {
provider: string;
port?: number;
azureBaseUrl?: string;
azureApiVersion?: string;
azureEnvKey?: string;
}): ProviderConfig {
const normalizedProvider = provider.trim();
switch (normalizedProvider) {
case "openai-proxy": {
if (port == null || Number.isNaN(port)) {
throw new Error(
"The --port option must be provided when configuring the OpenAI proxy provider."
);
}
return {
type: "openai-proxy",
port,
};
}
case "azure-openai": {
const rawBaseUrl = emptyAsNull(azureBaseUrl ?? "");
if (rawBaseUrl == null) {
throw new Error(
"The --azure-base-url option must be provided when configuring the Azure provider."
);
}
const apiVersion = emptyAsNull(azureApiVersion ?? "");
if (apiVersion == null) {
throw new Error(
"The --azure-api-version option must be provided when configuring the Azure provider."
);
}
const envKey =
emptyAsNull(azureEnvKey ?? "") ?? "AZURE_OPENAI_API_KEY";
return {
type: "azure-openai",
baseUrl: normalizeAzureBaseUrl(rawBaseUrl),
apiVersion,
envKey,
};
}
default:
throw new Error(
`Unsupported provider '${normalizedProvider}'. Expected 'openai-proxy' or 'azure-openai'.`
);
}
}

function parseIntStrict(value: string): number {
const parsed = parseInt(value, 10);
if (isNaN(parsed)) {
Expand All @@ -303,6 +389,23 @@ function parseIntStrict(value: string): number {
return parsed;
}

function normalizeAzureBaseUrl(raw: string): string {
let base = raw.trim();
if (base.length === 0) {
throw new Error("Azure base URL must not be empty.");
}

// Remove trailing slash to avoid duplicating when we append /openai.
if (base.endsWith("/")) {
base = base.slice(0, -1);
}

if (!base.toLowerCase().endsWith("/openai")) {
base = `${base}/openai`;
}
return base;
}

function parseExtraArgs(value: string): Array<string> {
if (value.length === 0) {
return [];
Expand Down
Loading