derive CODEX_HOME for unprivileged user (#23)

This commit is contained in:
Michael Bolin
2025-10-06 10:28:21 -07:00
committed by GitHub
parent ffac27a108
commit 685900c157
8 changed files with 461 additions and 181 deletions
+8 -3
View File
@@ -132,7 +132,11 @@ runs:
id: resolve_home
shell: bash
run: |
node "${{ github.action_path }}/dist/main.js" resolve-codex-home "${{ inputs['codex-home'] }}"
node "${{ github.action_path }}/dist/main.js" resolve-codex-home \
--codex-home-override "${{ inputs['codex-home'] }}" \
--safety-strategy "${{ inputs['safety-strategy'] }}" \
--codex-user "${{ inputs['codex-user'] }}" \
--github-run-id "${{ github.run_id }}"
- name: Determine server info path
id: derive_server_info
@@ -185,7 +189,7 @@ runs:
fi
if [ "${RUNNER_OS}" != "Windows" ]; then
chmod 444 "$server_info_file"
sudo chmod 444 "$server_info_file"
sudo chown root "$server_info_file"
fi
@@ -202,7 +206,8 @@ runs:
run: |
node "${{ github.action_path }}/dist/main.js" write-proxy-config \
--codex-home "${{ steps.resolve_home.outputs.codex-home }}" \
--port "${{ steps.read_server_info.outputs.port }}"
--port "${{ steps.read_server_info.outputs.port }}" \
--safety-strategy "${{ inputs['safety-strategy'] }}"
- name: Drop sudo privilege, if appropriate
if: ${{ inputs['safety-strategy'] == 'drop-sudo' && inputs['openai-api-key'] != '' }}
+177 -88
View File
File diff suppressed because one or more lines are too long
+40
View File
@@ -0,0 +1,40 @@
name: "Example: Run Codex as an unprivileged user"
on:
workflow_dispatch:
jobs:
run-codex-with-unprivileged-user:
runs-on: ubuntu-latest
steps:
- name: Create unprivileged user
run: |
sudo adduser --system --home /home/guest --shell /bin/bash --group guest
# Ensure the default runner account can collaborate with the guest user.
sudo usermod -a -G guest runner
# Allow the guest user to traverse runner-owned directories.
# In particular, actions/checkout@v5 checks out code under
# /home/runner/work, but /home/runner has permissions 750.
sudo usermod -a -G runner guest
- uses: actions/checkout@v5
- name: Change ownership
run: |
# Change the group ownership of the checked-out files to the `guest` group.
sudo chown -R runner:guest "$GITHUB_WORKSPACE"
# Extend user permissions to the group.
sudo chmod -R g+rwX "$GITHUB_WORKSPACE"
# Set the setgid bit so new directories stay in the shared group.
sudo find "$GITHUB_WORKSPACE" -type d -exec chmod g+s {} +
- name: Run Codex as unprivileged user
uses: openai/codex-action@main
with:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
safety-strategy: unprivileged-user
codex-user: guest
prompt: |
Report $USER, $HOME, and whoami.
+27
View File
@@ -0,0 +1,27 @@
import { spawn } from "child_process";
export function checkOutput(command: Array<string>): Promise<string> {
const [prog, ...args] = command;
return new Promise<string>((resolve, reject) => {
const proc = spawn(prog, args, {
env: process.env,
stdio: ["ignore", "pipe", "inherit"],
});
proc.on("error", reject);
let output = "";
proc.stdout.on("data", (chunk) => {
output += chunk.toString();
});
proc.on("close", (code) => {
if (code !== 0) {
reject(new Error(`${prog} exited with code ${code}`));
return;
}
resolve(output);
});
});
}
+130 -31
View File
@@ -16,6 +16,7 @@ import { dropSudo } from "./dropSudo";
import { ensureActorHasWriteAccess } from "./checkActorPermissions";
import parseArgsStringToArgv from "string-argv";
import { writeProxyConfig } from "./writeProxyConfig";
import { checkOutput } from "./checkOutput";
export async function main() {
const program = new Command();
@@ -38,20 +39,40 @@ export async function main() {
.description(
"Resolve the Codex home directory with precedence: input, env, default (~/.codex)"
)
.argument(
"[inputCodexHome]",
.requiredOption(
"--codex-home-override <DIRECTORY>",
"Optional codex-home input value (may be empty)"
)
.action(async (inputCodexHome?: string) => {
const resolved = await resolveCodexHome(
emptyAsNull(inputCodexHome ?? "")
);
// Ensure directory exists for downstream steps that will write files here.
await fs.mkdir(resolved, { recursive: true });
const { setOutput } = await import("@actions/core");
setOutput("codex-home", resolved);
console.log(`Resolved Codex home: ${resolved}`);
});
.requiredOption(
"--safety-strategy <strategy>",
"Safety strategy to take into account when picking defaults"
)
.requiredOption(
"--codex-user <user>",
"Codex user to consider when safety strategy is 'unprivileged-user'"
)
.requiredOption("--github-run-id <id>", "GitHub run ID")
.action(
async (options: {
codexHomeOverride: string;
safetyStrategy: string;
codexUser: string;
githubRunId: string;
}) => {
const safetyStrategy = toSafetyStrategy(options.safetyStrategy);
const codexUser = emptyAsNull(options.codexUser);
const resolved = await resolveCodexHome(
emptyAsNull(options.codexHomeOverride),
safetyStrategy,
codexUser,
options.githubRunId
);
const { setOutput } = await import("@actions/core");
setOutput("codex-home", resolved);
console.log(`Resolved Codex home: ${resolved}`);
}
);
program
.command("write-proxy-config")
@@ -60,9 +81,20 @@ export async function main() {
)
.requiredOption("--codex-home <DIRECTORY>", "Path to Codex home directory")
.requiredOption("--port <port>", "Proxy server port", parseIntStrict)
.action(async (options: { codexHome: string; port: number }) => {
await writeProxyConfig(options.codexHome, options.port);
});
.requiredOption(
"--safety-strategy <strategy>",
"Safety strategy to use. One of 'drop-sudo', 'read-only', 'unprivileged-user', or 'unsafe'."
)
.action(
async (options: {
codexHome: string;
port: number;
safetyStrategy: string;
}) => {
const safetyStrategy = toSafetyStrategy(options.safetyStrategy);
await writeProxyConfig(options.codexHome, options.port, safetyStrategy);
}
);
program
.command("drop-sudo")
@@ -234,23 +266,31 @@ export async function main() {
"Comma-separated list of GitHub usernames who can run this action, or '*' to allow all users.",
""
)
.action(async ({ allowBots, allowUsers }: { allowBots: boolean; allowUsers: string }) => {
const result = await ensureActorHasWriteAccess({
allowBotActors: allowBots,
.action(
async ({
allowBots,
allowUsers,
});
switch (result.status) {
case "approved": {
console.log(`Actor '${result.actor}' is permitted to continue.`);
break;
}
case "rejected": {
const message = `Actor '${result.actor}' is not permitted to run this action: ${result.reason}`;
console.error(message);
throw new Error(message);
}: {
allowBots: boolean;
allowUsers: string;
}) => {
const result = await ensureActorHasWriteAccess({
allowBotActors: allowBots,
allowUsers,
});
switch (result.status) {
case "approved": {
console.log(`Actor '${result.actor}' is permitted to continue.`);
break;
}
case "rejected": {
const message = `Actor '${result.actor}' is not permitted to run this action: ${result.reason}`;
console.error(message);
throw new Error(message);
}
}
}
});
);
program.parse();
}
@@ -320,7 +360,10 @@ function parseBoolean(value: string): boolean {
main();
async function resolveCodexHome(
inputCodexHome: string | null
inputCodexHome: string | null,
safetyStrategy: SafetyStrategy,
codexUser: string | null,
githubRunId: string
): Promise<string> {
if (inputCodexHome != null) {
return expandTilde(inputCodexHome);
@@ -329,7 +372,63 @@ async function resolveCodexHome(
if (envHome != null) {
return envHome;
}
return path.join(os.homedir(), ".codex");
if (safetyStrategy === "unprivileged-user") {
if (codexUser == null) {
throw new Error(
"codex-user input must be provided when using 'unprivileged-user' safety strategy and no codex-home is specified."
);
}
return await deriveSharedCodexHomeForUnprivilegedUser(
codexUser,
githubRunId
);
} else {
const codexHome = path.join(os.homedir(), ".codex");
// Ensure directory exists for downstream steps that will write files here.
await fs.mkdir(codexHome, { recursive: true });
return codexHome;
}
}
async function deriveSharedCodexHomeForUnprivilegedUser(
user: string,
githubRunId: string
): Promise<string> {
const home = (
await checkOutput(["sudo", "-u", user, "--", "printenv", "HOME"])
).trim();
if (!home) {
throw new Error(`Could not determine home directory for user '${user}'.`);
}
const codexHome = path.join(home, ".codex");
try {
const stat = await fs.stat(codexHome);
if (stat.isDirectory()) {
// Directory already exists and may contain a config.toml created by the
// user (or a previous invocation of codex-action), so assume it's
// correctly permissioned.
return codexHome;
}
} catch {
// Ignore stat errors and try to create the directory.
}
// We must use sudo for the following file system operations because we
// are writing to the home directory of a different user.
await checkOutput(["sudo", "mkdir", codexHome]);
await checkOutput(["sudo", "chown", `${user}`, codexHome]);
await checkOutput(["sudo", "chmod", "755", codexHome]);
// codex-responses-api-proxy will need to write the server info file.
const serverInfoFile = path.join(codexHome, `${githubRunId}.json`);
await checkOutput(["sudo", "touch", serverInfoFile]);
// Make the file world-writable for the moment, but this will be locked down
// to read-only by root before the action completes.
await checkOutput(["sudo", "chmod", "666", serverInfoFile]);
return codexHome;
}
function expandTilde(p: string): string {
+59 -24
View File
@@ -3,7 +3,7 @@ import { chmod, mkdtemp, readFile, rm, writeFile } from "fs/promises";
import path from "path";
import os from "os";
import { setOutput } from "@actions/core";
import { which } from "./which";
import { checkOutput } from "./checkOutput";
export type PromptSource =
| {
@@ -69,18 +69,19 @@ export async function runCodexExec({
break;
}
const needsSharedTempDir = safetyStrategy === "unprivileged-user";
const runAsUser: string | null =
safetyStrategy === "unprivileged-user" ? codexUser : null;
let outputFile: OutputFile;
if (explicitOutputFile != null) {
outputFile = { type: "explicit", file: explicitOutputFile };
} else {
outputFile = await createTempOutputFile({ shared: needsSharedTempDir });
outputFile = await createTempOutputFile({ runAsUser });
}
const resolvedOutputSchema = await resolveOutputSchema(
outputSchema,
needsSharedTempDir
runAsUser
);
const sandboxMode = await determineSandboxMode({
safetyStrategy,
@@ -106,12 +107,11 @@ export async function runCodexExec({
// We are currently running as a privileged user, but `codexUser` will run
// with a different $PATH variable, so we need to find the full path to
// `codex`.
const whichResult = await which("codex");
if (whichResult == null) {
pathToCodex = (await checkOutput(["which", "codex"])).trim();
if (!pathToCodex) {
throw new Error("could not find 'codex' in PATH");
}
pathToCodex = whichResult;
command.push("sudo", "-u", codexUser, "--");
}
@@ -169,7 +169,7 @@ export async function runCodexExec({
}
try {
await finalizeExecution(outputFile);
await finalizeExecution(outputFile, runAsUser);
resolve(undefined);
} catch (err) {
reject(err);
@@ -181,12 +181,27 @@ export async function runCodexExec({
}
}
async function finalizeExecution(outputFile: OutputFile): Promise<void> {
async function finalizeExecution(
outputFile: OutputFile,
runAsUser: string | null
): Promise<void> {
try {
const lastMessage = await readFile(outputFile.file, "utf8");
let lastMessage: string;
if (runAsUser == null) {
lastMessage = await readFile(outputFile.file, "utf8");
setOutput("final-message", lastMessage);
} else {
lastMessage = await checkOutput([
"sudo",
"-u",
runAsUser,
"cat",
outputFile.file,
]);
}
setOutput("final-message", lastMessage);
} finally {
await cleanupTempOutput(outputFile);
await cleanupTempOutput(outputFile, runAsUser);
}
}
@@ -212,23 +227,30 @@ type ResolvedOutputSchema =
};
async function createTempOutputFile({
shared,
runAsUser,
}: {
shared: boolean;
runAsUser: string | null;
}): Promise<OutputFile> {
const dir = await createTempDir("codex-exec-", shared);
const dir = await createTempDir("codex-exec-", runAsUser);
return { type: "temp", file: path.join(dir, "output.md") };
}
async function cleanupTempOutput(outputFile: OutputFile): Promise<void> {
async function cleanupTempOutput(
outputFile: OutputFile,
runAsUser: string | null
): Promise<void> {
switch (outputFile.type) {
case "explicit":
// Do not delete user-specified output files.
return;
case "temp": {
const { file } = outputFile;
const dir = path.dirname(file);
await rm(dir, { recursive: true, force: true });
if (runAsUser == null) {
const dir = path.dirname(file);
await rm(dir, { recursive: true, force: true });
} else {
await checkOutput(["sudo", "rm", "-rf", path.dirname(file)]);
}
break;
}
}
@@ -236,7 +258,7 @@ async function cleanupTempOutput(outputFile: OutputFile): Promise<void> {
async function resolveOutputSchema(
schema: OutputSchemaSource | null,
sharedTempDir: boolean
runAsUser: string | null
): Promise<ResolvedOutputSchema | null> {
if (schema == null) {
return null;
@@ -246,7 +268,7 @@ async function resolveOutputSchema(
case "file":
return { type: "explicit", file: schema.path };
case "inline": {
const dir = await createTempDir("codex-output-schema-", sharedTempDir);
const dir = await createTempDir("codex-output-schema-", runAsUser);
const file = path.join(dir, "schema.json");
await writeFile(file, schema.content);
return { type: "temp", file, dir };
@@ -270,12 +292,25 @@ async function cleanupOutputSchema(
}
}
async function createTempDir(prefix: string, shared: boolean): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), prefix));
if (shared) {
await chmod(dir, 0o755);
async function createTempDir(
prefix: string,
runAsUser: string | null
): Promise<string> {
if (runAsUser == null) {
return await mkdtemp(path.join(os.tmpdir(), prefix));
} else {
return (
await checkOutput([
"sudo",
"-u",
runAsUser,
"mktemp",
"-d",
"-t",
`${prefix}.XXXXXX`,
])
).trim();
}
return dir;
}
async function determineSandboxMode({
-32
View File
@@ -1,32 +0,0 @@
import { spawn } from "child_process";
export function which(command: string): Promise<string | null> {
return new Promise<string>((resolve, reject) => {
const which = spawn("which", [command], {
env: process.env,
stdio: ["ignore", "pipe", "inherit"],
});
which.on("error", reject);
let output = "";
which.stdout.on("data", (chunk) => {
output += chunk.toString();
});
which.on("close", (code) => {
if (code !== 0) {
reject(new Error(`which exited with code ${code}`));
return;
}
const path = output.trim();
if (path === "") {
reject(new Error("could not find 'codex' in PATH"));
return;
}
resolve(path);
});
});
}
+20 -3
View File
@@ -1,11 +1,15 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
import { SafetyStrategy } from "./runCodexExec";
import { checkOutput } from "./checkOutput";
const MODEL_PROVIDER = "openai-proxy";
export async function writeProxyConfig(
codexHome: string,
port: number
port: number,
safetyStrategy: SafetyStrategy
): Promise<void> {
const configPath = path.join(codexHome, "config.toml");
@@ -33,6 +37,19 @@ wire_api = "responses"
// Prepend model_provider at the very top.
let output = `${header}${existing}${table}`;
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(configPath, output, "utf8");
if (safetyStrategy === "unprivileged-user") {
// We know we have already created the CODEX_HOME directory, but it is owned
// by another user, so we need to use sudo to write the file.
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-config"));
try {
const tempConfigPath = path.join(tempDir, "config.toml");
await fs.writeFile(tempConfigPath, output, "utf8");
await checkOutput(["sudo", "mv", tempConfigPath, configPath]);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
} else {
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(configPath, output, "utf8");
}
}