import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path";import { afterEach, describe, expect, it } from "@effect/vitest"; import { scanCodexPlugins } from "./codex-plugins"; // ---------------------------------------------------------------------------// `scanCodexPlugins` reads a Codex home layout from disk://// <home>/computer-use/Codex Computer Use.app/…/SkyComputerUseClient (curated)// <home>/plugins/cache/<source>/<name>/<version>/.codex-plugin/plugin.json//// The three curated plugins must always be reported — available when the// client binary exists, with a setup hint when it does not — and the cache// scan must pick each plugin's newest version, resolve its relative command// and cwd against that version dir, skip remote (http) servers, and never// fail the whole scan on a malformed entry.// --------------------------------------------------------------------------- const CLIENT_RELATIVE = join( "computer-use", "Codex Computer Use.app", "Contents", "SharedSupport", "SkyComputerUseClient.app", "Contents", "MacOS", "SkyComputerUseClient",); const tempHomes: string[] = []; const makeHome = (): string => { const home = mkdtempSync(join(tmpdir(), "codex-home-")); tempHomes.push(home); return home;}; afterEach(() => { for (const home of tempHomes.splice(0)) rmSync(home, { recursive: true, force: true });}); const writeExecutable = (file: string): void => { mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, "#!/bin/sh\nexit 0\n", { mode: 0o755 });}; const writeCachedPlugin = ( home: string, input: { readonly source?: string; readonly name: string; readonly version: string; readonly manifest?: unknown; readonly servers?: unknown; },): string => { const versionDir = join( home, "plugins", "cache", input.source ?? "openai-curated-remote", input.name, input.version, ); mkdirSync(join(versionDir, ".codex-plugin"), { recursive: true }); writeFileSync( join(versionDir, ".codex-plugin", "plugin.json"), JSON.stringify( input.manifest ?? { name: input.name, mcpServers: "./.mcp.json", interface: { displayName: input.name, shortDescription: `${input.name} server` }, }, ), ); if (input.servers !== undefined) { writeFileSync(join(versionDir, ".mcp.json"), JSON.stringify(input.servers)); } return versionDir;}; /** Chrome's bundled browser client, reached through the `latest` symlink * Codex maintains beside the versioned directories. */const CHROME_CLIENT_RELATIVE = join( "plugins", "cache", "openai-bundled", "chrome", "latest", "scripts", "browser-client.mjs",); const writeChromePlugin = (home: string): void => { const file = join(home, CHROME_CLIENT_RELATIVE); mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, "export const setupBrowserRuntime = async () => ({});\n");}; /** A fake `codex` CLI inside the temp home, passed explicitly so the scan * never resolves the machine's real install through PATH. */const writeCodexCli = (home: string): string => { const cli = join(home, "bin", "codex"); writeExecutable(cli); return cli;}; describe("scanCodexPlugins", () => { it("reports the curated plugins as app-server recipes when Codex is fully installed", () => { const home = makeHome(); writeExecutable(join(home, CLIENT_RELATIVE)); writeChromePlugin(home); const cli = writeCodexCli(home); const entries = scanCodexPlugins({ codexHome: home, codexCli: cli }); const curated = entries.filter((entry) => entry.source === "curated"); expect(curated.map((entry) => entry.id)).toEqual([ "codex-messages", "codex-computer-use", "codex-chrome", "codex-openai-docs", "codex-computer-history", ]); for (const entry of curated) { expect(entry.available, entry.id).toBe(true); // Curated plugins go through the app-server bridge — the plugins' own // service only honours Codex host sessions, so their binaries are never // spawned directly. expect(entry.command).toBe(cli); expect(entry.args).toEqual(["app-server"]); expect(entry.env).toEqual({ CODEX_HOME: home }); expect(entry.setupHint).toBeUndefined(); } // Computer Use and Chrome have no MCP server of their own in current // Codex: both ship as node-repl content, so they target `node_repl` with a // projected surface. Chrome additionally carries the module its surface // imports, resolved through the version-proof `latest` symlink. expect(curated.map((entry) => entry.appServer)).toEqual([ { server: "messages" }, { server: "node_repl", surface: "sky" }, { server: "node_repl", surface: "browser", modulePath: join(home, CHROME_CLIENT_RELATIVE) }, { server: "openaiDeveloperDocs" }, { server: "computer-history" }, ]); }); it("reports every curated plugin with a setup hint when Codex is not installed", () => { const home = makeHome(); const entries = scanCodexPlugins({ codexHome: home, codexCli: join(home, "bin", "codex") }); const curated = entries.filter((entry) => entry.source === "curated"); expect(curated).toHaveLength(5); for (const entry of curated) { expect(entry.available, entry.id).toBe(false); expect(entry.setupHint, entry.id).toContain("Codex"); } }); it("gates each curated plugin on what it actually needs, not on Codex alone", () => { const home = makeHome(); const cli = writeCodexCli(home); const byId = new Map( scanCodexPlugins({ codexHome: home, codexCli: cli }).map((entry) => [entry.id, entry]), ); // `codex app-server` starts, but the plugins' own content is absent: no // `messages` server without the Computer Use app, no browser client // without the Chrome plugin. Those cards must not claim readiness. expect(byId.get("codex-messages")?.available).toBe(false); expect(byId.get("codex-computer-use")?.available).toBe(false); expect(byId.get("codex-chrome")?.available).toBe(false); // Each card's steps are about ITS requirement: Chrome needs the browser // extension, which no other card mentions. expect(byId.get("codex-chrome")?.setupHint).toContain("browser extension"); // A card that cannot be used still hands over where to go, and shows the // plugin's published mark rather than a gap. expect(byId.get("codex-messages")?.setupUrl).toBe("https://openai.com/codex"); expect(byId.get("codex-chrome")?.setupUrl).toContain("learn.chatgpt.com"); expect(byId.get("codex-computer-use")?.fallbackIcon).toContain("computer-use-plugin-icon"); expect(byId.get("codex-messages")?.fallbackIcon).toBe("/plugin-icons/messages.webp"); // Computer History has no published mark of its own, so it identifies its // provider instead. expect(byId.get("codex-computer-history")?.fallbackIcon).toContain("openai.com"); expect(byId.get("codex-messages")?.setupHint).not.toContain("browser extension"); // And the steps are numbered and name the plugin the person clicked. expect(byId.get("codex-messages")?.setupHint).toContain("1. Install the Codex app"); expect(byId.get("codex-messages")?.setupHint).toContain("Open Messages once inside Codex"); // The docs server ships with Codex itself, so the CLI alone is enough. expect(byId.get("codex-openai-docs")?.available).toBe(true); }); it("keeps Chrome unavailable when only the Computer Use app is installed", () => { const home = makeHome(); writeExecutable(join(home, CLIENT_RELATIVE)); const cli = writeCodexCli(home); const byId = new Map( scanCodexPlugins({ codexHome: home, codexCli: cli }).map((entry) => [entry.id, entry]), ); expect(byId.get("codex-messages")?.available).toBe(true); expect(byId.get("codex-chrome")?.available).toBe(false); }); it("scans cached plugins, resolving command and cwd against the newest version", () => { const home = makeHome(); // Two versions; numeric-aware pick must choose 0.1.10 over 0.1.9. writeCachedPlugin(home, { name: "sec-scan", version: "0.1.9", servers: { mcpServers: { "sec-scan": { command: "./scripts/run", args: ["--stdio"] } } }, }); const newest = writeCachedPlugin(home, { name: "sec-scan", version: "0.1.10", servers: { mcpServers: { "sec-scan": { command: "./scripts/run", args: ["--stdio"], cwd: "." } }, }, }); writeExecutable(join(newest, "scripts", "run")); const entries = scanCodexPlugins({ codexHome: home }); const scanned = entries.find((entry) => entry.id === "codex-sec-scan"); expect(scanned).toMatchObject({ name: "sec-scan", summary: "sec-scan server", available: true, slug: "codex_sec_scan", source: "scanned", command: join(newest, "scripts", "run"), cwd: newest, args: ["--stdio"], env: { CODEX_HOME: home }, }); }); it("reports a scanned plugin whose command is missing as unavailable", () => { const home = makeHome(); writeCachedPlugin(home, { name: "ghost", version: "1.0.0", servers: { mcpServers: { ghost: { command: "./bin/gone" } } }, }); const scanned = scanCodexPlugins({ codexHome: home }).find( (entry) => entry.id === "codex-ghost", ); expect(scanned?.available).toBe(false); expect(scanned?.setupHint).toContain("Install the Codex app"); }); it("skips remote servers, curated names, and malformed entries", () => { const home = makeHome(); // Remote (http) server — Executor connects to those directly, no spawn. writeCachedPlugin(home, { name: "github", version: "0.1.6", servers: { mcpServers: { github: { type: "http", url: "https://api.example.com/mcp/" } }, }, }); // Curated name in the cache — the curated card already covers it. writeCachedPlugin(home, { name: "messages", version: "1.0.0", servers: { mcpServers: { messages: { command: "./bin/launcher" } } }, }); // Skill-only plugin: no mcpServers in the manifest. writeCachedPlugin(home, { name: "templates", version: "0.1.1", manifest: { name: "templates" }, }); // Malformed manifest JSON must not break the scan. const brokenDir = join(home, "plugins", "cache", "x", "broken", "1.0.0", ".codex-plugin"); mkdirSync(brokenDir, { recursive: true }); writeFileSync(join(brokenDir, "plugin.json"), "{not json"); const entries = scanCodexPlugins({ codexHome: home }); expect(entries.filter((entry) => entry.source === "scanned")).toEqual([]); // Curated cards are still present (the cache copy of `messages` merged away). expect(entries.filter((entry) => entry.id === "codex-messages")).toHaveLength(1); }); it("collapses the same plugin cached under several sources into one entry", () => { const home = makeHome(); const a = writeCachedPlugin(home, { source: "openai-curated", name: "dup", version: "1.0.0", servers: { mcpServers: { dup: { command: "./bin/run" } } }, }); writeCachedPlugin(home, { source: "openai-curated-remote", name: "dup", version: "1.0.0", servers: { mcpServers: { dup: { command: "./bin/run" } } }, }); writeExecutable(join(a, "bin", "run")); const dups = scanCodexPlugins({ codexHome: home }).filter((entry) => entry.id === "codex-dup"); expect(dups).toHaveLength(1); // The available copy wins over the one whose binary is missing. expect(dups[0]?.available).toBe(true); });});
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "@effect/vitest";
import { scanCodexPlugins } from "./codex-plugins";
// ---------------------------------------------------------------------------
// `scanCodexPlugins` reads a Codex home layout from disk:
//
// <home>/computer-use/Codex Computer Use.app/…/SkyComputerUseClient (curated)
// <home>/plugins/cache/<source>/<name>/<version>/.codex-plugin/plugin.json
//
// The three curated plugins must always be reported — available when the
// client binary exists, with a setup hint when it does not — and the cache
// scan must pick each plugin's newest version, resolve its relative command
// and cwd against that version dir, skip remote (http) servers, and never
// fail the whole scan on a malformed entry.
// ---------------------------------------------------------------------------
const CLIENT_RELATIVE = join(
"computer-use",
"Codex Computer Use.app",
"Contents",
"SharedSupport",
"SkyComputerUseClient.app",
"Contents",
"MacOS",
"SkyComputerUseClient",
);
const tempHomes: string[] = [];
const makeHome = (): string => {
const home = mkdtempSync(join(tmpdir(), "codex-home-"));
tempHomes.push(home);
return home;
};
afterEach(() => {
for (const home of tempHomes.splice(0)) rmSync(home, { recursive: true, force: true });
});
const writeExecutable = (file: string): void => {
mkdirSync(join(file, ".."), { recursive: true });
writeFileSync(file, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
};
const writeCachedPlugin = (
home: string,
input: {
readonly source?: string;
readonly name: string;
readonly version: string;
readonly manifest?: unknown;
readonly servers?: unknown;
},
): string => {
const versionDir = join(
home,
"plugins",
"cache",
input.source ?? "openai-curated-remote",
input.name,
input.version,
);
mkdirSync(join(versionDir, ".codex-plugin"), { recursive: true });
writeFileSync(
join(versionDir, ".codex-plugin", "plugin.json"),
JSON.stringify(
input.manifest ?? {
name: input.name,
mcpServers: "./.mcp.json",
interface: { displayName: input.name, shortDescription: `${input.name} server` },
},
),
);
if (input.servers !== undefined) {
writeFileSync(join(versionDir, ".mcp.json"), JSON.stringify(input.servers));
}
return versionDir;
};
/** Chrome's bundled browser client, reached through the `latest` symlink
* Codex maintains beside the versioned directories. */
const CHROME_CLIENT_RELATIVE = join(
"plugins",
"cache",
"openai-bundled",
"chrome",
"latest",
"scripts",
"browser-client.mjs",
);
const writeChromePlugin = (home: string): void => {
const file = join(home, CHROME_CLIENT_RELATIVE);
mkdirSync(join(file, ".."), { recursive: true });
writeFileSync(file, "export const setupBrowserRuntime = async () => ({});\n");
};
/** A fake `codex` CLI inside the temp home, passed explicitly so the scan
* never resolves the machine's real install through PATH. */
const writeCodexCli = (home: string): string => {
const cli = join(home, "bin", "codex");
writeExecutable(cli);
return cli;
};
describe("scanCodexPlugins", () => {
it("reports the curated plugins as app-server recipes when Codex is fully installed", () => {
const home = makeHome();
writeExecutable(join(home, CLIENT_RELATIVE));
writeChromePlugin(home);
const cli = writeCodexCli(home);
const entries = scanCodexPlugins({ codexHome: home, codexCli: cli });
const curated = entries.filter((entry) => entry.source === "curated");
expect(curated.map((entry) => entry.id)).toEqual([
"codex-messages",
"codex-computer-use",
"codex-chrome",
"codex-openai-docs",
"codex-computer-history",
]);
for (const entry of curated) {
expect(entry.available, entry.id).toBe(true);
// Curated plugins go through the app-server bridge — the plugins' own
// service only honours Codex host sessions, so their binaries are never
// spawned directly.
expect(entry.command).toBe(cli);
expect(entry.args).toEqual(["app-server"]);
expect(entry.env).toEqual({ CODEX_HOME: home });
expect(entry.setupHint).toBeUndefined();
}
// Computer Use and Chrome have no MCP server of their own in current
// Codex: both ship as node-repl content, so they target `node_repl` with a
// projected surface. Chrome additionally carries the module its surface
// imports, resolved through the version-proof `latest` symlink.
expect(curated.map((entry) => entry.appServer)).toEqual([
{ server: "messages" },
{ server: "node_repl", surface: "sky" },
{ server: "node_repl", surface: "browser", modulePath: join(home, CHROME_CLIENT_RELATIVE) },
{ server: "openaiDeveloperDocs" },
{ server: "computer-history" },
]);
});
it("reports every curated plugin with a setup hint when Codex is not installed", () => {
const home = makeHome();
const entries = scanCodexPlugins({ codexHome: home, codexCli: join(home, "bin", "codex") });
const curated = entries.filter((entry) => entry.source === "curated");
expect(curated).toHaveLength(5);
for (const entry of curated) {
expect(entry.available, entry.id).toBe(false);
expect(entry.setupHint, entry.id).toContain("Codex");
}
});
it("gates each curated plugin on what it actually needs, not on Codex alone", () => {
const home = makeHome();
const cli = writeCodexCli(home);
const byId = new Map(
scanCodexPlugins({ codexHome: home, codexCli: cli }).map((entry) => [entry.id, entry]),
);
// `codex app-server` starts, but the plugins' own content is absent: no
// `messages` server without the Computer Use app, no browser client
// without the Chrome plugin. Those cards must not claim readiness.
expect(byId.get("codex-messages")?.available).toBe(false);
expect(byId.get("codex-computer-use")?.available).toBe(false);
expect(byId.get("codex-chrome")?.available).toBe(false);
// Each card's steps are about ITS requirement: Chrome needs the browser
// extension, which no other card mentions.
expect(byId.get("codex-chrome")?.setupHint).toContain("browser extension");
// A card that cannot be used still hands over where to go, and shows the
// plugin's published mark rather than a gap.
expect(byId.get("codex-messages")?.setupUrl).toBe("https://openai.com/codex");
expect(byId.get("codex-chrome")?.setupUrl).toContain("learn.chatgpt.com");
expect(byId.get("codex-computer-use")?.fallbackIcon).toContain("computer-use-plugin-icon");
expect(byId.get("codex-messages")?.fallbackIcon).toBe("/plugin-icons/messages.webp");
// Computer History has no published mark of its own, so it identifies its
// provider instead.
expect(byId.get("codex-computer-history")?.fallbackIcon).toContain("openai.com");
expect(byId.get("codex-messages")?.setupHint).not.toContain("browser extension");
// And the steps are numbered and name the plugin the person clicked.
expect(byId.get("codex-messages")?.setupHint).toContain("1. Install the Codex app");
expect(byId.get("codex-messages")?.setupHint).toContain("Open Messages once inside Codex");
// The docs server ships with Codex itself, so the CLI alone is enough.
expect(byId.get("codex-openai-docs")?.available).toBe(true);
});
it("keeps Chrome unavailable when only the Computer Use app is installed", () => {
const home = makeHome();
writeExecutable(join(home, CLIENT_RELATIVE));
const cli = writeCodexCli(home);
const byId = new Map(
scanCodexPlugins({ codexHome: home, codexCli: cli }).map((entry) => [entry.id, entry]),
);
expect(byId.get("codex-messages")?.available).toBe(true);
expect(byId.get("codex-chrome")?.available).toBe(false);
});
it("scans cached plugins, resolving command and cwd against the newest version", () => {
const home = makeHome();
// Two versions; numeric-aware pick must choose 0.1.10 over 0.1.9.
writeCachedPlugin(home, {
name: "sec-scan",
version: "0.1.9",
servers: { mcpServers: { "sec-scan": { command: "./scripts/run", args: ["--stdio"] } } },
});
const newest = writeCachedPlugin(home, {
name: "sec-scan",
version: "0.1.10",
servers: {
mcpServers: { "sec-scan": { command: "./scripts/run", args: ["--stdio"], cwd: "." } },
},
});
writeExecutable(join(newest, "scripts", "run"));
const entries = scanCodexPlugins({ codexHome: home });
const scanned = entries.find((entry) => entry.id === "codex-sec-scan");
expect(scanned).toMatchObject({
name: "sec-scan",
summary: "sec-scan server",
available: true,
slug: "codex_sec_scan",
source: "scanned",
command: join(newest, "scripts", "run"),
cwd: newest,
args: ["--stdio"],
env: { CODEX_HOME: home },
});
});
it("reports a scanned plugin whose command is missing as unavailable", () => {
const home = makeHome();
writeCachedPlugin(home, {
name: "ghost",
version: "1.0.0",
servers: { mcpServers: { ghost: { command: "./bin/gone" } } },
});
const scanned = scanCodexPlugins({ codexHome: home }).find(
(entry) => entry.id === "codex-ghost",
);
expect(scanned?.available).toBe(false);
expect(scanned?.setupHint).toContain("Install the Codex app");
});
it("skips remote servers, curated names, and malformed entries", () => {
const home = makeHome();
// Remote (http) server — Executor connects to those directly, no spawn.
writeCachedPlugin(home, {
name: "github",
version: "0.1.6",
servers: {
mcpServers: { github: { type: "http", url: "https://api.example.com/mcp/" } },
},
});
// Curated name in the cache — the curated card already covers it.
writeCachedPlugin(home, {
name: "messages",
version: "1.0.0",
servers: { mcpServers: { messages: { command: "./bin/launcher" } } },
});
// Skill-only plugin: no mcpServers in the manifest.
writeCachedPlugin(home, {
name: "templates",
version: "0.1.1",
manifest: { name: "templates" },
});
// Malformed manifest JSON must not break the scan.
const brokenDir = join(home, "plugins", "cache", "x", "broken", "1.0.0", ".codex-plugin");
mkdirSync(brokenDir, { recursive: true });
writeFileSync(join(brokenDir, "plugin.json"), "{not json");
const entries = scanCodexPlugins({ codexHome: home });
expect(entries.filter((entry) => entry.source === "scanned")).toEqual([]);
// Curated cards are still present (the cache copy of `messages` merged away).
expect(entries.filter((entry) => entry.id === "codex-messages")).toHaveLength(1);
});
it("collapses the same plugin cached under several sources into one entry", () => {
const home = makeHome();
const a = writeCachedPlugin(home, {
source: "openai-curated",
name: "dup",
version: "1.0.0",
servers: { mcpServers: { dup: { command: "./bin/run" } } },
});
writeCachedPlugin(home, {
source: "openai-curated-remote",
name: "dup",
version: "1.0.0",
servers: { mcpServers: { dup: { command: "./bin/run" } } },
});
writeExecutable(join(a, "bin", "run"));
const dups = scanCodexPlugins({ codexHome: home }).filter((entry) => entry.id === "codex-dup");
expect(dups).toHaveLength(1);
// The available copy wins over the one whose binary is missing.
expect(dups[0]?.available).toBe(true);
});
});