openai/codex-action

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
pr21

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/main.ts

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