openai/codex-action

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
main

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/main.ts

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