openai/codex-action

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
pr10

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/main.ts

281lines · modecode

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