openai/codex-action

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
bc00fb04d1ab89edaa8d990cff603506ef5cf158

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/main.ts

260lines · modecode

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