derive CODEX_HOME for unprivileged user (#23)
This commit is contained in:
+8
-3
@@ -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'] != '' }}
|
||||
|
||||
Vendored
+177
-88
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user