refactor(entrypoint): 強化暫存檔與 Codex 執行控管
This commit is contained in:
+105
-27
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup() {
|
cleanup() {
|
||||||
for (const filePath of Array.from(createdPaths).reverse()) {
|
for (const filePath of Array.from(this.files).reverse()) {
|
||||||
removeIfCreated(filePath);
|
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() {
|
||||||
|
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) {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user