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;
}openai/codex-action
Publicmirrored from https://github.com/openai/codex-actionAvailable
src/main.ts
337lines · modepreview