fix(providers): D21 — validateProvider enforces maxSpawnTimeMs contract field (round-2 F1)

cold-audit catch from 2026-05-24

Round-2 cold-audit Finding 1 (P2 contract drift). ADR 0002 Amendment 1
(D11, commit f659e29) added `maxSpawnTimeMs` to the Provider contract
v1.0 hints set alongside requiresTTY/concurrentSpawnSafe/maxConcurrent.
But `lib/providers/base.mjs` validateProvider was never updated —
ProviderHints typedef listed only the original 3 fields, and the
validator's error message + per-field checks ignored maxSpawnTimeMs
entirely. A plugin that omitted the field would still pass validation;
each plugin's `?? 600_000` runtime default masked the omission at the
spawn site. Round-1 cold audit + D11 diff review both missed this.

Changes (lib/providers/base.mjs +6 / -1, test-features.mjs +36):

1. ProviderHints typedef extended:
   `@property {number} [maxSpawnTimeMs] - optional integer milliseconds, default 600000`
   The `[name]` JSDoc marker correctly indicates optional.

2. Hints existence error message updated to include the new field:
   `'hints must be an object with { requiresTTY, concurrentSpawnSafe, maxConcurrent, maxSpawnTimeMs }'`

3. Per-field validation added inside the hints-object branch:
   ```
   if (p.hints.maxSpawnTimeMs !== undefined) {
     if (typeof !== 'number' || !Number.isInteger(...) || <= 0) push error
   }
   ```
   The `!== undefined` outer guard correctly short-circuits omission to
   "valid" (the field is optional per ADR). The inner check rejects:
   negative, zero, non-integer (catches floats, NaN, Infinity since
   Number.isInteger handles all non-finite cases), and non-number types.

4. test-features.mjs Suite 4: 6 new tests covering all rejection axes
   + both positive-path cases (omitted-is-valid, positive-integer-is-valid):
   - rejects negative
   - rejects zero
   - rejects non-integer (100.5)
   - rejects non-number ('600' string)
   - accepts omitted (optional field)
   - accepts positive integer (60000)

Tests: 328 → 334 (+6). All three shipped plugins (anthropic / codex /
mistral) declare maxSpawnTimeMs: 600_000 (positive integer) and continue
to pass validation. Suite 16 spawn-timeout tests confirm the existing
positive integer flows through to the runtime enforcement loop unchanged.

Authority:
- ADR 0002 § Amendments § Amendment 1 (D11, 2026-05-23) — establishes
  maxSpawnTimeMs as part of the v1.0 hints contract
  https://github.com/dtzp555-max/olp/blob/main/docs/adr/0002-plugin-architecture.md
- ADR 0004 § Trigger taxonomy — Hard triggers bullet 4 — the contract
  field's use site (per-plugin spawn-timeout enforcement → SPAWN_TIMEOUT
  hard trigger)
- CC 开发铁律 v1.6 § 10.x — Round-2 Cold Audit caught this

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Verified ADR Amendment 1 wording matches validator
behavior; verified all 4 rejection axes plus 2 positive axes covered
by tests; verified existing plugin declarations continue to pass via
334/334 (Suite 16 spawn-timeout suite included). No blocking issues.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 14:06:21 +10:00
co-authored by Claude Opus 4.7
parent d85a2dcf71
commit 1466d3a082
2 changed files with 43 additions and 1 deletions
+7 -1
View File
@@ -24,6 +24,7 @@
* @property {boolean} requiresTTY * @property {boolean} requiresTTY
* @property {boolean} concurrentSpawnSafe * @property {boolean} concurrentSpawnSafe
* @property {number} maxConcurrent * @property {number} maxConcurrent
* @property {number} [maxSpawnTimeMs] - optional integer milliseconds, default 600000
*/ */
/** /**
@@ -110,13 +111,18 @@ export function validateProvider(p) {
} }
if (!p.hints || typeof p.hints !== 'object') { if (!p.hints || typeof p.hints !== 'object') {
errors.push('hints must be an object with { requiresTTY, concurrentSpawnSafe, maxConcurrent }'); errors.push('hints must be an object with { requiresTTY, concurrentSpawnSafe, maxConcurrent, maxSpawnTimeMs }');
} else { } else {
if (typeof p.hints.requiresTTY !== 'boolean') errors.push('hints.requiresTTY must be a boolean'); if (typeof p.hints.requiresTTY !== 'boolean') errors.push('hints.requiresTTY must be a boolean');
if (typeof p.hints.concurrentSpawnSafe !== 'boolean') errors.push('hints.concurrentSpawnSafe must be a boolean'); if (typeof p.hints.concurrentSpawnSafe !== 'boolean') errors.push('hints.concurrentSpawnSafe must be a boolean');
if (typeof p.hints.maxConcurrent !== 'number' || !Number.isInteger(p.hints.maxConcurrent) || p.hints.maxConcurrent < 0) { if (typeof p.hints.maxConcurrent !== 'number' || !Number.isInteger(p.hints.maxConcurrent) || p.hints.maxConcurrent < 0) {
errors.push('hints.maxConcurrent must be a non-negative integer'); errors.push('hints.maxConcurrent must be a non-negative integer');
} }
if (p.hints.maxSpawnTimeMs !== undefined) {
if (typeof p.hints.maxSpawnTimeMs !== 'number' || !Number.isInteger(p.hints.maxSpawnTimeMs) || p.hints.maxSpawnTimeMs <= 0) {
errors.push('hints.maxSpawnTimeMs must be a positive integer (milliseconds) or omitted');
}
}
} }
return { valid: errors.length === 0, errors }; return { valid: errors.length === 0, errors };
+36
View File
@@ -521,6 +521,42 @@ describe('Provider contract validation', () => {
assert.ok(r.errors.some(e => e.includes('maxConcurrent'))); assert.ok(r.errors.some(e => e.includes('maxConcurrent')));
}); });
it('validateProvider rejects negative maxSpawnTimeMs', () => {
const r = validateProvider(makeProvider({ hints: { requiresTTY: false, concurrentSpawnSafe: true, maxConcurrent: 4, maxSpawnTimeMs: -1 } }));
assert.equal(r.valid, false);
assert.ok(r.errors.some(e => e.includes('maxSpawnTimeMs')));
});
it('validateProvider rejects zero maxSpawnTimeMs', () => {
const r = validateProvider(makeProvider({ hints: { requiresTTY: false, concurrentSpawnSafe: true, maxConcurrent: 4, maxSpawnTimeMs: 0 } }));
assert.equal(r.valid, false);
assert.ok(r.errors.some(e => e.includes('maxSpawnTimeMs')));
});
it('validateProvider rejects non-integer maxSpawnTimeMs (e.g., 100.5)', () => {
const r = validateProvider(makeProvider({ hints: { requiresTTY: false, concurrentSpawnSafe: true, maxConcurrent: 4, maxSpawnTimeMs: 100.5 } }));
assert.equal(r.valid, false);
assert.ok(r.errors.some(e => e.includes('maxSpawnTimeMs')));
});
it('validateProvider rejects non-number maxSpawnTimeMs (e.g., \'600\')', () => {
const r = validateProvider(makeProvider({ hints: { requiresTTY: false, concurrentSpawnSafe: true, maxConcurrent: 4, maxSpawnTimeMs: '600' } }));
assert.equal(r.valid, false);
assert.ok(r.errors.some(e => e.includes('maxSpawnTimeMs')));
});
it('validateProvider accepts omitted maxSpawnTimeMs (optional field)', () => {
const r = validateProvider(makeProvider({ hints: { requiresTTY: false, concurrentSpawnSafe: true, maxConcurrent: 4 } }));
assert.equal(r.valid, true);
assert.deepEqual(r.errors, []);
});
it('validateProvider accepts positive integer maxSpawnTimeMs (e.g., 60000)', () => {
const r = validateProvider(makeProvider({ hints: { requiresTTY: false, concurrentSpawnSafe: true, maxConcurrent: 4, maxSpawnTimeMs: 60000 } }));
assert.equal(r.valid, true);
assert.deepEqual(r.errors, []);
});
it('rejects non-object input', () => { it('rejects non-object input', () => {
const r = validateProvider(null); const r = validateProvider(null);
assert.equal(r.valid, false); assert.equal(r.valid, false);