Skip to content

Commit b9414e5

Browse files
xuiocodex
andcommitted
Add release and dev-link safety checks
Use package metadata for MCP/app-server versions and verify committed dist in CI. Harden the Claude development symlink installer with dry-run and safer replacement behavior. Co-Authored-By: OpenAI Codex <noreply@openai.com>
1 parent 8219dc5 commit b9414e5

9 files changed

Lines changed: 96 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,6 @@ jobs:
3434

3535
- name: Test
3636
run: npm run test:ci
37+
38+
- name: Verify committed dist
39+
run: git diff --exit-code -- dist/index.js

dist/index.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23621,6 +23621,14 @@ function isParallelResult(value) {
2362123621
// src/app-server.ts
2362223622
import { spawn as spawn2 } from "node:child_process";
2362323623
import { stat as stat2 } from "node:fs/promises";
23624+
23625+
// src/version.ts
23626+
import { createRequire } from "node:module";
23627+
var require2 = createRequire(import.meta.url);
23628+
var packageJson = require2("../package.json");
23629+
var packageVersion = packageJson.version ?? "0.0.0";
23630+
23631+
// src/app-server.ts
2362423632
var maxPendingJsonLineChars2 = 1e6;
2362523633
var AppServerUnavailableError = class extends Error {
2362623634
constructor(message) {
@@ -23884,7 +23892,7 @@ var CodexAppServerSession = class _CodexAppServerSession {
2388423892
}
2388523893
async initialize(timeoutMs) {
2388623894
const initialized = await this.request("initialize", {
23887-
clientInfo: { name: "claude-code-codex-subagents", version: "0.1.1" },
23895+
clientInfo: { name: "claude-code-codex-subagents", version: packageVersion },
2388823896
capabilities: null
2388923897
}, timeoutMs);
2389023898
this.capabilities.initialize = true;
@@ -25521,7 +25529,7 @@ var usageGuide = [
2552125529
var server = new McpServer(
2552225530
{
2552325531
name: "codex-subagents",
25524-
version: "0.1.0"
25532+
version: packageVersion
2552525533
},
2552625534
{
2552725535
instructions: usageGuide

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
},
1919
"scripts": {
2020
"build": "tsc --noEmit && esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/index.js --banner:js='#!/usr/bin/env node' && chmod 755 dist/index.js",
21+
"check:dist": "npm run build && git diff --exit-code -- dist/index.js",
2122
"test": "vitest run",
2223
"test:watch": "vitest",
2324
"dev:link": "node scripts/link-claude-dev-plugin.mjs",

scripts/link-claude-dev-plugin.mjs

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { lstat, mkdir, readFile, realpath, rename, symlink, writeFile } from "node:fs/promises";
1+
import { lstat, mkdir, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { fileURLToPath } from "node:url";
55

66
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7-
const claudeHome = process.env.CLAUDE_HOME ?? path.join(os.homedir(), ".claude");
7+
const dryRun = process.argv.includes("--dry-run") || process.env.CLAUDE_DEV_LINK_DRY_RUN === "1";
8+
const claudeHome = path.resolve(process.env.CLAUDE_HOME ?? path.join(os.homedir(), ".claude"));
89
const manifestPath = path.join(root, ".claude-plugin", "plugin.json");
910
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
1011
const marketplace = "codex-subagents-local";
@@ -21,6 +22,13 @@ const marketplacePluginPath = path.join(
2122
);
2223
const installPath = path.join(claudeHome, "plugins", "cache", marketplace, pluginName, version);
2324

25+
function assertSafePath(targetPath) {
26+
const resolved = path.resolve(targetPath);
27+
if (resolved === claudeHome || !resolved.startsWith(`${claudeHome}${path.sep}`)) {
28+
throw new Error(`Refusing to modify path outside CLAUDE_HOME: ${targetPath}`);
29+
}
30+
}
31+
2432
async function pathExists(targetPath) {
2533
try {
2634
await lstat(targetPath);
@@ -45,22 +53,36 @@ function backupPath(targetPath) {
4553
}
4654

4755
async function replaceWithSymlink(linkPath, targetPath) {
48-
await mkdir(path.dirname(linkPath), { recursive: true });
56+
assertSafePath(linkPath);
4957
const currentTarget = await resolvedPath(linkPath);
5058
if (currentTarget === targetPath) return { path: linkPath, changed: false };
5159

60+
if (dryRun) {
61+
return { path: linkPath, changed: true, dryRun: true };
62+
}
63+
64+
await mkdir(path.dirname(linkPath), { recursive: true });
65+
const tempLink = `${linkPath}.tmp-${process.pid}-${Date.now()}`;
66+
let backup = null;
5267
if (await pathExists(linkPath)) {
53-
const backup = backupPath(linkPath);
68+
backup = backupPath(linkPath);
5469
await rename(linkPath, backup);
5570
console.log(`Moved existing ${linkPath} to ${backup}`);
5671
}
5772

58-
await symlink(targetPath, linkPath, "dir");
59-
return { path: linkPath, changed: true };
73+
try {
74+
await symlink(targetPath, tempLink, "dir");
75+
await rename(tempLink, linkPath);
76+
return { path: linkPath, changed: true };
77+
} catch (error) {
78+
await rm(tempLink, { recursive: true, force: true }).catch(() => {});
79+
if (backup) await rename(backup, linkPath).catch(() => {});
80+
throw error;
81+
}
6082
}
6183

6284
async function ensureInstalledPluginsEntry() {
63-
await mkdir(path.dirname(installedPluginsPath), { recursive: true });
85+
assertSafePath(installedPluginsPath);
6486
let data = { version: 2, plugins: {} };
6587
if (await pathExists(installedPluginsPath)) {
6688
data = JSON.parse(await readFile(installedPluginsPath, "utf8"));
@@ -83,7 +105,10 @@ async function ensureInstalledPluginsEntry() {
83105
};
84106

85107
data.plugins[key] = [entry, ...entries.filter((candidate) => candidate !== existing)];
86-
await writeFile(installedPluginsPath, `${JSON.stringify(data, null, 2)}\n`);
108+
if (!dryRun) {
109+
await mkdir(path.dirname(installedPluginsPath), { recursive: true });
110+
await writeFile(installedPluginsPath, `${JSON.stringify(data, null, 2)}\n`);
111+
}
87112
return entry;
88113
}
89114

@@ -94,4 +119,5 @@ const entry = await ensureInstalledPluginsEntry();
94119
console.log(`Marketplace plugin path: ${marketplaceLink.path} -> ${root}`);
95120
console.log(`Installed plugin path: ${cacheLink.path} -> ${root}`);
96121
console.log(`Installed plugin entry: ${entry.installPath}`);
122+
if (dryRun) console.log("Dry run only; no Claude plugin files were modified.");
97123
console.log("Claude Code CLI and Claude Desktop CLI share this ~/.claude plugin install.");

src/app-server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "./subagents.js";
2424
import { OutputArtifactWriter } from "./artifacts.js";
2525
import { recordDiagnosticEvent } from "./diagnostics.js";
26+
import { packageVersion } from "./version.js";
2627

2728
type JsonObject = Record<string, unknown>;
2829

@@ -363,7 +364,7 @@ export class CodexAppServerSession {
363364

364365
private async initialize(timeoutMs: number): Promise<void> {
365366
const initialized = await this.request("initialize", {
366-
clientInfo: { name: "claude-code-codex-subagents", version: "0.1.1" },
367+
clientInfo: { name: "claude-code-codex-subagents", version: packageVersion },
367368
capabilities: null,
368369
}, timeoutMs) as JsonObject | undefined;
369370
this.capabilities.initialize = true;

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
} from "./response.js";
3838
import { sessionManager } from "./sessions.js";
3939
import { modelPresets } from "./subagents.js";
40+
import { packageVersion } from "./version.js";
4041

4142
const usageGuide = [
4243
"Claude Code integration guide for codex-subagents:",
@@ -88,7 +89,7 @@ const usageGuide = [
8889
const server = new McpServer(
8990
{
9091
name: "codex-subagents",
91-
version: "0.1.0",
92+
version: packageVersion,
9293
},
9394
{
9495
instructions: usageGuide,

src/version.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { createRequire } from "node:module";
2+
3+
const require = createRequire(import.meta.url);
4+
const packageJson = require("../package.json") as { version?: string };
5+
6+
export const packageVersion = packageJson.version ?? "0.0.0";

test/dev-link.mjs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { spawnSync } from "node:child_process";
2-
import { mkdtemp, readFile, realpath, rm } from "node:fs/promises";
2+
import { lstat, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
55

@@ -21,7 +21,31 @@ const marketplacePath = path.join(
2121
"plugins/marketplaces/codex-subagents-local/plugins/codex-subagents",
2222
);
2323

24+
async function pathExists(targetPath) {
25+
try {
26+
await lstat(targetPath);
27+
return true;
28+
} catch (error) {
29+
if (error?.code === "ENOENT") return false;
30+
throw error;
31+
}
32+
}
33+
2434
try {
35+
const dryRun = spawnSync("node", ["scripts/link-claude-dev-plugin.mjs", "--dry-run"], {
36+
cwd: root,
37+
encoding: "utf8",
38+
shell: false,
39+
env: {
40+
...process.env,
41+
CLAUDE_HOME: claudeHome,
42+
},
43+
});
44+
const dryRunOutput = [dryRun.stdout, dryRun.stderr].filter(Boolean).join("");
45+
assert(dryRun.status === 0, "dev link dry run should succeed", dryRunOutput);
46+
assert(dryRunOutput.includes("Dry run only"), "dry run should be explicit", dryRunOutput);
47+
assert(!(await pathExists(path.join(claudeHome, "plugins"))), "dry run should not create plugin directories", dryRunOutput);
48+
2549
for (const pass of [1, 2]) {
2650
const result = spawnSync("node", ["scripts/link-claude-dev-plugin.mjs"], {
2751
cwd: root,

test/version.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { readFile } from "node:fs/promises";
2+
import { describe, expect, it } from "vitest";
3+
import { packageVersion } from "../src/version.js";
4+
5+
describe("version metadata", () => {
6+
it("keeps package, plugin, and MCP server version source aligned", async () => {
7+
const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { version: string };
8+
const pluginJson = JSON.parse(await readFile(".claude-plugin/plugin.json", "utf8")) as { version: string };
9+
10+
expect(packageVersion).toBe(packageJson.version);
11+
expect(pluginJson.version).toBe(packageJson.version);
12+
});
13+
});

0 commit comments

Comments
 (0)