Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions scripts/ensureCodexHome.mjs
Original file line number Diff line number Diff line change
@@ -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}`));
});
});
}
197 changes: 197 additions & 0 deletions test/ensureCodexHome.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
Loading