openai/codex-action

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
pr18

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/main.ts

343lines · modecode

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