diff --git a/packages/omo-codex/scripts/install/agents.mjs b/packages/omo-codex/scripts/install/agents.mjs new file mode 100644 index 000000000..5754286c9 --- /dev/null +++ b/packages/omo-codex/scripts/install/agents.mjs @@ -0,0 +1,84 @@ +import { basename, join } from "node:path"; +import { copyFile, lstat, mkdir, readdir, rm, symlink, writeFile } from "node:fs/promises"; + +import { exists } from "./utils.mjs"; + +const MANIFEST_FILE = ".installed-agents.json"; + +export async function linkCachedPluginAgents({ codexHome, pluginRoot, platform = process.platform }) { + const bundledAgents = await discoverBundledAgents(pluginRoot); + if (bundledAgents.length === 0) { + await writeManifest(pluginRoot, []); + return []; + } + + const agentsDir = join(codexHome, "agents"); + await mkdir(agentsDir, { recursive: true }); + const linked = []; + for (const agentPath of bundledAgents) { + const linkPath = join(agentsDir, basename(agentPath)); + if (platform === "win32") { + await replaceWithCopy(linkPath, agentPath); + } else { + await replaceWithSymlink(linkPath, agentPath); + } + linked.push({ name: basename(agentPath), path: linkPath, target: agentPath }); + } + await writeManifest(pluginRoot, linked.map((entry) => entry.path)); + return linked; +} + +async function discoverBundledAgents(pluginRoot) { + const componentsRoot = join(pluginRoot, "components"); + if (!(await exists(componentsRoot))) return []; + + const componentEntries = await readdir(componentsRoot, { withFileTypes: true }); + const agents = []; + for (const entry of componentEntries) { + if (!entry.isDirectory()) continue; + const agentsRoot = join(componentsRoot, entry.name, "agents"); + if (!(await exists(agentsRoot))) continue; + const agentEntries = await readdir(agentsRoot, { withFileTypes: true }); + for (const file of agentEntries) { + if (!file.isFile() || !file.name.endsWith(".toml")) continue; + agents.push(join(agentsRoot, file.name)); + } + } + agents.sort(); + return agents; +} + +async function replaceWithSymlink(linkPath, target) { + await prepareReplacement(linkPath); + await symlink(target, linkPath); +} + +async function replaceWithCopy(linkPath, target) { + await prepareReplacement(linkPath); + await copyFile(target, linkPath); +} + +async function prepareReplacement(linkPath) { + if (!(await lstatExists(linkPath))) return; + const entryStat = await lstat(linkPath); + if (entryStat.isDirectory() && !entryStat.isSymbolicLink()) { + throw new Error(`${linkPath} already exists and is a directory; refusing to replace`); + } + await rm(linkPath, { force: true }); +} + +async function writeManifest(pluginRoot, agentPaths) { + const manifestPath = join(pluginRoot, MANIFEST_FILE); + const payload = { agents: [...agentPaths].sort() }; + await writeFile(manifestPath, `${JSON.stringify(payload, null, "\t")}\n`); +} + +async function lstatExists(path) { + try { + await lstat(path); + return true; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} diff --git a/packages/omo-codex/scripts/install/cache.mjs b/packages/omo-codex/scripts/install/cache.mjs new file mode 100644 index 000000000..c04315e80 --- /dev/null +++ b/packages/omo-codex/scripts/install/cache.mjs @@ -0,0 +1,245 @@ +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises"; + +import { createCachedMcpRuntimeArgRewriter } from "./mcp-runtime-cache.mjs"; +import { exists, isRecord } from "./utils.mjs"; +import { COMMAND_SHIM_MARKER } from "./command-shim.mjs"; +import { removeLegacyCodexComponentBins } from "./legacy-bins.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 rewriteCachedPackageLocalFileDependencies(targetPath, sourcePath); + await maybeRunNpmInstall(targetPath, runCommand, ["install", "--omit=dev"]); + await rewriteCachedMcpManifest(targetPath, sourcePath); + 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 pruneMarketplacePluginCaches({ codexHome, marketplaceName, pluginNames }) { + const cacheRoot = join(codexHome, "plugins", "cache", marketplaceName); + if (!(await exists(cacheRoot))) return; + for (const pluginName of pluginNames) { + await rm(join(cacheRoot, pluginName), { recursive: true, force: true }); + } + if ((await readdir(cacheRoot)).length === 0) { + await rm(cacheRoot, { recursive: true, force: true }); + } +} + +export async function linkCachedPluginBins({ binDir, pluginRoot, platform = process.platform }) { + const binLinks = await discoverPackageBins(pluginRoot); + await mkdir(binDir, { recursive: true }); + await removeLegacyCodexComponentBins(binDir, platform); + const linked = []; + for (const link of binLinks) { + const linkPath = await linkCachedPluginBin(binDir, link, platform); + linked.push({ name: link.name, path: linkPath, target: link.target }); + } + return linked; +} + +async function linkCachedPluginBin(binDir, link, platform) { + if (platform === "win32") { + const linkPath = join(binDir, `${link.name}.cmd`); + await replaceCommandShim(linkPath, link.target); + return linkPath; + } + + const linkPath = join(binDir, link.name); + await replaceSymlink(linkPath, link.target); + return linkPath; +} + +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 replaceCommandShim(linkPath, targetPath) { + if (await existingNonShim(linkPath)) { + throw new Error(`${linkPath} already exists and is not a command shim`); + } + await writeFile(linkPath, `@echo off\r\n${COMMAND_SHIM_MARKER}\r\nnode "${targetPath}" %*\r\n`); +} + +async function existingNonShim(path) { + try { + const stat = await lstat(path); + if (!stat.isFile()) return true; + const content = await readFile(path, "utf8"); + if (content.includes(COMMAND_SHIM_MARKER)) return false; + throw new Error(`${path} already exists and is not a generated command shim`); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} + +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); + if (parts[parts.length - 1] === "package-lock.json") return false; + return !parts.some((part) => part === ".git" || part === "node_modules"); +} + +export async function rewriteCachedMcpManifest(pluginRoot, sourceRoot = 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; + const rewriteRuntimeArg = createCachedMcpRuntimeArgRewriter(); + for (const [serverName, server] of Object.entries(parsed.mcpServers)) { + if (!isRecord(server)) continue; + if (server.cwd === "." || server.cwd === "./") { + delete server.cwd; + changed = true; + } + if (!Array.isArray(server.args)) continue; + const nextArgs = await Promise.all( + server.args.map((arg) => rewriteRuntimeArg({ arg, pluginRoot, serverName, sourceRoot })), + ); + 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`); +} + +async function rewriteCachedPackageLocalFileDependencies(pluginRoot, sourceRoot) { + const packageJsonPaths = []; + await collectPackageJsonPaths(pluginRoot, pluginRoot, packageJsonPaths); + for (const packageJsonPath of packageJsonPaths) { + const raw = await readFile(packageJsonPath, "utf8"); + const parsed = JSON.parse(raw); + if (!isRecord(parsed)) continue; + const packageDir = dirname(packageJsonPath); + const sourcePackageDir = join(sourceRoot, relative(pluginRoot, packageDir)); + let changed = false; + for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) { + const dependencies = parsed[field]; + if (!isRecord(dependencies)) continue; + for (const [name, specifier] of Object.entries(dependencies)) { + if (typeof specifier !== "string" || !specifier.startsWith("file:")) continue; + const filePath = specifier.slice("file:".length); + if (filePath.length === 0 || isAbsolute(filePath)) continue; + const targetPath = resolve(packageDir, filePath); + if (isPathInside(targetPath, pluginRoot)) continue; + dependencies[name] = `file:${resolve(sourcePackageDir, filePath)}`; + changed = true; + } + } + if (changed) await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, "\t")}\n`); + } +} + +async function collectPackageJsonPaths(directory, root, paths) { + const entries = await readdir(directory, { withFileTypes: true }); + if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) { + paths.push(join(directory, "package.json")); + } + 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 collectPackageJsonPaths(childPath, root, paths); + } +} + +function isPathInside(candidatePath, rootPath) { + const pathFromRoot = relative(rootPath, candidatePath); + return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot)); +} diff --git a/packages/omo-codex/scripts/install/command-shim.mjs b/packages/omo-codex/scripts/install/command-shim.mjs new file mode 100644 index 000000000..304042df0 --- /dev/null +++ b/packages/omo-codex/scripts/install/command-shim.mjs @@ -0,0 +1 @@ +export const COMMAND_SHIM_MARKER = ":: generated by oh-my-openagent Codex installer"; diff --git a/packages/omo-codex/scripts/install/config.mjs b/packages/omo-codex/scripts/install/config.mjs new file mode 100644 index 000000000..30ac0d8e4 --- /dev/null +++ b/packages/omo-codex/scripts/install/config.mjs @@ -0,0 +1,229 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { ensureCodexMultiAgentV2Config } from "./multi-agent-v2-config.mjs"; +import { appendBlock, findTomlSection, replaceOrInsertSetting } from "./toml-editor.mjs"; +import { exists } from "./utils.mjs"; + +const LEGACY_CODEX_PLUGIN_MARKETPLACE = ["code", "yeongyu", "codex", "plugins"].join("-"); +const SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", LEGACY_CODEX_PLUGIN_MARKETPLACE]; +const MANAGED_CODEX_AGENT_NAMES = [ + "codex-ultrawork-reviewer", + "explorer", + "librarian", + "metis", + "momus", + "plan", +]; + +export async function updateCodexConfig({ + configPath, + repoRoot, + marketplaceName, + marketplaceSource = defaultMarketplaceSource(marketplaceName, repoRoot), + pluginNames, + trustedHookStates = [], + agentConfigs = [], +}) { + await mkdir(dirname(configPath), { recursive: true }); + let config = ""; + if (await exists(configPath)) config = await readFile(configPath, "utf8"); + + for (const legacyMarketplaceName of legacyMarketplaceNames(marketplaceName)) { + config = removeMarketplaceBlock(config, legacyMarketplaceName); + config = removeStaleMarketplacePluginBlocks(config, legacyMarketplaceName, new Set()); + config = removeStaleMarketplaceHookStateBlocks(config, legacyMarketplaceName, new Set()); + } + config = removeStaleMarketplacePluginBlocks(config, marketplaceName, new Set(pluginNames)); + config = removeStaleMarketplaceHookStateBlocks(config, marketplaceName, new Set(pluginNames)); + config = removeStaleManagedAgentBlocks(config, new Set(agentConfigs.map((agentConfig) => agentConfig.name))); + config = ensureFeatureEnabled(config, "plugins"); + config = ensureFeatureEnabled(config, "plugin_hooks"); + config = ensureCodexMultiAgentV2Config(config); + config = ensureMarketplaceBlock(config, marketplaceName, marketplaceSource); + for (const pluginName of pluginNames) { + config = ensurePluginEnabled(config, `${pluginName}@${marketplaceName}`); + } + for (const state of trustedHookStates) { + config = ensureHookTrusted(config, state.key, state.trustedHash); + } + for (const agentConfig of agentConfigs) { + config = ensureAgentConfig(config, agentConfig); + } + + await writeFile(configPath, config.trimEnd() + "\n"); +} + +function legacyMarketplaceNames(marketplaceName) { + return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_MARKETPLACES : []; +} + +function removeMarketplaceBlock(config, marketplaceName) { + return removeTomlSections(config, (header) => header === `marketplaces.${marketplaceName}`); +} + +function defaultMarketplaceSource(marketplaceName, repoRoot) { + return { + sourceType: "local", + source: repoRoot, + }; +} + +function removeStaleMarketplacePluginBlocks(config, marketplaceName, keepPluginNames) { + return removeTomlSections(config, (header) => { + const pluginKey = parsePluginHeaderKey(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 removeStaleManagedAgentBlocks(config, keepAgentNames) { + const managedAgentNames = new Set(MANAGED_CODEX_AGENT_NAMES); + return splitTomlSections(config) + .filter((section) => { + if (section.header === null) return true; + const agentName = parseAgentHeaderName(section.header); + if (agentName === null || !managedAgentNames.has(agentName) || keepAgentNames.has(agentName)) return true; + return !section.text.includes(`config_file = ${JSON.stringify(`./agents/${agentName}.toml`)}`); + }) + .map((section) => section.text) + .join("") + .replace(/\n{3,}/g, "\n\n"); +} + +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, source) { + const header = `marketplaces.${marketplaceName}`; + const block = [ + `[${header}]`, + `last_updated = "${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}"`, + `source_type = ${JSON.stringify(source.sourceType)}`, + `source = ${JSON.stringify(source.source)}`, + source.ref === undefined ? null : `ref = ${JSON.stringify(source.ref)}`, + "", + ].filter((line) => line !== null).join("\n"); + const section = findTomlSection(config, header); + if (section) return config.slice(0, section.start) + block + config.slice(section.end); + return appendBlock(config, block); +} + +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 ensureAgentConfig(config, agentConfig) { + const header = `agents.${tomlKeySegment(agentConfig.name)}`; + const section = findTomlSection(config, header); + const configFile = JSON.stringify(agentConfig.configFile); + if (!section) return appendBlock(config, `[${header}]\nconfig_file = ${configFile}\n`); + return replaceOrInsertSetting(config, section, "config_file", configFile); +} + +function tomlKeySegment(value) { + return /^[A-Za-z0-9_-]+$/.test(value) ? value : JSON.stringify(value); +} + +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 parseTomlHeader(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null; + if (trimmed.startsWith("[[")) return null; + return trimmed.slice(1, -1); +} + +function parsePluginHeaderKey(header) { + const prefix = "plugins."; + if (!header.startsWith(prefix)) return null; + return parseLeadingJsonString(header.slice(prefix.length)); +} + +function parseAgentHeaderName(header) { + const prefix = "agents."; + if (!header.startsWith(prefix)) return null; + const key = header.slice(prefix.length); + return key.startsWith('"') ? parseLeadingJsonString(key) : key; +} + +function parseLeadingJsonString(value) { + if (!value.startsWith('"')) return parseJsonString(value); + let escaped = false; + for (let index = 1; index < value.length; index += 1) { + const char = value[index]; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === '"') return parseJsonString(value.slice(0, index + 1)); + } + return null; +} + +function parseJsonString(value) { + try { + const parsed = JSON.parse(value); + return typeof parsed === "string" ? parsed : null; + } catch { + return null; + } +} diff --git a/packages/omo-codex/scripts/install/hook-trust.mjs b/packages/omo-codex/scripts/install/hook-trust.mjs new file mode 100644 index 000000000..b2710f795 --- /dev/null +++ b/packages/omo-codex/scripts/install/hook-trust.mjs @@ -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; +} diff --git a/packages/omo-codex/scripts/install/legacy-bins.mjs b/packages/omo-codex/scripts/install/legacy-bins.mjs new file mode 100644 index 000000000..89c2961f8 --- /dev/null +++ b/packages/omo-codex/scripts/install/legacy-bins.mjs @@ -0,0 +1,57 @@ +import { lstat, readFile, readlink, rm } from "node:fs/promises"; +import { join } from "node:path"; + +import { COMMAND_SHIM_MARKER } from "./command-shim.mjs"; + +const LEGACY_CODEX_COMPONENT_BINS = [ + { name: "codex-comment-checker", component: "comment-checker" }, + { name: "codex-rules", component: "rules" }, + { name: "codex-start-work-continuation", component: "start-work-continuation" }, + { name: "codex-telemetry", component: "telemetry" }, + { name: "codex-ultrawork", component: "ultrawork" }, +]; + +export async function removeLegacyCodexComponentBins(binDir, platform) { + for (const entry of LEGACY_CODEX_COMPONENT_BINS) { + const linkPath = join(binDir, platform === "win32" ? `${entry.name}.cmd` : entry.name); + await removeLegacyCodexComponentBin(linkPath, entry.component, platform); + } +} + +async function removeLegacyCodexComponentBin(linkPath, component, platform) { + try { + const stat = await lstat(linkPath); + if (platform !== "win32") { + if (!stat.isSymbolicLink()) return; + const target = await readlink(linkPath); + if (isManagedLegacyComponentTarget(target, component)) await rm(linkPath, { force: true }); + return; + } + if (!stat.isFile()) return; + const content = await readFile(linkPath, "utf8"); + if (content.includes(COMMAND_SHIM_MARKER)) await rm(linkPath, { force: true }); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return; + throw error; + } +} + +function isManagedLegacyComponentTarget(target, component) { + const parts = target.split(/[\\/]+/); + const suffixStart = parts.length - 4; + const suffix = parts.slice(-4); + return ( + suffix[0] === "components" && + suffix[1] === component && + suffix[2] === "dist" && + suffix[3] === "cli.js" && + hasPluginCachePrefix(parts, suffixStart) + ); +} + +function hasPluginCachePrefix(parts, endExclusive) { + for (let index = 0; index < endExclusive - 1; index += 1) { + if (parts[index] === "plugins" && parts[index + 1] === "cache") return true; + } + return false; +} diff --git a/packages/omo-codex/scripts/install/marketplace.mjs b/packages/omo-codex/scripts/install/marketplace.mjs new file mode 100644 index 000000000..1d89bd284 --- /dev/null +++ b/packages/omo-codex/scripts/install/marketplace.mjs @@ -0,0 +1,104 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { isRecord } from "./utils.mjs"; + +const DEFAULT_MARKETPLACE_PATH = "packages/omo-codex/marketplace.json"; + +export async function readMarketplace(repoRoot, options = {}) { + const marketplacePath = options.marketplacePath ?? join(repoRoot, DEFAULT_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(marketplaceRoot, plugin, options = {}) { + const sourcePath = localSourcePath(options.pathOverride ?? plugin.source); + const relativePath = sourcePath.slice(2); + return join(marketplaceRoot, ...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"); + if (plugin.source === undefined || typeof plugin.source === "string") { + if (typeof plugin.source === "string") validateLocalSourcePath(plugin.source); + return { + name: plugin.name, + source: plugin.source, + }; + } + if (isRecord(plugin.source) && plugin.source.source === "local" && typeof plugin.source.path === "string") { + validateLocalSourcePath(plugin.source.path); + return { + name: plugin.name, + source: { source: "local", path: plugin.source.path }, + }; + } + throw new Error("local plugin source must be a string path or { source: \"local\", path } object"); +} + +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; +} diff --git a/packages/omo-codex/scripts/install/mcp-runtime-cache.mjs b/packages/omo-codex/scripts/install/mcp-runtime-cache.mjs new file mode 100644 index 000000000..79d52b96e --- /dev/null +++ b/packages/omo-codex/scripts/install/mcp-runtime-cache.mjs @@ -0,0 +1,77 @@ +import { existsSync } from "node:fs"; +import { cp } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; + +export function createCachedMcpRuntimeArgRewriter({ copyDist = cp } = {}) { + const copiedDistRoots = new Map(); + return async function rewriteCachedMcpRuntimeArg({ arg, pluginRoot, serverName, sourceRoot }) { + return rewriteCachedMcpRuntimeArgWithCache({ arg, pluginRoot, serverName, sourceRoot }, { copiedDistRoots, copyDist }); + }; +} + +export async function rewriteCachedMcpRuntimeArg(args) { + return createCachedMcpRuntimeArgRewriter()(args); +} + +async function rewriteCachedMcpRuntimeArgWithCache({ arg, pluginRoot, serverName, sourceRoot }, { copiedDistRoots, copyDist }) { + if (typeof arg !== "string" || (!arg.startsWith("./") && !arg.startsWith("../"))) return arg; + const fallback = resolveCachedRuntimePath(pluginRoot, sourceRoot, arg); + const targetPath = resolve(pluginRoot, arg); + const runtimePath = isPathInside(targetPath, pluginRoot) ? targetPath : resolve(sourceRoot, arg); + const packageRoot = resolveExternalMcpPackageRoot(runtimePath, sourceRoot); + if (packageRoot === undefined) return fallback; + const distRoot = join(packageRoot, "dist"); + const distPath = relative(distRoot, runtimePath); + if (distPath.startsWith("..") || isAbsolute(distPath)) return fallback; + const cachedRoot = join(pluginRoot, "mcp", safePathSegment(serverName)); + const cacheKey = `${distRoot}\0${cachedRoot}`; + let copyPromise = copiedDistRoots.get(cacheKey); + if (copyPromise === undefined) { + copyPromise = copyDist(distRoot, join(cachedRoot, "dist"), { recursive: true }); + copiedDistRoots.set(cacheKey, copyPromise); + } + await copyPromise; + return join(cachedRoot, "dist", distPath); +} + +function resolveExternalMcpPackageRoot(runtimePath, sourceRoot) { + const packagesRoot = findPackagesRoot(sourceRoot); + if (packagesRoot === undefined) return undefined; + if (!isPathInside(runtimePath, packagesRoot)) return undefined; + let packageRoot = dirname(runtimePath); + while (packageRoot !== packagesRoot) { + if (existsSync(join(packageRoot, "package.json")) && isPathInside(runtimePath, join(packageRoot, "dist"))) { + return packageRoot; + } + const parent = dirname(packageRoot); + if (parent === packageRoot) return undefined; + packageRoot = parent; + } + return undefined; +} + +function findPackagesRoot(path) { + let current = resolve(path); + for (let index = 0; index < 8; index++) { + if (basename(current) === "packages") return current; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } + return undefined; +} + +function resolveCachedRuntimePath(pluginRoot, sourceRoot, runtimePath) { + const targetPath = resolve(pluginRoot, runtimePath); + if (isPathInside(targetPath, pluginRoot)) return targetPath; + return resolve(sourceRoot, runtimePath); +} + +function isPathInside(candidatePath, rootPath) { + const pathFromRoot = relative(rootPath, candidatePath); + return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot)); +} + +function safePathSegment(value) { + return value.replace(/[^A-Za-z0-9._-]/g, "_"); +} diff --git a/packages/omo-codex/scripts/install/multi-agent-v2-config.mjs b/packages/omo-codex/scripts/install/multi-agent-v2-config.mjs new file mode 100644 index 000000000..168771a11 --- /dev/null +++ b/packages/omo-codex/scripts/install/multi-agent-v2-config.mjs @@ -0,0 +1,32 @@ +import { appendBlock, findTomlSection, removeSetting, replaceOrInsertSetting } from "./toml-editor.mjs"; + +const CODEX_MULTI_AGENT_V2_HEADER = "features.multi_agent_v2"; +const CODEX_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION = 10000; + +export function ensureCodexMultiAgentV2Config(config) { + const normalizedConfig = removeFeatureFlagSetting(config, "multi_agent_v2"); + const section = findTomlSection(normalizedConfig, CODEX_MULTI_AGENT_V2_HEADER); + const maxThreadsValue = CODEX_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION.toString(); + if (!section) { + return appendBlock( + normalizedConfig, + `[${CODEX_MULTI_AGENT_V2_HEADER}]\nenabled = true\nmax_concurrent_threads_per_session = ${maxThreadsValue}\n`, + ); + } + + const enabledConfig = replaceOrInsertSetting(normalizedConfig, section, "enabled", "true"); + const updatedSection = findTomlSection(enabledConfig, CODEX_MULTI_AGENT_V2_HEADER); + if (!updatedSection) { + return appendBlock( + enabledConfig, + `[${CODEX_MULTI_AGENT_V2_HEADER}]\nenabled = true\nmax_concurrent_threads_per_session = ${maxThreadsValue}\n`, + ); + } + return replaceOrInsertSetting(enabledConfig, updatedSection, "max_concurrent_threads_per_session", maxThreadsValue); +} + +function removeFeatureFlagSetting(config, featureName) { + const section = findTomlSection(config, "features"); + if (!section) return config; + return removeSetting(config, section, featureName); +} diff --git a/packages/omo-codex/scripts/install/process.mjs b/packages/omo-codex/scripts/install/process.mjs new file mode 100644 index 000000000..56b05ab43 --- /dev/null +++ b/packages/omo-codex/scripts/install/process.mjs @@ -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}`)); + }); + }); +} diff --git a/packages/omo-codex/scripts/install/snapshot.mjs b/packages/omo-codex/scripts/install/snapshot.mjs new file mode 100644 index 000000000..aff1269e4 --- /dev/null +++ b/packages/omo-codex/scripts/install/snapshot.mjs @@ -0,0 +1,54 @@ +import { cp, mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { join, sep } from "node:path"; + +import { rewriteCachedMcpManifest } from "./cache.mjs"; + +const INSTALLED_MARKETPLACES_DIR = ".tmp/marketplaces"; + +export async function writeInstalledMarketplaceSnapshot({ codexHome, marketplace, plugins }) { + const marketplaceRoot = installedMarketplaceRoot(codexHome, marketplace.name); + await mkdir(marketplaceRoot, { recursive: true }); + await writeMarketplaceManifest(marketplaceRoot, marketplace); + + const snapshotPlugins = []; + for (const plugin of plugins) { + snapshotPlugins.push(await writeSnapshotPlugin(marketplaceRoot, plugin)); + } + return snapshotPlugins; +} + +export function installedMarketplaceRoot(codexHome, marketplaceName) { + return join(codexHome, INSTALLED_MARKETPLACES_DIR, marketplaceName); +} + +async function writeMarketplaceManifest(marketplaceRoot, marketplace) { + const manifestDir = join(marketplaceRoot, ".agents", "plugins"); + await mkdir(manifestDir, { recursive: true }); + const tempPath = join(manifestDir, `.marketplace-${process.pid}-${Date.now()}.json.tmp`); + await writeFile(tempPath, `${JSON.stringify(marketplace, null, "\t")}\n`); + await rename(tempPath, join(manifestDir, "marketplace.json")); +} + +async function writeSnapshotPlugin(marketplaceRoot, plugin) { + const pluginsDir = join(marketplaceRoot, "plugins"); + await mkdir(pluginsDir, { recursive: true }); + const targetPath = join(pluginsDir, plugin.name); + const tempPath = join(pluginsDir, `.tmp-${plugin.name}-${process.pid}-${Date.now()}`); + await rm(tempPath, { recursive: true, force: true }); + await cp(plugin.sourcePath, tempPath, { + recursive: true, + filter: (source) => shouldCopyMarketplaceSourcePath(source, plugin.sourcePath), + }); + await rm(targetPath, { recursive: true, force: true }); + await rename(tempPath, targetPath); + await rewriteCachedMcpManifest(targetPath, plugin.sourcePath); + return { name: plugin.name, path: targetPath }; +} + +function shouldCopyMarketplaceSourcePath(path, root) { + const relative = path === root ? "" : path.slice(root.length + sep.length); + if (relative === "") return true; + const parts = relative.split(sep); + if (parts[parts.length - 1] === "package-lock.json") return false; + return !parts.some((part) => part === ".git" || part === "node_modules"); +} diff --git a/packages/omo-codex/scripts/install/toml-editor.mjs b/packages/omo-codex/scripts/install/toml-editor.mjs new file mode 100644 index 000000000..405cdd4a2 --- /dev/null +++ b/packages/omo-codex/scripts/install/toml-editor.mjs @@ -0,0 +1,47 @@ +export 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) }; +} + +export 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); +} + +export function removeSetting(config, section, key) { + const linePattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*(?:\\n|$)`, "m"); + const replacement = section.text.replace(linePattern, ""); + return config.slice(0, section.start) + replacement + config.slice(section.end); +} + +export function appendBlock(config, block) { + const prefix = config.trimEnd(); + return `${prefix}${prefix.length > 0 ? "\n\n" : ""}${block.trimEnd()}\n`; +} + +function insertSetting(sectionText, key, value) { + const lines = sectionText.split("\n"); + lines.splice(1, 0, `${key} = ${value}`); + return lines.join("\n"); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/omo-codex/scripts/install/utils.mjs b/packages/omo-codex/scripts/install/utils.mjs new file mode 100644 index 000000000..718db2c2a --- /dev/null +++ b/packages/omo-codex/scripts/install/utils.mjs @@ -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); +}