mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 02:32:54 +00:00
feat(plugins): make Claude Code and Codex installs self-contained (#7514)
Ship Claude Code and Codex from one release-pinned npm artifact with bundled runtime dependencies, extraction-only MCP qualification, plugin-aware recovery, and synchronized release metadata. Integrates #2815 and the original proposal #1977. The original commits remain available on the PR branch; retain their author and assistant credit in this squash. Validation: required CI passed, real Codex/Claude loading verified, and reviewed packaging/recovery findings addressed. Co-authored-by: GamerDubz <themastergamerplays24.7@gmail.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: OpenAI Codex <openai-codex@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
29 files changed
+1591
-132
No files matched your search
@@ -0,0 +1,35 @@
|
||||
interface PackageManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
devDependencies?: Record<string, string>;
|
||||
dependencies?: Record<string, string>;
|
||||
overrides?: Record<string, unknown>;
|
||||
// npm manifests are extensible; preserve metadata outside this projection.
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface LockedPackage {
|
||||
version?: string;
|
||||
dev?: boolean;
|
||||
// Keep unmodified integrity, resolution, platform, and other lock-entry facts.
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface PackageLock {
|
||||
name: string;
|
||||
version: string;
|
||||
lockfileVersion: number;
|
||||
packages: Record<string, LockedPackage>;
|
||||
// Preserve other npm lock metadata rather than dropping unfamiliar fields.
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function pluginPackageMetadata<Manifest extends PackageManifest>(
|
||||
manifest: Manifest,
|
||||
lock: PackageLock,
|
||||
): {
|
||||
packageJson: Omit<Manifest, "devDependencies" | "overrides"> & {
|
||||
bundleDependencies: string[];
|
||||
};
|
||||
shrinkwrap: PackageLock;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Project the development manifest and lock into the shared runtime artifact. */
|
||||
export function pluginPackageMetadata(manifest, lock) {
|
||||
if (
|
||||
lock.lockfileVersion !== 3 ||
|
||||
lock.name !== manifest.name ||
|
||||
lock.version !== manifest.version ||
|
||||
lock.packages?.[""]?.version !== manifest.version
|
||||
)
|
||||
throw new Error("Plugin packaging requires the matching v3 package lock");
|
||||
|
||||
// Claude skips dependency installation when a published manifest has
|
||||
// overrides. Preserve development security overrides in the source owner,
|
||||
// and reject any future runtime override rather than silently discarding it.
|
||||
for (const name of Object.keys(manifest.overrides ?? {})) {
|
||||
const matches = Object.entries(lock.packages).filter(([path]) =>
|
||||
path.endsWith(`node_modules/${name}`),
|
||||
);
|
||||
if (matches.length === 0 || matches.some(([, entry]) => entry.dev !== true))
|
||||
throw new Error(`Cannot omit a runtime or unresolved override: ${name}`);
|
||||
}
|
||||
|
||||
const packageJson = { ...manifest };
|
||||
delete packageJson.devDependencies;
|
||||
delete packageJson.overrides;
|
||||
// Codex's npm source fetches the archive without installing dependencies.
|
||||
// Keep optional Frida outside the bundle so ordinary npm installs can still
|
||||
// run its host-specific installer. PTY binaries are transitive optionals of
|
||||
// a runtime dependency and are included for every supported platform by the
|
||||
// archive producer.
|
||||
packageJson.bundleDependencies = Object.keys(manifest.dependencies ?? {});
|
||||
const root = { ...lock.packages[""] };
|
||||
delete root.devDependencies;
|
||||
const shrinkwrap = {
|
||||
...lock,
|
||||
packages: Object.fromEntries(
|
||||
Object.entries(lock.packages).filter(([, entry]) => entry.dev !== true),
|
||||
),
|
||||
};
|
||||
shrinkwrap.packages[""] = root;
|
||||
return { packageJson, shrinkwrap };
|
||||
}
|
||||
@@ -4,6 +4,37 @@ import { z } from "zod";
|
||||
const shaSchema = z.string().regex(/^[a-f0-9]{40}$/u);
|
||||
const versionSchema = z.object({ version: z.string() });
|
||||
const manifestSchema = z.object({ ".": z.string() });
|
||||
const PLUGIN_MANIFEST_PATHS = [
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
];
|
||||
const inlinePluginManifestSchema = versionSchema.extend({
|
||||
mcpServers: z.object({
|
||||
rea: z.object({
|
||||
command: z.literal("node"),
|
||||
args: z.tuple([
|
||||
z.literal("${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs"),
|
||||
z.string(),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const codexPluginManifestSchema = versionSchema.extend({
|
||||
mcpServers: z.literal("./.codex-plugin/mcp.json"),
|
||||
});
|
||||
const codexServerConfigSchema = z.object({
|
||||
mcpServers: z.object({
|
||||
rea: z.object({
|
||||
command: z.literal("node"),
|
||||
args: z.tuple([
|
||||
z.literal("./scripts/plugin-mcp-launcher.mjs"),
|
||||
z.string(),
|
||||
]),
|
||||
cwd: z.literal("."),
|
||||
startup_timeout_sec: z.number().int().positive(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const sectionsSchema = z.object({
|
||||
versioning: z.string().optional(),
|
||||
"changelog-sections": z.array(
|
||||
@@ -189,6 +220,26 @@ async function validateCandidate(git, sha, version, commits) {
|
||||
["server.json", server.version],
|
||||
["server.json rea-agents package", npmPackage.version],
|
||||
);
|
||||
// A stale manifest keeps the previous plugin cache key or launches the
|
||||
// previous server, so both are version authority like server.json.
|
||||
const launches = [];
|
||||
for (const path of PLUGIN_MANIFEST_PATHS) {
|
||||
const manifest =
|
||||
path === ".codex-plugin/plugin.json"
|
||||
? await readJson(git, sha, path, codexPluginManifestSchema)
|
||||
: await readJson(git, sha, path, inlinePluginManifestSchema);
|
||||
versions.push([path, manifest.version]);
|
||||
const server =
|
||||
path === ".codex-plugin/plugin.json"
|
||||
? await readJson(
|
||||
git,
|
||||
sha,
|
||||
".codex-plugin/mcp.json",
|
||||
codexServerConfigSchema,
|
||||
)
|
||||
: manifest;
|
||||
launches.push([path, server.mcpServers.rea.args[1]]);
|
||||
}
|
||||
// Catalogs are generated and checked from this candidate by the release job;
|
||||
// they are not version authority stored in the immutable source tree.
|
||||
const generated = await git([
|
||||
@@ -210,6 +261,14 @@ async function validateCandidate(git, sha, version, commits) {
|
||||
);
|
||||
}
|
||||
}
|
||||
// The launcher checks the scalar pin against the runtime in its own package.
|
||||
for (const [location, launched] of launches) {
|
||||
if (launched !== version) {
|
||||
throw new Error(
|
||||
`${location}@${sha} launches version ${launched ?? "no version"}; release branch requires ${version}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const notes = releaseNotes(
|
||||
await git(["show", `${sha}:CHANGELOG.md`]),
|
||||
version,
|
||||
|
||||
@@ -369,6 +369,60 @@ export function verifyReleaseNotes(raw, text, body) {
|
||||
};
|
||||
}
|
||||
|
||||
const VERSION_ONLY_PLUGIN_METADATA = new Set([
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
".codex-plugin/mcp.json",
|
||||
".claude-plugin/marketplace.json",
|
||||
".agents/plugins/marketplace.json",
|
||||
]);
|
||||
|
||||
/** Serialize plugin metadata with only its release-owned version fields blanked. */
|
||||
function pluginMetadataWithoutVersions(path, text) {
|
||||
const manifest = JSON.parse(text);
|
||||
if (manifest === null || typeof manifest !== "object") return text;
|
||||
if (path.endsWith("marketplace.json")) {
|
||||
if (Array.isArray(manifest.plugins))
|
||||
for (const plugin of manifest.plugins)
|
||||
if (
|
||||
plugin?.name === "rea" &&
|
||||
plugin?.source?.source === "npm" &&
|
||||
plugin.source.package === "rea-agents" &&
|
||||
typeof plugin.source.version === "string"
|
||||
)
|
||||
plugin.source.version = "";
|
||||
return JSON.stringify(manifest);
|
||||
}
|
||||
if ("version" in manifest) manifest.version = "";
|
||||
const launch = manifest.mcpServers?.rea?.args;
|
||||
// Only the scalar pinned version is release-owned; the launcher owns the
|
||||
// fixed package name and the executable/path remain product behavior.
|
||||
if (Array.isArray(launch) && typeof launch[1] === "string") launch[1] = "";
|
||||
return JSON.stringify(manifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin metadata also carries behavior, so a release may change only
|
||||
* its version, pinned server launch, and exact REA marketplace version. Any other difference, an unreadable
|
||||
* side, or a manifest added by the release counts as a product change.
|
||||
*/
|
||||
async function changesOnlyPluginVersions(run, path, from, to) {
|
||||
try {
|
||||
return (
|
||||
pluginMetadataWithoutVersions(
|
||||
path,
|
||||
await run("git", ["show", `${from}:${path}`]),
|
||||
) ===
|
||||
pluginMetadataWithoutVersions(
|
||||
path,
|
||||
await run("git", ["show", `${to}:${path}`]),
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject stale inventories and product changes made after the editorial target. */
|
||||
export async function verifyInventoryHistory(run, raw, source) {
|
||||
const inventory = releaseInventorySchema.parse(raw);
|
||||
@@ -437,7 +491,16 @@ export async function verifyInventoryHistory(run, raw, source) {
|
||||
)
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const product = paths.filter((path) => !allowed.has(path));
|
||||
const product = [];
|
||||
for (const path of paths) {
|
||||
if (allowed.has(path)) continue;
|
||||
if (
|
||||
VERSION_ONLY_PLUGIN_METADATA.has(path) &&
|
||||
(await changesOnlyPluginVersions(run, path, inventory.target, resolved))
|
||||
)
|
||||
continue;
|
||||
product.push(path);
|
||||
}
|
||||
if (product.length)
|
||||
throw new Error(
|
||||
`Product changed after notes target; regenerate inventory: ${product.join(", ")}`,
|
||||
|
||||
Reference in new issue
Block a user