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
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