#!/usr/bin/env node const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const { spawn } = require("child_process"); const DEFAULT_PROMPT = "請自我介紹"; const FILE_MODE_PRIVATE = 0o600; const DIR_MODE_PRIVATE = 0o700; const DEFAULT_CODEX_TIMEOUT_MS = 30 * 60 * 1000; const DEFAULT_OUTPUT_LIMIT_BYTES = 1024 * 1024; class TempFileRegistry { constructor() { this.files = new Set(); this.dirs = new Set(); } trackFile(filePath) { this.files.add(filePath); } trackDir(dirPath) { this.dirs.add(dirPath); } removeFile(filePath) { if (!filePath || !this.files.has(filePath)) { return; } try { fs.rmSync(filePath, { force: true }); this.files.delete(filePath); } catch (error) { console.error(`Unable to remove temporary file: ${error.message}`); } } cleanup() { for (const filePath of this.files) { this.removeFile(filePath); } for (const dirPath of this.dirs) { try { fs.rmSync(dirPath, { force: true, recursive: true }); this.dirs.delete(dirPath); } catch (error) { console.error(`Unable to remove temporary directory: ${error.message}`); } } } } const tempFiles = new TempFileRegistry(); class OutputCollector { constructor(maxBytes) { this.maxBytes = maxBytes; this.chunks = []; this.size = 0; this.truncated = false; } append(chunk) { const available = this.maxBytes - this.size; if (available <= 0) { this.truncated = true; return; } const storedChunk = chunk.length > available ? chunk.subarray(0, available) : chunk; this.chunks.push(storedChunk); this.size += storedChunk.length; if (storedChunk.length < chunk.length) { this.truncated = true; } } toString() { const truncationMessage = this.truncated ? "\n[Output truncated]\n" : ""; return `${Buffer.concat(this.chunks, this.size).toString()}${truncationMessage}`; } } function cleanup() { tempFiles.cleanup(); } function makeTempDir(dir) { const tempDir = fs.mkdtempSync(path.join(dir, ".codex-action-")); fs.chmodSync(tempDir, DIR_MODE_PRIVATE); tempFiles.trackDir(tempDir); return tempDir; } function makeTempFile(dir, prefix) { const random = crypto.randomBytes(16).toString("hex"); const filePath = path.join(dir, `${prefix}.${random}`); const fd = fs.openSync(filePath, "wx", FILE_MODE_PRIVATE); fs.closeSync(fd); tempFiles.trackFile(filePath); return filePath; } function appendGithubOutput(status, output) { const outputFile = process.env.GITHUB_OUTPUT; if (!outputFile) { return; } let delimiter; do { delimiter = `CODEX_OUTPUT_${crypto.randomBytes(12).toString("hex")}`; } while (output.includes(delimiter)); fs.appendFileSync( outputFile, `status=${status}\noutput<<${delimiter}\n${output}${output.endsWith("\n") ? "" : "\n"}${delimiter}\n`, { encoding: "utf8", mode: FILE_MODE_PRIVATE }, ); } function fail(message, code = 1) { console.error(message); appendGithubOutput("failed", message); cleanup(); process.exit(code); } function normalizeBase64(value) { return value.replace(/\s+/g, "").replace(/=+$/, ""); } function validateAuth(encodedAuth, authFile) { const decoded = Buffer.from(encodedAuth, "base64"); const normalizedDecoded = normalizeBase64(decoded.toString("base64")); const normalizedInput = normalizeBase64(encodedAuth); if (decoded.length === 0 && normalizedInput.length > 0) { fail("OAUTH must be valid base64 encoded Codex auth.json."); } if (normalizedDecoded !== normalizedInput) { fail("OAUTH must be valid base64 encoded Codex auth.json."); } fs.writeFileSync(authFile, decoded, { mode: FILE_MODE_PRIVATE }); let parsed; try { parsed = JSON.parse(decoded.toString("utf8")); } catch { fail("Decoded OAUTH must be a JSON object."); } if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { fail("Decoded OAUTH must be a JSON object."); } } function parsePositiveInteger(value, fallback) { const parsed = Number.parseInt(value || "", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function runCodex(model, prompt) { return new Promise((resolve) => { const timeoutMs = parsePositiveInteger(process.env.CODEX_TIMEOUT_MS, DEFAULT_CODEX_TIMEOUT_MS); const outputLimitBytes = parsePositiveInteger(process.env.CODEX_OUTPUT_LIMIT_BYTES, DEFAULT_OUTPUT_LIMIT_BYTES); const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); // This Docker Action runs inside an ephemeral CI container where Codex must be // able to edit the checked-out workspace without interactive approvals. const child = spawn( "codex", [ "exec", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--model", model, prompt, ], { cwd: workspace, stdio: ["ignore", "pipe", "pipe"] }, ); const output = new OutputCollector(outputLimitBytes); const timeout = setTimeout(() => { child.kill("SIGTERM"); output.append(Buffer.from(`Codex execution timed out after ${timeoutMs} ms.\n`)); }, timeoutMs); child.stdout.pipe(process.stdout); child.stderr.pipe(process.stdout); child.stdout.on("data", (chunk) => { output.append(chunk); }); child.stderr.on("data", (chunk) => { output.append(chunk); }); child.on("error", (error) => { clearTimeout(timeout); const message = error.code === "ENOENT" ? "Unable to find codex command.\n" : `${error.message}\n`; output.append(Buffer.from(message)); resolve({ status: 1, output: output.toString() }); }); child.on("close", (code, signal) => { clearTimeout(timeout); if (code === null && signal) { output.append(Buffer.from(`Codex process terminated by signal ${signal}.\n`)); } resolve({ status: code ?? 1, output: output.toString() }); }); }); } function registerCleanupHandlers() { process.on("exit", cleanup); process.on("SIGINT", () => { cleanup(); process.exit(130); }); process.on("SIGTERM", () => { cleanup(); process.exit(143); }); } function readConfig() { const oauth = process.env.OAUTH || ""; const model = process.env.MODEL || ""; const codexHome = process.env.CODEX_HOME || "/root/.codex"; const prompt = process.env.PROMPT || DEFAULT_PROMPT; if (!oauth) { fail("OAUTH is required: provide base64 encoded Codex auth.json."); } if (!model) { fail("MODEL is required."); } return { oauth, model, codexHome, prompt }; } function setupAuth(oauth, codexHome) { try { fs.mkdirSync(codexHome, { recursive: true, mode: DIR_MODE_PRIVATE }); fs.chmodSync(codexHome, DIR_MODE_PRIVATE); fs.accessSync(codexHome, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK); } catch { fail("Unable to create CODEX_HOME."); } const tempDir = makeTempDir(codexHome); const authFile = makeTempFile(tempDir, "auth"); const authPath = path.join(codexHome, "auth.json"); const lockName = crypto.createHash("sha256").update(codexHome).digest("hex"); const lockPath = path.join(codexHome, `.codex-auth-${lockName}.lock`); let lockHandle; try { lockHandle = fs.openSync(lockPath, "wx", FILE_MODE_PRIVATE); tempFiles.trackFile(lockPath); } catch { fail("Unable to lock Codex auth.json."); } validateAuth(oauth, authFile); if (fs.existsSync(authPath)) { fail("Refusing to overwrite existing Codex auth.json."); } fs.renameSync(authFile, authPath); fs.chmodSync(authPath, FILE_MODE_PRIVATE); tempFiles.trackFile(authPath); return lockHandle; } async function runCodexAction({ oauth, model, codexHome, prompt }) { const lockHandle = setupAuth(oauth, codexHome); const result = await runCodex(model, prompt); appendGithubOutput(result.status === 0 ? "completed" : "failed", result.output); if (lockHandle !== undefined) { fs.closeSync(lockHandle); } cleanup(); process.exit(result.status); } async function main() { registerCleanupHandlers(); await runCodexAction(readConfig()); } main().catch((error) => { fail(error instanceof Error ? error.message : String(error)); });