codex-plugin-doctor 1.71.1 → 1.72.1
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 +7 -4
- package/dist/core/mcp-stdio-tool-catalog.d.ts +1 -0
- package/dist/core/mcp-stdio-tool-catalog.js +2 -1
- package/dist/core/mcp-tool-impact.js +291 -15
- package/dist/core/runtime-sandbox.d.ts +1 -0
- package/dist/core/runtime-sandbox.js +5 -0
- package/dist/run-cli.js +37 -6
- package/package.json +1 -1
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.
|
|
588
|
+
- uses: Esquetta/CodexPluginDoctor@v1.72.1
|
|
586
589
|
with:
|
|
587
|
-
version: "1.
|
|
590
|
+
version: "1.72.1"
|
|
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",
|
|
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 =
|
|
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
|
-
|| !
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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"))
|
|
@@ -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 (
|
|
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
|
|
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