From 1dd3c5fb4618ddb8b1cad718da6da2169b756a7c Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:43:43 +0100 Subject: [PATCH 1/5] fix: ensure resolved Codex home exists --- scripts/ensureCodexHome.mjs | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 scripts/ensureCodexHome.mjs diff --git a/scripts/ensureCodexHome.mjs b/scripts/ensureCodexHome.mjs new file mode 100644 index 0000000..083d9c3 --- /dev/null +++ b/scripts/ensureCodexHome.mjs @@ -0,0 +1,44 @@ +import { mkdir } from "node:fs/promises"; +import { spawn } from "node:child_process"; + +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" + ); + } + + await run("sudo", ["-u", codexUser, "--", "mkdir", "-p", "--", codexHome]); +} else { + await mkdir(codexHome, { recursive: true }); +} + +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}`)); + }); + }); +} From 964c2a8cbdde3f8dafecce47504264c5be539d70 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:44:01 +0100 Subject: [PATCH 2/5] test: cover Codex home directory creation --- test/ensureCodexHome.test.mjs | 99 +++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 test/ensureCodexHome.test.mjs diff --git a/test/ensureCodexHome.test.mjs b/test/ensureCodexHome.test.mjs new file mode 100644 index 0000000..d2c8992 --- /dev/null +++ b/test/ensureCodexHome.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { + chmodSync, + existsSync, + 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 missing Codex home as the configured unprivileged user", () => { + 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"); + + try { + spawnSync("mkdir", ["-p", fakeBin], { encoding: "utf8" }); + writeFileSync( + fakeSudo, + `#!/bin/sh +printf '%s\n' "$*" >> "$SUDO_LOG" +if [ "$1" = "-u" ]; then shift 2; fi +if [ "$1" = "--" ]; then shift; fi +exec "$@" +`, + "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, + }, + } + ); + + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(codexHome), true); + assert.equal( + readFileSync(logFile, "utf8"), + `-u guest -- mkdir -p -- ${codexHome}\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" } + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /codex-user is required/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From 43700838b414b8101e10ae37d77bbc2ec13e15fd Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:45:06 +0100 Subject: [PATCH 3/5] fix: create resolved Codex home before proxy setup --- action.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 From d5e09a92a630357d5be1b6949f72b12f1aacde0e Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:56:23 +0100 Subject: [PATCH 4/5] fix: prepare unprivileged proxy server info path --- scripts/ensureCodexHome.mjs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/scripts/ensureCodexHome.mjs b/scripts/ensureCodexHome.mjs index 083d9c3..b46bd61 100644 --- a/scripts/ensureCodexHome.mjs +++ b/scripts/ensureCodexHome.mjs @@ -1,5 +1,6 @@ -import { mkdir } from "node:fs/promises"; +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); @@ -14,11 +15,41 @@ if (safetyStrategy === "unprivileged-user") { ); } - await run("sudo", ["-u", codexUser, "--", "mkdir", "-p", "--", codexHome]); + 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, { From 842a18a971173f44b5ff5de77a2674424a544818 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:56:47 +0100 Subject: [PATCH 5/5] test: cover shared unprivileged proxy state --- test/ensureCodexHome.test.mjs | 112 +++++++++++++++++++++++++++++++--- 1 file changed, 105 insertions(+), 7 deletions(-) diff --git a/test/ensureCodexHome.test.mjs b/test/ensureCodexHome.test.mjs index d2c8992..406ac1d 100644 --- a/test/ensureCodexHome.test.mjs +++ b/test/ensureCodexHome.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { chmodSync, existsSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, @@ -35,22 +36,30 @@ test("creates a missing Codex home for ordinary safety strategies", () => { } }); -test("creates a missing Codex home as the configured unprivileged user", () => { +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 { - spawnSync("mkdir", ["-p", fakeBin], { encoding: "utf8" }); + mkdirSync(fakeBin, { recursive: true }); writeFileSync( fakeSudo, `#!/bin/sh printf '%s\n' "$*" >> "$SUDO_LOG" -if [ "$1" = "-u" ]; then shift 2; fi -if [ "$1" = "--" ]; then shift; fi -exec "$@" +case "$1" in + mkdir|touch) + exec "$@" + ;; + chown|chmod) + exit 0 + ;; +esac +exit 2 `, "utf8" ); @@ -65,15 +74,81 @@ exec "$@" ...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"), - `-u guest -- mkdir -p -- ${codexHome}\n` + [ + `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 }); @@ -88,7 +163,10 @@ test("fails clearly when unprivileged-user has no codex-user", () => { const result = spawnSync( process.execPath, [scriptPath, codexHome, "unprivileged-user", ""], - { encoding: "utf8" } + { + encoding: "utf8", + env: { ...process.env, GITHUB_RUN_ID: "12345" }, + } ); assert.notEqual(result.status, 0); @@ -97,3 +175,23 @@ test("fails clearly when unprivileged-user has no codex-user", () => { 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 }); + } +});