處理 AI review findings 並改寫 Node.js entrypoint #2

Merged
admin merged 59 commits from develop into master 2026-06-24 14:13:11 +00:00
Showing only changes of commit 83de97b35a - Show all commits
+105 -27
View File
@@ -1,38 +1,78 @@
#!/usr/bin/env node #!/usr/bin/env node
const fs = require("fs"); const fs = require("fs");
const os = require("os");
const path = require("path"); const path = require("path");
const crypto = require("crypto"); const crypto = require("crypto");
const { spawn } = require("child_process"); const { spawn } = require("child_process");
const DEFAULT_PROMPT = "請自我介紹"; const DEFAULT_PROMPT = "請自我介紹";
const createdPaths = new Set(); 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;
function removeIfCreated(filePath) { class TempFileRegistry {
if (!filePath || !createdPaths.has(filePath)) { 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; return;
} }
try { try {
fs.rmSync(filePath, { force: true }); fs.rmSync(filePath, { force: true });
this.files.delete(filePath);
} catch (error) { } catch (error) {
console.error(`Unable to remove temporary file: ${error.message}`); console.error(`Unable to remove temporary file: ${error.message}`);
} }
}
cleanup() {
for (const filePath of Array.from(this.files).reverse()) {
this.removeFile(filePath);
}
for (const dirPath of Array.from(this.dirs).reverse()) {
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();
function cleanup() { function cleanup() {
for (const filePath of Array.from(createdPaths).reverse()) { tempFiles.cleanup();
removeIfCreated(filePath); }
}
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) { function makeTempFile(dir, prefix) {
const random = crypto.randomBytes(16).toString("hex"); const random = crypto.randomBytes(16).toString("hex");
const filePath = path.join(dir, `${prefix}.${random}`); const filePath = path.join(dir, `${prefix}.${random}`);
const fd = fs.openSync(filePath, "wx", 0o600); const fd = fs.openSync(filePath, "wx", FILE_MODE_PRIVATE);
fs.closeSync(fd); fs.closeSync(fd);
createdPaths.add(filePath); tempFiles.trackFile(filePath);
return filePath; return filePath;
} }
@@ -50,7 +90,7 @@ function appendGithubOutput(status, output) {
fs.appendFileSync( fs.appendFileSync(
outputFile, outputFile,
`status=${status}\noutput<<${delimiter}\n${output}${output.endsWith("\n") ? "" : "\n"}${delimiter}\n`, `status=${status}\noutput<<${delimiter}\n${output}${output.endsWith("\n") ? "" : "\n"}${delimiter}\n`,
{ encoding: "utf8", mode: 0o600 }, { encoding: "utf8", mode: FILE_MODE_PRIVATE },
); );
} }
@@ -75,7 +115,7 @@ function validateAuth(encodedAuth, authFile) {
fail("OAUTH must be valid base64 encoded Codex auth.json."); fail("OAUTH must be valid base64 encoded Codex auth.json.");
} }
fs.writeFileSync(authFile, decoded, { mode: 0o600 }); fs.writeFileSync(authFile, decoded, { mode: FILE_MODE_PRIVATE });
let parsed; let parsed;
try { try {
@@ -89,8 +129,29 @@ function validateAuth(encodedAuth, authFile) {
} }
} }
function parsePositiveInteger(value, fallback) {
const parsed = Number.parseInt(value || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function truncateOutput(chunks, nextChunk, maxBytes) {
const currentSize = chunks.reduce((total, chunk) => total + chunk.length, 0);
const available = maxBytes - currentSize;
if (available <= 0) {
return false;
}
chunks.push(nextChunk.length > available ? nextChunk.subarray(0, available) : nextChunk);
return nextChunk.length <= available;
}
function runCodex(model, prompt) { function runCodex(model, prompt) {
return new Promise((resolve) => { 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 // This Docker Action runs inside an ephemeral CI container where Codex must be
// able to edit the checked-out workspace without interactive approvals. // able to edit the checked-out workspace without interactive approvals.
const child = spawn( const child = spawn(
@@ -103,32 +164,48 @@ function runCodex(model, prompt) {
model, model,
prompt, prompt,
], ],
{ stdio: ["ignore", "pipe", "pipe"] }, { cwd: workspace, stdio: ["ignore", "pipe", "pipe"] },
); );
const outputChunks = []; const outputChunks = [];
let outputTruncated = false;
const appendOutput = (chunk) => { const appendOutput = (chunk) => {
outputChunks.push(chunk); if (!truncateOutput(outputChunks, chunk, outputLimitBytes)) {
outputTruncated = true;
}
return Buffer.concat(outputChunks).toString(); return Buffer.concat(outputChunks).toString();
}; };
const timeout = setTimeout(() => {
child.kill("SIGTERM");
appendOutput(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) => { child.stdout.on("data", (chunk) => {
process.stdout.write(chunk); if (!truncateOutput(outputChunks, chunk, outputLimitBytes)) {
outputChunks.push(chunk); outputTruncated = true;
}
}); });
child.stderr.on("data", (chunk) => { child.stderr.on("data", (chunk) => {
process.stdout.write(chunk); if (!truncateOutput(outputChunks, chunk, outputLimitBytes)) {
outputChunks.push(chunk); outputTruncated = true;
}
}); });
child.on("error", (error) => { child.on("error", (error) => {
clearTimeout(timeout);
const output = appendOutput(Buffer.from(`${error.message}\n`)); const output = appendOutput(Buffer.from(`${error.message}\n`));
resolve({ status: 1, output }); resolve({ status: 1, output });
}); });
child.on("close", (code) => { child.on("close", (code) => {
const output = Buffer.concat(outputChunks).toString(); clearTimeout(timeout);
const truncationMessage = outputTruncated ? "\n[Output truncated]\n" : "";
const output = `${Buffer.concat(outputChunks).toString()}${truncationMessage}`;
resolve({ status: code ?? 1, output }); resolve({ status: code ?? 1, output });
}); });
}); });
@@ -165,19 +242,21 @@ function readConfig() {
function setupAuth(oauth, codexHome) { function setupAuth(oauth, codexHome) {
try { try {
fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 }); fs.mkdirSync(codexHome, { recursive: true, mode: DIR_MODE_PRIVATE });
} catch { } catch {
fail("Unable to create CODEX_HOME."); fail("Unable to create CODEX_HOME.");
} }
const authFile = makeTempFile(codexHome, "auth"); const tempDir = makeTempDir(codexHome);
const authFile = makeTempFile(tempDir, "auth");
const authPath = path.join(codexHome, "auth.json"); const authPath = path.join(codexHome, "auth.json");
const lockPath = path.join(os.tmpdir(), `codex-auth-${Buffer.from(codexHome).toString("hex")}.lock`); const lockName = crypto.createHash("sha256").update(codexHome).digest("hex");
const lockPath = path.join(codexHome, `.codex-auth-${lockName}.lock`);
let lockHandle; let lockHandle;
try { try {
lockHandle = fs.openSync(lockPath, "wx", 0o600); lockHandle = fs.openSync(lockPath, "wx", FILE_MODE_PRIVATE);
createdPaths.add(lockPath); tempFiles.trackFile(lockPath);
} catch { } catch {
fail("Unable to lock Codex auth.json."); fail("Unable to lock Codex auth.json.");
} }
@@ -188,10 +267,9 @@ function setupAuth(oauth, codexHome) {
fail("Refusing to overwrite existing Codex auth.json."); fail("Refusing to overwrite existing Codex auth.json.");
} }
fs.copyFileSync(authFile, authPath); fs.renameSync(authFile, authPath);
fs.chmodSync(authPath, 0o600); fs.chmodSync(authPath, FILE_MODE_PRIVATE);
createdPaths.add(authPath); tempFiles.trackFile(authPath);
removeIfCreated(authFile);
return lockHandle; return lockHandle;
} }