openai/codex-action

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
pr22

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/main.ts

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