openai/codex-action
Publicmirrored from https://github.com/openai/codex-actionAvailable
src/main.ts
452lines · modeblame
e9d0a695Michael Bolin10 months ago | 1 | import { Command, Option } from "commander"; |
1c44f3d7Michael Bolin10 months ago | 2 | import * as fs from "node:fs/promises"; |
| 3 | import * as os from "node:os"; | |
| 4 | import * as path from "node:path"; | |
e9d0a695Michael Bolin10 months ago | 5 | import pkg from "../package.json" assert { type: "json" }; |
| 6 | | |
| 7 | import { readServerInfo } from "./readServerInfo"; | |
bc00fb04Michael Bolin10 months ago | 8 | import { |
911c5cb9Michael Bolin10 months ago | 9 | SandboxMode, |
bc00fb04Michael Bolin10 months ago | 10 | OutputSchemaSource, |
| 11 | PromptSource, | |
| 12 | runCodexExec, | |
| 13 | SafetyStrategy, | |
| 14 | } from "./runCodexExec"; | |
e9d0a695Michael Bolin10 months ago | 15 | import { dropSudo } from "./dropSudo"; |
| 16 | import { ensureActorHasWriteAccess } from "./checkActorPermissions"; | |
| 17 | import parseArgsStringToArgv from "string-argv"; | |
1c44f3d7Michael Bolin10 months ago | 18 | import { writeProxyConfig } from "./writeProxyConfig"; |
07db7939Michael Bolin10 months ago | 19 | import { checkOutput } from "./checkOutput"; |
e9d0a695Michael Bolin10 months ago | 20 | |
| 21 | export async function main() { | |
| 22 | const program = new Command(); | |
| 23 | | |
| 24 | program | |
151b4352Michael Bolin10 months ago | 25 | .name("codex-action") |
e9d0a695Michael Bolin10 months ago | 26 | .version(pkg.version) |
151b4352Michael Bolin10 months ago | 27 | .description("Multitool to support openai/codex-action."); |
e9d0a695Michael Bolin10 months ago | 28 | |
| 29 | program | |
| 30 | .command("read-server-info") | |
| 31 | .description("Read server info from the responses API proxy") | |
| 32 | .argument("<serverInfoFile>", "Path to the server info file") | |
| 33 | .action(async (serverInfoFile: string) => { | |
| 34 | await readServerInfo(serverInfoFile); | |
| 35 | }); | |
| 36 | | |
1c44f3d7Michael Bolin10 months ago | 37 | program |
| 38 | .command("resolve-codex-home") | |
| 39 | .description( | |
| 40 | "Resolve the Codex home directory with precedence: input, env, default (~/.codex)" | |
| 41 | ) | |
07db7939Michael Bolin10 months ago | 42 | .requiredOption( |
| 43 | "--codex-home-override <DIRECTORY>", | |
1c44f3d7Michael Bolin10 months ago | 44 | "Optional codex-home input value (may be empty)" |
| 45 | ) | |
07db7939Michael Bolin10 months ago | 46 | .requiredOption( |
| 47 | "--safety-strategy <strategy>", | |
| 48 | "Safety strategy to take into account when picking defaults" | |
| 49 | ) | |
| 50 | .requiredOption( | |
| 51 | "--codex-user <user>", | |
| 52 | "Codex user to consider when safety strategy is 'unprivileged-user'" | |
| 53 | ) | |
bcb3128dMichael Bolin10 months ago | 54 | .requiredOption("--github-run-id <id>", "GitHub run ID") |
07db7939Michael Bolin10 months ago | 55 | .action( |
| 56 | async (options: { | |
| 57 | codexHomeOverride: string; | |
| 58 | safetyStrategy: string; | |
| 59 | codexUser: string; | |
bcb3128dMichael Bolin10 months ago | 60 | githubRunId: string; |
07db7939Michael Bolin10 months ago | 61 | }) => { |
| 62 | const safetyStrategy = toSafetyStrategy(options.safetyStrategy); | |
| 63 | const codexUser = emptyAsNull(options.codexUser); | |
| 64 | const resolved = await resolveCodexHome( | |
| 65 | emptyAsNull(options.codexHomeOverride), | |
| 66 | safetyStrategy, | |
bcb3128dMichael Bolin10 months ago | 67 | codexUser, |
| 68 | options.githubRunId | |
07db7939Michael Bolin10 months ago | 69 | ); |
| 70 | // Ensure directory exists for downstream steps that will write files here. | |
| 71 | await fs.mkdir(resolved, { recursive: true }); | |
| 72 | if (safetyStrategy === "unprivileged-user") { | |
| 73 | await ensureDirIsWorldReadable(resolved); | |
| 74 | } | |
| 75 | const { setOutput } = await import("@actions/core"); | |
| 76 | setOutput("codex-home", resolved); | |
| 77 | console.log(`Resolved Codex home: ${resolved}`); | |
| 78 | } | |
| 79 | ); | |
1c44f3d7Michael Bolin10 months ago | 80 | |
| 81 | program | |
| 82 | .command("write-proxy-config") | |
| 83 | .description( | |
| 84 | "Write the OpenAI Proxy model provider config into CODEX_HOME/config.toml" | |
| 85 | ) | |
| 86 | .requiredOption("--codex-home <DIRECTORY>", "Path to Codex home directory") | |
| 87 | .requiredOption("--port <port>", "Proxy server port", parseIntStrict) | |
bcb3128dMichael Bolin10 months ago | 88 | .requiredOption( |
| 89 | "--safety-strategy <strategy>", | |
| 90 | "Safety strategy to use. One of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'." | |
| 91 | ) | |
| 92 | .action( | |
| 93 | async (options: { | |
| 94 | codexHome: string; | |
| 95 | port: number; | |
| 96 | safetyStrategy: string; | |
| 97 | }) => { | |
| 98 | const safetyStrategy = toSafetyStrategy(options.safetyStrategy); | |
| 99 | await writeProxyConfig(options.codexHome, options.port, safetyStrategy); | |
| 100 | } | |
| 101 | ); | |
1c44f3d7Michael Bolin10 months ago | 102 | |
e9d0a695Michael Bolin10 months ago | 103 | program |
| 104 | .command("drop-sudo") | |
| 105 | .description("Drops sudo privileges for the configured user.") | |
| 106 | .addOption(new Option("--user <user>", "User to modify").default("runner")) | |
| 107 | .addOption( | |
| 108 | new Option("--group <group>", "Group granting sudo privileges").default( | |
| 109 | "sudo" | |
| 110 | ) | |
| 111 | ) | |
| 112 | .addOption(new Option("--root-phase", "internal").default(false).hideHelp()) | |
| 113 | .action( | |
| 114 | async (options: { user: string; group: string; rootPhase: boolean }) => { | |
| 115 | await dropSudo({ | |
| 116 | user: options.user, | |
| 117 | group: options.group, | |
| 118 | rootPhase: options.rootPhase, | |
| 119 | }); | |
| 120 | } | |
| 121 | ); | |
| 122 | | |
| 123 | program | |
| 124 | .command("run-codex-exec") | |
| 125 | .description("Invokes `codex exec` with the appropriate arguments") | |
| 126 | .requiredOption("--prompt <prompt>", "Prompt to pass to `codex exec`.") | |
| 127 | .requiredOption( | |
| 128 | "--prompt-file <FILE>", | |
| 129 | "File containing the prompt to pass to `codex exec`." | |
| 130 | ) | |
| 131 | .requiredOption( | |
| 132 | "--codex-home <DIRECTORY>", | |
| 133 | "Path to the Codex CLI home directory (where config files are stored)." | |
| 134 | ) | |
| 135 | .requiredOption("--cd <DIRECTORY>", "Working directory for Codex") | |
| 136 | .requiredOption( | |
| 137 | "--extra-args <args>", | |
| 138 | "Additional args to pass through to `codex exec` as JSON array or shell string.", | |
| 139 | parseExtraArgs | |
| 140 | ) | |
| 141 | .requiredOption( | |
| 142 | "--output-file <FILE>", | |
| 143 | "Path where the final message from `codex exec` will be written." | |
| 144 | ) | |
b8896131Michael Bolin10 months ago | 145 | .requiredOption( |
| 146 | "--output-schema-file <FILE>", | |
| 147 | "Path to a schema file to pass to `codex exec --output-schema`." | |
| 148 | ) | |
bc00fb04Michael Bolin10 months ago | 149 | .requiredOption( |
| 150 | "--output-schema <SCHEMA>", | |
| 151 | "Inline schema contents to pass to `codex exec --output-schema`." | |
| 152 | ) | |
911c5cb9Michael Bolin10 months ago | 153 | .requiredOption( |
| 154 | "--sandbox <SANDBOX>", | |
| 155 | "Sandbox mode override to pass to `codex exec`." | |
| 156 | ) | |
7304b0a7Michael Bolin10 months ago | 157 | .requiredOption("--model <model>", "Model the agent should use") |
e9d0a695Michael Bolin10 months ago | 158 | .requiredOption( |
| 159 | "--safety-strategy <strategy>", | |
d49a23a3Michael Bolin10 months ago | 160 | "Safety strategy to use. One of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'." |
e9d0a695Michael Bolin10 months ago | 161 | ) |
| 162 | .requiredOption( | |
| 163 | "--codex-user <user>", | |
d49a23a3Michael Bolin10 months ago | 164 | "User to run codex exec as when using the 'unprivileged-user' safety strategy." |
e9d0a695Michael Bolin10 months ago | 165 | ) |
| 166 | .action( | |
| 167 | async (options: { | |
| 168 | prompt: string; | |
| 169 | promptFile: string; | |
| 170 | codexHome: string; | |
| 171 | cd: string; | |
| 172 | extraArgs: Array<string>; | |
| 173 | outputFile: string; | |
b8896131Michael Bolin10 months ago | 174 | outputSchemaFile: string; |
bc00fb04Michael Bolin10 months ago | 175 | outputSchema: string; |
911c5cb9Michael Bolin10 months ago | 176 | sandbox: string; |
7304b0a7Michael Bolin10 months ago | 177 | model: string; |
e9d0a695Michael Bolin10 months ago | 178 | safetyStrategy: string; |
| 179 | codexUser: string; | |
| 180 | }) => { | |
| 181 | const { | |
| 182 | prompt, | |
| 183 | promptFile, | |
bc00fb04Michael Bolin10 months ago | 184 | outputFile, |
e9d0a695Michael Bolin10 months ago | 185 | codexHome, |
| 186 | cd, | |
| 187 | extraArgs, | |
bc00fb04Michael Bolin10 months ago | 188 | outputSchema, |
b8896131Michael Bolin10 months ago | 189 | outputSchemaFile, |
911c5cb9Michael Bolin10 months ago | 190 | sandbox, |
7304b0a7Michael Bolin10 months ago | 191 | model, |
e9d0a695Michael Bolin10 months ago | 192 | safetyStrategy, |
| 193 | codexUser, | |
| 194 | } = options; | |
| 195 | | |
| 196 | const normalizedPrompt = emptyAsNull(prompt); | |
| 197 | const normalizedPromptFile = emptyAsNull(promptFile); | |
1c44f3d7Michael Bolin10 months ago | 198 | if (normalizedPrompt != null && normalizedPromptFile != null) { |
| 199 | throw new Error( | |
| 200 | "Only one of `prompt` or `prompt-file` may be specified." | |
| 201 | ); | |
| 202 | } | |
| 203 | | |
e9d0a695Michael Bolin10 months ago | 204 | let promptSource: PromptSource; |
| 205 | if (normalizedPrompt != null) { | |
bc00fb04Michael Bolin10 months ago | 206 | promptSource = { type: "inline", content: normalizedPrompt }; |
e9d0a695Michael Bolin10 months ago | 207 | } else if (normalizedPromptFile != null) { |
| 208 | promptSource = { type: "file", path: normalizedPromptFile }; | |
| 209 | } else { | |
| 210 | throw new Error( | |
d49a23a3Michael Bolin10 months ago | 211 | "Either `prompt` or `prompt-file` must be specified." |
e9d0a695Michael Bolin10 months ago | 212 | ); |
| 213 | } | |
| 214 | | |
| 215 | // Custom option processing to coerces to null does not work with | |
| 216 | // Commander.js's requiredOption, so we have to post-process here. | |
bc00fb04Michael Bolin10 months ago | 217 | const normalizedOutputSchemaFile = emptyAsNull(outputSchemaFile); |
| 218 | const normalizedOutputSchema = emptyAsNull(outputSchema); | |
| 219 | | |
| 220 | if ( | |
| 221 | normalizedOutputSchemaFile != null && | |
| 222 | normalizedOutputSchema != null | |
| 223 | ) { | |
| 224 | throw new Error( | |
d49a23a3Michael Bolin10 months ago | 225 | "Only one of `output-schema` or `output-schema-file` may be specified." |
bc00fb04Michael Bolin10 months ago | 226 | ); |
| 227 | } | |
| 228 | | |
| 229 | let outputSchemaSource: OutputSchemaSource | null = null; | |
| 230 | if (normalizedOutputSchema != null) { | |
| 231 | outputSchemaSource = { | |
| 232 | type: "inline", | |
| 233 | content: normalizedOutputSchema, | |
| 234 | }; | |
| 235 | } else if (normalizedOutputSchemaFile != null) { | |
| 236 | outputSchemaSource = { | |
| 237 | type: "file", | |
| 238 | path: normalizedOutputSchemaFile, | |
| 239 | }; | |
| 240 | } | |
| 241 | | |
e9d0a695Michael Bolin10 months ago | 242 | await runCodexExec({ |
| 243 | prompt: promptSource, | |
| 244 | codexHome: emptyAsNull(codexHome), | |
| 245 | cd, | |
| 246 | extraArgs, | |
| 247 | explicitOutputFile: emptyAsNull(outputFile), | |
bc00fb04Michael Bolin10 months ago | 248 | outputSchema: outputSchemaSource, |
911c5cb9Michael Bolin10 months ago | 249 | sandbox: toSandboxMode(sandbox), |
7304b0a7Michael Bolin10 months ago | 250 | model: emptyAsNull(model), |
e9d0a695Michael Bolin10 months ago | 251 | safetyStrategy: toSafetyStrategy(safetyStrategy), |
| 252 | codexUser: emptyAsNull(codexUser), | |
| 253 | }); | |
| 254 | } | |
| 255 | ); | |
| 256 | | |
| 257 | program | |
| 258 | .command("check-write-access") | |
| 259 | .description( | |
| 260 | "Checks that the triggering actor has write access to the repository" | |
| 261 | ) | |
| 262 | .option( | |
| 263 | "--allow-bots <boolean>", | |
| 264 | "Allow GitHub App and bot actors to bypass the write-access check (default: true).", | |
| 265 | parseBoolean, | |
| 266 | true | |
| 267 | ) | |
b8d81868Michael Bolin10 months ago | 268 | .option( |
| 269 | "--allow-users <users>", | |
| 270 | "Comma-separated list of GitHub usernames who can run this action, or '*' to allow all users.", | |
| 271 | "" | |
| 272 | ) | |
07db7939Michael Bolin10 months ago | 273 | .action( |
| 274 | async ({ | |
| 275 | allowBots, | |
b8d81868Michael Bolin10 months ago | 276 | allowUsers, |
07db7939Michael Bolin10 months ago | 277 | }: { |
| 278 | allowBots: boolean; | |
| 279 | allowUsers: string; | |
| 280 | }) => { | |
| 281 | const result = await ensureActorHasWriteAccess({ | |
| 282 | allowBotActors: allowBots, | |
| 283 | allowUsers, | |
| 284 | }); | |
| 285 | switch (result.status) { | |
| 286 | case "approved": { | |
| 287 | console.log(`Actor '${result.actor}' is permitted to continue.`); | |
| 288 | break; | |
| 289 | } | |
| 290 | case "rejected": { | |
| 291 | const message = `Actor '${result.actor}' is not permitted to run this action: ${result.reason}`; | |
| 292 | console.error(message); | |
| 293 | throw new Error(message); | |
| 294 | } | |
e9d0a695Michael Bolin10 months ago | 295 | } |
| 296 | } | |
07db7939Michael Bolin10 months ago | 297 | ); |
e9d0a695Michael Bolin10 months ago | 298 | |
| 299 | program.parse(); | |
| 300 | } | |
| 301 | | |
| 302 | function parseIntStrict(value: string): number { | |
| 303 | const parsed = parseInt(value, 10); | |
| 304 | if (isNaN(parsed)) { | |
| 305 | throw new Error(`Invalid integer: ${value}`); | |
| 306 | } | |
| 307 | return parsed; | |
| 308 | } | |
| 309 | | |
| 310 | function parseExtraArgs(value: string): Array<string> { | |
| 311 | if (value.length === 0) { | |
| 312 | return []; | |
| 313 | } | |
| 314 | | |
| 315 | if (value.startsWith("[")) { | |
| 316 | return JSON.parse(value); | |
| 317 | } else { | |
| 318 | return parseArgsStringToArgv(value); | |
| 319 | } | |
| 320 | } | |
| 321 | | |
| 322 | function toSafetyStrategy(value: string): SafetyStrategy { | |
| 323 | switch (value) { | |
d49a23a3Michael Bolin10 months ago | 324 | case "drop-sudo": |
| 325 | case "read-only": | |
| 326 | case "unprivileged-user": | |
e9d0a695Michael Bolin10 months ago | 327 | case "unsafe": |
| 328 | return value; | |
| 329 | default: | |
| 330 | throw new Error( | |
d49a23a3Michael Bolin10 months ago | 331 | `Invalid safety strategy: ${value}. Must be one of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'.` |
e9d0a695Michael Bolin10 months ago | 332 | ); |
| 333 | } | |
| 334 | } | |
| 335 | | |
911c5cb9Michael Bolin10 months ago | 336 | function toSandboxMode(value: string): SandboxMode { |
| 337 | switch (value) { | |
| 338 | case "read-only": | |
| 339 | case "workspace-write": | |
| 340 | case "danger-full-access": | |
| 341 | return value; | |
| 342 | default: | |
| 343 | throw new Error( | |
| 344 | `Invalid sandbox: ${value}. Must be one of 'read-only', 'workspace-write', or 'danger-full-access'.` | |
| 345 | ); | |
| 346 | } | |
| 347 | } | |
| 348 | | |
e9d0a695Michael Bolin10 months ago | 349 | function emptyAsNull(value: string): string | null { |
| 350 | return value.trim().length == 0 ? null : value; | |
| 351 | } | |
| 352 | | |
| 353 | function parseBoolean(value: string): boolean { | |
| 354 | const normalized = value.trim().toLowerCase(); | |
| 355 | if (["true", "1", "yes", "y"].includes(normalized)) { | |
| 356 | return true; | |
| 357 | } | |
| 358 | if (["false", "0", "no", "n"].includes(normalized)) { | |
| 359 | return false; | |
| 360 | } | |
| 361 | throw new Error(`Invalid boolean value: ${value}`); | |
| 362 | } | |
| 363 | | |
| 364 | main(); | |
1c44f3d7Michael Bolin10 months ago | 365 | |
| 366 | async function resolveCodexHome( | |
07db7939Michael Bolin10 months ago | 367 | inputCodexHome: string | null, |
| 368 | safetyStrategy: SafetyStrategy, | |
bcb3128dMichael Bolin10 months ago | 369 | codexUser: string | null, |
| 370 | githubRunId: string | |
1c44f3d7Michael Bolin10 months ago | 371 | ): Promise<string> { |
| 372 | if (inputCodexHome != null) { | |
| 373 | return expandTilde(inputCodexHome); | |
| 374 | } | |
| 375 | const envHome = emptyAsNull(process.env.CODEX_HOME ?? ""); | |
| 376 | if (envHome != null) { | |
| 377 | return envHome; | |
| 378 | } | |
07db7939Michael Bolin10 months ago | 379 | if (safetyStrategy === "unprivileged-user") { |
| 380 | if (codexUser == null) { | |
| 381 | throw new Error( | |
| 382 | "codex-user input must be provided when using 'unprivileged-user' safety strategy and no codex-home is specified." | |
| 383 | ); | |
| 384 | } | |
| 385 | | |
bcb3128dMichael Bolin10 months ago | 386 | return await deriveSharedCodexHomeForUnprivilegedUser( |
| 387 | codexUser, | |
| 388 | githubRunId | |
| 389 | ); | |
07db7939Michael Bolin10 months ago | 390 | } |
1c44f3d7Michael Bolin10 months ago | 391 | return path.join(os.homedir(), ".codex"); |
| 392 | } | |
| 393 | | |
07db7939Michael Bolin10 months ago | 394 | async function ensureDirIsWorldReadable(dir: string): Promise<void> { |
| 395 | if (process.platform === "win32") { | |
| 396 | return; | |
| 397 | } | |
| 398 | try { | |
| 399 | await fs.chmod(dir, 0o755); | |
| 400 | } catch { | |
| 401 | // Best-effort: ignore chmod failures so the command still succeeds. | |
| 402 | } | |
| 403 | } | |
| 404 | | |
bcb3128dMichael Bolin10 months ago | 405 | async function deriveSharedCodexHomeForUnprivilegedUser( |
| 406 | user: string, | |
| 407 | githubRunId: string | |
| 408 | ): Promise<string> { | |
07db7939Michael Bolin10 months ago | 409 | const home = ( |
| 410 | await checkOutput(["sudo", "-u", user, "--", "printenv", "HOME"]) | |
| 411 | ).trim(); | |
| 412 | if (!home) { | |
| 413 | throw new Error(`Could not determine home directory for user '${user}'.`); | |
| 414 | } | |
bcb3128dMichael Bolin10 months ago | 415 | const codexHome = path.join(home, ".codex"); |
| 416 | try { | |
| 417 | const stat = await fs.stat(codexHome); | |
| 418 | if (stat.isDirectory()) { | |
| 419 | // Directory already exists and may contain a config.toml created by the | |
| 420 | // user (or a previous invocation of codex-action), so assume it's | |
| 421 | // correctly permissioned. | |
| 422 | return codexHome; | |
| 423 | } | |
| 424 | } catch { | |
| 425 | // Ignore stat errors and try to create the directory. | |
| 426 | } | |
| 427 | | |
| 428 | // We must use sudo for the following file system operations because we | |
| 429 | // are writing to the home directory of a different user. | |
| 430 | await checkOutput(["sudo", "mkdir", codexHome]); | |
| 431 | await checkOutput(["sudo", "chown", `${user}`, codexHome]); | |
| 432 | await checkOutput(["sudo", "chmod", "755", codexHome]); | |
| 433 | | |
| 434 | // codex-responses-api-proxy will need to write the server info file. | |
| 435 | const serverInfoFile = path.join(codexHome, `${githubRunId}.json`); | |
| 436 | await checkOutput(["sudo", "touch", serverInfoFile]); | |
| 437 | // Make the file world-writable for the moment, but this will be locked down | |
| 438 | // to read-only by root before the action completes. | |
| 439 | await checkOutput(["sudo", "chmod", "666", serverInfoFile]); | |
| 440 | | |
| 441 | return codexHome; | |
07db7939Michael Bolin10 months ago | 442 | } |
| 443 | | |
1c44f3d7Michael Bolin10 months ago | 444 | function expandTilde(p: string): string { |
| 445 | if (p === "~") { | |
| 446 | return os.homedir(); | |
| 447 | } | |
| 448 | if (p.startsWith("~/") || p.startsWith("~\\")) { | |
| 449 | return path.join(os.homedir(), p.slice(2)); | |
| 450 | } | |
| 451 | return p; | |
| 452 | } |