diff --git a/action.yml b/action.yml index 50e3079..9c616f3 100644 --- a/action.yml +++ b/action.yml @@ -178,6 +178,19 @@ runs: --codex-user "$CODEX_USER" \ --github-run-id "$CODEX_RUN_ID" + - name: Ensure Codex home exists + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + CODEX_HOME: ${{ steps.resolve_home.outputs.codex-home }} + SAFETY_STRATEGY: ${{ inputs['safety-strategy'] }} + CODEX_USER: ${{ inputs['codex-user'] }} + run: | + node "$ACTION_PATH/scripts/ensureCodexHome.mjs" \ + "$CODEX_HOME" \ + "$SAFETY_STRATEGY" \ + "$CODEX_USER" + - name: Determine server info path id: derive_server_info shell: bash diff --git a/scripts/ensureCodexHome.mjs b/scripts/ensureCodexHome.mjs new file mode 100644 index 0000000..b46bd61 --- /dev/null +++ b/scripts/ensureCodexHome.mjs @@ -0,0 +1,75 @@ +import { access, mkdir } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import path from "node:path"; + +const [codexHome, safetyStrategy, codexUser = ""] = process.argv.slice(2); + +if (!codexHome) { + throw new Error("Codex home path is required"); +} + +if (safetyStrategy === "unprivileged-user") { + if (!codexUser) { + throw new Error( + "codex-user is required when ensuring a Codex home for unprivileged-user" + ); + } + + const runId = process.env.GITHUB_RUN_ID ?? ""; + if (!runId) { + throw new Error( + "GITHUB_RUN_ID is required when preparing an unprivileged Codex home" + ); + } + + if (!(await pathExists(codexHome))) { + await run("sudo", ["mkdir", "-p", "--", codexHome]); + await run("sudo", ["chown", codexUser, codexHome]); + await run("sudo", ["chmod", "755", codexHome]); + } + + // The proxy runs as the action's current user, while Codex runs as codexUser. + // Pre-create the per-run file so the proxy can write server info even when + // CODEX_HOME itself is owned by the unprivileged user and is not writable by + // the runner. The existing wait step locks this file back down to root:0444. + const serverInfoFile = path.join(codexHome, `${runId}.json`); + if (!(await pathExists(serverInfoFile))) { + await run("sudo", ["touch", "--", serverInfoFile]); + await run("sudo", ["chmod", "666", serverInfoFile]); + } +} else { + await mkdir(codexHome, { recursive: true }); +} + +async function pathExists(target) { + try { + await access(target); + return true; + } catch { + return false; + } +} + +async function run(command, args) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + env: process.env, + stdio: "inherit", + }); + + child.once("error", reject); + child.once("close", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + + if (signal) { + reject(new Error(`${command} terminated by signal ${signal}`)); + return; + } + + reject(new Error(`${command} exited with code ${code}`)); + }); + }); +} diff --git a/test/ensureCodexHome.test.mjs b/test/ensureCodexHome.test.mjs new file mode 100644 index 0000000..406ac1d --- /dev/null +++ b/test/ensureCodexHome.test.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + 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/ensureCodexHome.mjs", import.meta.url) +); + +test("creates a missing Codex home for ordinary safety strategies", () => { + const root = mkdtempSync(path.join(tmpdir(), "codex-home-test-")); + const codexHome = path.join(root, "nested", "codex-home"); + + try { + const result = spawnSync( + process.execPath, + [scriptPath, codexHome, "drop-sudo", ""], + { encoding: "utf8" } + ); + + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(codexHome), true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("creates a shared unprivileged home and writable per-run server-info file", () => { + const root = mkdtempSync(path.join(tmpdir(), "codex-home-user-test-")); + const codexHome = path.join(root, "guest-home", ".codex"); + const fakeBin = path.join(root, "bin"); + const fakeSudo = path.join(fakeBin, "sudo"); + const logFile = path.join(root, "sudo.log"); + const runId = "12345"; + const serverInfoFile = path.join(codexHome, `${runId}.json`); + + try { + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + fakeSudo, + `#!/bin/sh +printf '%s\n' "$*" >> "$SUDO_LOG" +case "$1" in + mkdir|touch) + exec "$@" + ;; + chown|chmod) + exit 0 + ;; +esac +exit 2 +`, + "utf8" + ); + chmodSync(fakeSudo, 0o755); + + const result = spawnSync( + process.execPath, + [scriptPath, codexHome, "unprivileged-user", "guest"], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, + SUDO_LOG: logFile, + GITHUB_RUN_ID: runId, + }, + } + ); + + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(codexHome), true); + assert.equal(existsSync(serverInfoFile), true); + assert.equal( + readFileSync(logFile, "utf8"), + [ + `mkdir -p -- ${codexHome}`, + `chown guest ${codexHome}`, + `chmod 755 ${codexHome}`, + `touch -- ${serverInfoFile}`, + `chmod 666 ${serverInfoFile}`, + "", + ].join("\n") + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("prepares a new run file when the unprivileged Codex home already exists", () => { + const root = mkdtempSync(path.join(tmpdir(), "codex-home-existing-test-")); + const codexHome = path.join(root, ".codex"); + const fakeBin = path.join(root, "bin"); + const fakeSudo = path.join(fakeBin, "sudo"); + const logFile = path.join(root, "sudo.log"); + const runId = "67890"; + const serverInfoFile = path.join(codexHome, `${runId}.json`); + + try { + mkdirSync(codexHome, { recursive: true }); + mkdirSync(fakeBin, { recursive: true }); + writeFileSync( + fakeSudo, + `#!/bin/sh +printf '%s\n' "$*" >> "$SUDO_LOG" +case "$1" in + touch) + exec "$@" + ;; + chmod) + exit 0 + ;; +esac +exit 2 +`, + "utf8" + ); + chmodSync(fakeSudo, 0o755); + + const result = spawnSync( + process.execPath, + [scriptPath, codexHome, "unprivileged-user", "guest"], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, + SUDO_LOG: logFile, + GITHUB_RUN_ID: runId, + }, + } + ); + + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(serverInfoFile), true); + assert.equal( + readFileSync(logFile, "utf8"), + [`touch -- ${serverInfoFile}`, `chmod 666 ${serverInfoFile}`, ""].join( + "\n" + ) + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("fails clearly when unprivileged-user has no codex-user", () => { + const root = mkdtempSync(path.join(tmpdir(), "codex-home-user-test-")); + const codexHome = path.join(root, ".codex"); + + try { + const result = spawnSync( + process.execPath, + [scriptPath, codexHome, "unprivileged-user", ""], + { + encoding: "utf8", + env: { ...process.env, GITHUB_RUN_ID: "12345" }, + } + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /codex-user is required/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("fails clearly when an unprivileged run has no GitHub run id", () => { + const root = mkdtempSync(path.join(tmpdir(), "codex-home-run-id-test-")); + const codexHome = path.join(root, ".codex"); + const env = { ...process.env }; + delete env.GITHUB_RUN_ID; + + try { + const result = spawnSync( + process.execPath, + [scriptPath, codexHome, "unprivileged-user", "guest"], + { encoding: "utf8", env } + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /GITHUB_RUN_ID is required/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});