// --------------------------------------------------------------------------- // Codex plugin discovery — surface locally installed OpenAI Codex plugins that // ship a stdio MCP server as one-click stdio presets. // // A Codex plugin is a directory under `$CODEX_HOME/plugins/cache// // //` whose `.codex-plugin/plugin.json` manifest points at an // `.mcp.json` describing how to spawn its server. The binaries are OpenAI's, // installed and licensed through the user's own Codex install — nothing here // bundles or downloads them; this module only READS what is already on disk // and reports whether each server's command exists. // // Node-only (fs/os): reached exclusively through a dynamic import in the // plugin extension (mirroring `stdio-connector.ts`'s isolation), never from a // barrel, so remote-only bundles and workerd never evaluate it. // --------------------------------------------------------------------------- import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Option, Schema } from "effect"; import { CURATED_CODEX_PLUGINS, setupHint, setupUrl, type CuratedCodexPlugin, } from "./codex-plugin-presets"; export interface CodexPluginEntry { /** Stable card id, e.g. `codex-messages`. */ readonly id: string; readonly name: string; /** Executor's own wording — manifests' long-form copy stays on disk. */ readonly summary: string; /** Whether the server's command exists (and Codex itself is installed). */ readonly available: boolean; /** Suggested integration slug, e.g. `codex_messages`. */ readonly slug: string; readonly source: "curated" | "scanned"; readonly command: string; readonly args: readonly string[]; readonly cwd?: string; /** Non-interactive env the spawn needs (currently only CODEX_HOME). */ readonly env?: Readonly>; /** Present on curated entries: the spawn is `codex app-server` and the * connector bridges MCP to it in process, calling tools on this named * server inside Codex. See `appserver-connector.ts`. */ readonly appServer?: { readonly server: string; readonly surface?: "sky" | "browser"; readonly modulePath?: string; }; /** Shown when `available` is false: the ordered steps, and where to go. */ readonly setupHint?: string; readonly setupUrl?: string; /** Public image for this plugin, for machines with no local install. */ readonly fallbackIcon?: string; /** The plugin's own icon from its local install, as a data URI. Read at * runtime from the user's disk — never shipped with executor. */ readonly icon?: string; /** The plugin's own display metadata from its local manifest, so the add * screen can mirror how the plugin presents itself in Codex. */ readonly displayName?: string; readonly tagline?: string; readonly description?: string; } /** The Codex Computer Use client binary — the shared "Codex Computer Use" * app that implements every curated plugin. Not spawned any more (its * service refuses tool calls from non-Codex hosts; the app-server bridge is * the working path) but still the install marker: when it is absent the * plugins are not installed and the bridge would find no such server. */ const clientBinaryPath = (codexHome: string): string => path.join( codexHome, "computer-use", "Codex Computer Use.app", "Contents", "SharedSupport", "SkyComputerUseClient.app", "Contents", "MacOS", "SkyComputerUseClient", ); /** The Chrome plugin's bundled browser client — the module the projected * browser surface imports inside `node_repl`. * * Reached through the `latest` symlink Codex maintains beside the versioned * directories, so a plugin update does not strand the stored path. Same * reasoning as pointing Computer Use at the unversioned client binary. */ const browserClientPath = (codexHome: string): string => path.join( codexHome, "plugins", "cache", "openai-bundled", "chrome", "latest", "scripts", "browser-client.mjs", ); const CURATED_PLUGIN_NAMES: ReadonlySet = new Set( CURATED_CODEX_PLUGINS.map((c) => c.pluginName), ); /** The `codex` CLI the app-server bridge spawns. PATH first (the local * executor server usually inherits the user's shell PATH), then the common * install locations for launch contexts that do not. */ const resolveCodexCli = (codexCli?: string): string | undefined => { if (codexCli !== undefined) return isExecutableFile(codexCli) ? codexCli : undefined; const dirs = [ ...(process.env["PATH"] ?? "").split(path.delimiter), path.join(os.homedir(), ".bun", "bin"), path.join(os.homedir(), ".local", "bin"), "/opt/homebrew/bin", "/usr/local/bin", ]; for (const dir of dirs) { if (dir.length === 0) continue; const candidate = path.join(dir, "codex"); if (isExecutableFile(candidate)) return candidate; } return undefined; }; // --------------------------------------------------------------------------- // Manifest shapes — only the fields discovery needs. Everything else in the // manifest is OpenAI's and stays unread. // --------------------------------------------------------------------------- const PluginManifest = Schema.Struct({ name: Schema.String, mcpServers: Schema.optional(Schema.Unknown), interface: Schema.optional( Schema.Struct({ displayName: Schema.optional(Schema.String), shortDescription: Schema.optional(Schema.String), longDescription: Schema.optional(Schema.String), logo: Schema.optional(Schema.String), }), ), description: Schema.optional(Schema.String), }); const McpServerSpec = Schema.Struct({ command: Schema.optional(Schema.String), args: Schema.optional(Schema.Array(Schema.String)), cwd: Schema.optional(Schema.String), type: Schema.optional(Schema.String), url: Schema.optional(Schema.String), }); const McpServersFile = Schema.Struct({ mcpServers: Schema.Record(Schema.String, McpServerSpec), }); const decodeManifestJson = Schema.decodeUnknownOption(Schema.fromJsonString(PluginManifest)); const decodeServersFileJson = Schema.decodeUnknownOption(Schema.fromJsonString(McpServersFile)); const decodeServersFile = Schema.decodeUnknownOption(McpServersFile); // --------------------------------------------------------------------------- // fs helpers — every call is best-effort: a missing or malformed entry means // "not a discoverable plugin", never a failure of the whole scan. // --------------------------------------------------------------------------- const tryOrElse = (evaluate: () => A, orElse: A): A => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: every fs read here is best-effort; a missing or unreadable entry is "not discoverable", not an error try { return evaluate(); } catch { return orElse; } }; const listDirs = (dir: string): readonly string[] => tryOrElse( () => fs .readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name), [], ); const readText = (file: string): string | undefined => tryOrElse(() => fs.readFileSync(file, "utf-8"), undefined); const isReadableFile = (file: string): boolean => tryOrElse(() => fs.statSync(file).isFile(), false); const isExecutableFile = (file: string): boolean => tryOrElse(() => { fs.accessSync(file, fs.constants.X_OK); return fs.statSync(file).isFile(); }, false); /** Numeric-aware descending compare so `1.0.10 > 1.0.9` and date-like builds * (`26.825.32147`) order correctly. */ const versionPart = (parts: readonly number[], index: number): number => { const value = parts[index]; return value === undefined || Number.isNaN(value) ? -1 : value; }; const compareVersionsDesc = (a: string, b: string): number => { const as = a.split(/[.-]/).map((part) => Number.parseInt(part, 10)); const bs = b.split(/[.-]/).map((part) => Number.parseInt(part, 10)); for (let i = 0; i < Math.max(as.length, bs.length); i++) { const av = versionPart(as, i); const bv = versionPart(bs, i); if (av !== bv) return bv - av; } return b.localeCompare(a); }; const sanitizeSlug = (value: string): string => value .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); const sanitizeId = (value: string): string => value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); /** Bound on inlined icon bytes. The bundled icons run 100KB–1MB; anything * past this is not worth inlining into a list response for a 20px avatar. */ const MAX_ICON_BYTES = 1_000_000; const ICON_MIME: Readonly> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".svg": "image/svg+xml", }; /** The plugin's own icon as a data URI, read from the user's local install. */ const readIconDataUri = (file: string): string | undefined => tryOrElse(() => { const mime = ICON_MIME[path.extname(file).toLowerCase()]; if (mime === undefined) return undefined; if (fs.statSync(file).size > MAX_ICON_BYTES) return undefined; return `data:${mime};base64,${fs.readFileSync(file).toString("base64")}`; }, undefined); interface CodexPluginDisplay { readonly icon?: string; readonly displayName?: string; readonly tagline?: string; readonly description?: string; } /** Display metadata (icon, names, descriptions) for a curated plugin from its * cache entry (any source, newest version) — how the plugin presents itself * in Codex. The curated SPAWN target is the stable client binary, but this * metadata only exists in the cache; absent cache, the curated card falls * back to executor's own wording. */ const curatedDisplayMetadata = (codexHome: string, pluginName: string): CodexPluginDisplay => { const cacheDir = path.join(codexHome, "plugins", "cache"); for (const sourceName of listDirs(cacheDir)) { const pluginDir = path.join(cacheDir, sourceName, pluginName); const version = [...listDirs(pluginDir)].sort(compareVersionsDesc)[0]; if (version === undefined) continue; const versionDir = path.join(pluginDir, version); const manifest = Option.getOrUndefined( decodeManifestJson(readText(path.join(versionDir, ".codex-plugin", "plugin.json"))), ); if (manifest === undefined) continue; const logo = manifest.interface?.logo; const icon = logo === undefined ? undefined : readIconDataUri(path.resolve(versionDir, logo)); return { ...(icon === undefined ? {} : { icon }), ...(manifest.interface?.displayName === undefined ? {} : { displayName: manifest.interface.displayName }), ...(manifest.interface?.shortDescription === undefined ? {} : { tagline: manifest.interface.shortDescription }), ...(manifest.interface?.longDescription === undefined ? {} : { description: manifest.interface.longDescription }), }; } return {}; }; // --------------------------------------------------------------------------- // Scan // --------------------------------------------------------------------------- const scanCachedPlugin = ( codexHome: string, pluginDir: string, pluginName: string, ): readonly CodexPluginEntry[] => { const versions = [...listDirs(pluginDir)].sort(compareVersionsDesc); const version = versions[0]; if (version === undefined) return []; const versionDir = path.join(pluginDir, version); const manifest = Option.getOrUndefined( decodeManifestJson(readText(path.join(versionDir, ".codex-plugin", "plugin.json"))), ); if (manifest?.mcpServers === undefined) return []; // `mcpServers` is a relative path to an `.mcp.json` in every known manifest; // tolerate an inline object of the same shape. const servers = Option.getOrUndefined( typeof manifest.mcpServers === "string" ? decodeServersFileJson(readText(path.resolve(versionDir, manifest.mcpServers))) : decodeServersFile({ mcpServers: manifest.mcpServers }), ); if (servers === undefined) return []; const displayName = manifest.interface?.displayName ?? manifest.name; const summary = manifest.interface?.shortDescription ?? manifest.description?.split("\n")[0] ?? `Local MCP server from the Codex plugin "${manifest.name}".`; const icon = manifest.interface?.logo === undefined ? undefined : readIconDataUri(path.resolve(versionDir, manifest.interface.logo)); const display: CodexPluginDisplay = { ...(icon === undefined ? {} : { icon }), ...(manifest.interface?.displayName === undefined ? {} : { displayName: manifest.interface.displayName }), ...(manifest.interface?.shortDescription === undefined ? {} : { tagline: manifest.interface.shortDescription }), ...(manifest.interface?.longDescription === undefined ? {} : { description: manifest.interface.longDescription }), }; const localServers = Object.entries(servers.mcpServers).flatMap(([serverKey, spec]) => spec.command !== undefined && spec.type !== "http" && spec.url === undefined ? [{ serverKey, command: spec.command, args: spec.args, cwd: spec.cwd }] : [], ); return localServers.map(({ serverKey, ...spec }) => { // Manifest paths are relative to the versioned plugin dir. The versioned // dir moves on plugin updates — the connection health check surfaces that // as "command missing" and the card re-adds against the new path. const command = path.resolve(versionDir, spec.command); const cwd = path.resolve(versionDir, spec.cwd ?? "."); const available = isExecutableFile(command); const idSuffix = localServers.length > 1 ? `-${sanitizeId(serverKey)}` : ""; return { id: `codex-${sanitizeId(pluginName)}${idSuffix}`, name: localServers.length > 1 ? `${displayName} — ${serverKey}` : displayName, summary, available, slug: `codex_${sanitizeSlug(pluginName)}${idSuffix.replace(/-/g, "_")}`, source: "scanned" as const, command, args: spec.args === undefined ? [] : [...spec.args], cwd, // Only CODEX_HOME travels: the manifests' wider `env_vars` lists name // host variables whose VALUES would have to be copied out of this // process's environment sight-unseen. A user can declare more env on // the integration after adding it. env: { CODEX_HOME: codexHome }, ...(available ? {} : { setupHint: setupHint("codex", displayName) }), ...display, }; }); }; /** * Discover locally installed Codex plugins that expose a stdio MCP server. * * The three plugins implemented by the shared "Codex Computer Use" app are * curated: they are always listed (so the integration is discoverable on a * machine without Codex) and they are reached through the `codex app-server` * bridge — their service only honours tool calls from a Codex host session, * so a direct client spawn can list tools but never call them. Everything * else found in the plugin cache with a local-command MCP server is reported * as scanned and spawned directly. */ export const scanCodexPlugins = (options?: { readonly codexHome?: string; /** Explicit `codex` CLI path (tests); default resolves PATH + fallbacks. */ readonly codexCli?: string; }): readonly CodexPluginEntry[] => { const codexHome = options?.codexHome ?? process.env["CODEX_HOME"] ?? path.join(os.homedir(), ".codex"); const codexCli = resolveCodexCli(options?.codexCli); const computerUseApp = isExecutableFile(clientBinaryPath(codexHome)); const browserClient = browserClientPath(codexHome); const chromePlugin = isReadableFile(browserClient); /** Each curated card states what it needs; the `codex` CLI is required by * all of them because every one is reached through `codex app-server`. */ const requirementMet: Record = { "computer-use-app": computerUseApp, "chrome-plugin": chromePlugin, codex: true, }; const curated: readonly CodexPluginEntry[] = CURATED_CODEX_PLUGINS.map((entry) => { const display = curatedDisplayMetadata(codexHome, entry.pluginName); const available = codexCli !== undefined && requirementMet[entry.requires]; return { id: entry.id, name: entry.name, summary: entry.summary, available, slug: entry.slug, source: "curated" as const, command: codexCli ?? "codex", args: ["app-server"], env: { CODEX_HOME: codexHome }, appServer: { server: entry.server, ...(entry.surface === undefined ? {} : { surface: entry.surface }), ...(entry.surface === "browser" ? { modulePath: browserClient } : {}), }, ...(available ? {} : { setupHint: setupHint(entry.requires, entry.name), setupUrl: setupUrl(entry.requires) }), fallbackIcon: entry.publicIcon ?? "https://integrations.sh/logo/openai.com", ...display, }; }); const cacheDir = path.join(codexHome, "plugins", "cache"); const scanned = listDirs(cacheDir).flatMap((sourceName) => { const sourceDir = path.join(cacheDir, sourceName); return listDirs(sourceDir) .filter((pluginName) => !CURATED_PLUGIN_NAMES.has(pluginName)) .flatMap((pluginName) => scanCachedPlugin(codexHome, path.join(sourceDir, pluginName), pluginName), ); }); // One card per id: the same plugin can appear under several cache sources // (e.g. a curated and a remote copy); the first (available-first) entry wins. const byId = new Map(); for (const entry of [...scanned].sort((a, b) => Number(b.available) - Number(a.available))) { if (!byId.has(entry.id)) byId.set(entry.id, entry); } return [...curated, ...byId.values()]; };