diff --git a/Dockerfile b/Dockerfile index 913fb33..9348293 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ ARG CODEX_DOC_MARKETPLACE_REF=f8da961328a85f267b4402566e127310370169da ARG CODEX_CODE_REVIEW_MARKETPLACE_REF=9e016edff016d58f4d64e0a5468220d35a0f657b # 安裝必要的工具 -RUN apk add --no-cache --no-check-certificate bash ca-certificates curl git jq util-linux +RUN apk add --no-cache --no-check-certificate bash ca-certificates curl git jq nodejs # 安裝 Codex CLI 工具 RUN install_script="$(mktemp)" \ @@ -27,8 +27,9 @@ RUN codex plugin marketplace add "https://gitea.jsc.idv.tw/plugins/doc.git" --re && codex plugin add "jsc@doc" \ && codex plugin add "jsc@code-review" +COPY app /app COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +RUN chmod +x /entrypoint.sh /app/main.js ENTRYPOINT ["/entrypoint.sh"] diff --git a/app/main.js b/app/main.js new file mode 100644 index 0000000..bfa0c75 --- /dev/null +++ b/app/main.js @@ -0,0 +1,193 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawn } = require("child_process"); + +const DEFAULT_PROMPT = "請自我介紹"; +const createdPaths = new Set(); + +function removeIfCreated(filePath) { + if (!filePath || !createdPaths.has(filePath)) { + return; + } + + try { + fs.rmSync(filePath, { force: true }); + } catch { + // Best-effort cleanup only. + } +} + +function cleanup() { + for (const filePath of Array.from(createdPaths).reverse()) { + removeIfCreated(filePath); + } +} + +function makeTempFile(dir, prefix) { + const random = `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`; + const filePath = path.join(dir, `${prefix}.${random}`); + const fd = fs.openSync(filePath, "wx", 0o600); + fs.closeSync(fd); + createdPaths.add(filePath); + return filePath; +} + +function appendGithubOutput(status, output) { + const outputFile = process.env.GITHUB_OUTPUT; + if (!outputFile) { + return; + } + + let delimiter; + do { + delimiter = `CODEX_OUTPUT_${Math.random().toString(36).slice(2)}${Date.now()}`; + } while (output.includes(delimiter)); + + fs.appendFileSync( + outputFile, + `status=${status}\noutput<<${delimiter}\n${output}${output.endsWith("\n") ? "" : "\n"}${delimiter}\n`, + { encoding: "utf8", mode: 0o600 }, + ); +} + +function fail(message, code = 1) { + console.error(message); + appendGithubOutput("failed", message); + cleanup(); + process.exit(code); +} + +function validateAuth(encodedAuth, authFile) { + const decoded = Buffer.from(encodedAuth, "base64"); + + if (decoded.length === 0 && encodedAuth.length > 0) { + fail("OAUTH must be valid base64 encoded Codex auth.json."); + } + + const normalized = encodedAuth.replace(/\s+/g, ""); + if (decoded.toString("base64").replace(/=+$/, "") !== normalized.replace(/=+$/, "")) { + fail("OAUTH must be valid base64 encoded Codex auth.json."); + } + + fs.writeFileSync(authFile, decoded, { mode: 0o600 }); + + 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 runCodex(model, prompt) { + return new Promise((resolve) => { + const child = spawn( + "codex", + [ + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--skip-git-repo-check", + "--model", + model, + prompt, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + let output = ""; + + child.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + output += chunk.toString(); + }); + + child.stderr.on("data", (chunk) => { + process.stdout.write(chunk); + output += chunk.toString(); + }); + + child.on("error", (error) => { + output += `${error.message}\n`; + resolve({ status: 1, output }); + }); + + child.on("close", (code) => { + resolve({ status: code ?? 1, output }); + }); + }); +} + +async function main() { + process.on("exit", cleanup); + process.on("SIGINT", () => { + cleanup(); + process.exit(130); + }); + process.on("SIGTERM", () => { + cleanup(); + process.exit(143); + }); + + 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."); + } + + try { + fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 }); + } catch { + fail("Unable to create CODEX_HOME."); + } + + const authFile = makeTempFile(codexHome, "auth"); + const authPath = path.join(codexHome, "auth.json"); + const lockPath = path.join(os.tmpdir(), `codex-auth-${Buffer.from(codexHome).toString("hex")}.lock`); + + let lockHandle; + try { + lockHandle = fs.openSync(lockPath, "wx", 0o600); + createdPaths.add(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.copyFileSync(authFile, authPath); + fs.chmodSync(authPath, 0o600); + createdPaths.add(authPath); + removeIfCreated(authFile); + + 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); +} + +main().catch((error) => { + fail(error instanceof Error ? error.message : String(error)); +}); diff --git a/entrypoint.sh b/entrypoint.sh index 5b5f268..b7c1b53 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,85 +1,7 @@ #!/bin/bash -set -eo pipefail +set -euo pipefail -die() { - echo "$1" >&2 - exit 1 -} +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cleanup() { - rm -f "${auth_file:-}" "${auth_path:-}" "${auth_lock:-}" "${codex_output:-}" -} - -trap cleanup EXIT - -if [[ -z "${OAUTH:-}" ]]; then - die "OAUTH is required: provide base64 encoded Codex auth.json." -fi - -if [[ -z "${MODEL:-}" ]]; then - die "MODEL is required." -fi - -CODEX_HOME="${CODEX_HOME:-/root/.codex}" -PROMPT="${PROMPT:-請自我介紹}" -mkdir -p "$CODEX_HOME" || die "Unable to create CODEX_HOME." -umask 077 - -auth_file="$(mktemp "$CODEX_HOME/auth.XXXXXX")" -auth_path="$CODEX_HOME/auth.json" -auth_lock="$(mktemp "$CODEX_HOME/auth.lock.XXXXXX")" - -exec 9>"$auth_lock" -flock -n 9 || die "Unable to lock Codex auth.json." - -if ! printf '%s\n' "$OAUTH" | base64 -d > "$auth_file"; then - die "OAUTH must be valid base64 encoded Codex auth.json." -fi - -if ! jq -e 'type == "object"' "$auth_file" >/dev/null; then - die "Decoded OAUTH must be a JSON object." -fi - -if [[ -e "$auth_path" ]]; then - die "Refusing to overwrite existing Codex auth.json." -fi - -install -m 600 "$auth_file" "$auth_path" -rm -f "$auth_file" - -codex_output="$(mktemp)" - -if codex exec \ - --dangerously-bypass-approvals-and-sandbox \ - --skip-git-repo-check \ - --model "$MODEL" \ - "$PROMPT" 2>&1 | tee "$codex_output"; then - codex_status=0 -else - codex_status="${PIPESTATUS[0]}" -fi - -if [[ -n "${GITHUB_OUTPUT:-}" ]]; then - while :; do - output_delimiter="CODEX_OUTPUT_$(mktemp -u XXXXXXXXXXXXXXXX)" - - if ! grep -qxF "$output_delimiter" "$codex_output"; then - break - fi - done - - if [[ "$codex_status" -eq 0 ]]; then - echo "status=completed" >> "$GITHUB_OUTPUT" - else - echo "status=failed" >> "$GITHUB_OUTPUT" - fi - - { - echo "output<<$output_delimiter" - cat "$codex_output" - echo "$output_delimiter" - } >> "$GITHUB_OUTPUT" -fi - -exit "$codex_status" +exec node "$SCRIPT_DIR/app/main.js"