openai/codex-action

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
pr4

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/main.ts

213lines · modecode

1import { Command, Option } from "commander";
2import pkg from "../package.json" assert { type: "json" };
3
4import { readServerInfo } from "./readServerInfo";
5import { PromptSource, runCodexExec, SafetyStrategy } from "./runCodexExec";
6import { dropSudo } from "./dropSudo";
7import { ensureActorHasWriteAccess } from "./checkActorPermissions";
8import parseArgsStringToArgv from "string-argv";
9
10export async function main() {
11 const program = new Command();
12
13 program
14 .name("codex-action")
15 .version(pkg.version)
16 .description("Multitool to support openai/codex-action.");
17
18 program
19 .command("read-server-info")
20 .description("Read server info from the responses API proxy")
21 .argument("<serverInfoFile>", "Path to the server info file")
22 .action(async (serverInfoFile: string) => {
23 await readServerInfo(serverInfoFile);
24 });
25
26 program
27 .command("drop-sudo")
28 .description("Drops sudo privileges for the configured user.")
29 .addOption(new Option("--user <user>", "User to modify").default("runner"))
30 .addOption(
31 new Option("--group <group>", "Group granting sudo privileges").default(
32 "sudo"
33 )
34 )
35 .addOption(new Option("--root-phase", "internal").default(false).hideHelp())
36 .action(
37 async (options: { user: string; group: string; rootPhase: boolean }) => {
38 await dropSudo({
39 user: options.user,
40 group: options.group,
41 rootPhase: options.rootPhase,
42 });
43 }
44 );
45
46 program
47 .command("run-codex-exec")
48 .description("Invokes `codex exec` with the appropriate arguments")
49 .requiredOption("--prompt <prompt>", "Prompt to pass to `codex exec`.")
50 .requiredOption(
51 "--prompt-file <FILE>",
52 "File containing the prompt to pass to `codex exec`."
53 )
54 .requiredOption(
55 "--codex-home <DIRECTORY>",
56 "Path to the Codex CLI home directory (where config files are stored)."
57 )
58 .requiredOption("--cd <DIRECTORY>", "Working directory for Codex")
59 .requiredOption(
60 "--proxy-port <port>",
61 "Port of the Responses API Proxy",
62 parseIntStrict
63 )
64 .requiredOption(
65 "--extra-args <args>",
66 "Additional args to pass through to `codex exec` as JSON array or shell string.",
67 parseExtraArgs
68 )
69 .requiredOption(
70 "--output-file <FILE>",
71 "Path where the final message from `codex exec` will be written."
72 )
73 .requiredOption(
74 "--safety-strategy <strategy>",
75 "Safety strategy to use. One of 'drop_sudo', 'read_only', 'unprivileged_user', or 'unsafe'."
76 )
77 .requiredOption(
78 "--codex-user <user>",
79 "User to run codex exec as when using the 'unprivileged_user' safety strategy."
80 )
81 .action(
82 async (options: {
83 prompt: string;
84 promptFile: string;
85 codexHome: string;
86 cd: string;
87 proxyPort: number;
88 extraArgs: Array<string>;
89 outputFile: string;
90 safetyStrategy: string;
91 codexUser: string;
92 }) => {
93 const {
94 prompt,
95 promptFile,
96 codexHome,
97 cd,
98 proxyPort,
99 extraArgs,
100 outputFile,
101 safetyStrategy,
102 codexUser,
103 } = options;
104
105 const normalizedPrompt = emptyAsNull(prompt);
106 const normalizedPromptFile = emptyAsNull(promptFile);
107 let promptSource: PromptSource;
108 if (normalizedPrompt != null) {
109 promptSource = { type: "text", content: normalizedPrompt };
110 } else if (normalizedPromptFile != null) {
111 promptSource = { type: "file", path: normalizedPromptFile };
112 } else {
113 throw new Error(
114 "Either `prompt` or `prompt_file` must be specified."
115 );
116 }
117
118 // Custom option processing to coerces to null does not work with
119 // Commander.js's requiredOption, so we have to post-process here.
120 await runCodexExec({
121 prompt: promptSource,
122 codexHome: emptyAsNull(codexHome),
123 cd,
124 proxyPort,
125 extraArgs,
126 explicitOutputFile: emptyAsNull(outputFile),
127 safetyStrategy: toSafetyStrategy(safetyStrategy),
128 codexUser: emptyAsNull(codexUser),
129 });
130 }
131 );
132
133 program
134 .command("check-write-access")
135 .description(
136 "Checks that the triggering actor has write access to the repository"
137 )
138 .option(
139 "--allow-bots <boolean>",
140 "Allow GitHub App and bot actors to bypass the write-access check (default: true).",
141 parseBoolean,
142 true
143 )
144 .action(async ({ allowBots }: { allowBots: boolean }) => {
145 const result = await ensureActorHasWriteAccess({
146 allowBotActors: allowBots,
147 });
148 switch (result.status) {
149 case "approved": {
150 console.log(`Actor '${result.actor}' is permitted to continue.`);
151 break;
152 }
153 case "rejected": {
154 const message = `Actor '${result.actor}' is not permitted to run this action: ${result.reason}`;
155 console.error(message);
156 throw new Error(message);
157 }
158 }
159 });
160
161 program.parse();
162}
163
164function parseIntStrict(value: string): number {
165 const parsed = parseInt(value, 10);
166 if (isNaN(parsed)) {
167 throw new Error(`Invalid integer: ${value}`);
168 }
169 return parsed;
170}
171
172function parseExtraArgs(value: string): Array<string> {
173 if (value.length === 0) {
174 return [];
175 }
176
177 if (value.startsWith("[")) {
178 return JSON.parse(value);
179 } else {
180 return parseArgsStringToArgv(value);
181 }
182}
183
184function toSafetyStrategy(value: string): SafetyStrategy {
185 switch (value) {
186 case "drop_sudo":
187 case "read_only":
188 case "unprivileged_user":
189 case "unsafe":
190 return value;
191 default:
192 throw new Error(
193 `Invalid safety strategy: ${value}. Must be one of 'drop_sudo', 'read_only', 'unprivileged_user', or 'unsafe'.`
194 );
195 }
196}
197
198function emptyAsNull(value: string): string | null {
199 return value.trim().length == 0 ? null : value;
200}
201
202function parseBoolean(value: string): boolean {
203 const normalized = value.trim().toLowerCase();
204 if (["true", "1", "yes", "y"].includes(normalized)) {
205 return true;
206 }
207 if (["false", "0", "no", "n"].includes(normalized)) {
208 return false;
209 }
210 throw new Error(`Invalid boolean value: ${value}`);
211}
212
213main();
214