openai/codex-action

Public

mirrored from https://github.com/openai/codex-actionAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
pr11

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

src/main.ts

337lines · modepreview

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";
import {
  SandboxMode,
  OutputSchemaSource,
  PromptSource,
  runCodexExec,
  SafetyStrategy,
} from "./runCodexExec";
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();

  program
    .name("codex-action")
    .version(pkg.version)
    .description("Multitool to support openai/codex-action.");

  program
    .command("read-server-info")
    .description("Read server info from the responses API proxy")
    .argument("<serverInfoFile>", "Path to the server info file")
    .action(async (serverInfoFile: string) => {
      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.")
    .addOption(new Option("--user <user>", "User to modify").default("runner"))
    .addOption(
      new Option("--group <group>", "Group granting sudo privileges").default(
        "sudo"
      )
    )
    .addOption(new Option("--root-phase", "internal").default(false).hideHelp())
    .action(
      async (options: { user: string; group: string; rootPhase: boolean }) => {
        await dropSudo({
          user: options.user,
          group: options.group,
          rootPhase: options.rootPhase,
        });
      }
    );

  program
    .command("run-codex-exec")
    .description("Invokes `codex exec` with the appropriate arguments")
    .requiredOption("--prompt <prompt>", "Prompt to pass to `codex exec`.")
    .requiredOption(
      "--prompt-file <FILE>",
      "File containing the prompt to pass to `codex exec`."
    )
    .requiredOption(
      "--codex-home <DIRECTORY>",
      "Path to the Codex CLI home directory (where config files are stored)."
    )
    .requiredOption("--cd <DIRECTORY>", "Working directory for Codex")
    .requiredOption(
      "--extra-args <args>",
      "Additional args to pass through to `codex exec` as JSON array or shell string.",
      parseExtraArgs
    )
    .requiredOption(
      "--output-file <FILE>",
      "Path where the final message from `codex exec` will be written."
    )
    .requiredOption(
      "--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(
      "--sandbox <SANDBOX>",
      "Sandbox mode override to pass to `codex exec`."
    )
    .requiredOption("--model <model>", "Model the agent should use")
    .requiredOption(
      "--safety-strategy <strategy>",
      "Safety strategy to use. One of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'."
    )
    .requiredOption(
      "--codex-user <user>",
      "User to run codex exec as when using the 'unprivileged-user' safety strategy."
    )
    .action(
      async (options: {
        prompt: string;
        promptFile: string;
        codexHome: string;
        cd: string;
        extraArgs: Array<string>;
        outputFile: string;
        outputSchemaFile: string;
        outputSchema: string;
        sandbox: string;
        model: string;
        safetyStrategy: string;
        codexUser: string;
      }) => {
        const {
          prompt,
          promptFile,
          outputFile,
          codexHome,
          cd,
          extraArgs,
          outputSchema,
          outputSchemaFile,
          sandbox,
          model,
          safetyStrategy,
          codexUser,
        } = options;

        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 };
        } else if (normalizedPromptFile != null) {
          promptSource = { type: "file", path: normalizedPromptFile };
        } else {
          throw new Error(
            "Either `prompt` or `prompt-file` must be specified."
          );
        }

        // 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),
          cd,
          extraArgs,
          explicitOutputFile: emptyAsNull(outputFile),
          outputSchema: outputSchemaSource,
          sandbox: toSandboxMode(sandbox),
          model: emptyAsNull(model),
          safetyStrategy: toSafetyStrategy(safetyStrategy),
          codexUser: emptyAsNull(codexUser),
        });
      }
    );

  program
    .command("check-write-access")
    .description(
      "Checks that the triggering actor has write access to the repository"
    )
    .option(
      "--allow-bots <boolean>",
      "Allow GitHub App and bot actors to bypass the write-access check (default: true).",
      parseBoolean,
      true
    )
    .action(async ({ allowBots }: { allowBots: boolean }) => {
      const result = await ensureActorHasWriteAccess({
        allowBotActors: allowBots,
      });
      switch (result.status) {
        case "approved": {
          console.log(`Actor '${result.actor}' is permitted to continue.`);
          break;
        }
        case "rejected": {
          const message = `Actor '${result.actor}' is not permitted to run this action: ${result.reason}`;
          console.error(message);
          throw new Error(message);
        }
      }
    });

  program.parse();
}

function parseIntStrict(value: string): number {
  const parsed = parseInt(value, 10);
  if (isNaN(parsed)) {
    throw new Error(`Invalid integer: ${value}`);
  }
  return parsed;
}

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

  if (value.startsWith("[")) {
    return JSON.parse(value);
  } else {
    return parseArgsStringToArgv(value);
  }
}

function toSafetyStrategy(value: string): SafetyStrategy {
  switch (value) {
    case "drop-sudo":
    case "read-only":
    case "unprivileged-user":
    case "unsafe":
      return value;
    default:
      throw new Error(
        `Invalid safety strategy: ${value}. Must be one of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'.`
      );
  }
}

function toSandboxMode(value: string): SandboxMode {
  switch (value) {
    case "read-only":
    case "workspace-write":
    case "danger-full-access":
      return value;
    default:
      throw new Error(
        `Invalid sandbox: ${value}. Must be one of 'read-only', 'workspace-write', or 'danger-full-access'.`
      );
  }
}

function emptyAsNull(value: string): string | null {
  return value.trim().length == 0 ? null : value;
}

function parseBoolean(value: string): boolean {
  const normalized = value.trim().toLowerCase();
  if (["true", "1", "yes", "y"].includes(normalized)) {
    return true;
  }
  if (["false", "0", "no", "n"].includes(normalized)) {
    return false;
  }
  throw new Error(`Invalid boolean value: ${value}`);
}

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;
}