Skip to content
Open
85 changes: 85 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolvePluginSettings } from "./config.js";

const ORIGINAL_HOME = process.env.HOME;
const ORIGINAL_PATH = process.env.PATH;
const ORIGINAL_XDG_BIN_HOME = process.env.XDG_BIN_HOME;

function restoreEnv(key: "HOME" | "PATH" | "XDG_BIN_HOME", value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
}

function makeTempHome(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-config-"));
}

function writeExecutable(filePath: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n", "utf8");
fs.chmodSync(filePath, 0o755);
}

afterEach(() => {
restoreEnv("HOME", ORIGINAL_HOME);
restoreEnv("PATH", ORIGINAL_PATH);
restoreEnv("XDG_BIN_HOME", ORIGINAL_XDG_BIN_HOME);
});

describe("resolvePluginSettings", () => {
it("keeps an explicit command when one is configured", () => {
const homeDir = makeTempHome();
writeExecutable(path.join(homeDir, ".local", "bin", "codex"));
process.env.HOME = homeDir;

expect(resolvePluginSettings({ command: "/custom/codex" }).command).toBe("/custom/codex");
});

it("prefers XDG_BIN_HOME when command is omitted", () => {
const homeDir = makeTempHome();
const xdgBinHome = path.join(homeDir, "xdg-bin");
writeExecutable(path.join(xdgBinHome, "codex"));
process.env.HOME = homeDir;
process.env.XDG_BIN_HOME = xdgBinHome;
process.env.PATH = "/usr/bin";

expect(resolvePluginSettings({}).command).toBe(path.join(xdgBinHome, "codex"));
});

it("prefers a user-local codex binary before falling back to bare codex", () => {
const homeDir = makeTempHome();
const localBin = path.join(homeDir, ".local", "bin");
writeExecutable(path.join(localBin, "codex"));
process.env.HOME = homeDir;
process.env.PATH = "/usr/bin";

expect(resolvePluginSettings({}).command).toBe(path.join(localBin, "codex"));
});

it("keeps PATH order when multiple home-local codex binaries exist", () => {
const homeDir = makeTempHome();
const localBin = path.join(homeDir, ".local", "bin");
const asdfBin = path.join(homeDir, ".asdf", "shims");
writeExecutable(path.join(localBin, "codex"));
writeExecutable(path.join(asdfBin, "codex"));
process.env.HOME = homeDir;
process.env.PATH = [asdfBin, localBin, "/usr/bin"].join(path.delimiter);

expect(resolvePluginSettings({}).command).toBe(path.join(asdfBin, "codex"));
});

it("falls back to bare codex when no preferred user-local binary exists", () => {
const homeDir = makeTempHome();
process.env.HOME = homeDir;
process.env.PATH = "/usr/bin";
delete process.env.XDG_BIN_HOME;

expect(resolvePluginSettings({}).command).toBe("codex");
});
});
85 changes: 84 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { accessSync, constants } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { PluginSettings } from "./types.js";
import {
DEFAULT_REQUEST_TIMEOUT_MS,
Expand Down Expand Up @@ -56,10 +59,90 @@ function readNumber(
return fallback;
}

function listExecutableNames(command: string): string[] {
if (process.platform !== "win32") {
return [command];
}
if (path.extname(command)) {
return [command];
}
const extensions = (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD")
.split(";")
.map((entry) => entry.trim())
.filter(Boolean);
return [command, ...extensions.map((extension) => `${command}${extension.toLowerCase()}`)];
}

function isExecutableFile(candidate: string): boolean {
try {
accessSync(candidate, constants.X_OK);
return true;
} catch {
return false;
}
}

function resolveExecutableInDir(dir: string, command: string): string | undefined {
for (const executableName of listExecutableNames(command)) {
const candidate = path.join(dir, executableName);
if (isExecutableFile(candidate)) {
return candidate;
}
}
return undefined;
}

function uniqueDirs(entries: Array<string | undefined>): string[] {
const seen = new Set<string>();
const dirs: string[] = [];
for (const entry of entries) {
const trimmed = entry?.trim();
if (!trimmed) {
continue;
}
const resolved = path.resolve(trimmed);
if (seen.has(resolved)) {
continue;
}
seen.add(resolved);
dirs.push(resolved);
}
return dirs;
}

function resolveDefaultStdioCommand(): string | undefined {
const command = "codex";
const homeDir = os.homedir().trim();
const pathDirs = (process.env.PATH || "")
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
const homePathDirs = homeDir
? pathDirs.filter((entry) => {
const resolved = path.resolve(entry);
return resolved === homeDir || resolved.startsWith(`${homeDir}${path.sep}`);
})
: [];
const candidateDirs = uniqueDirs([
...homePathDirs,
process.env.XDG_BIN_HOME,
homeDir ? path.join(homeDir, ".local", "bin") : undefined,
homeDir ? path.join(homeDir, "bin") : undefined,
]);
for (const dir of candidateDirs) {
const resolved = resolveExecutableInDir(dir, command);
if (resolved) {
return resolved;
}
}
return undefined;
}

export function resolvePluginSettings(rawConfig: unknown): PluginSettings {
const record = asRecord(rawConfig);
const transport = record.transport === "websocket" ? "websocket" : "stdio";
const authToken = readString(record, "authToken");
const configuredCommand = readString(record, "command");
const configuredHeaders = readHeaders(record);
const headers = {
...(configuredHeaders ?? {}),
Expand All @@ -69,7 +152,7 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings {
return {
enabled: record.enabled !== false,
transport,
command: readString(record, "command") ?? "codex",
command: configuredCommand ?? resolveDefaultStdioCommand() ?? "codex",
args: readStringArray(record, "args"),
url: readString(record, "url"),
headers: Object.keys(headers).length > 0 ? headers : undefined,
Expand Down
Loading