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/runCodexExec.ts

338lines · modecode

1import { spawn } from "child_process";
2import { chmod, mkdtemp, readFile, rm, writeFile } from "fs/promises";
3import path from "path";
4import os from "os";
5import { setOutput } from "@actions/core";
6import { checkOutput } from "./checkOutput";
7
8export type PromptSource =
9 | {
10 type: "inline";
11 content: string;
12 }
13 | {
14 type: "file";
15 path: string;
16 };
17
18export type SafetyStrategy =
19 | "drop-sudo"
20 | "read-only"
21 | "unprivileged-user"
22 | "unsafe";
23
24export type SandboxMode =
25 | "read-only"
26 | "workspace-write"
27 | "danger-full-access";
28
29export type OutputSchemaSource =
30 | {
31 type: "file";
32 path: string;
33 }
34 | {
35 type: "inline";
36 content: string;
37 };
38
39export async function runCodexExec({
40 prompt,
41 codexHome,
42 cd,
43 extraArgs,
44 explicitOutputFile,
45 outputSchema,
46 model,
47 effort,
48 safetyStrategy,
49 codexUser,
50 sandbox,
51}: {
52 prompt: PromptSource;
53 codexHome: string | null;
54 cd: string;
55 extraArgs: Array<string>;
56 explicitOutputFile: string | null;
57 outputSchema: OutputSchemaSource | null;
58 model: string | null;
59 effort: string | null;
60 safetyStrategy: SafetyStrategy;
61 codexUser: string | null;
62 sandbox: SandboxMode;
63}): Promise<void> {
64 let input: string;
65 switch (prompt.type) {
66 case "inline":
67 input = prompt.content;
68 break;
69 case "file":
70 input = await readFile(prompt.path, "utf8");
71 break;
72 }
73
74 const runAsUser: string | null =
75 safetyStrategy === "unprivileged-user" ? codexUser : null;
76
77 let outputFile: OutputFile;
78 if (explicitOutputFile != null) {
79 outputFile = { type: "explicit", file: explicitOutputFile };
80 } else {
81 outputFile = await createTempOutputFile({ runAsUser });
82 }
83
84 const resolvedOutputSchema = await resolveOutputSchema(
85 outputSchema,
86 runAsUser
87 );
88 const sandboxMode = await determineSandboxMode({
89 safetyStrategy,
90 requestedSandbox: sandbox,
91 });
92
93 const command: Array<string> = [];
94
95 let pathToCodex = "codex";
96 if (safetyStrategy === "unprivileged-user") {
97 if (codexUser == null) {
98 throw new Error(
99 "codexUser must be specified when using the 'unprivileged-user' safety strategy."
100 );
101 }
102
103 if (process.platform === "win32") {
104 throw new Error(
105 "the 'unprivileged-user' safety strategy is not supported on Windows."
106 );
107 }
108
109 // We are currently running as a privileged user, but `codexUser` will run
110 // with a different $PATH variable, so we need to find the full path to
111 // `codex`.
112 pathToCodex = (await checkOutput(["which", "codex"])).trim();
113 if (!pathToCodex) {
114 throw new Error("could not find 'codex' in PATH");
115 }
116
117 command.push("sudo", "-u", codexUser, "--");
118 }
119
120 command.push(
121 pathToCodex,
122 "exec",
123 "--skip-git-repo-check",
124 "--cd",
125 cd,
126 "--output-last-message",
127 outputFile.file
128 );
129
130 if (resolvedOutputSchema != null) {
131 command.push("--output-schema", resolvedOutputSchema.file);
132 }
133
134 if (model != null) {
135 command.push("--model", model);
136 }
137
138 if (effort != null) {
139 // https://github.com/openai/codex/blob/00debb6399eb51c4b9273f0bc012912c42fe6c91/docs/config.md#config
140 // https://github.com/openai/codex/blob/00debb6399eb51c4b9273f0bc012912c42fe6c91/docs/config.md#model_reasoning_effort
141 command.push("--config", `model_reasoning_effort="${effort}"`);
142 }
143
144 command.push(...extraArgs);
145
146 command.push("--sandbox", sandboxMode);
147
148 const env = { ...process.env };
149 if (!env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE) {
150 env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE = "codex_github_action";
151 }
152 let extraEnv = "";
153 if (codexHome != null) {
154 env.CODEX_HOME = codexHome;
155 extraEnv = `CODEX_HOME=${codexHome} `;
156 }
157
158 // Split the `program` from the `args` for `spawn()`.
159 const program = command.shift()!;
160 console.log(
161 `Running: ${extraEnv}${program} ${command
162 .map((a) => JSON.stringify(a))
163 .join(" ")}`
164 );
165 try {
166 await new Promise((resolve, reject) => {
167 const child = spawn(program, command, {
168 env,
169 stdio: ["pipe", "inherit", "inherit"],
170 });
171 child.stdin.write(input);
172 child.stdin.end();
173
174 child.on("error", reject);
175
176 child.on("close", async (code) => {
177 if (code !== 0) {
178 reject(new Error(`${program} exited with code ${code}`));
179 return;
180 }
181
182 try {
183 await finalizeExecution(outputFile, runAsUser);
184 resolve(undefined);
185 } catch (err) {
186 reject(err);
187 }
188 });
189 });
190 } finally {
191 await cleanupOutputSchema(resolvedOutputSchema);
192 }
193}
194
195async function finalizeExecution(
196 outputFile: OutputFile,
197 runAsUser: string | null
198): Promise<void> {
199 try {
200 let lastMessage: string;
201 if (runAsUser == null) {
202 lastMessage = await readFile(outputFile.file, "utf8");
203 } else {
204 lastMessage = await checkOutput([
205 "sudo",
206 "-u",
207 runAsUser,
208 "cat",
209 outputFile.file,
210 ]);
211 }
212 setOutput("final-message", lastMessage);
213 } finally {
214 await cleanupTempOutput(outputFile, runAsUser);
215 }
216}
217
218type OutputFile =
219 | {
220 type: "explicit";
221 file: string;
222 }
223 | {
224 type: "temp";
225 file: string;
226 };
227
228type ResolvedOutputSchema =
229 | {
230 type: "explicit";
231 file: string;
232 }
233 | {
234 type: "temp";
235 file: string;
236 dir: string;
237 };
238
239async function createTempOutputFile({
240 runAsUser,
241}: {
242 runAsUser: string | null;
243}): Promise<OutputFile> {
244 const dir = await createTempDir("codex-exec-", runAsUser);
245 return { type: "temp", file: path.join(dir, "output.md") };
246}
247
248async function cleanupTempOutput(
249 outputFile: OutputFile,
250 runAsUser: string | null
251): Promise<void> {
252 switch (outputFile.type) {
253 case "explicit":
254 // Do not delete user-specified output files.
255 return;
256 case "temp": {
257 const { file } = outputFile;
258 if (runAsUser == null) {
259 const dir = path.dirname(file);
260 await rm(dir, { recursive: true, force: true });
261 } else {
262 await checkOutput(["sudo", "rm", "-rf", path.dirname(file)]);
263 }
264 break;
265 }
266 }
267}
268
269async function resolveOutputSchema(
270 schema: OutputSchemaSource | null,
271 runAsUser: string | null
272): Promise<ResolvedOutputSchema | null> {
273 if (schema == null) {
274 return null;
275 }
276
277 switch (schema.type) {
278 case "file":
279 return { type: "explicit", file: schema.path };
280 case "inline": {
281 const dir = await createTempDir("codex-output-schema-", runAsUser);
282 const file = path.join(dir, "schema.json");
283 await writeFile(file, schema.content);
284 return { type: "temp", file, dir };
285 }
286 }
287}
288
289async function cleanupOutputSchema(
290 schema: ResolvedOutputSchema | null
291): Promise<void> {
292 if (schema == null) {
293 return;
294 }
295
296 switch (schema.type) {
297 case "explicit":
298 return;
299 case "temp":
300 await rm(schema.dir, { recursive: true, force: true });
301 return;
302 }
303}
304
305async function createTempDir(
306 prefix: string,
307 runAsUser: string | null
308): Promise<string> {
309 if (runAsUser == null) {
310 return await mkdtemp(path.join(os.tmpdir(), prefix));
311 } else {
312 return (
313 await checkOutput([
314 "sudo",
315 "-u",
316 runAsUser,
317 "mktemp",
318 "-d",
319 "-t",
320 `${prefix}.XXXXXX`,
321 ])
322 ).trim();
323 }
324}
325
326async function determineSandboxMode({
327 safetyStrategy,
328 requestedSandbox,
329}: {
330 safetyStrategy: SafetyStrategy;
331 requestedSandbox: SandboxMode;
332}): Promise<SandboxMode> {
333 if (safetyStrategy === "read-only") {
334 return "read-only";
335 } else {
336 return requestedSandbox;
337 }
338}
339