diff --git a/action.yml b/action.yml index 50e3079..a08df46 100644 --- a/action.yml +++ b/action.yml @@ -136,6 +136,14 @@ runs: with: node-version: "24" + - name: Validate action paths + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + CODEX_PROMPT_FILE: ${{ inputs['prompt-file'] }} + CODEX_WORKING_DIRECTORY: ${{ inputs['working-directory'] || github.workspace }} + run: node "$ACTION_PATH/scripts/validateActionPaths.mjs" + - name: Check repository write access env: ACTION_PATH: ${{ github.action_path }} diff --git a/scripts/validateActionPaths.mjs b/scripts/validateActionPaths.mjs new file mode 100644 index 0000000..9916bb9 --- /dev/null +++ b/scripts/validateActionPaths.mjs @@ -0,0 +1,46 @@ +import { access, stat } from "node:fs/promises"; +import { constants } from "node:fs"; + +const promptFile = (process.env.CODEX_PROMPT_FILE ?? "").trim(); +const workingDirectory = (process.env.CODEX_WORKING_DIRECTORY ?? "").trim(); + +if (promptFile) { + await requireReadableFile("prompt-file", promptFile); +} + +if (!workingDirectory) { + throw new Error("working-directory resolved to an empty path"); +} +await requireDirectory("working-directory", workingDirectory); + +async function requireReadableFile(label, target) { + let info; + try { + info = await stat(target); + } catch { + throw new Error(`${label} does not exist: ${target}`); + } + + if (info.isDirectory()) { + throw new Error(`${label} must reference a file, not a directory: ${target}`); + } + + try { + await access(target, constants.R_OK); + } catch { + throw new Error(`${label} is not readable: ${target}`); + } +} + +async function requireDirectory(label, target) { + let info; + try { + info = await stat(target); + } catch { + throw new Error(`${label} does not exist: ${target}`); + } + + if (!info.isDirectory()) { + throw new Error(`${label} must reference a directory: ${target}`); + } +} diff --git a/test/validateActionPaths.test.mjs b/test/validateActionPaths.test.mjs new file mode 100644 index 0000000..2b09410 --- /dev/null +++ b/test/validateActionPaths.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath( + new URL("../scripts/validateActionPaths.mjs", import.meta.url) +); + +function run({ promptFile = "", workingDirectory }) { + return spawnSync(process.execPath, [scriptPath], { + encoding: "utf8", + env: { + ...process.env, + CODEX_PROMPT_FILE: promptFile, + CODEX_WORKING_DIRECTORY: workingDirectory, + }, + }); +} + +test("accepts a readable prompt file and existing working directory", () => { + const root = mkdtempSync(path.join(tmpdir(), "codex-path-test-")); + const promptFile = path.join(root, "prompt.md"); + writeFileSync(promptFile, "Review this change", "utf8"); + + try { + const result = run({ promptFile, workingDirectory: root }); + assert.equal(result.status, 0, result.stderr); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("allows an empty prompt-file when inline prompt is used", () => { + const root = mkdtempSync(path.join(tmpdir(), "codex-path-test-")); + try { + const result = run({ promptFile: " ", workingDirectory: root }); + assert.equal(result.status, 0, result.stderr); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects a missing prompt-file", () => { + const root = mkdtempSync(path.join(tmpdir(), "codex-path-test-")); + const promptFile = path.join(root, "missing.md"); + + try { + const result = run({ promptFile, workingDirectory: root }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /prompt-file does not exist/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects a directory used as prompt-file", () => { + const root = mkdtempSync(path.join(tmpdir(), "codex-path-test-")); + const promptDirectory = path.join(root, "prompt-dir"); + mkdirSync(promptDirectory); + + try { + const result = run({ + promptFile: promptDirectory, + workingDirectory: root, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /prompt-file must reference a file/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects a missing working-directory", () => { + const root = mkdtempSync(path.join(tmpdir(), "codex-path-test-")); + const missingDirectory = path.join(root, "missing"); + + try { + const result = run({ workingDirectory: missingDirectory }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /working-directory does not exist/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects a file used as working-directory", () => { + const root = mkdtempSync(path.join(tmpdir(), "codex-path-test-")); + const filePath = path.join(root, "not-a-directory"); + writeFileSync(filePath, "x", "utf8"); + + try { + const result = run({ workingDirectory: filePath }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /working-directory must reference a directory/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});