codex-plugin-doctor 1.71.0 → 1.72.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/README.md CHANGED
@@ -148,7 +148,8 @@ approved loopback endpoint; it is never printed or saved. The GitHub Action can
148
148
  live list with `tools-capture-url` (and `tools-capture-token-env`) or, for a local
149
149
  stdio server with `runtime: "true"`, `tools-capture-server`, and skip the
150
150
  plugin package check with `check: "false"` in repositories that only ship an MCP
151
- server.
151
+ server. With `tools-diff-comment: "true"` it also posts the comparison as one
152
+ pull request comment that later runs update.
152
153
 
153
154
  Local stdio servers from a package's MCP configuration can be cataloged too.
154
155
  `--runtime` is required because this starts package code; the server is stopped
@@ -161,7 +162,9 @@ codex-plugin-doctor doctor tools ./my-plugin --server my-server --runtime --save
161
162
  Doctor tries `server/discover` (`2026-07-28`) first and falls back to the
162
163
  `initialize` handshake for older stdio servers, so the saved list works with
163
164
  `tools-file` and `tools-diff` either way. `--sandbox docker` and
164
- `--require-runtime-approval` work as they do for `check --runtime`.
165
+ `--require-runtime-approval` work as they do for `check --runtime`; add
166
+ `--env <NAME>` to pass a host variable such as an API key into the Docker
167
+ container by name.
165
168
  See [MCP Tool Catalog](docs/guides/mcp-tool-catalog.md).
166
169
 
167
170
  ### Remote MCP Readiness
@@ -582,9 +585,9 @@ jobs:
582
585
  runs-on: ubuntu-latest
583
586
  steps:
584
587
  - uses: actions/checkout@v5
585
- - uses: Esquetta/CodexPluginDoctor@v1.71.0
588
+ - uses: Esquetta/CodexPluginDoctor@v1.72.0
586
589
  with:
587
- version: "1.71.0"
590
+ version: "1.72.0"
588
591
  path: .
589
592
  runtime: "true"
590
593
  policy: codex-publish
@@ -17,6 +17,7 @@ export type McpStdioServerResolution = {
17
17
  };
18
18
  export interface McpStdioToolCatalogOptions {
19
19
  sandbox?: RuntimeSandboxMode;
20
+ environment?: readonly string[];
20
21
  now?: () => number;
21
22
  spawnImpl?: typeof spawn;
22
23
  removeContainerImpl?: (containerName: string) => Promise<boolean>;
@@ -390,7 +390,8 @@ export async function captureStdioMcpToolCatalog(server, options = {}) {
390
390
  command: server.command,
391
391
  args: server.args,
392
392
  cwd: server.cwd,
393
- containerName: `codex-doctor-${randomUUID()}`
393
+ containerName: `codex-doctor-${randomUUID()}`,
394
+ environment: options.environment
394
395
  })
395
396
  : {
396
397
  command: server.command,
@@ -29,14 +29,31 @@ const DOCUMENTATION_KEYWORDS = new Set(["title", "description", "examples", "def
29
29
  const ROOT_STRUCTURAL_KEYWORDS = new Set(["type", "properties", "required", "additionalProperties", "$schema"]);
30
30
  const LOWER_BOUND_KEYWORDS = ["minimum", "minLength", "minItems"];
31
31
  const UPPER_BOUND_KEYWORDS = ["maximum", "maxLength", "maxItems"];
32
+ // String keywords that can only be introduced or removed with a known effect; a changed value is unclassified.
33
+ const STRING_CONSTRAINT_KEYWORDS = ["pattern", "format"];
32
34
  const NESTED_STRUCTURAL_KEYWORDS = new Set([
33
- "type", "enum", "properties", "required", "additionalProperties", "items", ...LOWER_BOUND_KEYWORDS, ...UPPER_BOUND_KEYWORDS
35
+ "type", "enum", "const", "properties", "required", "additionalProperties", "items",
36
+ ...LOWER_BOUND_KEYWORDS, ...UPPER_BOUND_KEYWORDS, ...STRING_CONSTRAINT_KEYWORDS
34
37
  ]);
38
+ // When every reference is a same-document fragment, definition containers are compared only
39
+ // where a resolved `$ref` reaches into them; otherwise they are compared as written.
40
+ const DEFINITION_CONTAINERS = ["$defs", "definitions"];
41
+ const ROOT_STRUCTURAL_WITH_DEFINITIONS = new Set([...ROOT_STRUCTURAL_KEYWORDS, ...DEFINITION_CONTAINERS]);
42
+ const NESTED_STRUCTURAL_WITH_DEFINITIONS = new Set([...NESTED_STRUCTURAL_KEYWORDS, ...DEFINITION_CONTAINERS]);
43
+ // Keywords whose values are data rather than subschemas, so reference-like keys inside them are ignored.
44
+ // `example` is the OpenAPI spelling that generated MCP schemas often carry.
45
+ const DATA_KEYWORDS = new Set(["enum", "const", "examples", "example", "default"]);
46
+ // Keywords whose values map names to subschemas; their keys are names, never keywords.
47
+ const NAME_MAP_KEYWORDS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"]);
35
48
  // Subschemas nested deeper than this below the root are not compared and stay unclassified.
36
49
  const MAX_NESTED_DEPTH = 8;
37
50
  // Keywords that only hold or name subschemas and never validate the object's own keys, so a
38
51
  // new property beside them stays compatible. Any other unclassified keyword may govern keys.
39
52
  const DEFINITION_KEYWORDS = new Set(["$defs", "definitions", "$id", "$anchor", "$dynamicAnchor"]);
53
+ // A `$ref` chain longer than this, such as an alias cycle, is not resolved.
54
+ const MAX_REFERENCE_HOPS = 8;
55
+ // Schema comparisons allowed per tool field; a schema that needs more is unclassified.
56
+ const MAX_COMPARISONS = 10_000;
40
57
  const CODES = {
41
58
  input: {
42
59
  unclassified: "input-schema-unclassified",
@@ -91,6 +108,14 @@ function typesCovered(inner, outer) {
91
108
  }
92
109
  return true;
93
110
  }
111
+ /** The values `enum` and `const` together admit; `const` behaves as a single-value `enum`. */
112
+ function allowedValues(schema, equal) {
113
+ if (!Object.hasOwn(schema, "const"))
114
+ return schema.enum;
115
+ if (schema.enum === undefined)
116
+ return [schema.const];
117
+ return Array.isArray(schema.enum) ? schema.enum.filter((value) => equal(value, schema.const)) : schema.enum;
118
+ }
94
119
  function enumCovered(inner, outer, equal) {
95
120
  if (outer === undefined)
96
121
  return true;
@@ -143,19 +168,267 @@ function compareBounds(inner, outer, codes, reasons) {
143
168
  }
144
169
  }
145
170
  }
171
+ /** Flags a `pattern` or `format` that `outer` adds and `inner` lacks; a changed value cannot be compared. */
172
+ function compareStringConstraints(inner, outer, codes, reasons) {
173
+ for (const keyword of STRING_CONSTRAINT_KEYWORDS) {
174
+ const outerValue = outer[keyword];
175
+ const innerValue = inner[keyword];
176
+ if ((outerValue !== undefined && typeof outerValue !== "string") || (innerValue !== undefined && typeof innerValue !== "string")) {
177
+ reasons.add(codes.unclassified);
178
+ }
179
+ else if (outerValue !== undefined && innerValue !== outerValue) {
180
+ reasons.add(innerValue === undefined ? codes.constraint : codes.unclassified);
181
+ }
182
+ }
183
+ }
184
+ /** Resolves an RFC 6901 JSON Pointer fragment such as `#/$defs/Item` against a root schema. */
185
+ function pointerTarget(document, pointer) {
186
+ if (pointer === "#")
187
+ return document;
188
+ if (!pointer.startsWith("#/"))
189
+ return undefined;
190
+ // RFC 6901 section 6: the fragment is percent-decoded as a whole before it is split into tokens.
191
+ let decoded;
192
+ try {
193
+ decoded = decodeURIComponent(pointer.slice(2));
194
+ }
195
+ catch {
196
+ return undefined;
197
+ }
198
+ let current = document;
199
+ for (const escaped of decoded.split("/")) {
200
+ const token = escaped.replace(/~1/gu, "/").replace(/~0/gu, "~");
201
+ if (Array.isArray(current) && /^(0|[1-9]\d*)$/u.test(token))
202
+ current = current[Number(token)];
203
+ else if (isJsonObject(current) && Object.hasOwn(current, token))
204
+ current = current[token];
205
+ else
206
+ return undefined;
207
+ }
208
+ return current;
209
+ }
210
+ /**
211
+ * Visits every schema position below `value`. It walks with an explicit stack, because tool files may
212
+ * nest JSON far deeper than the call stack allows, and skips data values but not names in name maps.
213
+ */
214
+ function collect(value, visit) {
215
+ const pending = [{ value, names: false }];
216
+ while (pending.length > 0) {
217
+ const current = pending.pop();
218
+ if (Array.isArray(current.value)) {
219
+ for (const entry of current.value)
220
+ pending.push({ value: entry, names: false });
221
+ }
222
+ else if (current.names && isJsonObject(current.value)) {
223
+ for (const entry of Object.values(current.value))
224
+ pending.push({ value: entry, names: false });
225
+ }
226
+ else if (isJsonObject(current.value)) {
227
+ visit(current.value);
228
+ for (const [key, entry] of Object.entries(current.value)) {
229
+ if (!DATA_KEYWORDS.has(key))
230
+ pending.push({ value: entry, names: NAME_MAP_KEYWORDS.has(key) });
231
+ }
232
+ }
233
+ }
234
+ }
235
+ const nestedIdCache = new WeakMap();
236
+ /** A nested `$id` starts a new base URI, so fragments below it no longer point into the root. */
237
+ function hasNestedId(document) {
238
+ if (!isJsonObject(document))
239
+ return false;
240
+ const cached = nestedIdCache.get(document);
241
+ if (cached !== undefined)
242
+ return cached;
243
+ let found = false;
244
+ collect(document, (schema) => {
245
+ // Draft-04 names a resource with `id`.
246
+ if (schema !== document && (typeof schema.$id === "string" || typeof schema.id === "string"))
247
+ found = true;
248
+ });
249
+ nestedIdCache.set(document, found);
250
+ return found;
251
+ }
252
+ const opaqueCache = new WeakMap();
253
+ /**
254
+ * True when a reference target holds a reference that is not a same-document fragment, or a
255
+ * resource identifier. Whole-document scans skip data values, so a `$ref` that points into one
256
+ * could otherwise reach references those scans never saw.
257
+ */
258
+ function opaqueTarget(target, document) {
259
+ if (!isJsonObject(target) && !Array.isArray(target))
260
+ return false;
261
+ const cached = opaqueCache.get(target);
262
+ if (cached !== undefined)
263
+ return cached;
264
+ let opaque = false;
265
+ collect(target, (schema) => {
266
+ if (schema.$dynamicRef !== undefined || schema.$recursiveRef !== undefined
267
+ || (typeof schema.$ref === "string" && !schema.$ref.startsWith("#"))
268
+ || (schema !== document && (typeof schema.$id === "string" || typeof schema.id === "string")))
269
+ opaque = true;
270
+ });
271
+ opaqueCache.set(target, opaque);
272
+ return opaque;
273
+ }
274
+ /**
275
+ * False when a definition may be reached other than through a same-document `$ref` fragment:
276
+ * through `$dynamicRef` or `$recursiveRef`, a URI reference, or a nested `$id` resource.
277
+ */
278
+ function reachesDefinitionsOnlyThroughFragments(document) {
279
+ let fragmentsOnly = !hasNestedId(document);
280
+ collect(document, (schema) => {
281
+ if (schema.$dynamicRef !== undefined || schema.$recursiveRef !== undefined
282
+ || (typeof schema.$ref === "string" && !schema.$ref.startsWith("#")))
283
+ fragmentsOnly = false;
284
+ });
285
+ return fragmentsOnly;
286
+ }
287
+ function referenceScope(before, after) {
288
+ return {
289
+ before,
290
+ after,
291
+ fragmentsOnly: reachesDefinitionsOnlyThroughFragments(before) && reachesDefinitionsOnlyThroughFragments(after),
292
+ active: new Set(),
293
+ completed: new Map(),
294
+ same: new Map(),
295
+ pointers: new Map(),
296
+ remaining: MAX_COMPARISONS
297
+ };
298
+ }
299
+ /** Records a resolved pair as compared at this depth; returns false when it already was. */
300
+ function markCompleted(scope, before, after, depth) {
301
+ let byAfter = scope.completed.get(before);
302
+ if (byAfter === undefined)
303
+ scope.completed.set(before, byAfter = new Map());
304
+ let depths = byAfter.get(after);
305
+ if (depths === undefined)
306
+ byAfter.set(after, depths = new Set());
307
+ if (depths.has(depth))
308
+ return false;
309
+ depths.add(depth);
310
+ return true;
311
+ }
312
+ /**
313
+ * Follows same-document `$ref` pointers. Returns the referenced schema and the last pointer, the
314
+ * schema itself when it is not a reference, or null when a reference cannot be resolved safely.
315
+ */
316
+ function resolveReferences(schema, document) {
317
+ let current = schema;
318
+ let ref;
319
+ for (let hop = 0; isJsonObject(current) && current.$ref !== undefined; hop += 1) {
320
+ const pointer = current.$ref;
321
+ // Siblings other than documentation would validate alongside the target.
322
+ if (hop === MAX_REFERENCE_HOPS || typeof pointer !== "string" || hasNestedId(document)
323
+ || Object.keys(current).some((key) => key !== "$ref" && !DOCUMENTATION_KEYWORDS.has(key)))
324
+ return null;
325
+ const target = pointerTarget(document, pointer);
326
+ if (target === undefined || opaqueTarget(target, document))
327
+ return null;
328
+ ref = pointer;
329
+ current = target;
330
+ }
331
+ return { schema: current, ref };
332
+ }
333
+ /** True when the schemas are equal and every same-document schema they reach is equal in both roots. */
334
+ function sameMeaning(before, after, equal, scope) {
335
+ if (typeof before !== "object" || before === null || typeof after !== "object" || after === null) {
336
+ return computeSameMeaning(before, after, equal, scope);
337
+ }
338
+ let byAfter = scope.same.get(before);
339
+ if (byAfter === undefined)
340
+ scope.same.set(before, byAfter = new Map());
341
+ let result = byAfter.get(after);
342
+ if (result === undefined)
343
+ byAfter.set(after, result = computeSameMeaning(before, after, equal, scope));
344
+ return result;
345
+ }
346
+ function fragmentPointers(value) {
347
+ const pointers = [];
348
+ collect(value, (schema) => {
349
+ if (typeof schema.$ref === "string" && schema.$ref.startsWith("#"))
350
+ pointers.push(schema.$ref);
351
+ });
352
+ return pointers;
353
+ }
354
+ /** Compares a pointer's targets once per scope, so a large shared definition is walked once. */
355
+ function pointerInfo(pointer, equal, scope) {
356
+ let info = scope.pointers.get(pointer);
357
+ if (info === undefined) {
358
+ const target = pointerTarget(scope.before, pointer);
359
+ const afterTarget = pointerTarget(scope.after, pointer);
360
+ info = target === undefined || !equal(target, afterTarget)
361
+ || opaqueTarget(target, scope.before) || opaqueTarget(afterTarget, scope.after)
362
+ ? { same: false, inner: [] }
363
+ : { same: true, inner: fragmentPointers(target) };
364
+ scope.pointers.set(pointer, info);
365
+ }
366
+ return info;
367
+ }
368
+ function computeSameMeaning(before, after, equal, scope) {
369
+ if (!equal(before, after))
370
+ return false;
371
+ const pending = fragmentPointers(before);
372
+ if (pending.length > 0 && (hasNestedId(scope.before) || hasNestedId(scope.after)))
373
+ return false;
374
+ const seen = new Set();
375
+ while (pending.length > 0) {
376
+ const pointer = pending.pop();
377
+ if (seen.has(pointer))
378
+ continue;
379
+ seen.add(pointer);
380
+ const info = pointerInfo(pointer, equal, scope);
381
+ if (!info.same)
382
+ return false;
383
+ for (const inner of info.inner)
384
+ pending.push(inner);
385
+ }
386
+ return true;
387
+ }
146
388
  /**
147
389
  * Compares one schema and its object and array subschemas. `direction` is "input" when callers
148
390
  * must keep being accepted (after must cover before) and "output" when consumers must keep
149
391
  * understanding results (before must cover after). Depth 0 is the tool's root schema.
150
392
  */
151
- function compareSchema(before, after, direction, depth, equal, reasons) {
152
- if (equal(before, after))
153
- return;
393
+ function compareSchema(before, after, direction, depth, equal, reasons, scope) {
154
394
  const codes = CODES[direction];
395
+ if (scope.remaining <= 0) {
396
+ reasons.add(codes.unclassified);
397
+ return;
398
+ }
399
+ scope.remaining -= 1;
400
+ if (sameMeaning(before, after, equal, scope))
401
+ return;
402
+ const resolvedBefore = resolveReferences(before, scope.before);
403
+ const resolvedAfter = resolveReferences(after, scope.after);
404
+ if (resolvedBefore === null || resolvedAfter === null) {
405
+ reasons.add(codes.unclassified);
406
+ return;
407
+ }
408
+ if (resolvedBefore.ref !== undefined || resolvedAfter.ref !== undefined) {
409
+ // A reference pair already being compared further up is assumed to hold, so recursive
410
+ // definitions terminate; a pair with one inline side is bounded by the depth limit. A pair
411
+ // already compared at this depth added its reasons then.
412
+ const pair = resolvedBefore.ref !== undefined && resolvedAfter.ref !== undefined
413
+ ? JSON.stringify([resolvedBefore.ref, resolvedAfter.ref])
414
+ : undefined;
415
+ if (pair !== undefined && scope.active.has(pair))
416
+ return;
417
+ if (!markCompleted(scope, resolvedBefore.schema, resolvedAfter.schema, depth))
418
+ return;
419
+ if (pair !== undefined)
420
+ scope.active.add(pair);
421
+ compareSchema(resolvedBefore.schema, resolvedAfter.schema, direction, depth, equal, reasons, scope);
422
+ if (pair !== undefined)
423
+ scope.active.delete(pair);
424
+ return;
425
+ }
155
426
  const root = depth === 0;
156
- const structural = root ? ROOT_STRUCTURAL_KEYWORDS : NESTED_STRUCTURAL_KEYWORDS;
427
+ const structural = scope.fragmentsOnly
428
+ ? root ? ROOT_STRUCTURAL_WITH_DEFINITIONS : NESTED_STRUCTURAL_WITH_DEFINITIONS
429
+ : root ? ROOT_STRUCTURAL_KEYWORDS : NESTED_STRUCTURAL_KEYWORDS;
157
430
  if (depth > MAX_NESTED_DEPTH || !isJsonObject(before) || !isJsonObject(after)
158
- || !equal(keywordsOutside(before, structural), keywordsOutside(after, structural))
431
+ || !sameMeaning(keywordsOutside(before, structural), keywordsOutside(after, structural), equal, scope)
159
432
  || !equal(before.$schema, after.$schema)) {
160
433
  reasons.add(codes.unclassified);
161
434
  return;
@@ -167,19 +440,20 @@ function compareSchema(before, after, direction, depth, equal, reasons) {
167
440
  if (!typesCovered(typeSet(inner), typeSet(outer)))
168
441
  reasons.add(root ? codes.rootType : codes.propertyType);
169
442
  if (!root) {
170
- const enumResult = enumCovered(inner.enum, outer.enum, equal);
443
+ const enumResult = enumCovered(allowedValues(inner, equal), allowedValues(outer, equal), equal);
171
444
  if (enumResult === null)
172
445
  reasons.add(codes.unclassified);
173
446
  else if (!enumResult)
174
447
  reasons.add(codes.enumChanged);
175
448
  compareBounds(inner, outer, codes, reasons);
176
- if (!equal(before.items, after.items)) {
449
+ compareStringConstraints(inner, outer, codes, reasons);
450
+ if (!sameMeaning(before.items, after.items, equal, scope)) {
177
451
  if (unevaluated)
178
452
  reasons.add(codes.unclassified);
179
453
  else if (outer.items !== undefined && inner.items === undefined)
180
454
  reasons.add(codes.constraint);
181
455
  else if (outer.items !== undefined)
182
- compareSchema(before.items, after.items, direction, depth + 1, equal, reasons);
456
+ compareSchema(before.items, after.items, direction, depth + 1, equal, reasons, scope);
183
457
  }
184
458
  }
185
459
  const innerRequired = stringSet(inner.required);
@@ -198,14 +472,15 @@ function compareSchema(before, after, direction, depth, equal, reasons) {
198
472
  if (!Object.hasOwn(afterProperties, key))
199
473
  reasons.add(codes.propertyRemoved);
200
474
  else
201
- compareSchema(beforeProperties[key], afterProperties[key], direction, depth + 1, equal, reasons);
475
+ compareSchema(beforeProperties[key], afterProperties[key], direction, depth + 1, equal, reasons, scope);
202
476
  }
203
477
  // In an object whose undeclared keys were governed by other keywords, or a nested input object
204
478
  // that left them open, a newly declared property can reject values callers send (input) or
205
479
  // produce values consumers did not expect (output).
206
480
  const addedKeys = Object.keys(afterProperties).filter((key) => !Object.hasOwn(beforeProperties, key));
207
481
  if (addedKeys.length > 0) {
208
- const governed = isJsonObject(before.additionalProperties)
482
+ // A `const` object fixes every key, so it governs undeclared keys too.
483
+ const governed = isJsonObject(before.additionalProperties) || Object.hasOwn(before, "const")
209
484
  || Object.keys(keywordsOutside(before, structural)).some((key) => !DEFINITION_KEYWORDS.has(key));
210
485
  const open = !root && Object.keys(beforeProperties).length === 0 && before.additionalProperties !== false;
211
486
  if (governed || (direction === "input" && open))
@@ -214,7 +489,7 @@ function compareSchema(before, after, direction, depth, equal, reasons) {
214
489
  // Callers already send a key that was required before it was declared, with any value.
215
490
  if (direction === "input" && addedKeys.some((key) => innerRequired.has(key)))
216
491
  reasons.add(codes.unclassified);
217
- if (!equal(before.additionalProperties, after.additionalProperties)) {
492
+ if (!sameMeaning(before.additionalProperties, after.additionalProperties, equal, scope)) {
218
493
  if (unevaluated)
219
494
  reasons.add(codes.unclassified);
220
495
  else if (direction === "input" && after.additionalProperties === false)
@@ -230,7 +505,7 @@ function classifyOutputSchema(before, after, equal, reasons) {
230
505
  reasons.add("output-schema-removed");
231
506
  return;
232
507
  }
233
- compareSchema(before, after, "output", 0, equal, reasons);
508
+ compareSchema(before, after, "output", 0, equal, reasons, referenceScope(before, after));
234
509
  }
235
510
  function hint(annotations, key, absent) {
236
511
  if (annotations === undefined)
@@ -265,8 +540,9 @@ export function classifyAddedTool() {
265
540
  }
266
541
  export function classifyChangedTool(before, after, fields, equal) {
267
542
  const reasons = new Set();
268
- if (fields.includes("inputSchema"))
269
- compareSchema(before.inputSchema, after.inputSchema, "input", 0, equal, reasons);
543
+ if (fields.includes("inputSchema")) {
544
+ compareSchema(before.inputSchema, after.inputSchema, "input", 0, equal, reasons, referenceScope(before.inputSchema, after.inputSchema));
545
+ }
270
546
  if (fields.includes("outputSchema"))
271
547
  classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons);
272
548
  if (fields.includes("annotations"))
@@ -6,6 +6,7 @@ export interface RuntimeLaunchInput {
6
6
  command: string;
7
7
  args: string[];
8
8
  containerName: string;
9
+ environment?: readonly string[];
9
10
  }
10
11
  export interface RuntimeLaunchSpec {
11
12
  command: string;
@@ -19,6 +19,10 @@ export function buildRuntimeLaunch(input) {
19
19
  if (input.packageRoot.includes(",")) {
20
20
  throw new RuntimeSandboxError("Docker sandbox does not support commas in package paths.");
21
21
  }
22
+ const environment = input.environment ?? [];
23
+ if (environment.some((name) => !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name))) {
24
+ throw new RuntimeSandboxError("Invalid environment variable name.");
25
+ }
22
26
  const workdir = relativeCwd
23
27
  ? `/workspace/${relativeCwd.split(path.sep).join("/")}`
24
28
  : "/workspace";
@@ -52,6 +56,7 @@ export function buildRuntimeLaunch(input) {
52
56
  `type=bind,source=${input.packageRoot},target=/workspace,readonly`,
53
57
  "--workdir",
54
58
  workdir,
59
+ ...environment.flatMap((name) => ["-e", name]),
55
60
  DOCKER_RUNTIME_IMAGE,
56
61
  "node",
57
62
  ...input.args
package/dist/run-cli.js CHANGED
@@ -113,10 +113,12 @@ class CliUsageError extends Error {
113
113
  }
114
114
  const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json|--markdown] [--fail-on any|breaking]";
115
115
  const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--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>]";
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 [--env <NAME>]...] [--require-runtime-approval [--runtime-approval-digest <digest>]] [--json] [--save-response <path>]";
117
117
  const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json] [--bearer-token-env <NAME>]";
118
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
119
  const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
120
+ const MAX_FORWARDED_ENVIRONMENT_VARIABLES = 32;
121
+ const forwardedEnvironmentHelp = "--env forwards the named host environment variable into the Docker sandbox by name; Doctor never prints its value, writes it to a report, or passes it as a command argument. Native servers already inherit Doctor's environment.";
120
122
  function resolveBearerToken(name, env) {
121
123
  if (name === undefined)
122
124
  return {};
@@ -155,14 +157,24 @@ async function finishToolCatalogCommand(io, snapshot, json, savePath) {
155
157
  io.writeStderr(`Saved a complete tools/list response with ${saved.tools} tools (${saved.bytes} bytes).`);
156
158
  return exitCode;
157
159
  }
158
- async function runStdioToolCatalogCommand(targetPath, flags, io, options) {
160
+ async function runStdioToolCatalogCommand(targetPath, flags, io, options, env) {
159
161
  const allowedSwitches = new Set(["--runtime", "--json", "--require-runtime-approval"]);
160
162
  const switches = [];
161
163
  const values = new Map();
164
+ const environment = [];
162
165
  let invalid = false;
163
166
  for (let index = 0; index < flags.length; index += 1) {
164
167
  const flag = flags[index];
165
- if (["--server", "--save-response", "--sandbox", "--runtime-approval-digest"].includes(flag) && !values.has(flag)) {
168
+ if (flag === "--env") {
169
+ const value = flags[index + 1];
170
+ if (!value || value.startsWith("-") || environment.includes(value)) {
171
+ invalid = true;
172
+ break;
173
+ }
174
+ environment.push(value);
175
+ index += 1;
176
+ }
177
+ else if (["--server", "--save-response", "--sandbox", "--runtime-approval-digest"].includes(flag) && !values.has(flag)) {
166
178
  const value = flags[index + 1];
167
179
  if (!value || value.startsWith("-")) {
168
180
  invalid = true;
@@ -196,6 +208,25 @@ async function runStdioToolCatalogCommand(targetPath, flags, io, options) {
196
208
  io.writeStderr("--runtime-approval-digest requires --require-runtime-approval.");
197
209
  return 2;
198
210
  }
211
+ if (environment.length > 0 && sandbox !== "docker") {
212
+ io.writeStderr("--env requires --sandbox docker; native servers already inherit Doctor's environment.");
213
+ return 2;
214
+ }
215
+ if (environment.length > MAX_FORWARDED_ENVIRONMENT_VARIABLES) {
216
+ io.writeStderr(`At most ${MAX_FORWARDED_ENVIRONMENT_VARIABLES} --env variables can be forwarded.`);
217
+ return 2;
218
+ }
219
+ for (const name of environment) {
220
+ if (!ENVIRONMENT_VARIABLE_NAME.test(name)) {
221
+ io.writeStderr("--env requires an environment variable name.");
222
+ return 2;
223
+ }
224
+ // Docker silently skips an unset name, which would start the server without it.
225
+ if (env[name] === undefined || env[name] === "") {
226
+ io.writeStderr(`Environment variable is not set: ${name}`);
227
+ return 2;
228
+ }
229
+ }
199
230
  if (isBlockedOfflinePath(targetPath)) {
200
231
  io.writeStderr("doctor tools --server requires a local package path.");
201
232
  return 2;
@@ -225,7 +256,7 @@ async function runStdioToolCatalogCommand(targetPath, flags, io, options) {
225
256
  return 1;
226
257
  }
227
258
  }
228
- const snapshot = await (options.captureStdioMcpToolCatalogImpl ?? captureStdioMcpToolCatalog)(resolved.server, sandbox === "docker" ? { sandbox: "docker" } : {});
259
+ const snapshot = await (options.captureStdioMcpToolCatalogImpl ?? captureStdioMcpToolCatalog)(resolved.server, sandbox !== "docker" ? {} : environment.length > 0 ? { sandbox: "docker", environment } : { sandbox: "docker" });
229
260
  return finishToolCatalogCommand(io, snapshot, switches.includes("--json"), savePath);
230
261
  }
231
262
  function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
@@ -1400,7 +1431,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
1400
1431
  }
1401
1432
  if (maybePath === "tools") {
1402
1433
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
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}`);
1434
+ 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${forwardedEnvironmentHelp}\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}`);
1404
1435
  return 0;
1405
1436
  }
1406
1437
  const [url, ...flags] = remainingArgs;
@@ -1409,7 +1440,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
1409
1440
  return 2;
1410
1441
  }
1411
1442
  if (flags.includes("--server")) {
1412
- return runStdioToolCatalogCommand(url, flags, io, options);
1443
+ return runStdioToolCatalogCommand(url, flags, io, options, terminalContext.env);
1413
1444
  }
1414
1445
  const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
1415
1446
  const switches = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.71.0",
3
+ "version": "1.72.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",