diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..a4ecb31 --- /dev/null +++ b/src/config.test.ts @@ -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"); + }); +}); diff --git a/src/config.ts b/src/config.ts index 5d1ab5f..e6d8803 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, @@ -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[] { + const seen = new Set(); + 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 ?? {}), @@ -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,