@heretek-ai/epistemic-swarm 0.7.21 → 0.7.23
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/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/bin/cli.js +3 -2
- package/extensions/pi/index.js +3 -2
- package/package.json +5 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/factory/skills/factory/SKILL.md +1 -1
- package/plugins/opencode/analysis-bridge.js +566 -0
- package/plugins/opencode/config-io.js +598 -31
- package/plugins/opencode/dep-health.js +1039 -0
- package/plugins/opencode/hook-bus.js +434 -0
- package/plugins/opencode/index.js +252 -19
- package/plugins/opencode/tui.js +230 -17
- package/plugins/opencode/weight-baseline.json +16 -0
- package/plugins/opencode/weight-signals.js +769 -0
- package/plugins/research-cache/skills/research_cache/hasher.py +33 -8
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +249 -81
- package/runner/refinement.py +5 -0
- package/runner/schema_validate.py +63 -1
- package/runner/schemas.py +9 -4
- package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
- package/runner/tests/fixtures/config_validation_cases.json +60 -0
- package/runner/tests/test_analysis_bridge.py +356 -0
- package/runner/tests/test_config_schema.py +119 -15
- package/runner/tests/test_dep_health.py +484 -0
- package/runner/tests/test_hardening_contract.py +391 -0
- package/runner/tests/test_hook_bus.py +802 -0
- package/runner/tests/test_opencode_ux.py +232 -0
- package/runner/tests/test_opencode_v2_registrars.py +548 -16
- package/runner/tests/test_phase08_waiver_reopens.py +539 -0
- package/runner/tests/test_weight_signals.py +361 -0
- package/schemas/config.schema.json +4 -3
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/factory/SKILL.md +1 -1
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/research_cache/hasher.py +33 -8
- package/skills/swarm_config/SKILL.md +6 -1
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- package/skills/swarm_config/configure.py +254 -17
|
@@ -23,6 +23,7 @@ import hashlib
|
|
|
23
23
|
import json
|
|
24
24
|
import os
|
|
25
25
|
import re
|
|
26
|
+
import stat
|
|
26
27
|
import sys
|
|
27
28
|
import tempfile
|
|
28
29
|
from contextlib import contextmanager
|
|
@@ -219,6 +220,38 @@ class ConfigStaleError(ConfigError):
|
|
|
219
220
|
)
|
|
220
221
|
|
|
221
222
|
|
|
223
|
+
class ConfigNullError(ConfigError):
|
|
224
|
+
"""A caller-supplied null targets a schema-known non-nullable key.
|
|
225
|
+
|
|
226
|
+
Structural unification (R2): `save_config` emits the same
|
|
227
|
+
`NULL_FOR_NON_NULLABLE_KEY` code as the MCP `_handle_config` pre-check,
|
|
228
|
+
with dotted paths for nested nulls (e.g. `verify.min_fuzzy_confidence`).
|
|
229
|
+
Scoping: only schema-known paths are reported here; nulls under
|
|
230
|
+
unknown top-level keys or unknown roles (e.g. `agents.gamma`) carry no
|
|
231
|
+
nullability info and are preserved-verbatim for generic validation to
|
|
232
|
+
decide (unknown object keys are forward-compatible).
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
code = "NULL_FOR_NON_NULLABLE_KEY"
|
|
236
|
+
|
|
237
|
+
def __init__(self, paths: List[str]):
|
|
238
|
+
names = sorted(set(str(p) for p in paths))
|
|
239
|
+
super().__init__(
|
|
240
|
+
"null is not accepted for non-nullable key(s): "
|
|
241
|
+
+ ", ".join(names)
|
|
242
|
+
+ "; omit the key to leave it unchanged, or pass a valid value",
|
|
243
|
+
details={"errors": names},
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def null_errors(self) -> List[str]:
|
|
248
|
+
return list(self.details.get("errors") or [])
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def errors(self) -> List[str]:
|
|
252
|
+
return list(self.details.get("errors") or [])
|
|
253
|
+
|
|
254
|
+
|
|
222
255
|
class ConfigHashError(ConfigError):
|
|
223
256
|
"""`expected_hash` is not a full sha256 / unique-prefix hex string."""
|
|
224
257
|
|
|
@@ -232,6 +265,85 @@ class ConfigHashError(ConfigError):
|
|
|
232
265
|
)
|
|
233
266
|
|
|
234
267
|
|
|
268
|
+
class ConfigUnreadableError(ConfigError):
|
|
269
|
+
"""The config file exists but is not a readable, bounded regular file.
|
|
270
|
+
|
|
271
|
+
Phase-08 R2 (W17): `save_config` raises this (fail-closed, nothing
|
|
272
|
+
written) instead of hanging on a FIFO/special device or silently merging
|
|
273
|
+
over an unreadable/oversize file.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
code = "CONFIG_UNREADABLE"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# bounded reads (phase-08 R2 FIFO hardening)
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
#: Cap for any single `config.json` read. The file is ~1KB in practice; the
|
|
284
|
+
#: cap exists only so a pathological workspace entry can never wedge a reader
|
|
285
|
+
#: on an unbounded slurp (mirrors `runner/preflight.py::STATE_MAX_BYTES`).
|
|
286
|
+
CONFIG_MAX_BYTES = 1024 * 1024
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _read_bounded_bytes(
|
|
290
|
+
path: Path, max_bytes: int = CONFIG_MAX_BYTES
|
|
291
|
+
) -> Optional[bytes]:
|
|
292
|
+
"""Read at most `max_bytes` from `path` iff it is a regular file.
|
|
293
|
+
|
|
294
|
+
Phase-08 R2 (W17): a FIFO named `config.json` hung `load_config` /
|
|
295
|
+
`heal_config` on a plain `open`. Ports the `runner/preflight.py`
|
|
296
|
+
`_read_bounded_text` semantics — `S_ISREG` on BOTH the pre-open `stat`
|
|
297
|
+
and the post-open `fstat` (closing the stat->open race), an `O_NONBLOCK`
|
|
298
|
+
open, and a byte cap — so a pathological workspace entry (FIFO, device,
|
|
299
|
+
socket, directory, runaway file) can never block a config reader.
|
|
300
|
+
Returns `None` when the path is absent, unreadable, non-regular, or
|
|
301
|
+
over the cap.
|
|
302
|
+
|
|
303
|
+
Symlinks are FOLLOWED (`os.stat`, no `O_NOFOLLOW`): the pre-fix reader
|
|
304
|
+
followed them too, so a symlinked config keeps working; a symlink to a
|
|
305
|
+
non-regular target is still refused by the `S_ISREG` checks. Residual
|
|
306
|
+
(documented): a symlink swapped for a FIFO between the pre-open stat and
|
|
307
|
+
the open races the check — the post-open `fstat` still refuses anything
|
|
308
|
+
that is non-regular at read time.
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
if not stat.S_ISREG(os.stat(path).st_mode):
|
|
312
|
+
return None
|
|
313
|
+
except OSError:
|
|
314
|
+
return None
|
|
315
|
+
flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)
|
|
316
|
+
try:
|
|
317
|
+
fd = os.open(path, flags)
|
|
318
|
+
except OSError:
|
|
319
|
+
return None
|
|
320
|
+
try:
|
|
321
|
+
try:
|
|
322
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
323
|
+
return None
|
|
324
|
+
except OSError:
|
|
325
|
+
return None
|
|
326
|
+
chunks = []
|
|
327
|
+
remaining = max_bytes + 1
|
|
328
|
+
while remaining > 0:
|
|
329
|
+
try:
|
|
330
|
+
chunk = os.read(fd, min(remaining, 65536))
|
|
331
|
+
except BlockingIOError:
|
|
332
|
+
break
|
|
333
|
+
except OSError:
|
|
334
|
+
return None
|
|
335
|
+
if not chunk:
|
|
336
|
+
break
|
|
337
|
+
chunks.append(chunk)
|
|
338
|
+
remaining -= len(chunk)
|
|
339
|
+
finally:
|
|
340
|
+
os.close(fd)
|
|
341
|
+
data = b"".join(chunks)
|
|
342
|
+
if len(data) > max_bytes:
|
|
343
|
+
return None
|
|
344
|
+
return data
|
|
345
|
+
|
|
346
|
+
|
|
235
347
|
# ---------------------------------------------------------------------------
|
|
236
348
|
# paths + snapshots
|
|
237
349
|
# ---------------------------------------------------------------------------
|
|
@@ -250,11 +362,15 @@ def config_hash(base_dir: str = DEFAULT_RESEARCH_DIR) -> Optional[str]:
|
|
|
250
362
|
|
|
251
363
|
The hash covers the file as written (whitespace included), so it doubles as
|
|
252
364
|
an optimistic-concurrency version for `expected_hash` / the RPC bridge.
|
|
365
|
+
Phase-08 R2: read through the bounded reader, so a FIFO/special device
|
|
366
|
+
yields None (it has no stable bytes to hash; a guarded write then fails
|
|
367
|
+
closed as stale) instead of hanging the caller.
|
|
253
368
|
"""
|
|
254
369
|
cfg_file = get_config_path(base_dir)
|
|
255
|
-
|
|
370
|
+
raw = _read_bounded_bytes(cfg_file)
|
|
371
|
+
if raw is None:
|
|
256
372
|
return None
|
|
257
|
-
return hashlib.sha256(
|
|
373
|
+
return hashlib.sha256(raw).hexdigest()
|
|
258
374
|
|
|
259
375
|
|
|
260
376
|
def snapshot(base_dir: str = DEFAULT_RESEARCH_DIR) -> Dict[str, Any]:
|
|
@@ -327,14 +443,83 @@ def validate_config(cfg: Any) -> List[str]:
|
|
|
327
443
|
|
|
328
444
|
|
|
329
445
|
def _read_raw_config(cfg_file: Path) -> Optional[Dict[str, Any]]:
|
|
330
|
-
"""Best-effort raw JSON object on disk (None when absent/malformed).
|
|
446
|
+
"""Best-effort raw JSON object on disk (None when absent/malformed).
|
|
447
|
+
|
|
448
|
+
Phase-08 R2: read through the bounded reader, so a FIFO/special device
|
|
449
|
+
yields None (callers already treat None as absent/malformed) instead of
|
|
450
|
+
hanging the caller.
|
|
451
|
+
"""
|
|
331
452
|
try:
|
|
332
|
-
|
|
453
|
+
raw = _read_bounded_bytes(cfg_file)
|
|
454
|
+
if raw is None:
|
|
455
|
+
return None
|
|
456
|
+
data = json.loads(raw.decode("utf-8"))
|
|
333
457
|
except (OSError, ValueError):
|
|
334
458
|
return None
|
|
335
459
|
return data if isinstance(data, dict) else None
|
|
336
460
|
|
|
337
461
|
|
|
462
|
+
def _caller_schema_allows_null(subschema: Any) -> bool:
|
|
463
|
+
"""True when a canonical subschema accepts null (type includes "null")."""
|
|
464
|
+
if not isinstance(subschema, dict):
|
|
465
|
+
return False
|
|
466
|
+
types = subschema.get("type")
|
|
467
|
+
if isinstance(types, list):
|
|
468
|
+
return "null" in types
|
|
469
|
+
return types == "null"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def collect_caller_null_violations(caller: Any) -> List[str]:
|
|
473
|
+
"""Dotted paths in caller-supplied `caller` where null is banned (R2).
|
|
474
|
+
|
|
475
|
+
Shared nested-null pre-check: `save_config` raises `ConfigNullError` for
|
|
476
|
+
these paths so ALL writers emit `NULL_FOR_NON_NULLABLE_KEY` for
|
|
477
|
+
schema-known paths (unifying the MCP `_handle_config` pre-check). Only
|
|
478
|
+
schema-known paths are reported; nulls under unknown top-level keys or
|
|
479
|
+
unknown roles (e.g. `agents.gamma`) carry no nullability info and are
|
|
480
|
+
preserved-verbatim for generic validation to decide. Degrades to `[]`
|
|
481
|
+
when the canonical schema is unavailable (standalone skill copy).
|
|
482
|
+
"""
|
|
483
|
+
if not isinstance(caller, dict):
|
|
484
|
+
return []
|
|
485
|
+
if _CONFIG_SCHEMA is None:
|
|
486
|
+
return []
|
|
487
|
+
props = _CONFIG_SCHEMA.get("properties") or {}
|
|
488
|
+
found: List[str] = []
|
|
489
|
+
|
|
490
|
+
def walk(value: Any, subschema: Any, path: str) -> None:
|
|
491
|
+
if value is None:
|
|
492
|
+
if isinstance(subschema, dict) and not _caller_schema_allows_null(
|
|
493
|
+
subschema
|
|
494
|
+
):
|
|
495
|
+
found.append(path)
|
|
496
|
+
return
|
|
497
|
+
if isinstance(value, dict) and isinstance(subschema, dict):
|
|
498
|
+
sub_props = subschema.get("properties") or {}
|
|
499
|
+
additional = subschema.get("additionalProperties")
|
|
500
|
+
for key, child in value.items():
|
|
501
|
+
if key in sub_props:
|
|
502
|
+
walk(child, sub_props[key], f"{path}.{key}" if path else str(key))
|
|
503
|
+
elif isinstance(additional, dict):
|
|
504
|
+
walk(child, additional, f"{path}.{key}" if path else str(key))
|
|
505
|
+
return
|
|
506
|
+
if isinstance(value, list) and isinstance(subschema, dict):
|
|
507
|
+
items = subschema.get("items")
|
|
508
|
+
if isinstance(items, dict):
|
|
509
|
+
for idx, child in enumerate(value):
|
|
510
|
+
walk(child, items, f"{path}[{idx}]")
|
|
511
|
+
|
|
512
|
+
for key, value in caller.items():
|
|
513
|
+
if key not in props:
|
|
514
|
+
continue # unknown top-level keys: preserved verbatim
|
|
515
|
+
if value is None:
|
|
516
|
+
if not _caller_schema_allows_null(props[key]):
|
|
517
|
+
found.append(str(key))
|
|
518
|
+
elif isinstance(value, (dict, list)):
|
|
519
|
+
walk(value, props[key], str(key))
|
|
520
|
+
return sorted(set(found))
|
|
521
|
+
|
|
522
|
+
|
|
338
523
|
# ---------------------------------------------------------------------------
|
|
339
524
|
# load / save
|
|
340
525
|
# ---------------------------------------------------------------------------
|
|
@@ -392,17 +577,30 @@ def load_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> Dict[str, Any]:
|
|
|
392
577
|
"""Load config merged over defaults. Pure — never writes to disk.
|
|
393
578
|
|
|
394
579
|
Applies the legacy-pin migration in memory so a stale config stops forcing
|
|
395
|
-
Claude immediately
|
|
396
|
-
|
|
397
|
-
|
|
580
|
+
Claude immediately. No production read path persists that migration (reads
|
|
581
|
+
are non-healing by design, R12/F2): it is applied in memory on every load
|
|
582
|
+
and persisted opportunistically the next time a write leg merges the loaded
|
|
583
|
+
config (`configure.py` CLI save, the MCP `iumbtems_config` update leg);
|
|
584
|
+
`heal_config` remains available as an explicit opt-in repair helper but no
|
|
585
|
+
production read path calls it (phase-08 R3). Unknown keys are preserved
|
|
586
|
+
verbatim (they are part of the contract's forward-compatibility rule).
|
|
587
|
+
|
|
588
|
+
Phase-08 R2: the file is read through the bounded reader, so a FIFO /
|
|
589
|
+
special device / oversize file warns once and yields defaults instead of
|
|
590
|
+
hanging the caller.
|
|
398
591
|
"""
|
|
399
592
|
base = copy.deepcopy(DEFAULT_CONFIG)
|
|
400
593
|
cfg_file = get_config_path(base_dir)
|
|
401
|
-
|
|
594
|
+
raw_bytes = _read_bounded_bytes(cfg_file)
|
|
595
|
+
if raw_bytes is None:
|
|
596
|
+
if os.path.lexists(cfg_file):
|
|
597
|
+
sys.stderr.write(
|
|
598
|
+
f"[swarm-config] Warning: {cfg_file} is not a readable regular "
|
|
599
|
+
"file (or exceeds the read cap). Using defaults.\n"
|
|
600
|
+
)
|
|
402
601
|
return base
|
|
403
602
|
try:
|
|
404
|
-
|
|
405
|
-
data = json.load(f)
|
|
603
|
+
data = json.loads(raw_bytes.decode("utf-8"))
|
|
406
604
|
except Exception as e:
|
|
407
605
|
sys.stderr.write(
|
|
408
606
|
f"[swarm-config] Warning: Failed to parse {cfg_file}: {e}. Using defaults.\n"
|
|
@@ -426,21 +624,30 @@ def load_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> Dict[str, Any]:
|
|
|
426
624
|
def heal_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> bool:
|
|
427
625
|
"""Persist a legacy agent-backend migration to disk; True if rewritten.
|
|
428
626
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
627
|
+
Explicit opt-in repair helper (exported, tested). Phase-08 R3 truth: NO
|
|
628
|
+
production read path calls this — `--show` / bare CLI, the MCP `show`
|
|
629
|
+
leg, and `load_config` itself are strictly non-healing (R12/F2); the
|
|
630
|
+
migration persists opportunistically when a write leg merges the loaded
|
|
631
|
+
(in-memory-migrated) config. Call this directly when you want the
|
|
632
|
+
migration persisted outside any other write. The write is guarded by the
|
|
633
|
+
hash of the bytes this call read, so a migration never clobbers a
|
|
634
|
+
concurrent writer (it simply defers to the next touch).
|
|
433
635
|
|
|
434
636
|
The would-be-persisted dict is validated first (R1): a file that already
|
|
435
637
|
fails the canonical schema is NOT rewritten — persisting it would legitimise
|
|
436
638
|
the invalid values and feed them to SwarmRunner. The migration then stays in
|
|
437
639
|
memory only, and a warning names every violation.
|
|
640
|
+
|
|
641
|
+
Phase-08 R2: the pre-read goes through the bounded reader, so a FIFO /
|
|
642
|
+
special device returns False instead of hanging the caller.
|
|
438
643
|
"""
|
|
439
644
|
cfg_file = get_config_path(base_dir)
|
|
440
645
|
if not cfg_file.exists():
|
|
441
646
|
return False
|
|
442
647
|
try:
|
|
443
|
-
raw_bytes = cfg_file
|
|
648
|
+
raw_bytes = _read_bounded_bytes(cfg_file)
|
|
649
|
+
if raw_bytes is None:
|
|
650
|
+
return False
|
|
444
651
|
raw = json.loads(raw_bytes.decode("utf-8"))
|
|
445
652
|
except Exception:
|
|
446
653
|
return False
|
|
@@ -486,7 +693,11 @@ def save_config(
|
|
|
486
693
|
* Cross-process advisory lock over `.research/.config.lock` around the whole
|
|
487
694
|
read-merge-write, so concurrent writers cannot lose each other's keys.
|
|
488
695
|
* Unknown keys already on disk survive; `cfg` keys (deep-merged) win.
|
|
489
|
-
*
|
|
696
|
+
* Caller-supplied null for a schema-known non-nullable key (any depth)
|
|
697
|
+
raises `ConfigNullError` (`NULL_FOR_NON_NULLABLE_KEY`) with dotted paths
|
|
698
|
+
before anything is written — the same code as the MCP surface; nulls
|
|
699
|
+
under unknown keys/roles stay preserved-verbatim for generic validation.
|
|
700
|
+
* Schema validation against `runner.schemas.CONFIG`; other failures raise
|
|
490
701
|
`ConfigValidationError` with dotted paths and write nothing. `validate`
|
|
491
702
|
may be disabled only by a caller that has already validated the exact
|
|
492
703
|
dict it is persisting (see `heal_config`).
|
|
@@ -503,13 +714,39 @@ def save_config(
|
|
|
503
714
|
"(config must be a JSON object)"
|
|
504
715
|
]
|
|
505
716
|
)
|
|
717
|
+
null_paths = collect_caller_null_violations(cfg)
|
|
718
|
+
if null_paths:
|
|
719
|
+
raise ConfigNullError(null_paths)
|
|
506
720
|
guard = normalize_expected_hash(expected_hash)
|
|
507
721
|
target_dir = Path(base_dir)
|
|
508
722
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
509
723
|
cfg_file = target_dir / "config.json"
|
|
510
724
|
|
|
511
725
|
with _file_lock(get_lock_path(base_dir)):
|
|
512
|
-
|
|
726
|
+
# Phase-08 R2: bounded pre-read — a FIFO/special device here hung the
|
|
727
|
+
# writer on a plain `read_bytes`. Fail closed (nothing written) rather
|
|
728
|
+
# than hang or silently merge over an unreadable/oversize file.
|
|
729
|
+
if cfg_file.is_file():
|
|
730
|
+
existing_bytes = _read_bounded_bytes(cfg_file)
|
|
731
|
+
if existing_bytes is None:
|
|
732
|
+
raise ConfigUnreadableError(
|
|
733
|
+
f"{cfg_file} exists but is not a readable, bounded "
|
|
734
|
+
"regular file (FIFO, device, or over the read cap); "
|
|
735
|
+
"refusing to merge over it.",
|
|
736
|
+
details={"written": False},
|
|
737
|
+
)
|
|
738
|
+
elif os.path.lexists(cfg_file):
|
|
739
|
+
# A non-file entry (FIFO, directory, dangling symlink): the old
|
|
740
|
+
# code silently replaced it via merge-over-{} + os.replace.
|
|
741
|
+
# Refuse instead — replacing a caller's FIFO/symlink out from
|
|
742
|
+
# under them is never the safe default.
|
|
743
|
+
raise ConfigUnreadableError(
|
|
744
|
+
f"{cfg_file} exists but is not a regular file; "
|
|
745
|
+
"refusing to merge over it.",
|
|
746
|
+
details={"written": False},
|
|
747
|
+
)
|
|
748
|
+
else:
|
|
749
|
+
existing_bytes = b""
|
|
513
750
|
current_hash = (
|
|
514
751
|
hashlib.sha256(existing_bytes).hexdigest() if existing_bytes else None
|
|
515
752
|
)
|