codex-plugin-doctor 1.68.0 → 1.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/run-cli.js CHANGED
@@ -21,11 +21,14 @@ import { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestatio
21
21
  import { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js";
22
22
  import { buildSubmissionArchivePreflight, submissionArchiveExitCode } from "./core/submission-archive-preflight.js";
23
23
  import { buildSubmissionPreflight } from "./core/submission-preflight.js";
24
+ import { isValidBearerToken } from "./core/bounded-http-client.js";
24
25
  import { discoverMcpServer } from "./core/mcp-discovery.js";
25
26
  import { captureMcpToolCatalog, inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
27
+ import { captureStdioMcpToolCatalog, resolveMcpStdioServer } from "./core/mcp-stdio-tool-catalog.js";
26
28
  import { inspectMcpToolFile, isBlockedOfflinePath } from "./core/mcp-tool-file.js";
27
29
  import { saveMcpToolsListResponse } from "./core/mcp-tool-response-file.js";
28
30
  import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
31
+ import { renderMcpToolDiffMarkdown } from "./reporting/render-mcp-tool-diff-markdown.js";
29
32
  import { renderMcpToolDiffReport } from "./reporting/render-mcp-tool-diff-report.js";
30
33
  import { renderMcpToolFileReport } from "./reporting/render-mcp-tool-file-report.js";
31
34
  import { renderMcpToolCatalogReport } from "./reporting/render-mcp-tool-catalog-report.js";
@@ -108,10 +111,123 @@ function writeExactStdout(io, message) {
108
111
  }
109
112
  class CliUsageError extends Error {
110
113
  }
111
- const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json] [--fail-on any|breaking]";
114
+ const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json|--markdown] [--fail-on any|breaking]";
112
115
  const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
113
- const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json] [--save-response <path>]";
114
- const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json]";
116
+ const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json] [--save-response <path>] [--bearer-token-env <NAME>]\n codex-plugin-doctor doctor tools <path> --server <name> --runtime [--sandbox docker] [--require-runtime-approval [--runtime-approval-digest <digest>]] [--json] [--save-response <path>]";
117
+ const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json] [--bearer-token-env <NAME>]";
118
+ const bearerTokenHelp = "--bearer-token-env reads a bearer token from the named environment variable and sends it only over HTTPS or to an approved loopback target; the token is never printed or saved.";
119
+ const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
120
+ function resolveBearerToken(name, env) {
121
+ if (name === undefined)
122
+ return {};
123
+ if (!ENVIRONMENT_VARIABLE_NAME.test(name))
124
+ return { error: "--bearer-token-env requires an environment variable name." };
125
+ const token = env[name];
126
+ if (token === undefined || token === "")
127
+ return { error: `Bearer token environment variable is not set: ${name}` };
128
+ if (!isValidBearerToken(token))
129
+ return { error: `Bearer token environment variable does not hold a valid bearer token: ${name}` };
130
+ return { token };
131
+ }
132
+ async function finishToolCatalogCommand(io, snapshot, json, savePath) {
133
+ const report = snapshot.report;
134
+ io.writeStdout(json
135
+ ? JSON.stringify(report, null, 2)
136
+ : renderMcpToolCatalogReport(report));
137
+ const exitCode = report.status === "pass" ? 0
138
+ : report.status === "incomplete" || report.status === "blocked" ? 2 : 1;
139
+ if (savePath === undefined)
140
+ return exitCode;
141
+ if (snapshot.tools === null || snapshot.cache === null) {
142
+ io.writeStderr("Tool catalog response was not saved because enumeration did not complete.");
143
+ return 2;
144
+ }
145
+ const saved = await saveMcpToolsListResponse(savePath, snapshot.tools, snapshot.cache);
146
+ if (saved.kind !== "saved") {
147
+ io.writeStderr({
148
+ "path-blocked": "Tool catalog response was not saved because the path is not a local file path.",
149
+ "not-regular": "Tool catalog response was not saved because the existing target is not a regular file.",
150
+ "too-large": "Tool catalog response was not saved because it exceeds the one MiB tools-file limit.",
151
+ "write-failed": "Tool catalog response could not be written."
152
+ }[saved.kind]);
153
+ return 2;
154
+ }
155
+ io.writeStderr(`Saved a complete tools/list response with ${saved.tools} tools (${saved.bytes} bytes).`);
156
+ return exitCode;
157
+ }
158
+ async function runStdioToolCatalogCommand(targetPath, flags, io, options) {
159
+ const allowedSwitches = new Set(["--runtime", "--json", "--require-runtime-approval"]);
160
+ const switches = [];
161
+ const values = new Map();
162
+ let invalid = false;
163
+ for (let index = 0; index < flags.length; index += 1) {
164
+ const flag = flags[index];
165
+ if (["--server", "--save-response", "--sandbox", "--runtime-approval-digest"].includes(flag) && !values.has(flag)) {
166
+ const value = flags[index + 1];
167
+ if (!value || value.startsWith("-")) {
168
+ invalid = true;
169
+ break;
170
+ }
171
+ values.set(flag, value);
172
+ index += 1;
173
+ }
174
+ else if (allowedSwitches.has(flag) && !switches.includes(flag)) {
175
+ switches.push(flag);
176
+ }
177
+ else {
178
+ invalid = true;
179
+ break;
180
+ }
181
+ }
182
+ const serverName = values.get("--server");
183
+ const savePath = values.get("--save-response");
184
+ const sandbox = values.get("--sandbox");
185
+ const approvalDigest = values.get("--runtime-approval-digest");
186
+ const requireApproval = switches.includes("--require-runtime-approval");
187
+ if (invalid || serverName === undefined || (sandbox !== undefined && sandbox !== "docker")) {
188
+ io.writeStderr(`Invalid or duplicate tool catalog arguments. ${toolsUsage}`);
189
+ return 2;
190
+ }
191
+ if (!switches.includes("--runtime")) {
192
+ io.writeStderr("doctor tools --server starts a local MCP server and requires explicit --runtime consent.");
193
+ return 2;
194
+ }
195
+ if (approvalDigest !== undefined && !requireApproval) {
196
+ io.writeStderr("--runtime-approval-digest requires --require-runtime-approval.");
197
+ return 2;
198
+ }
199
+ if (isBlockedOfflinePath(targetPath)) {
200
+ io.writeStderr("doctor tools --server requires a local package path.");
201
+ return 2;
202
+ }
203
+ if (savePath !== undefined && isBlockedOfflinePath(savePath)) {
204
+ io.writeStderr("doctor tools --save-response requires a local file path.");
205
+ return 2;
206
+ }
207
+ const resolved = await resolveMcpStdioServer(targetPath, serverName);
208
+ if (!resolved.ok) {
209
+ io.writeStderr(resolved.message);
210
+ return 2;
211
+ }
212
+ if (requireApproval) {
213
+ // Runtime plan digests cover only the manifest mcpServers file, so .mcp.json servers cannot be approved.
214
+ if (resolved.server.source !== "manifest") {
215
+ io.writeStderr("Runtime approval covers servers from the plugin manifest mcpServers file; this server comes from .mcp.json.");
216
+ return 2;
217
+ }
218
+ const runtimePlan = await buildDoctorRuntimePlan(targetPath, new Date().toISOString(), sandbox === "docker" ? { sandbox: "docker" } : {});
219
+ const approval = evaluateRuntimeApproval(runtimePlan, {
220
+ required: true,
221
+ approvedDigest: approvalDigest ?? null
222
+ });
223
+ if (!runtimeApprovalPassed(approval)) {
224
+ io.writeStderr(`${approval.message}\nCurrent runtime plan digest: ${runtimePlan.digest}`);
225
+ return 1;
226
+ }
227
+ }
228
+ const snapshot = await (options.captureStdioMcpToolCatalogImpl ?? captureStdioMcpToolCatalog)(resolved.server, sandbox === "docker" ? { sandbox: "docker" } : {});
229
+ return finishToolCatalogCommand(io, snapshot, switches.includes("--json"), savePath);
230
+ }
115
231
  function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
116
232
  const index = flags.indexOf("--sandbox");
117
233
  if (index === -1) {
@@ -1160,9 +1276,10 @@ export async function runCli(args, io = defaultIo, options = {}) {
1160
1276
  (command === "release" && maybePath === "check") ||
1161
1277
  (command === "doctor" &&
1162
1278
  (["runtime-plan", "runtime-policy"].includes(maybePath ?? "") ||
1163
- (maybePath === "release-evidence" && remainingArgs[0] !== "verify")));
1279
+ (maybePath === "release-evidence" && remainingArgs[0] !== "verify") ||
1280
+ (maybePath === "tools" && remainingArgs.includes("--server"))));
1164
1281
  if (args.includes("--sandbox") && !runtimeSandboxSupported) {
1165
- io.writeStderr("--sandbox is supported only by single-package check, release check, runtime-plan, runtime-policy, and release-evidence.");
1282
+ io.writeStderr("--sandbox is supported only by single-package check, release check, runtime-plan, runtime-policy, release-evidence, and doctor tools --server.");
1166
1283
  return 2;
1167
1284
  }
1168
1285
  if (command === "--version" || command === "-v" || command === "version") {
@@ -1215,13 +1332,13 @@ export async function runCli(args, io = defaultIo, options = {}) {
1215
1332
  }
1216
1333
  let beforePath;
1217
1334
  let afterPath;
1218
- let json = false;
1335
+ let format;
1219
1336
  let failOn;
1220
1337
  let invalid = false;
1221
1338
  for (let index = 0; index < remainingArgs.length; index += 1) {
1222
1339
  const flag = remainingArgs[index];
1223
- if (flag === "--json" && !json) {
1224
- json = true;
1340
+ if ((flag === "--json" || flag === "--markdown") && format === undefined) {
1341
+ format = flag === "--json" ? "json" : "markdown";
1225
1342
  }
1226
1343
  else if (flag === "--fail-on" && failOn === undefined) {
1227
1344
  const value = remainingArgs[index + 1];
@@ -1254,7 +1371,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
1254
1371
  return 2;
1255
1372
  }
1256
1373
  const report = await (options.compareMcpToolFilesImpl ?? compareMcpToolFiles)(beforePath, afterPath);
1257
- io.writeStdout(json ? JSON.stringify(report, null, 2) : renderMcpToolDiffReport(report));
1374
+ io.writeStdout(format === "json" ? JSON.stringify(report, null, 2)
1375
+ : format === "markdown" ? renderMcpToolDiffMarkdown(report)
1376
+ : renderMcpToolDiffReport(report));
1258
1377
  if (report.status !== "pass" && report.status !== "warn")
1259
1378
  return 2;
1260
1379
  if (failOn === "breaking") {
@@ -1281,7 +1400,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
1281
1400
  }
1282
1401
  if (maybePath === "tools") {
1283
1402
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1284
- io.writeStdout(`${toolsUsage}\nHTTP tool catalog structure only; tools are not executed.\n--save-response writes a complete enumeration as one tools/list response for doctor tools-file and tools-diff; nothing is written when enumeration is incomplete.`);
1403
+ io.writeStdout(`${toolsUsage}\nTool catalog structure only; tools are not executed.\n--server starts the named stdio server from the package MCP configuration after explicit --runtime consent and stops it after reading the tool list.\n--save-response writes a complete enumeration as one tools/list response for doctor tools-file and tools-diff; nothing is written when enumeration is incomplete.\n${bearerTokenHelp}`);
1285
1404
  return 0;
1286
1405
  }
1287
1406
  const [url, ...flags] = remainingArgs;
@@ -1289,19 +1408,26 @@ export async function runCli(args, io = defaultIo, options = {}) {
1289
1408
  io.writeStderr(toolsUsage);
1290
1409
  return 2;
1291
1410
  }
1411
+ if (flags.includes("--server")) {
1412
+ return runStdioToolCatalogCommand(url, flags, io, options);
1413
+ }
1292
1414
  const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
1293
1415
  const switches = [];
1294
1416
  let savePath;
1417
+ let tokenEnv;
1295
1418
  let invalid = false;
1296
1419
  for (let index = 0; index < flags.length; index += 1) {
1297
1420
  const flag = flags[index];
1298
- if (flag === "--save-response" && savePath === undefined) {
1421
+ if ((flag === "--save-response" && savePath === undefined) || (flag === "--bearer-token-env" && tokenEnv === undefined)) {
1299
1422
  const value = flags[index + 1];
1300
1423
  if (!value || value.startsWith("-")) {
1301
1424
  invalid = true;
1302
1425
  break;
1303
1426
  }
1304
- savePath = value;
1427
+ if (flag === "--save-response")
1428
+ savePath = value;
1429
+ else
1430
+ tokenEnv = value;
1305
1431
  index += 1;
1306
1432
  }
1307
1433
  else if (allowedFlags.has(flag) && !switches.includes(flag)) {
@@ -1324,38 +1450,24 @@ export async function runCli(args, io = defaultIo, options = {}) {
1324
1450
  io.writeStderr("doctor tools --save-response requires a local file path.");
1325
1451
  return 2;
1326
1452
  }
1327
- const catalogOptions = { allowNetwork: true, allowLocalNetwork: switches.includes("--allow-local-network") };
1453
+ const bearer = resolveBearerToken(tokenEnv, terminalContext.env);
1454
+ if ("error" in bearer) {
1455
+ io.writeStderr(bearer.error);
1456
+ return 2;
1457
+ }
1458
+ const catalogOptions = {
1459
+ allowNetwork: true,
1460
+ allowLocalNetwork: switches.includes("--allow-local-network"),
1461
+ ...(bearer.token === undefined ? {} : { bearerToken: bearer.token })
1462
+ };
1328
1463
  const snapshot = savePath === undefined
1329
1464
  ? { report: await (options.inspectMcpToolCatalogImpl ?? inspectMcpToolCatalog)(url, catalogOptions), tools: null, cache: null }
1330
1465
  : await (options.captureMcpToolCatalogImpl ?? captureMcpToolCatalog)(url, catalogOptions);
1331
- const report = snapshot.report;
1332
- io.writeStdout(switches.includes("--json")
1333
- ? JSON.stringify(report, null, 2)
1334
- : renderMcpToolCatalogReport(report));
1335
- const exitCode = report.status === "pass" ? 0
1336
- : report.status === "incomplete" || report.status === "blocked" ? 2 : 1;
1337
- if (savePath === undefined)
1338
- return exitCode;
1339
- if (snapshot.tools === null || snapshot.cache === null) {
1340
- io.writeStderr("Tool catalog response was not saved because enumeration did not complete.");
1341
- return 2;
1342
- }
1343
- const saved = await saveMcpToolsListResponse(savePath, snapshot.tools, snapshot.cache);
1344
- if (saved.kind !== "saved") {
1345
- io.writeStderr({
1346
- "path-blocked": "Tool catalog response was not saved because the path is not a local file path.",
1347
- "not-regular": "Tool catalog response was not saved because the existing target is not a regular file.",
1348
- "too-large": "Tool catalog response was not saved because it exceeds the one MiB tools-file limit.",
1349
- "write-failed": "Tool catalog response could not be written."
1350
- }[saved.kind]);
1351
- return 2;
1352
- }
1353
- io.writeStderr(`Saved a complete tools/list response with ${saved.tools} tools (${saved.bytes} bytes).`);
1354
- return exitCode;
1466
+ return finishToolCatalogCommand(io, snapshot, switches.includes("--json"), savePath);
1355
1467
  }
1356
1468
  if (maybePath === "discover") {
1357
1469
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1358
- io.writeStdout(`${discoveryUsage}\nHTTP discovery only; runtime behavior is not tested.`);
1470
+ io.writeStdout(`${discoveryUsage}\nHTTP discovery only; runtime behavior is not tested.\n${bearerTokenHelp}`);
1359
1471
  return 0;
1360
1472
  }
1361
1473
  const [url, ...flags] = remainingArgs;
@@ -1364,19 +1476,47 @@ export async function runCli(args, io = defaultIo, options = {}) {
1364
1476
  return 2;
1365
1477
  }
1366
1478
  const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
1367
- if (flags.some((flag) => !allowedFlags.has(flag)) || new Set(flags).size !== flags.length) {
1479
+ const switches = [];
1480
+ let tokenEnv;
1481
+ let invalid = false;
1482
+ for (let index = 0; index < flags.length; index += 1) {
1483
+ const flag = flags[index];
1484
+ if (flag === "--bearer-token-env" && tokenEnv === undefined) {
1485
+ const value = flags[index + 1];
1486
+ if (!value || value.startsWith("-")) {
1487
+ invalid = true;
1488
+ break;
1489
+ }
1490
+ tokenEnv = value;
1491
+ index += 1;
1492
+ }
1493
+ else if (allowedFlags.has(flag) && !switches.includes(flag)) {
1494
+ switches.push(flag);
1495
+ }
1496
+ else {
1497
+ invalid = true;
1498
+ break;
1499
+ }
1500
+ }
1501
+ if (invalid) {
1368
1502
  io.writeStderr(`Invalid or duplicate discovery arguments. ${discoveryUsage}`);
1369
1503
  return 2;
1370
1504
  }
1371
- if (!flags.includes("--allow-network")) {
1505
+ if (!switches.includes("--allow-network")) {
1372
1506
  io.writeStderr("doctor discover requires explicit --allow-network consent.");
1373
1507
  return 2;
1374
1508
  }
1509
+ const bearer = resolveBearerToken(tokenEnv, terminalContext.env);
1510
+ if ("error" in bearer) {
1511
+ io.writeStderr(bearer.error);
1512
+ return 2;
1513
+ }
1375
1514
  const report = await (options.discoverMcpServerImpl ?? discoverMcpServer)(url, {
1376
1515
  allowNetwork: true,
1377
- allowLocalNetwork: flags.includes("--allow-local-network")
1516
+ allowLocalNetwork: switches.includes("--allow-local-network"),
1517
+ ...(bearer.token === undefined ? {} : { bearerToken: bearer.token })
1378
1518
  });
1379
- io.writeStdout(flags.includes("--json")
1519
+ io.writeStdout(switches.includes("--json")
1380
1520
  ? JSON.stringify(report, null, 2)
1381
1521
  : renderMcpDiscoveryReport(report));
1382
1522
  return report.status === "discovered" ? 0 : report.status === "unsupported" ? 1 : 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.68.0",
3
+ "version": "1.70.0",
4
4
  "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",