openai/codex-action

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
codex/viyatb/use-runner-temp-for-output

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/main.ts

451lines · 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 getCodexExecTempRoot,
10 SandboxMode,
11 OutputSchemaSource,
12 PromptSource,
13 runCodexExec,
14 SafetyStrategy,
15} from "./runCodexExec";
16import { dropSudo } from "./dropSudo";
17import { ensureActorHasWriteAccess } from "./checkActorPermissions";
18import parseArgsStringToArgv from "string-argv";
19import { writeProxyConfig } from "./writeProxyConfig";
20import { checkOutput } from "./checkOutput";
21
22export async function main() {
23 const program = new Command();
24
25 program
26 .name("codex-action")
27 .version(pkg.version)
28 .description("Multitool to support openai/codex-action.");
29
30 program
31 .command("read-server-info")
32 .description("Read server info from the responses API proxy")
33 .argument("<serverInfoFile>", "Path to the server info file")
34 .action(async (serverInfoFile: string) => {
35 await readServerInfo(serverInfoFile);
36 });
37
38 program
39 .command("resolve-codex-home")
40 .description(
41 "Resolve the Codex home directory with precedence: input, env, default (~/.codex)"
42 )
43 .requiredOption(
44 "--codex-home-override <DIRECTORY>",
45 "Optional codex-home input value (may be empty)"
46 )
47 .requiredOption(
48 "--safety-strategy <strategy>",
49 "Safety strategy to take into account when picking defaults"
50 )
51 .requiredOption(
52 "--codex-user <user>",
53 "Codex user to consider when safety strategy is 'unprivileged-user'"
54 )
55 .requiredOption("--github-run-id <id>", "GitHub run ID")
56 .action(
57 async (options: {
58 codexHomeOverride: string;
59 safetyStrategy: string;
60 codexUser: string;
61 githubRunId: string;
62 }) => {
63 const safetyStrategy = toSafetyStrategy(options.safetyStrategy);
64 const codexUser = emptyAsNull(options.codexUser);
65 const resolved = await resolveCodexHome(
66 emptyAsNull(options.codexHomeOverride),
67 safetyStrategy,
68 codexUser,
69 options.githubRunId
70 );
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 .requiredOption(
86 "--safety-strategy <strategy>",
87 "Safety strategy to use. One of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'."
88 )
89 .action(
90 async (options: {
91 codexHome: string;
92 port: number;
93 safetyStrategy: string;
94 }) => {
95 const safetyStrategy = toSafetyStrategy(options.safetyStrategy);
96 await writeProxyConfig(options.codexHome, options.port, safetyStrategy);
97 }
98 );
99
100 program
101 .command("drop-sudo")
102 .description("Drops sudo privileges for the configured user.")
103 .addOption(new Option("--user <user>", "User to modify").default("runner"))
104 .addOption(
105 new Option("--group <group>", "Group granting sudo privileges").default(
106 "sudo"
107 )
108 )
109 .addOption(new Option("--root-phase", "internal").default(false).hideHelp())
110 .action(
111 async (options: { user: string; group: string; rootPhase: boolean }) => {
112 await dropSudo({
113 user: options.user,
114 group: options.group,
115 rootPhase: options.rootPhase,
116 });
117 }
118 );
119
120 program
121 .command("run-codex-exec")
122 .description("Invokes `codex exec` with the appropriate arguments")
123 .requiredOption("--prompt <prompt>", "Prompt to pass to `codex exec`.")
124 .requiredOption(
125 "--prompt-file <FILE>",
126 "File containing the prompt to pass to `codex exec`."
127 )
128 .requiredOption(
129 "--codex-home <DIRECTORY>",
130 "Path to the Codex CLI home directory (where config files are stored)."
131 )
132 .requiredOption("--cd <DIRECTORY>", "Working directory for Codex")
133 .requiredOption(
134 "--extra-args <args>",
135 "Additional args to pass through to `codex exec` as JSON array or shell string.",
136 parseExtraArgs
137 )
138 .requiredOption(
139 "--output-file <FILE>",
140 "Path where the final message from `codex exec` will be written."
141 )
142 .requiredOption(
143 "--output-schema-file <FILE>",
144 "Path to a schema file to pass to `codex exec --output-schema`."
145 )
146 .requiredOption(
147 "--output-schema <SCHEMA>",
148 "Inline schema contents to pass to `codex exec --output-schema`."
149 )
150 .requiredOption(
151 "--sandbox <SANDBOX>",
152 "Sandbox mode override to pass to `codex exec`."
153 )
154 .requiredOption("--model <model>", "Model the agent should use")
155 .requiredOption("--effort <effort>", "Reasoning effort the agent should use")
156 .requiredOption(
157 "--safety-strategy <strategy>",
158 "Safety strategy to use. One of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'."
159 )
160 .requiredOption(
161 "--codex-user <user>",
162 "User to run codex exec as when using the 'unprivileged-user' safety strategy."
163 )
164 .action(
165 async (options: {
166 prompt: string;
167 promptFile: string;
168 codexHome: string;
169 cd: string;
170 extraArgs: Array<string>;
171 outputFile: string;
172 outputSchemaFile: string;
173 outputSchema: string;
174 sandbox: string;
175 model: string;
176 effort: string;
177 safetyStrategy: string;
178 codexUser: string;
179 }) => {
180 const {
181 prompt,
182 promptFile,
183 outputFile,
184 codexHome,
185 cd,
186 extraArgs,
187 outputSchema,
188 outputSchemaFile,
189 sandbox,
190 model,
191 effort,
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 effort: emptyAsNull(effort),
252 safetyStrategy: toSafetyStrategy(safetyStrategy),
253 codexUser: emptyAsNull(codexUser),
254 });
255 }
256 );
257
258 program
259 .command("check-write-access")
260 .description(
261 "Checks that the triggering actor has write access to the repository"
262 )
263 .option(
264 "--allow-bots <boolean>",
265 "Allow GitHub App and bot actors to bypass the write-access check (default: true).",
266 parseBoolean,
267 true
268 )
269 .option(
270 "--allow-users <users>",
271 "Comma-separated list of GitHub usernames who can run this action, or '*' to allow all users.",
272 ""
273 )
274 .action(
275 async ({
276 allowBots,
277 allowUsers,
278 }: {
279 allowBots: boolean;
280 allowUsers: string;
281 }) => {
282 const result = await ensureActorHasWriteAccess({
283 allowBotActors: allowBots,
284 allowUsers,
285 });
286 switch (result.status) {
287 case "approved": {
288 console.log(`Actor '${result.actor}' is permitted to continue.`);
289 break;
290 }
291 case "rejected": {
292 const message = `Actor '${result.actor}' is not permitted to run this action: ${result.reason}`;
293 console.error(message);
294 throw new Error(message);
295 }
296 }
297 }
298 );
299
300 program.parse();
301}
302
303function parseIntStrict(value: string): number {
304 const parsed = parseInt(value, 10);
305 if (isNaN(parsed)) {
306 throw new Error(`Invalid integer: ${value}`);
307 }
308 return parsed;
309}
310
311function parseExtraArgs(value: string): Array<string> {
312 if (value.length === 0) {
313 return [];
314 }
315
316 if (value.startsWith("[")) {
317 return JSON.parse(value);
318 } else {
319 return parseArgsStringToArgv(value);
320 }
321}
322
323function toSafetyStrategy(value: string): SafetyStrategy {
324 switch (value) {
325 case "drop-sudo":
326 case "read-only":
327 case "unprivileged-user":
328 case "unsafe":
329 return value;
330 default:
331 throw new Error(
332 `Invalid safety strategy: ${value}. Must be one of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'.`
333 );
334 }
335}
336
337function toSandboxMode(value: string): SandboxMode {
338 switch (value) {
339 case "read-only":
340 case "workspace-write":
341 case "danger-full-access":
342 return value;
343 default:
344 throw new Error(
345 `Invalid sandbox: ${value}. Must be one of 'read-only', 'workspace-write', or 'danger-full-access'.`
346 );
347 }
348}
349
350function emptyAsNull(value: string): string | null {
351 return value.trim().length == 0 ? null : value;
352}
353
354function parseBoolean(value: string): boolean {
355 const normalized = value.trim().toLowerCase();
356 if (["true", "1", "yes", "y"].includes(normalized)) {
357 return true;
358 }
359 if (["false", "0", "no", "n"].includes(normalized)) {
360 return false;
361 }
362 throw new Error(`Invalid boolean value: ${value}`);
363}
364
365export { getCodexExecTempRoot };
366
367if (require.main === module) {
368 main();
369}
370
371async function resolveCodexHome(
372 inputCodexHome: string | null,
373 safetyStrategy: SafetyStrategy,
374 codexUser: string | null,
375 githubRunId: string
376): Promise<string> {
377 if (inputCodexHome != null) {
378 return expandTilde(inputCodexHome);
379 }
380 const envHome = emptyAsNull(process.env.CODEX_HOME ?? "");
381 if (envHome != null) {
382 return envHome;
383 }
384
385 if (safetyStrategy === "unprivileged-user") {
386 if (codexUser == null) {
387 throw new Error(
388 "codex-user input must be provided when using 'unprivileged-user' safety strategy and no codex-home is specified."
389 );
390 }
391
392 return await deriveSharedCodexHomeForUnprivilegedUser(
393 codexUser,
394 githubRunId
395 );
396 } else {
397 const codexHome = path.join(os.homedir(), ".codex");
398 // Ensure directory exists for downstream steps that will write files here.
399 await fs.mkdir(codexHome, { recursive: true });
400 return codexHome;
401 }
402}
403
404async function deriveSharedCodexHomeForUnprivilegedUser(
405 user: string,
406 githubRunId: string
407): Promise<string> {
408 const home = (
409 await checkOutput(["sudo", "-u", user, "--", "printenv", "HOME"])
410 ).trim();
411 if (!home) {
412 throw new Error(`Could not determine home directory for user '${user}'.`);
413 }
414 const codexHome = path.join(home, ".codex");
415 try {
416 const stat = await fs.stat(codexHome);
417 if (stat.isDirectory()) {
418 // Directory already exists and may contain a config.toml created by the
419 // user (or a previous invocation of codex-action), so assume it's
420 // correctly permissioned.
421 return codexHome;
422 }
423 } catch {
424 // Ignore stat errors and try to create the directory.
425 }
426
427 // We must use sudo for the following file system operations because we
428 // are writing to the home directory of a different user.
429 await checkOutput(["sudo", "mkdir", codexHome]);
430 await checkOutput(["sudo", "chown", `${user}`, codexHome]);
431 await checkOutput(["sudo", "chmod", "755", codexHome]);
432
433 // codex-responses-api-proxy will need to write the server info file.
434 const serverInfoFile = path.join(codexHome, `${githubRunId}.json`);
435 await checkOutput(["sudo", "touch", serverInfoFile]);
436 // Make the file world-writable for the moment, but this will be locked down
437 // to read-only by root before the action completes.
438 await checkOutput(["sudo", "chmod", "666", serverInfoFile]);
439
440 return codexHome;
441}
442
443function expandTilde(p: string): string {
444 if (p === "~") {
445 return os.homedir();
446 }
447 if (p.startsWith("~/") || p.startsWith("~\\")) {
448 return path.join(os.homedir(), p.slice(2));
449 }
450 return p;
451}
452