vendor: import codex-plugins as packages/omo-codex/{plugin,scripts,marketplace.json,MARKETPLACE.md}
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache } from "./install/cache.mjs";
|
||||
import { updateCodexConfig } from "./install/config.mjs";
|
||||
import { trustedHookStatesForPlugin } from "./install/hook-trust.mjs";
|
||||
import { defaultRunCommand } from "./install/process.mjs";
|
||||
import {
|
||||
readMarketplace,
|
||||
readPluginManifest,
|
||||
resolvePluginSource,
|
||||
validatePathSegment,
|
||||
} from "./install/marketplace.mjs";
|
||||
|
||||
export async function installMarketplaceLocally(options = {}) {
|
||||
const repoRoot = resolve(options.repoRoot ?? process.cwd());
|
||||
const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"));
|
||||
const binDir = resolve(options.binDir ?? process.env.CODEX_LOCAL_BIN_DIR ?? join(homedir(), ".local", "bin"));
|
||||
const runCommand = options.runCommand ?? defaultRunCommand;
|
||||
const log = options.log ?? console.log;
|
||||
const marketplace = await readMarketplace(repoRoot);
|
||||
const installed = [];
|
||||
|
||||
for (const entry of marketplace.plugins) {
|
||||
const sourcePath = resolvePluginSource(repoRoot, entry);
|
||||
const manifest = await readPluginManifest(sourcePath);
|
||||
if (manifest.name !== entry.name) {
|
||||
throw new Error(
|
||||
`plugin manifest name ${JSON.stringify(manifest.name)} does not match marketplace name ${JSON.stringify(entry.name)}`,
|
||||
);
|
||||
}
|
||||
const version = manifest.version ?? "local";
|
||||
validatePathSegment(version, "plugin version");
|
||||
|
||||
log(`Building ${entry.name}@${version}`);
|
||||
const plugin = await installCachedPlugin({
|
||||
codexHome,
|
||||
marketplaceName: marketplace.name,
|
||||
name: entry.name,
|
||||
runCommand,
|
||||
sourcePath,
|
||||
version,
|
||||
});
|
||||
const binLinks = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path });
|
||||
for (const link of binLinks) {
|
||||
log(`Linked ${link.name} -> ${link.target}`);
|
||||
}
|
||||
installed.push(plugin);
|
||||
}
|
||||
|
||||
const pluginNames = marketplace.plugins.map((plugin) => plugin.name);
|
||||
const trustedHookStates = (
|
||||
await Promise.all(
|
||||
installed.map((plugin) =>
|
||||
trustedHookStatesForPlugin({
|
||||
marketplaceName: marketplace.name,
|
||||
pluginName: plugin.name,
|
||||
pluginRoot: plugin.path,
|
||||
}),
|
||||
),
|
||||
)
|
||||
).flat();
|
||||
await pruneMarketplaceCache({ codexHome, marketplaceName: marketplace.name, keepPluginNames: pluginNames });
|
||||
await updateCodexConfig({
|
||||
configPath: join(codexHome, "config.toml"),
|
||||
repoRoot,
|
||||
marketplaceName: marketplace.name,
|
||||
pluginNames,
|
||||
trustedHookStates,
|
||||
});
|
||||
|
||||
for (const plugin of installed) {
|
||||
log(`Installed ${plugin.name}@${marketplace.name} -> ${plugin.path}`);
|
||||
}
|
||||
|
||||
return { marketplaceName: marketplace.name, installed };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const repoRoot = process.argv[2] ? resolve(process.argv[2]) : process.cwd();
|
||||
const result = await installMarketplaceLocally({ repoRoot });
|
||||
console.log(`Installed ${result.installed.length} plugin(s) from ${result.marketplaceName}.`);
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
|
||||
if (invokedPath === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, readlink, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
|
||||
import { installMarketplaceLocally } from "./install-local.mjs";
|
||||
|
||||
async function makeTempDir() {
|
||||
return mkdtemp(join(tmpdir(), "codex-plugins-install-"));
|
||||
}
|
||||
|
||||
async function writeJson(path, value) {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function writePlugin(root, name, version) {
|
||||
const pluginRoot = join(root, "plugins", name);
|
||||
await mkdir(join(pluginRoot, ".codex-plugin"), { recursive: true });
|
||||
await mkdir(join(pluginRoot, "dist"), { recursive: true });
|
||||
await mkdir(join(pluginRoot, "hooks"), { recursive: true });
|
||||
await mkdir(join(pluginRoot, "skills", name), { recursive: true });
|
||||
await writeJson(join(pluginRoot, ".codex-plugin", "plugin.json"), {
|
||||
name,
|
||||
version,
|
||||
description: `${name} test plugin`,
|
||||
mcpServers: "./.mcp.json",
|
||||
hooks: "./hooks/hooks.json",
|
||||
skills: "./skills/",
|
||||
});
|
||||
await writeJson(join(pluginRoot, ".mcp.json"), {
|
||||
mcpServers: {
|
||||
[name]: {
|
||||
command: "node",
|
||||
args: ["./dist/cli.js", "mcp"],
|
||||
cwd: ".",
|
||||
},
|
||||
},
|
||||
});
|
||||
await writeJson(join(pluginRoot, "hooks", "hooks.json"), { hooks: {} });
|
||||
await writeFile(join(pluginRoot, "skills", name, "SKILL.md"), "---\nname: test\n---\n");
|
||||
await writeJson(join(pluginRoot, "package.json"), {
|
||||
name: `@example/${name}`,
|
||||
version,
|
||||
bin: {
|
||||
[name]: "./dist/cli.js",
|
||||
},
|
||||
scripts: {
|
||||
build: "node -e \"require('fs').writeFileSync('dist/cli.js', 'console.log(1)')\"",
|
||||
},
|
||||
dependencies: {},
|
||||
});
|
||||
}
|
||||
|
||||
test("#given local marketplace #when installing #then copies versioned plugins and enables config", async () => {
|
||||
const repoRoot = await makeTempDir();
|
||||
const codexHome = await makeTempDir();
|
||||
const binDir = await makeTempDir();
|
||||
|
||||
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
|
||||
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
|
||||
name: "debug-marketplace",
|
||||
plugins: [
|
||||
{
|
||||
name: "alpha",
|
||||
source: "./plugins/alpha",
|
||||
},
|
||||
{
|
||||
name: "beta",
|
||||
source: {
|
||||
source: "local",
|
||||
path: "./plugins/beta",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await writePlugin(repoRoot, "alpha", "1.2.3");
|
||||
await writePlugin(repoRoot, "beta", "0.4.0");
|
||||
await mkdir(join(repoRoot, "plugins", "alpha", "node_modules"), { recursive: true });
|
||||
await writeFile(join(repoRoot, "plugins", "alpha", "node_modules", "skip.txt"), "skip");
|
||||
await mkdir(join(codexHome, "plugins", "cache", "debug-marketplace", "stale", "0.1.0"), { recursive: true });
|
||||
await writeFile(
|
||||
join(codexHome, "config.toml"),
|
||||
[
|
||||
'[plugins."stale@debug-marketplace"]',
|
||||
"enabled = true",
|
||||
"",
|
||||
'[hooks.state."stale@debug-marketplace:hooks/hooks.json:user_prompt_submit:0:0"]',
|
||||
'trusted_hash = "sha256:old"',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const commands = [];
|
||||
const result = await installMarketplaceLocally({
|
||||
repoRoot,
|
||||
codexHome,
|
||||
binDir,
|
||||
runCommand: async (command, args, options) => {
|
||||
commands.push([command, args, options.cwd]);
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result.installed.map((plugin) => `${plugin.name}@${plugin.version}`),
|
||||
["alpha@1.2.3", "beta@0.4.0"],
|
||||
);
|
||||
const alphaCacheRoot = join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3");
|
||||
assert.equal((await stat(join(alphaCacheRoot, ".mcp.json"))).isFile(), true);
|
||||
assert.equal(await readlink(join(binDir, "alpha")), join(alphaCacheRoot, "dist", "cli.js"));
|
||||
const alphaMcp = JSON.parse(await readFile(join(alphaCacheRoot, ".mcp.json"), "utf8"));
|
||||
assert.deepEqual(alphaMcp.mcpServers.alpha.args, [join(alphaCacheRoot, "dist", "cli.js"), "mcp"]);
|
||||
assert.equal(
|
||||
Object.hasOwn(alphaMcp.mcpServers.alpha, "cwd"),
|
||||
false,
|
||||
"`cwd: \".\"` must be stripped so the spawned MCP server inherits the caller's workspace cwd",
|
||||
);
|
||||
assert.equal(alphaMcp.mcpServers.alpha.command, "node");
|
||||
await assert.rejects(
|
||||
stat(join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3", "node_modules")),
|
||||
/code: 'ENOENT'|ENOENT/,
|
||||
);
|
||||
await assert.rejects(
|
||||
stat(join(codexHome, "plugins", "cache", "debug-marketplace", "stale")),
|
||||
/code: 'ENOENT'|ENOENT/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
commands.map(([command, args, cwd]) => [command, args.join(" "), cwd]),
|
||||
[
|
||||
["npm", "install", join(repoRoot, "plugins", "alpha")],
|
||||
["npm", "run build", join(repoRoot, "plugins", "alpha")],
|
||||
["npm", "install --omit=dev", join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3")],
|
||||
["npm", "install", join(repoRoot, "plugins", "beta")],
|
||||
["npm", "run build", join(repoRoot, "plugins", "beta")],
|
||||
["npm", "install --omit=dev", join(codexHome, "plugins", "cache", "debug-marketplace", "beta", "0.4.0")],
|
||||
],
|
||||
);
|
||||
|
||||
const config = await readFile(join(codexHome, "config.toml"), "utf8");
|
||||
assert.match(config, /\[features\]\n(?:plugin_hooks = true\n)?plugins = true/);
|
||||
assert.match(config, /\[marketplaces\.debug-marketplace\]/);
|
||||
assert.match(config, /source_type = "local"/);
|
||||
assert.match(config, /\[plugins\."alpha@debug-marketplace"\]\nenabled = true/);
|
||||
assert.match(config, /\[plugins\."beta@debug-marketplace"\]\nenabled = true/);
|
||||
assert.doesNotMatch(config, /stale@debug-marketplace/);
|
||||
});
|
||||
|
||||
test("#given plugin hooks #when installing #then records trusted hook hashes", async () => {
|
||||
const repoRoot = await makeTempDir();
|
||||
const codexHome = await makeTempDir();
|
||||
|
||||
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
|
||||
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
|
||||
name: "debug-marketplace",
|
||||
plugins: [{ name: "alpha", source: "./plugins/alpha" }],
|
||||
});
|
||||
await writePlugin(repoRoot, "alpha", "1.2.3");
|
||||
await writeJson(join(repoRoot, "plugins", "alpha", "hooks", "hooks.json"), {
|
||||
hooks: {
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit",
|
||||
timeout: 10,
|
||||
statusMessage: "checking alpha",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await installMarketplaceLocally({
|
||||
repoRoot,
|
||||
codexHome,
|
||||
runCommand: async () => {},
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
const config = await readFile(join(codexHome, "config.toml"), "utf8");
|
||||
assert.match(config, /\[hooks\.state\."alpha@debug-marketplace:hooks\/hooks\.json:user_prompt_submit:0:0"\]/);
|
||||
assert.match(config, /trusted_hash = "sha256:[a-f0-9]{64}"/);
|
||||
});
|
||||
|
||||
test("#given bad plugin source path #when installing #then rejects traversal", async () => {
|
||||
const repoRoot = await makeTempDir();
|
||||
const codexHome = await makeTempDir();
|
||||
|
||||
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
|
||||
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
|
||||
name: "debug-marketplace",
|
||||
plugins: [
|
||||
{
|
||||
name: "escape",
|
||||
source: "../escape",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
installMarketplaceLocally({ repoRoot, codexHome, log: () => {} }),
|
||||
/local plugin source path must start with \.\//,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { basename, dirname, join, sep } from "node:path";
|
||||
import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
|
||||
|
||||
import { exists, isRecord } from "./utils.mjs";
|
||||
|
||||
export async function installCachedPlugin({ codexHome, marketplaceName, name, runCommand, sourcePath, version }) {
|
||||
await maybeRunNpmInstall(sourcePath, runCommand);
|
||||
await maybeRunNpmBuild(sourcePath, runCommand);
|
||||
|
||||
const targetPath = join(codexHome, "plugins", "cache", marketplaceName, name, version);
|
||||
await replaceDirectory(sourcePath, targetPath, shouldCopyPluginPath);
|
||||
await maybeRunNpmInstall(targetPath, runCommand, ["install", "--omit=dev"]);
|
||||
await rewriteCachedMcpManifest(targetPath);
|
||||
return { name, version, path: targetPath };
|
||||
}
|
||||
|
||||
export async function pruneMarketplaceCache({ codexHome, marketplaceName, keepPluginNames }) {
|
||||
const cacheRoot = join(codexHome, "plugins", "cache", marketplaceName);
|
||||
if (!(await exists(cacheRoot))) return;
|
||||
const keep = new Set(keepPluginNames);
|
||||
const entries = await readdir(cacheRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || keep.has(entry.name)) continue;
|
||||
await rm(join(cacheRoot, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function linkCachedPluginBins({ binDir, pluginRoot }) {
|
||||
const binLinks = await discoverPackageBins(pluginRoot);
|
||||
await mkdir(binDir, { recursive: true });
|
||||
const linked = [];
|
||||
for (const link of binLinks) {
|
||||
const linkPath = join(binDir, link.name);
|
||||
await replaceSymlink(linkPath, link.target);
|
||||
linked.push({ name: link.name, path: linkPath, target: link.target });
|
||||
}
|
||||
return linked;
|
||||
}
|
||||
|
||||
async function maybeRunNpmInstall(cwd, runCommand, args = ["install"]) {
|
||||
if (!(await exists(join(cwd, "package.json")))) return;
|
||||
await runCommand("npm", args, { cwd });
|
||||
}
|
||||
|
||||
async function maybeRunNpmBuild(cwd, runCommand) {
|
||||
if (!(await exists(join(cwd, "package.json")))) return;
|
||||
const packageJson = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
|
||||
if (!isRecord(packageJson.scripts) || typeof packageJson.scripts.build !== "string") return;
|
||||
await runCommand("npm", ["run", "build"], { cwd });
|
||||
}
|
||||
|
||||
async function replaceDirectory(sourcePath, targetPath, filter) {
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
const tempPath = join(dirname(targetPath), `.tmp-${basename(targetPath)}-${process.pid}-${Date.now()}`);
|
||||
await rm(tempPath, { recursive: true, force: true });
|
||||
await cp(sourcePath, tempPath, {
|
||||
recursive: true,
|
||||
filter: (source) => filter(source, sourcePath),
|
||||
});
|
||||
await rm(targetPath, { recursive: true, force: true });
|
||||
await rename(tempPath, targetPath);
|
||||
}
|
||||
|
||||
async function discoverPackageBins(root) {
|
||||
const links = [];
|
||||
await collectPackageBins(root, root, links);
|
||||
return links;
|
||||
}
|
||||
|
||||
async function collectPackageBins(directory, root, links) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const packageJsonPath = join(directory, "package.json");
|
||||
if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) {
|
||||
await appendPackageBinLinks(packageJsonPath, directory, links);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
|
||||
const childPath = join(directory, entry.name);
|
||||
if (!childPath.startsWith(root)) continue;
|
||||
await collectPackageBins(childPath, root, links);
|
||||
}
|
||||
}
|
||||
|
||||
async function appendPackageBinLinks(packageJsonPath, packageRoot, links) {
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
||||
if (!isRecord(packageJson)) return;
|
||||
const bin = packageJson.bin;
|
||||
if (typeof bin === "string" && typeof packageJson.name === "string") {
|
||||
links.push({ name: basename(packageJson.name), target: join(packageRoot, bin) });
|
||||
return;
|
||||
}
|
||||
if (!isRecord(bin)) return;
|
||||
for (const [name, target] of Object.entries(bin)) {
|
||||
if (typeof target !== "string") continue;
|
||||
links.push({ name, target: join(packageRoot, target) });
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceSymlink(linkPath, targetPath) {
|
||||
if (await existingNonSymlink(linkPath)) {
|
||||
throw new Error(`${linkPath} already exists and is not a symlink`);
|
||||
}
|
||||
await rm(linkPath, { force: true });
|
||||
await symlink(targetPath, linkPath);
|
||||
}
|
||||
|
||||
async function existingNonSymlink(path) {
|
||||
try {
|
||||
const stat = await lstat(path);
|
||||
if (!stat.isSymbolicLink()) return true;
|
||||
await readlink(path);
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldCopyPluginPath(path, root) {
|
||||
const relative = path === root ? "" : path.slice(root.length + sep.length);
|
||||
if (relative === "") return true;
|
||||
const parts = relative.split(sep);
|
||||
return !parts.some((part) => part === ".git" || part === "node_modules");
|
||||
}
|
||||
|
||||
async function rewriteCachedMcpManifest(pluginRoot) {
|
||||
const manifestPath = join(pluginRoot, ".mcp.json");
|
||||
if (!(await exists(manifestPath))) return;
|
||||
const raw = await readFile(manifestPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) return;
|
||||
let changed = false;
|
||||
for (const server of Object.values(parsed.mcpServers)) {
|
||||
if (!isRecord(server)) continue;
|
||||
if (server.cwd === "." || server.cwd === "./") {
|
||||
delete server.cwd;
|
||||
changed = true;
|
||||
}
|
||||
if (!Array.isArray(server.args)) continue;
|
||||
const nextArgs = server.args.map((arg) => {
|
||||
if (typeof arg !== "string") return arg;
|
||||
if (arg.startsWith("./") || arg.startsWith("../")) return join(pluginRoot, arg);
|
||||
return arg;
|
||||
});
|
||||
if (nextArgs.some((value, index) => value !== server.args[index])) {
|
||||
server.args = nextArgs;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) await writeFile(manifestPath, `${JSON.stringify(parsed, null, "\t")}\n`);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { exists } from "./utils.mjs";
|
||||
|
||||
export async function updateCodexConfig({ configPath, repoRoot, marketplaceName, pluginNames, trustedHookStates = [] }) {
|
||||
await mkdir(dirname(configPath), { recursive: true });
|
||||
let config = "";
|
||||
if (await exists(configPath)) config = await readFile(configPath, "utf8");
|
||||
|
||||
config = removeStaleMarketplacePluginBlocks(config, marketplaceName, new Set(pluginNames));
|
||||
config = removeStaleMarketplaceHookStateBlocks(config, marketplaceName, new Set(pluginNames));
|
||||
config = ensureFeatureEnabled(config, "plugins");
|
||||
config = ensureFeatureEnabled(config, "plugin_hooks");
|
||||
config = ensureMarketplaceBlock(config, marketplaceName, repoRoot);
|
||||
for (const pluginName of pluginNames) {
|
||||
config = ensurePluginEnabled(config, `${pluginName}@${marketplaceName}`);
|
||||
}
|
||||
for (const state of trustedHookStates) {
|
||||
config = ensureHookTrusted(config, state.key, state.trustedHash);
|
||||
}
|
||||
|
||||
await writeFile(configPath, config.trimEnd() + "\n");
|
||||
}
|
||||
|
||||
function removeStaleMarketplacePluginBlocks(config, marketplaceName, keepPluginNames) {
|
||||
return removeTomlSections(config, (header) => {
|
||||
const pluginKey = parseQuotedPluginHeader(header);
|
||||
if (pluginKey === null) return false;
|
||||
const suffix = `@${marketplaceName}`;
|
||||
if (!pluginKey.endsWith(suffix)) return false;
|
||||
return !keepPluginNames.has(pluginKey.slice(0, -suffix.length));
|
||||
});
|
||||
}
|
||||
|
||||
function removeStaleMarketplaceHookStateBlocks(config, marketplaceName, keepPluginNames) {
|
||||
return removeTomlSections(config, (header) => {
|
||||
const prefix = "hooks.state.";
|
||||
if (!header.startsWith(prefix)) return false;
|
||||
const hookKey = parseJsonString(header.slice(prefix.length));
|
||||
if (hookKey === null) return false;
|
||||
const separator = hookKey.indexOf(":");
|
||||
if (separator === -1) return false;
|
||||
const pluginKey = hookKey.slice(0, separator);
|
||||
const suffix = `@${marketplaceName}`;
|
||||
if (!pluginKey.endsWith(suffix)) return false;
|
||||
return !keepPluginNames.has(pluginKey.slice(0, -suffix.length));
|
||||
});
|
||||
}
|
||||
|
||||
function ensureFeatureEnabled(config, featureName) {
|
||||
const section = findTomlSection(config, "features");
|
||||
if (!section) return appendBlock(config, `[features]\n${featureName} = true\n`);
|
||||
return replaceOrInsertSetting(config, section, featureName, "true");
|
||||
}
|
||||
|
||||
function ensureMarketplaceBlock(config, marketplaceName, repoRoot) {
|
||||
const header = `marketplaces.${marketplaceName}`;
|
||||
if (findTomlSection(config, header)) return config;
|
||||
return appendBlock(
|
||||
config,
|
||||
[
|
||||
`[${header}]`,
|
||||
`last_updated = "${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}"`,
|
||||
"source_type = \"local\"",
|
||||
`source = ${JSON.stringify(repoRoot)}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function ensurePluginEnabled(config, pluginKey) {
|
||||
const header = `plugins.${JSON.stringify(pluginKey)}`;
|
||||
const section = findTomlSection(config, header);
|
||||
if (!section) return appendBlock(config, `[${header}]\nenabled = true\n`);
|
||||
return replaceOrInsertSetting(config, section, "enabled", "true");
|
||||
}
|
||||
|
||||
function ensureHookTrusted(config, key, trustedHash) {
|
||||
const header = `hooks.state.${JSON.stringify(key)}`;
|
||||
const section = findTomlSection(config, header);
|
||||
if (!section) return appendBlock(config, `[${header}]\ntrusted_hash = ${JSON.stringify(trustedHash)}\n`);
|
||||
return replaceOrInsertSetting(config, section, "trusted_hash", JSON.stringify(trustedHash));
|
||||
}
|
||||
|
||||
function removeTomlSections(config, shouldRemove) {
|
||||
return splitTomlSections(config)
|
||||
.filter((section) => section.header === null || !shouldRemove(section.header))
|
||||
.map((section) => section.text)
|
||||
.join("")
|
||||
.replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
function splitTomlSections(config) {
|
||||
const lines = config.match(/[^\n]*\n?|$/g) ?? [];
|
||||
const sections = [];
|
||||
let current = { header: null, text: "" };
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) break;
|
||||
const header = parseTomlHeader(line);
|
||||
if (header !== null) {
|
||||
if (current.text.length > 0) sections.push(current);
|
||||
current = { header, text: line };
|
||||
} else {
|
||||
current.text += line;
|
||||
}
|
||||
}
|
||||
if (current.text.length > 0) sections.push(current);
|
||||
return sections;
|
||||
}
|
||||
|
||||
function findTomlSection(config, header) {
|
||||
const headerLine = `[${header}]`;
|
||||
const lines = config.match(/[^\n]*\n?|$/g) ?? [];
|
||||
let offset = 0;
|
||||
let start = -1;
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) break;
|
||||
const trimmed = line.trim();
|
||||
if (start === -1) {
|
||||
if (trimmed === headerLine) start = offset;
|
||||
} else if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
return { start, end: offset, text: config.slice(start, offset) };
|
||||
}
|
||||
offset += line.length;
|
||||
}
|
||||
if (start === -1) return null;
|
||||
return { start, end: config.length, text: config.slice(start) };
|
||||
}
|
||||
|
||||
function replaceOrInsertSetting(config, section, key, value) {
|
||||
const linePattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, "m");
|
||||
const replacement = linePattern.test(section.text)
|
||||
? section.text.replace(linePattern, `${key} = ${value}`)
|
||||
: insertSetting(section.text, key, value);
|
||||
return config.slice(0, section.start) + replacement + config.slice(section.end);
|
||||
}
|
||||
|
||||
function insertSetting(sectionText, key, value) {
|
||||
const lines = sectionText.split("\n");
|
||||
lines.splice(1, 0, `${key} = ${value}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function parseTomlHeader(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
|
||||
if (trimmed.startsWith("[[")) return null;
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
function parseQuotedPluginHeader(header) {
|
||||
const prefix = "plugins.";
|
||||
if (!header.startsWith(prefix)) return null;
|
||||
return parseJsonString(header.slice(prefix.length));
|
||||
}
|
||||
|
||||
function parseJsonString(value) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "string" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendBlock(config, block) {
|
||||
const prefix = config.trimEnd();
|
||||
return `${prefix}${prefix.length > 0 ? "\n\n" : ""}${block.trimEnd()}\n`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { exists, isRecord } from "./utils.mjs";
|
||||
|
||||
const EVENT_LABELS = new Map([
|
||||
["PreToolUse", "pre_tool_use"],
|
||||
["PermissionRequest", "permission_request"],
|
||||
["PostToolUse", "post_tool_use"],
|
||||
["PreCompact", "pre_compact"],
|
||||
["PostCompact", "post_compact"],
|
||||
["SessionStart", "session_start"],
|
||||
["UserPromptSubmit", "user_prompt_submit"],
|
||||
["SubagentStart", "subagent_start"],
|
||||
["SubagentStop", "subagent_stop"],
|
||||
["Stop", "stop"],
|
||||
]);
|
||||
|
||||
export async function trustedHookStatesForPlugin({ marketplaceName, pluginName, pluginRoot }) {
|
||||
const manifestPath = join(pluginRoot, ".codex-plugin", "plugin.json");
|
||||
if (!(await exists(manifestPath))) return [];
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
if (!isRecord(manifest) || typeof manifest.hooks !== "string") return [];
|
||||
|
||||
const hooksPath = join(pluginRoot, manifest.hooks);
|
||||
if (!(await exists(hooksPath))) return [];
|
||||
const parsed = JSON.parse(await readFile(hooksPath, "utf8"));
|
||||
if (!isRecord(parsed) || !isRecord(parsed.hooks)) return [];
|
||||
|
||||
const keySource = `${pluginName}@${marketplaceName}:${stripDotSlash(manifest.hooks)}`;
|
||||
const states = [];
|
||||
for (const [eventName, groups] of Object.entries(parsed.hooks)) {
|
||||
if (!Array.isArray(groups)) continue;
|
||||
const eventLabel = EVENT_LABELS.get(eventName);
|
||||
if (eventLabel === undefined) continue;
|
||||
for (const [groupIndex, group] of groups.entries()) {
|
||||
if (!isRecord(group) || !Array.isArray(group.hooks)) continue;
|
||||
for (const [handlerIndex, handler] of group.hooks.entries()) {
|
||||
if (!isRecord(handler) || handler.type !== "command") continue;
|
||||
if (handler.async === true) continue;
|
||||
if (typeof handler.command !== "string" || handler.command.trim() === "") continue;
|
||||
const key = `${keySource}:${eventLabel}:${groupIndex}:${handlerIndex}`;
|
||||
states.push({
|
||||
key,
|
||||
trustedHash: commandHookHash(eventLabel, group.matcher, handler),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
function commandHookHash(eventName, matcher, handler) {
|
||||
const command = handler.command;
|
||||
const timeout = Math.max(Number(handler.timeout ?? 600), 1);
|
||||
const normalizedHandler = {
|
||||
type: "command",
|
||||
command,
|
||||
timeout,
|
||||
async: false,
|
||||
};
|
||||
if (typeof handler.statusMessage === "string") normalizedHandler.statusMessage = handler.statusMessage;
|
||||
const identity = {
|
||||
event_name: eventName,
|
||||
hooks: [normalizedHandler],
|
||||
};
|
||||
if (typeof matcher === "string") identity.matcher = matcher;
|
||||
return `sha256:${createHash("sha256").update(JSON.stringify(canonicalJson(identity))).digest("hex")}`;
|
||||
}
|
||||
|
||||
function canonicalJson(value) {
|
||||
if (Array.isArray(value)) return value.map(canonicalJson);
|
||||
if (!isRecord(value)) return value;
|
||||
const result = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
result[key] = canonicalJson(value[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stripDotSlash(value) {
|
||||
return value.startsWith("./") ? value.slice(2) : value;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { isRecord } from "./utils.mjs";
|
||||
|
||||
const MARKETPLACE_PATH = ".agents/plugins/marketplace.json";
|
||||
|
||||
export async function readMarketplace(repoRoot) {
|
||||
const marketplacePath = join(repoRoot, MARKETPLACE_PATH);
|
||||
const raw = await readFile(marketplacePath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!isRecord(parsed)) throw new Error("marketplace.json must be an object");
|
||||
if (typeof parsed.name !== "string" || parsed.name.trim() === "") {
|
||||
throw new Error("marketplace.json name must be a non-empty string");
|
||||
}
|
||||
validatePathSegment(parsed.name, "marketplace name");
|
||||
if (!Array.isArray(parsed.plugins)) throw new Error("marketplace.json plugins must be an array");
|
||||
|
||||
return {
|
||||
name: parsed.name,
|
||||
plugins: parsed.plugins.map((plugin, index) => normalizeMarketplacePlugin(plugin, index)),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePluginSource(repoRoot, plugin) {
|
||||
const sourcePath = localSourcePath(plugin.source);
|
||||
const relativePath = sourcePath.slice(2);
|
||||
return join(repoRoot, ...relativePath.split(/[\\/]/));
|
||||
}
|
||||
|
||||
export async function readPluginManifest(pluginRoot) {
|
||||
const raw = await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!isRecord(parsed)) throw new Error(`${pluginRoot} plugin.json must be an object`);
|
||||
if (typeof parsed.name !== "string" || parsed.name.trim() === "") {
|
||||
throw new Error(`${pluginRoot} plugin.json name must be a non-empty string`);
|
||||
}
|
||||
const manifest = { name: parsed.name };
|
||||
if (parsed.version !== undefined) {
|
||||
if (typeof parsed.version !== "string" || parsed.version.trim() === "") {
|
||||
throw new Error(`${pluginRoot} plugin.json version must be a non-empty string`);
|
||||
}
|
||||
manifest.version = parsed.version.trim();
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function validatePathSegment(value, label) {
|
||||
if (!/^[A-Za-z0-9._+-]+$/.test(value)) {
|
||||
throw new Error(`${label} contains unsupported characters: ${value}`);
|
||||
}
|
||||
if (value === "." || value === "..") {
|
||||
throw new Error(`${label} must not be a path traversal segment`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMarketplacePlugin(plugin, index) {
|
||||
if (!isRecord(plugin)) throw new Error(`marketplace plugin ${index} must be an object`);
|
||||
if (typeof plugin.name !== "string" || plugin.name.trim() === "") {
|
||||
throw new Error(`marketplace plugin ${index} name must be a non-empty string`);
|
||||
}
|
||||
validatePathSegment(plugin.name, "plugin name");
|
||||
return {
|
||||
name: plugin.name,
|
||||
source: plugin.source,
|
||||
};
|
||||
}
|
||||
|
||||
function localSourcePath(source) {
|
||||
if (typeof source === "string") return validateLocalSourcePath(source);
|
||||
if (
|
||||
isRecord(source) &&
|
||||
source.source === "local" &&
|
||||
typeof source.path === "string"
|
||||
) {
|
||||
return validateLocalSourcePath(source.path);
|
||||
}
|
||||
throw new Error("local plugin source must be a string path or { source: \"local\", path } object");
|
||||
}
|
||||
|
||||
function validateLocalSourcePath(path) {
|
||||
if (!path.startsWith("./")) {
|
||||
throw new Error("local plugin source path must start with ./");
|
||||
}
|
||||
const relative = path.slice(2);
|
||||
if (relative.length === 0) throw new Error("local plugin source path must not be empty");
|
||||
for (const part of relative.split(/[\\/]/)) {
|
||||
if (part === "" || part === "." || part === "..") {
|
||||
throw new Error("local plugin source path must stay within the marketplace root");
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export async function defaultRunCommand(command, args, options) {
|
||||
await new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
stdio: "inherit",
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
const suffix = signal ? `signal ${signal}` : `exit code ${code}`;
|
||||
reject(new Error(`${command} ${args.join(" ")} failed in ${options.cwd} with ${suffix}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { access } from "node:fs/promises";
|
||||
|
||||
export async function exists(path) {
|
||||
try {
|
||||
await access(path, fsConstants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRecord(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
Reference in New Issue
Block a user