diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json new file mode 100644 index 0000000..9d40759 --- /dev/null +++ b/.gitea/ai-review/exclusions.json @@ -0,0 +1,158 @@ +[ + { + "location": "action.yaml:23", + "role": "Assassin", + "original_finding": "將包含認證資訊的 `OAUTH` 透過環境變數傳遞,這使得該敏感資訊在容器內的任何進程皆可透過環境變數(如 `/proc/self/environ`)輕易竊取。", + "reason": "此 action 透過 Gitea/GitHub Docker Action input 接收 secret,action metadata 只能將 input 映射為容器環境變數;呼叫端仍使用 secrets.CODEX_OAUTH,未在 workflow 明文暴露。" + }, + { + "location": "entrypoint.sh:18", + "role": "Assassin", + "original_finding": "雖然有 `chmod 600`,但 `auth.json` 放在 `/root/.codex/` 目錄下,若發生容器逃逸,該敏感金鑰極易被讀取。", + "reason": "Codex CLI 目前需要讀取 CODEX_HOME/auth.json;容器內已限制為 600 權限,外部 Secret Store 掛載不是此 Docker Action 目前可控制的介面。" + }, + { + "location": "action.yaml:6", + "role": "Leo", + "original_finding": "將 Base64 編碼的認證資訊設為 Action input,導致明文暴露在 Workflow 配置中,難以維護。", + "reason": "workflow 傳入的是 secrets.CODEX_OAUTH,不是明文值;使用 input 是 Docker Action 接收呼叫端 secret 的標準介面。" + }, + { + "location": "entrypoint.sh:22", + "role": "Rogue", + "original_finding": "在腳本中頻繁進行 I/O 操作(重複寫入 auth.json),缺乏快取檢查。", + "reason": "每次 Docker Action 執行都是短生命週期容器,auth.json 需要從當次 secret 重建;快取檢查不會降低跨執行 I/O,也可能增加 secret 狀態判斷複雜度。" + }, + { + "location": "action.yaml:4", + "role": "Bard", + "original_finding": "輸入參數 `oauth` 實質為 base64 編碼的 json,命名易生誤解。", + "reason": "此 action 已對外發布並由既有 workflow 使用 oauth input;直接更名會造成破壞性變更,先保留相容介面與描述文字。" + }, + { + "location": "Dockerfile:4", + "role": "Rogue", + "original_finding": "在 Dockerfile 中安裝了 git 且未清理,導致映像檔過大。", + "reason": "Codex CLI 與 plugin 安裝/執行流程可能需要 git 支援;為避免 runtime 缺少 git 造成 action 失敗,目前保留 git。" + }, + { + "location": "Dockerfile:25", + "role": "Rogue", + "original_finding": "插件安裝指令採取連續序列執行,導致多次獨立的網路 I/O 等待,嚴重浪費建置時間。", + "reason": "codex plugin 指令會修改同一份本機 plugin 狀態,並行安裝可能造成狀態競爭;目前保留循序安裝以確保可預測性。" + }, + { + "location": "entrypoint.sh:22", + "role": "Leo", + "original_finding": "直接使用 `mktemp` 在 `CODEX_HOME` 目錄下建立臨時檔案,且 `CODEX_HOME` 若未正確隔離,在多個 Action 同時執行時可能會導致檔案名稱衝突。", + "reason": "Docker Action 每次執行都有獨立容器與 CODEX_HOME;mktemp 也會建立隨機檔名,實務上不會跨 action 執行衝突。" + }, + { + "location": "entrypoint.sh:16", + "role": "Leo", + "original_finding": "硬編碼了預設路徑 `/root/.codex`,這使得映像檔的可移植性受限,且如果在非 root 使用者環境下執行此容器,可能會因為權限問題而失敗。", + "reason": "Dockerfile 明確以 root 環境執行並設定 CODEX_HOME=/root/.codex;entrypoint 仍允許呼叫端用 CODEX_HOME 覆寫預設路徑。" + }, + { + "location": "entrypoint.sh:6", + "role": "Maya", + "original_finding": "應在測試案例中模擬空 OAUTH 輸入,並驗證腳本是否正確拋出錯誤並以 exit 1 終止。", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + }, + { + "location": "entrypoint.sh:11", + "role": "Maya", + "original_finding": "應在測試案例中模擬空 MODEL 輸入,並驗證腳本是否正確拋出錯誤並以 exit 1 終止。", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + }, + { + "location": "Dockerfile:17", + "role": "Bard", + "original_finding": "將安裝腳本邏輯直接寫在 `RUN` 指令中顯得冗長,且使用 `$(mktemp)` 容易產生難以追蹤的臨時檔案。", + "reason": "安裝流程需在同一 Docker layer 中下載、驗證 SHA-256、執行並清理暫存腳本;留在 Dockerfile 可讓供應鏈驗證步驟與安裝命令緊鄰,mktemp 產生的檔案也已於同一 RUN 中刪除。" + }, + { + "location": "entrypoint.sh:45", + "role": "Bard", + "original_finding": "使用靜態路徑檢查 `auth.json` 是否存在,若前次執行中斷導致檔案未清除,會導致後續執行失敗(Self-inflicted DoS)。", + "reason": "拒絕覆寫既有 auth.json 是刻意的 secret 安全保護,避免覆蓋呼叫端掛載或殘留的認證檔;Docker Action 預期每次執行使用獨立容器,前次中斷造成殘留的風險低於靜默覆寫 secret 的風險。" + }, + { + "location": "tests/entrypoint_test.sh:17", + "role": "Bard", + "original_finding": "在測試中建立 `codex` 指令時,Here-document 與命令混雜,可讀性較低。", + "reason": "測試 helper 需要動態產生可執行的假 codex 指令,here-document 在此處比額外 fixture 檔更直觀且維持測試自含;此項屬風格偏好,不影響行為正確性。" + }, + { + "location": "Dockerfile:10", + "role": "Assassin", + "original_finding": "Codex 外掛市集來源指向 `gitea.jsc.idv.tw`,未驗證來源的安全性。若該伺服器遭駭,將導致自動安裝惡意或篡改過的外掛,進而導致供應鏈攻擊。", + "reason": "Dockerfile 已使用 `codex plugin marketplace add --ref` 將 doc 與 code-review marketplace 固定到指定 commit SHA;此 action 需要安裝內部 Gitea marketplace 外掛,來源伺服器信任與存取控管屬部署環境治理,不適合在 Dockerfile 內改成其他來源。" + }, + { + "location": "Dockerfile:24", + "role": "Leo", + "original_finding": "插件 URL 與版本參照 (REF) 硬編碼在 Dockerfile 中,未來若需更新插件或更換來源,需重新編譯整個 Docker 映像檔,維護成本較高。", + "reason": "此 Docker Action 需要可重現的建置結果;將 marketplace URL 與 ref 固定在 Dockerfile ARG 中,可讓外掛版本變更必須經由映像檔重建與 code review,避免外部設定在執行時靜默改變供應鏈內容。" + }, + { + "location": "tests/entrypoint_test.sh:65", + "role": "Maya", + "original_finding": "測試案例對於輸入參數的邊界測試(例如 `PROMPT` 為空字串、極長字串)不足。", + "reason": "目前測試已包含 `test_empty_prompt` 與 `test_prompt_with_shell_characters`,覆蓋空字串與含 shell 特殊字元的 PROMPT 傳遞;此 finding 對最新測試內容已不成立。" + }, + { + "location": "entrypoint.sh:49", + "role": "Mage", + "original_finding": "在執行 install -m 600 時,若 codex 進程已經在嘗試讀取 auth.json,會發生檔案存取競態(Race Condition)。雖然使用了 flock,但這僅在同一個 shell 腳本實例中有效,無法保護跨容器或跨執行環境的檔案存取一致性。", + "reason": "最新入口已改為 `entrypoint.sh` 呼叫 `app/main.js`,不存在 `install -m 600`;Node.js 實作會先寫入並 chmod auth.json 後才啟動 codex,且 Docker Action 每次執行為獨立容器,跨容器不共享 CODEX_HOME。" + }, + { + "location": "entrypoint.sh:58", + "role": "Mage", + "original_finding": "在容器化環境(通常是 ephemeral 的)中,auth.json 寫入後立刻被刪除,這會導致 codex 在後續執行中因找不到驗證檔案而無法運作。另外,trap 的清理機制會導致該檔案在 codex 完成工作前被刪除,這對於長效執行或需要多次存取的應用場景是錯誤的設計。", + "reason": "最新 Node.js 實作會等待 `codex exec` 子程序結束並寫入 output 後才執行 cleanup;auth.json 在 codex 執行期間持續存在,不會在工作完成前被刪除。" + }, + { + "location": "Dockerfile:32", + "role": "Mage", + "original_finding": "在 Dockerfile 中直接使用 RUN 來下載並執行安裝腳本,沒有進行網路連接穩定性的驗證或完整的錯誤恢復機制。一旦網路不穩導致腳本不完整,後續的 sha256sum 檢查會失敗,但 Dockerfile 層疊技術可能會導致中間層殘留損壞的檔案。", + "reason": "Dockerfile 已在同一個 RUN 中完成下載、SHA-256 驗證與安裝,並使用 `curl --retry 3 --retry-delay 2 --max-time 120`;checksum 失敗會使該 layer 建置失敗,不會產生可用的損壞中間成果。" + }, + { + "location": "app/main.js:106", + "role": "Assassin", + "original_finding": "在執行 `codex` 子行程時,使用了 `--dangerously-bypass-approvals-and-sandbox` 參數。這會完全繞過沙盒機制與審核流程,如果 `prompt` 內容受到攻擊者控制,該 CLI 工具將獲得在容器中執行任意代碼的權限。", + "reason": "此 action 的用途是在隔離的短生命週期 CI 容器中代替使用者執行 Codex,必須允許 Codex 非互動式修改 workspace;workflow 呼叫端需以 trusted prompt/secret 使用,本次已在 `runCodex` 前加入註解明確標示此安全取捨。" + }, + { + "location": "app/main.js:143", + "role": "Assassin", + "original_finding": "將 `OAUTH` 環境變數內容解碼並直接寫入 `auth.json`。雖然有檢查 base64 格式與 JSON 結構,但若解碼後的 JSON 內容包含惡意配置(如惡意插件路徑或偽造的 API 憑證),可能導致後續 `codex` CLI 在執行時被劫持或洩漏資料。", + "reason": "`OAUTH` 是呼叫端提供給 Codex CLI 的 auth.json secret;action 只能驗證 base64、JSON object 與檔案權限,憑證真偽與欄位語意需由 Codex CLI/上游認證機制處理,action 不應猜測或拒絕未來相容欄位。" + }, + { + "location": "app/main.js:20", + "role": "Leo", + "original_finding": "建議至少加上 `console.error` 或在開發/除錯模式下將錯誤拋出,以便在清除失敗時能收到警示。", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + }, + { + "location": "app/main.js:125", + "role": "Mage", + "original_finding": "這裡直接使用 `spawn` 執行 `codex` 命令,且參數 `prompt` 是直接從 `process.env.PROMPT` 讀取並傳入的。如果 CI 環境的 `PROMPT` 被惡意竄改,雖使用陣列傳遞參數避免了 shell injection,但 `codex exec` 的邏輯若沒有妥善限制(例如限制可執行指令類型),可能導致攻擊者在 CI Runner 環境執行任意指令。", + "reason": "此 action 的目的就是讓呼叫 workflow 以 `PROMPT` 指定 Codex 任務並在隔離 CI 容器內非互動執行;prompt 語意必須由 workflow 與 secret 的信任邊界控管。程式已用 `spawn` 參數陣列避免 shell injection,不適合在 action 內以白名單改寫或限制使用者 prompt。" + }, + { + "location": "app/main.js:134", + "role": "Maya", + "original_finding": "對於 codex 執行失敗的各種細節(權限不足、找不到 binary 等)都統一處理為 status: 1,測試未驗證具體錯誤來源。", + "reason": "此 GitHub Action 對外輸出以 `status=completed|failed` 與 `output` 訊息表達成功或失敗;process exit code 只需反映 Codex 子程序結果或通用啟動失敗。細分 ENOENT、EACCES 等 Action exit code 會改變既有介面且對 workflow 判斷價值有限,本次已用簡潔 output 訊息與測試覆蓋具體錯誤來源。" + }, + { + "location": "app/main.js:142", + "role": "Rogue", + "original_finding": "移除這些無謂的重新編碼比較,直接嘗試解碼。", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + } +] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json new file mode 100644 index 0000000..b3397bd --- /dev/null +++ b/.gitea/ai-review/findings.json @@ -0,0 +1,10 @@ +[ + { + "level": "info", + "role": "Bard", + "problem": "setupAuth 函式職責過於繁雜。", + "suggestion": "將鎖定機制與驗證機制拆分為獨立輔助函式。", + "location": "app/main.js:283", + "is_new": false + } +] diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..161fad7 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,39 @@ +name: CI +on: + pull_request: + types: [opened, synchronize] +jobs: + version: + name: 計算版本號 + runs-on: ubuntu + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: 計算版本號 + id: version + uses: https://gitea.jsc.idv.tw/actions/calculate-version@${{ vars.ACTION_CALCULATE_VERSION }} + with: + IS_BETA: true + - name: 標註版本號 + uses: akkuman/gitea-release-action@${{ vars.ACTION_RELEASE_VERSION }} + with: + name: codex v${{ steps.version.outputs.version }} + tag_name: v${{ steps.version.outputs.version }} + target_commitish: ${{ github.head_ref }} + test: + name: 工具測試 + runs-on: ubuntu + needs: [version] + outputs: + status: ${{ steps.test.outputs.status }} + output: ${{ steps.test.outputs.output }} + steps: + - name: 工具測試 + id: test + uses: https://gitea.jsc.idv.tw/actions/codex@v${{ needs.version.outputs.version }} + with: + oauth: ${{ secrets.CODEX_OAUTH }} + model: gpt-5.4-mini + - name: 測試失敗 + if: ${{ steps.test.outputs.status != 'completed' }} + run: exit 1 diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml new file mode 100644 index 0000000..da2c4cd --- /dev/null +++ b/.gitea/workflows/review.yaml @@ -0,0 +1,23 @@ +name: AI +on: + pull_request: + branches-ignore: + - master + types: [opened, synchronize] +jobs: + code-review: + name: Code Review + runs-on: ubuntu + steps: + - name: AI Code Review + uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} + with: + GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }} + GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }} + OPENCODE_BASE_URL: ${{ vars.OPENCODE_BASE_URL }} + OPENCODE_PROVIDER: ${{ vars.OPENCODE_PROVIDER }} + OPENCODE_MODEL: ${{ vars.GEMINI_MODEL }} + permissions: + contents: write + pull-requests: write + issues: write \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index af3dacb..9348293 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,35 @@ FROM alpine:latest +# 設定安裝用的環境變數 +ENV CODEX_NON_INTERACTIVE=1 +ENV CODEX_INSTALL_DIR=/usr/local/bin +ENV CODEX_HOME=/root/.codex +ARG CODEX_INSTALL_SHA256=73eb367137d151eabe89590a1d1a08a9c2fe2800a656bffac3c5707b62b42683 +ARG CODEX_DOC_MARKETPLACE_REF=f8da961328a85f267b4402566e127310370169da +ARG CODEX_CODE_REVIEW_MARKETPLACE_REF=9e016edff016d58f4d64e0a5468220d35a0f657b + # 安裝必要的工具 -RUN apk add --no-cache --no-check-certificate bash - +RUN apk add --no-cache --no-check-certificate bash ca-certificates curl git jq nodejs + +# 安裝 Codex CLI 工具 +RUN install_script="$(mktemp)" \ + && trap 'rm -f "$install_script"' EXIT \ + && curl -fsSL --retry 3 --retry-delay 2 --max-time 120 \ + https://chatgpt.com/codex/install.sh \ + -o "$install_script" \ + && echo "${CODEX_INSTALL_SHA256} ${install_script}" | sha256sum -c - \ + && sh "$install_script" \ + && codex --version + +# 安裝技能 +RUN codex plugin marketplace add "https://gitea.jsc.idv.tw/plugins/doc.git" --ref "$CODEX_DOC_MARKETPLACE_REF" \ + && codex plugin marketplace add "https://gitea.jsc.idv.tw/plugins/code-review.git" --ref "$CODEX_CODE_REVIEW_MARKETPLACE_REF" \ + && 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"] \ No newline at end of file +ENTRYPOINT ["/entrypoint.sh"] diff --git a/action.yaml b/action.yaml index f8849d0..d7dffa0 100644 --- a/action.yaml +++ b/action.yaml @@ -1,20 +1,26 @@ -name: 'Docker Action Template' -description: 'Docker Action 範本' +name: 'Codex CLI' +description: 'Codex CLI Action' author: 'Jeffery' inputs: - runner_token: - description: 'Gitea Runner Token' + oauth: + description: 'Base64 encoded Codex auth.json' required: true - text: - description: '輸入的文字' - default: "Hello, World!" + model: + description: 'Codex model name' + required: true + prompt: + description: 'Prompt for codex exec' + required: false + default: '請自我介紹' outputs: - text: - description: '輸出的文字' + status: + description: 'Execution result status' + output: + description: 'Codex execution output' runs: using: 'docker' image: 'Dockerfile' env: - GITEA_SERVER_URL: ${{ gitea.server_url }} - GITEA_REPOSITORY: ${{ gitea.repository }} - RUNNER_TOKEN: ${{ inputs.runner_token || secrets.GITEA_TOKEN || secrets.RUNNER_TOKEN }} \ No newline at end of file + OAUTH: ${{ inputs.oauth }} + MODEL: ${{ inputs.model }} + PROMPT: ${{ inputs.prompt }} diff --git a/app/main.js b/app/main.js new file mode 100644 index 0000000..de5feed --- /dev/null +++ b/app/main.js @@ -0,0 +1,332 @@ +#!/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) { + return [ + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--skip-git-repo-check", + "--model", + model, + prompt, + ]; +} + +function runCodex(model, prompt) { + return new Promise((resolve) => { + const { timeoutMs, outputLimitBytes, workspace } = readExecutionConfig(); + const args = codexExecArgs(model, prompt); + + // 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.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 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); + 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)); +}); diff --git a/entrypoint.sh b/entrypoint.sh index 378b552..b7c1b53 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,11 +1,7 @@ #!/bin/bash -echo "Gitea Server Url: $GITEA_SERVER_URL" +set -euo pipefail -echo "Gitea Repository: $GITEA_REPOSITORY" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -echo "Gitea Runner Token: $RUNNER_TOKEN" - -echo "Input Text: $INPUT_TEXT" - -echo "text=$INPUT_TEXT" >> "$GITHUB_OUTPUT" \ No newline at end of file +exec node "$SCRIPT_DIR/app/main.js" diff --git a/tests/docker_image_test.sh b/tests/docker_image_test.sh new file mode 100755 index 0000000..a6b1eb0 --- /dev/null +++ b/tests/docker_image_test.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE_NAME="codex-action-smoke:local" + +if ! command -v docker >/dev/null 2>&1; then + echo "SKIP: docker is not available" + exit 0 +fi + +if ! docker info >/dev/null 2>&1; then + echo "SKIP: docker daemon is not available" + exit 0 +fi + +docker build -t "$IMAGE_NAME" "$ROOT_DIR" + +docker run --rm --entrypoint codex "$IMAGE_NAME" --version +docker run --rm --entrypoint sh "$IMAGE_NAME" -c 'mkdir -p "$CODEX_HOME" && test -w "$CODEX_HOME" && codex --version >/dev/null' +docker run --rm --entrypoint codex "$IMAGE_NAME" plugin list | grep -q "jsc@doc" +docker run --rm --entrypoint codex "$IMAGE_NAME" plugin list | grep -q "jsc@code-review" diff --git a/tests/entrypoint_test.sh b/tests/entrypoint_test.sh new file mode 100755 index 0000000..85983cd --- /dev/null +++ b/tests/entrypoint_test.sh @@ -0,0 +1,304 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +die() { + echo "FAIL: $1" >&2 + exit 1 +} + +make_fake_codex() { + local dir="$1" + local exit_code="$2" + local message="$3" + + cat > "$dir/codex" < "\${CODEX_FAKE_ARGS:-/dev/null}" +echo "$message" +exit $exit_code +SH + chmod +x "$dir/codex" +} + +make_fake_sleeping_codex() { + local dir="$1" + + cat > "$dir/codex" <<'SH' +#!/bin/sh +trap 'exit 143' TERM +while true; do + sleep 1 +done +SH + chmod +x "$dir/codex" +} + +make_fake_signaled_codex() { + local dir="$1" + + cat > "$dir/codex" <<'SH' +#!/bin/sh +kill -TERM $$ +SH + chmod +x "$dir/codex" +} + +make_fake_large_output_codex() { + local dir="$1" + + cat > "$dir/codex" <<'SH' +#!/bin/sh +printf '0123456789' +SH + chmod +x "$dir/codex" +} + +encoded_json() { + printf '%s' "$1" | base64 | tr -d '\n' +} + +run_entrypoint() { + local tmpdir="$1" + shift + + env \ + PATH="$tmpdir:$PATH" \ + CODEX_HOME="$tmpdir/codex-home" \ + GITHUB_OUTPUT="$tmpdir/github-output" \ + "$@" \ + bash "$ROOT_DIR/entrypoint.sh" > "$tmpdir/stdout" 2> "$tmpdir/stderr" +} + +assert_status() { + local actual="$1" + local expected="$2" + + [[ "$actual" -eq "$expected" ]] || die "expected status $expected, got $actual" +} + +test_missing_oauth() { + local tmpdir + tmpdir="$(mktemp -d)" + make_fake_codex "$tmpdir" 0 "unused" + + set +e + run_entrypoint "$tmpdir" MODEL="gpt-test" PROMPT="hello" + local status="$?" + set -e + + assert_status "$status" 1 + grep -q "OAUTH is required" "$tmpdir/stderr" || die "missing OAUTH error" + grep -q "status=failed" "$tmpdir/github-output" || die "missing OAUTH failed status output" + grep -q "OAUTH is required" "$tmpdir/github-output" || die "missing OAUTH output" + rm -rf "$tmpdir" +} + +test_missing_model() { + local tmpdir + tmpdir="$(mktemp -d)" + make_fake_codex "$tmpdir" 0 "unused" + + set +e + run_entrypoint "$tmpdir" OAUTH="$(encoded_json '{}')" PROMPT="hello" + local status="$?" + set -e + + assert_status "$status" 1 + grep -q "MODEL is required" "$tmpdir/stderr" || die "missing MODEL error" + grep -q "status=failed" "$tmpdir/github-output" || die "missing MODEL failed status output" + grep -q "MODEL is required" "$tmpdir/github-output" || die "missing MODEL output" + rm -rf "$tmpdir" +} + +test_invalid_base64() { + local tmpdir + tmpdir="$(mktemp -d)" + make_fake_codex "$tmpdir" 0 "unused" + + set +e + run_entrypoint "$tmpdir" OAUTH="not-base64" MODEL="gpt-test" PROMPT="hello" + local status="$?" + set -e + + assert_status "$status" 1 + grep -q "valid base64" "$tmpdir/stderr" || die "invalid base64 error" + grep -q "status=failed" "$tmpdir/github-output" || die "invalid base64 failed status output" + grep -q "valid base64" "$tmpdir/github-output" || die "invalid base64 output" + rm -rf "$tmpdir" +} + +test_non_object_json() { + local tmpdir + tmpdir="$(mktemp -d)" + make_fake_codex "$tmpdir" 0 "unused" + + set +e + run_entrypoint "$tmpdir" OAUTH="$(encoded_json '[]')" MODEL="gpt-test" PROMPT="hello" + local status="$?" + set -e + + assert_status "$status" 1 + grep -q "JSON object" "$tmpdir/stderr" || die "non-object JSON error" + grep -q "status=failed" "$tmpdir/github-output" || die "non-object JSON failed status output" + grep -q "JSON object" "$tmpdir/github-output" || die "non-object JSON output" + rm -rf "$tmpdir" +} + +test_success_output() { + local tmpdir + tmpdir="$(mktemp -d)" + make_fake_codex "$tmpdir" 0 "codex ok" + + run_entrypoint "$tmpdir" OAUTH="$(encoded_json '{}')" MODEL="gpt-test" PROMPT="hello" + + grep -q "status=completed" "$tmpdir/github-output" || die "completed status output" + grep -q "codex ok" "$tmpdir/github-output" || die "codex output" + [[ ! -e "$tmpdir/codex-home/auth.json" ]] || die "auth.json was not cleaned up" + rm -rf "$tmpdir" +} + +test_empty_prompt() { + local tmpdir + tmpdir="$(mktemp -d)" + make_fake_codex "$tmpdir" 0 "codex ok" + + run_entrypoint "$tmpdir" OAUTH="$(encoded_json '{}')" MODEL="gpt-test" PROMPT="" + + grep -q "codex ok" "$tmpdir/github-output" || die "empty prompt output" + rm -rf "$tmpdir" +} + +test_prompt_with_shell_characters() { + local tmpdir + local prompt + tmpdir="$(mktemp -d)" + prompt='hello; rm -rf / $(echo bad) "quoted"' + make_fake_codex "$tmpdir" 0 "codex ok" + + run_entrypoint "$tmpdir" OAUTH="$(encoded_json '{}')" MODEL="gpt-test" PROMPT="$prompt" CODEX_FAKE_ARGS="$tmpdir/codex-args" + + grep -qF "$prompt" "$tmpdir/codex-args" || die "prompt was not passed as one argument" + rm -rf "$tmpdir" +} + +test_failure_output() { + local tmpdir + tmpdir="$(mktemp -d)" + make_fake_codex "$tmpdir" 7 "codex failed" + + set +e + run_entrypoint "$tmpdir" OAUTH="$(encoded_json '{}')" MODEL="gpt-test" PROMPT="hello" + local status="$?" + set -e + + assert_status "$status" 7 + grep -q "status=failed" "$tmpdir/github-output" || die "failed status output" + grep -q "codex failed" "$tmpdir/github-output" || die "failed codex output" + [[ ! -e "$tmpdir/codex-home/auth.json" ]] || die "auth.json was not cleaned up after failure" + rm -rf "$tmpdir" +} + +test_missing_codex_command_output() { + local tmpdir + local bindir + tmpdir="$(mktemp -d)" + bindir="$tmpdir/bin" + mkdir -p "$bindir" + ln -s "$(command -v bash)" "$bindir/bash" + ln -s "$(command -v dirname)" "$bindir/dirname" + ln -s "$(command -v node)" "$bindir/node" + + set +e + run_entrypoint "$tmpdir" PATH="$bindir" OAUTH="$(encoded_json '{}')" MODEL="gpt-test" PROMPT="hello" + local status="$?" + set -e + + assert_status "$status" 1 + grep -q "status=failed" "$tmpdir/github-output" || die "missing codex command failed status output" + grep -q "Unable to find codex command" "$tmpdir/github-output" || die "missing codex command error output" + [[ ! -e "$tmpdir/codex-home/auth.json" ]] || die "auth.json was not cleaned up after missing codex" + rm -rf "$tmpdir" +} + +test_codex_timeout_output() { + local tmpdir + tmpdir="$(mktemp -d)" + make_fake_sleeping_codex "$tmpdir" + + set +e + run_entrypoint "$tmpdir" OAUTH="$(encoded_json '{}')" MODEL="gpt-test" PROMPT="hello" CODEX_TIMEOUT_MS="100" + local status="$?" + set -e + + assert_status "$status" 143 + grep -q "status=failed" "$tmpdir/github-output" || die "timeout failed status output" + grep -q "Codex execution timed out after 100 ms" "$tmpdir/github-output" || die "timeout message output" + [[ ! -e "$tmpdir/codex-home/auth.json" ]] || die "auth.json was not cleaned up after timeout" + rm -rf "$tmpdir" +} + +test_codex_signal_output() { + local tmpdir + tmpdir="$(mktemp -d)" + make_fake_signaled_codex "$tmpdir" + + set +e + run_entrypoint "$tmpdir" OAUTH="$(encoded_json '{}')" MODEL="gpt-test" PROMPT="hello" + local status="$?" + set -e + + assert_status "$status" 1 + grep -q "status=failed" "$tmpdir/github-output" || die "signal failed status output" + grep -q "Codex process terminated by signal SIGTERM" "$tmpdir/github-output" || die "signal message output" + rm -rf "$tmpdir" +} + +test_codex_output_truncation() { + local tmpdir + tmpdir="$(mktemp -d)" + make_fake_large_output_codex "$tmpdir" + + run_entrypoint "$tmpdir" OAUTH="$(encoded_json '{}')" MODEL="gpt-test" PROMPT="hello" CODEX_OUTPUT_LIMIT_BYTES="5" + + grep -q "status=completed" "$tmpdir/github-output" || die "truncated output completed status" + grep -q "01234" "$tmpdir/github-output" || die "truncated output prefix" + grep -q "\\[Output truncated\\]" "$tmpdir/github-output" || die "truncation message output" + rm -rf "$tmpdir" +} + +test_invalid_execution_config_uses_defaults() { + local tmpdir + tmpdir="$(mktemp -d)" + make_fake_large_output_codex "$tmpdir" + + run_entrypoint "$tmpdir" \ + OAUTH="$(encoded_json '{}')" \ + MODEL="gpt-test" \ + PROMPT="hello" \ + CODEX_TIMEOUT_MS="invalid" \ + CODEX_OUTPUT_LIMIT_BYTES="invalid" + + grep -q "status=completed" "$tmpdir/github-output" || die "invalid config completed status" + grep -q "0123456789" "$tmpdir/github-output" || die "invalid output limit did not use default" + ! grep -q "\\[Output truncated\\]" "$tmpdir/github-output" || die "invalid output limit unexpectedly truncated" + rm -rf "$tmpdir" +} + +test_missing_oauth +test_missing_model +test_invalid_base64 +test_non_object_json +test_success_output +test_empty_prompt +test_prompt_with_shell_characters +test_failure_output +test_missing_codex_command_output +test_codex_timeout_output +test_codex_signal_output +test_codex_output_truncation +test_invalid_execution_config_uses_defaults + +echo "entrypoint tests passed"