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 · modeblame

e9d0a695Michael Bolin10 months ago1import { Command, Option } from "commander";
1c44f3d7Michael Bolin10 months ago2import * as fs from "node:fs/promises";
3import * as os from "node:os";
4import * as path from "node:path";
e9d0a695Michael Bolin10 months ago5import pkg from "../package.json" assert { type: "json" };
6
7import { readServerInfo } from "./readServerInfo";
bc00fb04Michael Bolin10 months ago8import {
911c5cb9Michael Bolin10 months ago9SandboxMode,
bc00fb04Michael Bolin10 months ago10OutputSchemaSource,
11PromptSource,
12runCodexExec,
13SafetyStrategy,
14} from "./runCodexExec";
e9d0a695Michael Bolin10 months ago15import { dropSudo } from "./dropSudo";
16import { ensureActorHasWriteAccess } from "./checkActorPermissions";
17import parseArgsStringToArgv from "string-argv";
1c44f3d7Michael Bolin10 months ago18import { writeProxyConfig } from "./writeProxyConfig";
07db7939Michael Bolin10 months ago19import { checkOutput } from "./checkOutput";
e9d0a695Michael Bolin10 months ago20
21export async function main() {
22const program = new Command();
23
24program
151b4352Michael Bolin10 months ago25.name("codex-action")
e9d0a695Michael Bolin10 months ago26.version(pkg.version)
151b4352Michael Bolin10 months ago27.description("Multitool to support openai/codex-action.");
e9d0a695Michael Bolin10 months ago28
29program
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) => {
34await readServerInfo(serverInfoFile);
35});
36
1c44f3d7Michael Bolin10 months ago37program
38.command("resolve-codex-home")
39.description(
40"Resolve the Codex home directory with precedence: input, env, default (~/.codex)"
41)
07db7939Michael Bolin10 months ago42.requiredOption(
43"--codex-home-override <DIRECTORY>",
1c44f3d7Michael Bolin10 months ago44"Optional codex-home input value (may be empty)"
45)
07db7939Michael Bolin10 months ago46.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)
bcb3128dMichael Bolin10 months ago54.requiredOption("--github-run-id <id>", "GitHub run ID")
07db7939Michael Bolin10 months ago55.action(
56async (options: {
57codexHomeOverride: string;
58safetyStrategy: string;
59codexUser: string;
bcb3128dMichael Bolin10 months ago60githubRunId: string;
07db7939Michael Bolin10 months ago61}) => {
62const safetyStrategy = toSafetyStrategy(options.safetyStrategy);
63const codexUser = emptyAsNull(options.codexUser);
64const resolved = await resolveCodexHome(
65emptyAsNull(options.codexHomeOverride),
66safetyStrategy,
bcb3128dMichael Bolin10 months ago67codexUser,
68options.githubRunId
07db7939Michael Bolin10 months ago69);
70// Ensure directory exists for downstream steps that will write files here.
71await fs.mkdir(resolved, { recursive: true });
72if (safetyStrategy === "unprivileged-user") {
73await ensureDirIsWorldReadable(resolved);
74}
75const { setOutput } = await import("@actions/core");
76setOutput("codex-home", resolved);
77console.log(`Resolved Codex home: ${resolved}`);
78}
79);
1c44f3d7Michael Bolin10 months ago80
81program
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)
bcb3128dMichael Bolin10 months ago88.requiredOption(
89"--safety-strategy <strategy>",
90"Safety strategy to use. One of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'."
91)
92.action(
93async (options: {
94codexHome: string;
95port: number;
96safetyStrategy: string;
97}) => {
98const safetyStrategy = toSafetyStrategy(options.safetyStrategy);
99await writeProxyConfig(options.codexHome, options.port, safetyStrategy);
100}
101);
1c44f3d7Michael Bolin10 months ago102
e9d0a695Michael Bolin10 months ago103program
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(
108new Option("--group <group>", "Group granting sudo privileges").default(
109"sudo"
110)
111)
112.addOption(new Option("--root-phase", "internal").default(false).hideHelp())
113.action(
114async (options: { user: string; group: string; rootPhase: boolean }) => {
115await dropSudo({
116user: options.user,
117group: options.group,
118rootPhase: options.rootPhase,
119});
120}
121);
122
123program
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.",
139parseExtraArgs
140)
141.requiredOption(
142"--output-file <FILE>",
143"Path where the final message from `codex exec` will be written."
144)
b8896131Michael Bolin10 months ago145.requiredOption(
146"--output-schema-file <FILE>",
147"Path to a schema file to pass to `codex exec --output-schema`."
148)
bc00fb04Michael Bolin10 months ago149.requiredOption(
150"--output-schema <SCHEMA>",
151"Inline schema contents to pass to `codex exec --output-schema`."
152)
911c5cb9Michael Bolin10 months ago153.requiredOption(
154"--sandbox <SANDBOX>",
155"Sandbox mode override to pass to `codex exec`."
156)
7304b0a7Michael Bolin10 months ago157.requiredOption("--model <model>", "Model the agent should use")
e9d0a695Michael Bolin10 months ago158.requiredOption(
159"--safety-strategy <strategy>",
d49a23a3Michael Bolin10 months ago160"Safety strategy to use. One of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'."
e9d0a695Michael Bolin10 months ago161)
162.requiredOption(
163"--codex-user <user>",
d49a23a3Michael Bolin10 months ago164"User to run codex exec as when using the 'unprivileged-user' safety strategy."
e9d0a695Michael Bolin10 months ago165)
166.action(
167async (options: {
168prompt: string;
169promptFile: string;
170codexHome: string;
171cd: string;
172extraArgs: Array<string>;
173outputFile: string;
b8896131Michael Bolin10 months ago174outputSchemaFile: string;
bc00fb04Michael Bolin10 months ago175outputSchema: string;
911c5cb9Michael Bolin10 months ago176sandbox: string;
7304b0a7Michael Bolin10 months ago177model: string;
e9d0a695Michael Bolin10 months ago178safetyStrategy: string;
179codexUser: string;
180}) => {
181const {
182prompt,
183promptFile,
bc00fb04Michael Bolin10 months ago184outputFile,
e9d0a695Michael Bolin10 months ago185codexHome,
186cd,
187extraArgs,
bc00fb04Michael Bolin10 months ago188outputSchema,
b8896131Michael Bolin10 months ago189outputSchemaFile,
911c5cb9Michael Bolin10 months ago190sandbox,
7304b0a7Michael Bolin10 months ago191model,
e9d0a695Michael Bolin10 months ago192safetyStrategy,
193codexUser,
194} = options;
195
196const normalizedPrompt = emptyAsNull(prompt);
197const normalizedPromptFile = emptyAsNull(promptFile);
1c44f3d7Michael Bolin10 months ago198if (normalizedPrompt != null && normalizedPromptFile != null) {
199throw new Error(
200"Only one of `prompt` or `prompt-file` may be specified."
201);
202}
203
e9d0a695Michael Bolin10 months ago204let promptSource: PromptSource;
205if (normalizedPrompt != null) {
bc00fb04Michael Bolin10 months ago206promptSource = { type: "inline", content: normalizedPrompt };
e9d0a695Michael Bolin10 months ago207} else if (normalizedPromptFile != null) {
208promptSource = { type: "file", path: normalizedPromptFile };
209} else {
210throw new Error(
d49a23a3Michael Bolin10 months ago211"Either `prompt` or `prompt-file` must be specified."
e9d0a695Michael Bolin10 months ago212);
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.
bc00fb04Michael Bolin10 months ago217const normalizedOutputSchemaFile = emptyAsNull(outputSchemaFile);
218const normalizedOutputSchema = emptyAsNull(outputSchema);
219
220if (
221normalizedOutputSchemaFile != null &&
222normalizedOutputSchema != null
223) {
224throw new Error(
d49a23a3Michael Bolin10 months ago225"Only one of `output-schema` or `output-schema-file` may be specified."
bc00fb04Michael Bolin10 months ago226);
227}
228
229let outputSchemaSource: OutputSchemaSource | null = null;
230if (normalizedOutputSchema != null) {
231outputSchemaSource = {
232type: "inline",
233content: normalizedOutputSchema,
234};
235} else if (normalizedOutputSchemaFile != null) {
236outputSchemaSource = {
237type: "file",
238path: normalizedOutputSchemaFile,
239};
240}
241
e9d0a695Michael Bolin10 months ago242await runCodexExec({
243prompt: promptSource,
244codexHome: emptyAsNull(codexHome),
245cd,
246extraArgs,
247explicitOutputFile: emptyAsNull(outputFile),
bc00fb04Michael Bolin10 months ago248outputSchema: outputSchemaSource,
911c5cb9Michael Bolin10 months ago249sandbox: toSandboxMode(sandbox),
7304b0a7Michael Bolin10 months ago250model: emptyAsNull(model),
e9d0a695Michael Bolin10 months ago251safetyStrategy: toSafetyStrategy(safetyStrategy),
252codexUser: emptyAsNull(codexUser),
253});
254}
255);
256
257program
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).",
265parseBoolean,
266true
267)
b8d81868Michael Bolin10 months ago268.option(
269"--allow-users <users>",
270"Comma-separated list of GitHub usernames who can run this action, or '*' to allow all users.",
271""
272)
07db7939Michael Bolin10 months ago273.action(
274async ({
275allowBots,
b8d81868Michael Bolin10 months ago276allowUsers,
07db7939Michael Bolin10 months ago277}: {
278allowBots: boolean;
279allowUsers: string;
280}) => {
281const result = await ensureActorHasWriteAccess({
282allowBotActors: allowBots,
283allowUsers,
284});
285switch (result.status) {
286case "approved": {
287console.log(`Actor '${result.actor}' is permitted to continue.`);
288break;
289}
290case "rejected": {
291const message = `Actor '${result.actor}' is not permitted to run this action: ${result.reason}`;
292console.error(message);
293throw new Error(message);
294}
e9d0a695Michael Bolin10 months ago295}
296}
07db7939Michael Bolin10 months ago297);
e9d0a695Michael Bolin10 months ago298
299program.parse();
300}
301
302function parseIntStrict(value: string): number {
303const parsed = parseInt(value, 10);
304if (isNaN(parsed)) {
305throw new Error(`Invalid integer: ${value}`);
306}
307return parsed;
308}
309
310function parseExtraArgs(value: string): Array<string> {
311if (value.length === 0) {
312return [];
313}
314
315if (value.startsWith("[")) {
316return JSON.parse(value);
317} else {
318return parseArgsStringToArgv(value);
319}
320}
321
322function toSafetyStrategy(value: string): SafetyStrategy {
323switch (value) {
d49a23a3Michael Bolin10 months ago324case "drop-sudo":
325case "read-only":
326case "unprivileged-user":
e9d0a695Michael Bolin10 months ago327case "unsafe":
328return value;
329default:
330throw new Error(
d49a23a3Michael Bolin10 months ago331`Invalid safety strategy: ${value}. Must be one of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'.`
e9d0a695Michael Bolin10 months ago332);
333}
334}
335
911c5cb9Michael Bolin10 months ago336function toSandboxMode(value: string): SandboxMode {
337switch (value) {
338case "read-only":
339case "workspace-write":
340case "danger-full-access":
341return value;
342default:
343throw new Error(
344`Invalid sandbox: ${value}. Must be one of 'read-only', 'workspace-write', or 'danger-full-access'.`
345);
346}
347}
348
e9d0a695Michael Bolin10 months ago349function emptyAsNull(value: string): string | null {
350return value.trim().length == 0 ? null : value;
351}
352
353function parseBoolean(value: string): boolean {
354const normalized = value.trim().toLowerCase();
355if (["true", "1", "yes", "y"].includes(normalized)) {
356return true;
357}
358if (["false", "0", "no", "n"].includes(normalized)) {
359return false;
360}
361throw new Error(`Invalid boolean value: ${value}`);
362}
363
364main();
1c44f3d7Michael Bolin10 months ago365
366async function resolveCodexHome(
07db7939Michael Bolin10 months ago367inputCodexHome: string | null,
368safetyStrategy: SafetyStrategy,
bcb3128dMichael Bolin10 months ago369codexUser: string | null,
370githubRunId: string
1c44f3d7Michael Bolin10 months ago371): Promise<string> {
372if (inputCodexHome != null) {
373return expandTilde(inputCodexHome);
374}
375const envHome = emptyAsNull(process.env.CODEX_HOME ?? "");
376if (envHome != null) {
377return envHome;
378}
07db7939Michael Bolin10 months ago379if (safetyStrategy === "unprivileged-user") {
380if (codexUser == null) {
381throw new Error(
382"codex-user input must be provided when using 'unprivileged-user' safety strategy and no codex-home is specified."
383);
384}
385
bcb3128dMichael Bolin10 months ago386return await deriveSharedCodexHomeForUnprivilegedUser(
387codexUser,
388githubRunId
389);
07db7939Michael Bolin10 months ago390}
1c44f3d7Michael Bolin10 months ago391return path.join(os.homedir(), ".codex");
392}
393
07db7939Michael Bolin10 months ago394async function ensureDirIsWorldReadable(dir: string): Promise<void> {
395if (process.platform === "win32") {
396return;
397}
398try {
399await fs.chmod(dir, 0o755);
400} catch {
401// Best-effort: ignore chmod failures so the command still succeeds.
402}
403}
404
bcb3128dMichael Bolin10 months ago405async function deriveSharedCodexHomeForUnprivilegedUser(
406user: string,
407githubRunId: string
408): Promise<string> {
07db7939Michael Bolin10 months ago409const home = (
410await checkOutput(["sudo", "-u", user, "--", "printenv", "HOME"])
411).trim();
412if (!home) {
413throw new Error(`Could not determine home directory for user '${user}'.`);
414}
bcb3128dMichael Bolin10 months ago415const codexHome = path.join(home, ".codex");
416try {
417const stat = await fs.stat(codexHome);
418if (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.
422return 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.
430await checkOutput(["sudo", "mkdir", codexHome]);
431await checkOutput(["sudo", "chown", `${user}`, codexHome]);
432await checkOutput(["sudo", "chmod", "755", codexHome]);
433
434// codex-responses-api-proxy will need to write the server info file.
435const serverInfoFile = path.join(codexHome, `${githubRunId}.json`);
436await 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.
439await checkOutput(["sudo", "chmod", "666", serverInfoFile]);
440
441return codexHome;
07db7939Michael Bolin10 months ago442}
443
1c44f3d7Michael Bolin10 months ago444function expandTilde(p: string): string {
445if (p === "~") {
446return os.homedir();
447}
448if (p.startsWith("~/") || p.startsWith("~\\")) {
449return path.join(os.homedir(), p.slice(2));
450}
451return p;
452}