openai/codex-action

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
pr5

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/main.ts

220lines · 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 "--output-schema-file <FILE>",
75 "Path to a schema file to pass to `codex exec --output-schema`."
76 )
77 .requiredOption(
78 "--safety-strategy <strategy>",
79 "Safety strategy to use. One of 'drop_sudo', 'read_only', 'unprivileged_user', or 'unsafe'."
80 )
81 .requiredOption(
82 "--codex-user <user>",
83 "User to run codex exec as when using the 'unprivileged_user' safety strategy."
84 )
85 .action(
86 async (options: {
87 prompt: string;
88 promptFile: string;
89 codexHome: string;
90 cd: string;
91 proxyPort: number;
92 extraArgs: Array<string>;
93 outputFile: string;
94 outputSchemaFile: string;
95 safetyStrategy: string;
96 codexUser: string;
97 }) => {
98 const {
99 prompt,
100 promptFile,
101 codexHome,
102 cd,
103 proxyPort,
104 extraArgs,
105 outputFile,
106 outputSchemaFile,
107 safetyStrategy,
108 codexUser,
109 } = options;
110
111 const normalizedPrompt = emptyAsNull(prompt);
112 const normalizedPromptFile = emptyAsNull(promptFile);
113 let promptSource: PromptSource;
114 if (normalizedPrompt != null) {
115 promptSource = { type: "text", content: normalizedPrompt };
116 } else if (normalizedPromptFile != null) {
117 promptSource = { type: "file", path: normalizedPromptFile };
118 } else {
119 throw new Error(
120 "Either `prompt` or `prompt_file` must be specified."
121 );
122 }
123
124 // Custom option processing to coerces to null does not work with
125 // Commander.js's requiredOption, so we have to post-process here.
126 await runCodexExec({
127 prompt: promptSource,
128 codexHome: emptyAsNull(codexHome),
129 cd,
130 proxyPort,
131 extraArgs,
132 explicitOutputFile: emptyAsNull(outputFile),
133 outputSchemaFile: emptyAsNull(outputSchemaFile),
134 safetyStrategy: toSafetyStrategy(safetyStrategy),
135 codexUser: emptyAsNull(codexUser),
136 });
137 }
138 );
139
140 program
141 .command("check-write-access")
142 .description(
143 "Checks that the triggering actor has write access to the repository"
144 )
145 .option(
146 "--allow-bots <boolean>",
147 "Allow GitHub App and bot actors to bypass the write-access check (default: true).",
148 parseBoolean,
149 true
150 )
151 .action(async ({ allowBots }: { allowBots: boolean }) => {
152 const result = await ensureActorHasWriteAccess({
153 allowBotActors: allowBots,
154 });
155 switch (result.status) {
156 case "approved": {
157 console.log(`Actor '${result.actor}' is permitted to continue.`);
158 break;
159 }
160 case "rejected": {
161 const message = `Actor '${result.actor}' is not permitted to run this action: ${result.reason}`;
162 console.error(message);
163 throw new Error(message);
164 }
165 }
166 });
167
168 program.parse();
169}
170
171function parseIntStrict(value: string): number {
172 const parsed = parseInt(value, 10);
173 if (isNaN(parsed)) {
174 throw new Error(`Invalid integer: ${value}`);
175 }
176 return parsed;
177}
178
179function parseExtraArgs(value: string): Array<string> {
180 if (value.length === 0) {
181 return [];
182 }
183
184 if (value.startsWith("[")) {
185 return JSON.parse(value);
186 } else {
187 return parseArgsStringToArgv(value);
188 }
189}
190
191function toSafetyStrategy(value: string): SafetyStrategy {
192 switch (value) {
193 case "drop_sudo":
194 case "read_only":
195 case "unprivileged_user":
196 case "unsafe":
197 return value;
198 default:
199 throw new Error(
200 `Invalid safety strategy: ${value}. Must be one of 'drop_sudo', 'read_only', 'unprivileged_user', or 'unsafe'.`
201 );
202 }
203}
204
205function emptyAsNull(value: string): string | null {
206 return value.trim().length == 0 ? null : value;
207}
208
209function parseBoolean(value: string): boolean {
210 const normalized = value.trim().toLowerCase();
211 if (["true", "1", "yes", "y"].includes(normalized)) {
212 return true;
213 }
214 if (["false", "0", "no", "n"].includes(normalized)) {
215 return false;
216 }
217 throw new Error(`Invalid boolean value: ${value}`);
218}
219
220main();
221