342 lines
9.1 KiB
JavaScript
342 lines
9.1 KiB
JavaScript
#!/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;
|
|
const TEMP_DIR_PREFIX = ".codex-action-";
|
|
const OUTPUT_DELIMITER_PREFIX = "CODEX_OUTPUT_";
|
|
|
|
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, TEMP_DIR_PREFIX));
|
|
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 = `${OUTPUT_DELIMITER_PREFIX}${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 compactBase64(value) {
|
|
return value.replace(/\s+/g, "");
|
|
}
|
|
|
|
function isBase64(value) {
|
|
const normalized = compactBase64(value);
|
|
const paddingIndex = normalized.indexOf("=");
|
|
|
|
if (!normalized || normalized.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
|
|
return false;
|
|
}
|
|
|
|
return paddingIndex === -1 || /^=+$/.test(normalized.slice(paddingIndex));
|
|
}
|
|
|
|
function validateAuth(encodedAuth, authFile) {
|
|
if (!isBase64(encodedAuth)) {
|
|
fail("OAUTH must be valid base64 encoded Codex auth.json.");
|
|
}
|
|
|
|
const decoded = Buffer.from(compactBase64(encodedAuth), "base64");
|
|
|
|
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 readExecutionConfig() {
|
|
return {
|
|
timeoutMs: parsePositiveInteger(process.env.CODEX_TIMEOUT_MS, DEFAULT_CODEX_TIMEOUT_MS),
|
|
outputLimitBytes: parsePositiveInteger(process.env.CODEX_OUTPUT_LIMIT_BYTES, DEFAULT_OUTPUT_LIMIT_BYTES),
|
|
workspace: process.env.GITHUB_WORKSPACE || process.cwd(),
|
|
};
|
|
}
|
|
|
|
function codexExecArgs(model, prompt, lastMessageFile) {
|
|
return [
|
|
"exec",
|
|
"--dangerously-bypass-approvals-and-sandbox",
|
|
"--skip-git-repo-check",
|
|
"--output-last-message",
|
|
lastMessageFile,
|
|
"--model",
|
|
model,
|
|
prompt,
|
|
];
|
|
}
|
|
|
|
function runCodex(model, prompt) {
|
|
return new Promise((resolve) => {
|
|
const { timeoutMs, outputLimitBytes, workspace } = readExecutionConfig();
|
|
const tempDir = makeTempDir(process.env.CODEX_HOME || "/root/.codex");
|
|
const lastMessageFile = path.join(tempDir, "last-message.txt");
|
|
tempFiles.trackFile(lastMessageFile);
|
|
const args = codexExecArgs(model, prompt, lastMessageFile);
|
|
|
|
// 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", args, { 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.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`));
|
|
}
|
|
|
|
const lastMessage = fs.existsSync(lastMessageFile) ? fs.readFileSync(lastMessageFile, "utf8") : null;
|
|
const diagnosticOutput = output.toString();
|
|
const resultOutput = code === 0 && lastMessage !== null ? lastMessage : diagnosticOutput || lastMessage || "";
|
|
|
|
resolve({ status: code ?? 1, output: resultOutput });
|
|
});
|
|
});
|
|
}
|
|
|
|
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 createAuthLock(codexHome) {
|
|
const lockName = crypto.createHash("sha256").update(codexHome).digest("hex");
|
|
const lockPath = path.join(codexHome, `.codex-auth-${lockName}.lock`);
|
|
|
|
try {
|
|
const lockHandle = fs.openSync(lockPath, "wx", FILE_MODE_PRIVATE);
|
|
tempFiles.trackFile(lockPath);
|
|
return lockHandle;
|
|
} catch {
|
|
fail("Unable to lock Codex auth.json.");
|
|
}
|
|
}
|
|
|
|
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 lockHandle = createAuthLock(codexHome);
|
|
|
|
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);
|
|
if (result.output) {
|
|
process.stdout.write(result.output.endsWith("\n") ? result.output : `${result.output}\n`);
|
|
}
|
|
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));
|
|
});
|