activegraph 1.6.0__tar.gz → 1.7.1__tar.gz
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.
- {activegraph-1.6.0/activegraph.egg-info → activegraph-1.7.1}/PKG-INFO +1 -1
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/__init__.py +1 -1
- activegraph-1.7.1/activegraph/sandbox/__init__.py +502 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/sandbox/_child.py +96 -11
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/retention.py +55 -3
- {activegraph-1.6.0 → activegraph-1.7.1/activegraph.egg-info}/PKG-INFO +1 -1
- {activegraph-1.6.0 → activegraph-1.7.1}/pyproject.toml +1 -1
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_compaction.py +35 -0
- activegraph-1.7.1/tests/test_sandbox_trial.py +730 -0
- activegraph-1.6.0/activegraph/sandbox/__init__.py +0 -257
- activegraph-1.6.0/tests/test_sandbox_trial.py +0 -367
- {activegraph-1.6.0 → activegraph-1.7.1}/LICENSE +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/NOTICE +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/README.md +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/__main__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/_signature.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/behaviors/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/behaviors/base.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/behaviors/decorators.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/cli/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/cli/main.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/cli/quickstart.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/cli/renderers.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/clock.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/event.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/graph.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/graph_store.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/ids.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/patch.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/core/view.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/frame.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/anthropic.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/cache.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/embedding.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/native.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/openai.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/parsing.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/prompt.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/provider.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/recorded.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/types.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/llm/wire.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/logging.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/metrics.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/migration.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/otel.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/prometheus.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/observability/status.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/behaviors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/fixtures/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/fixtures/companies.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/object_types.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/prompts/document_researcher.md +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/prompts/memo_synthesizer.md +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/prompts/question_generator.md +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/prompts/risk_identifier.md +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/settings.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/diligence/tools.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/loader.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/manifest.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/packs/scaffold.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/policy.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/_live.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/behavior_graph.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/budget.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/config_errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/diff.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/exec_errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/patterns.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/promote.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/queue.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/registration_errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/registry.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/runtime.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/scheduler.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/runtime/view_builder.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/base.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/conformance.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/falkordb.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/graph_conformance.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/memory.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/postgres.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/serde.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/sqlite.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/store/url.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/base.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/cache.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/context.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/decorators.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/errors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/graph_query.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/recorded.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/tools/web_fetch.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/trace/__init__.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/trace/causal.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph/trace/printer.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph.egg-info/SOURCES.txt +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph.egg-info/dependency_links.txt +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph.egg-info/entry_points.txt +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph.egg-info/requires.txt +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/activegraph.egg-info/top_level.txt +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/setup.cfg +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_activate_after.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_causal_cross_tool.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_cli.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_cli_docs_flags.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_clock.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_diff.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_diligence_pack.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_diligence_with_tools.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_disable_pack.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_doc_links.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_doc_python_snippets.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_doc_site_reachable.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_embedding_provider.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_errors_format.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_event.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_falkordb_store.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_fork.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_graph.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_graph_store.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_ids.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_license.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_anthropic.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_behavior.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_budget.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_causal.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_claim_extraction.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_default_model.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_determinism.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_failure.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_native_structured_output.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_openai.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_prompt.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_provider_fixtures.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_replay.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_tool_loop.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_trace.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_trace_snapshot.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_types.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llm_wire.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_llms_txt.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_manifest_warning_tier.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_migration.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_observability_logging.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_observability_metrics.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_operate_example.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_pack_manifest.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_pack_scaffold.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_packs.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_patch.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_pattern_matcher.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_pattern_parser.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_pattern_subscriptions.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_persistence.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_postgres_store.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_promote.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_quickstart.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_quickstart_snapshot.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_reason_codes_docs.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_registration_validation.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_replay.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_replay_trace_snapshot.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_requeue_unfired.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_resume_example.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_runtime.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_runtime_status.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_serde.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_store_conformance.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_store_url.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_tool_replay.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_tool_trace_snapshot.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_tools.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_trace.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_trace_accessors.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_tutorial_snippets.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_v1_0_1_patches.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_v1_0_3_behavior_failed_ux.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_v1_0_3_tool_multiturn.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_version_sync.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_view.py +0 -0
- {activegraph-1.6.0 → activegraph-1.7.1}/tests/test_wheel_completeness.py +0 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Subprocess fork-trial isolation. CONTRACT v1.5 #1.
|
|
2
|
+
|
|
3
|
+
Implements ``trial-isolation-design.md``: candidate pack code trials
|
|
4
|
+
run in a **fresh interpreter**, against a fork of the parent run,
|
|
5
|
+
materialized from artifacts pinned by the bundle hash — so the parent
|
|
6
|
+
process is out of the blast radius of the T1 accident class (runaway
|
|
7
|
+
memory/CPU, corrupted in-process state) and the bytes trialed are the
|
|
8
|
+
bytes the proposal recorded.
|
|
9
|
+
|
|
10
|
+
The division of authority is the design's:
|
|
11
|
+
|
|
12
|
+
* **The parent forks.** The child receives only the fork's run id —
|
|
13
|
+
it never gets fork authority, and it appends only to that run.
|
|
14
|
+
* **The child is a fresh interpreter** (``sys.executable -m
|
|
15
|
+
activegraph.sandbox._child``), not ``os.fork()`` — no inherited
|
|
16
|
+
Python state, no shared clients. Environment is allow-list only.
|
|
17
|
+
* **Three independent nets**: ``resource.setrlimit`` (address
|
|
18
|
+
space + CPU, POSIX; degrade to the other two nets on Windows),
|
|
19
|
+
parent-side wall-clock kill, and the runtime's own event/LLM
|
|
20
|
+
budgets inside the child.
|
|
21
|
+
* **The store is the record.** The child's stdout tail is a signal;
|
|
22
|
+
the parent re-reads the fork run from the store and the store's
|
|
23
|
+
numbers win any disagreement.
|
|
24
|
+
|
|
25
|
+
TWO CHANNELS, KEPT SEPARATE (v1.7, the Replit soak fix):
|
|
26
|
+
|
|
27
|
+
* **The environment allow-list stays CLOSED.** Only ``PATH``,
|
|
28
|
+
``HOME``, ``LANG`` plus an explicit ``env_passthrough`` cross the
|
|
29
|
+
boundary. No ambient parent env — secrets, API keys, platform
|
|
30
|
+
config like ``REPLIT_*`` — leaks into a child running hostile
|
|
31
|
+
candidate code. This is a security control and does not widen.
|
|
32
|
+
* **Code location is an EXPLICIT channel, not an ambient one.** A
|
|
33
|
+
child must be able to import ``activegraph`` (and its deps) on any
|
|
34
|
+
box where the *parent* could — but on some platforms (Replit,
|
|
35
|
+
Nix) the package is discoverable only via a platform env var the
|
|
36
|
+
allow-list correctly strips, so a naive child dies at import
|
|
37
|
+
before running. The parent therefore RESOLVES the ``sys.path``
|
|
38
|
+
entries that make its own code importable and passes them to the
|
|
39
|
+
child as a computed ``PYTHONPATH`` (see :func:`_child_env`).
|
|
40
|
+
``PYTHONPATH`` is thus derived from the parent's resolved
|
|
41
|
+
``sys.path``, never forwarded from ambient env — code locations
|
|
42
|
+
are not secrets, and making the child's discovery of its own code
|
|
43
|
+
an explicit input keeps the allow-list closed while fixing the
|
|
44
|
+
restricted-env break. Call :func:`preflight` once at boot to
|
|
45
|
+
verify a child can start under this env and fail loud if not.
|
|
46
|
+
|
|
47
|
+
HONEST LIMITS (the design's §5, restated where users will read it):
|
|
48
|
+
this is crash/state isolation, **not a security sandbox**. A fresh
|
|
49
|
+
interpreter with rlimits does not stop a malicious candidate from
|
|
50
|
+
opening sockets or reading the filesystem — syscall and network
|
|
51
|
+
confinement are host territory (containers, seccomp), and this module
|
|
52
|
+
does not imitate them. The child can also, in principle, open the
|
|
53
|
+
shared SQLite file directly and touch other runs; per-run
|
|
54
|
+
authorization inside one SQLite file is not something SQLite gives us
|
|
55
|
+
honestly, so that caveat is stated rather than solved. The evolution
|
|
56
|
+
pack's static gates remain the pre-execution filter.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import json
|
|
62
|
+
import logging
|
|
63
|
+
import os
|
|
64
|
+
import subprocess
|
|
65
|
+
import sys
|
|
66
|
+
from dataclasses import dataclass, field
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
from typing import Optional
|
|
69
|
+
|
|
70
|
+
_log = logging.getLogger("activegraph.sandbox")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
#: The closed outcome set. ``crashed`` is the implementation's one
|
|
74
|
+
#: addition over the design draft: a child that dies without a
|
|
75
|
+
#: parseable tail (segfault, os._exit, kill) is reported as what it
|
|
76
|
+
#: is, never guessed into a neater bucket.
|
|
77
|
+
TRIAL_OUTCOMES = (
|
|
78
|
+
"completed",
|
|
79
|
+
"scenario_failed",
|
|
80
|
+
"limits_exceeded",
|
|
81
|
+
"materialization_failed",
|
|
82
|
+
"crashed",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Child exit codes, mirrored in _child.py. Anything else → crashed.
|
|
86
|
+
_EXIT_TO_OUTCOME = {
|
|
87
|
+
0: "completed",
|
|
88
|
+
30: "scenario_failed",
|
|
89
|
+
40: "limits_exceeded",
|
|
90
|
+
50: "materialization_failed",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class PackSource:
|
|
96
|
+
"""Where the child materializes the candidate pack from.
|
|
97
|
+
|
|
98
|
+
``expected_bundle_hash`` is the external pin (the §4 walk WITH
|
|
99
|
+
``manifest.toml``, per the v1.4 bundle-hash amendment) — the child
|
|
100
|
+
verifies it via ``activegraph.packs.manifest.verify_bundle_hash``
|
|
101
|
+
before importing anything, so the bytes trialed are the bytes the
|
|
102
|
+
proposal recorded. ``manifest_required=True`` additionally runs
|
|
103
|
+
``load_manifest`` + ``verify_surface`` against the live ``Pack``,
|
|
104
|
+
making the trial child the first consumer of the full manifest
|
|
105
|
+
chain end-to-end.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
root_dir: str
|
|
109
|
+
expected_bundle_hash: str = ""
|
|
110
|
+
manifest_required: bool = True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class TrialLimits:
|
|
115
|
+
"""The trial's resource nets. Zero/None disables a given net.
|
|
116
|
+
|
|
117
|
+
``max_llm_calls=0`` (the default) means key-freedom is
|
|
118
|
+
STRUCTURAL: the child configures no LLM provider at all, so a
|
|
119
|
+
candidate with LLM behaviors fails loud at registration
|
|
120
|
+
(``MissingProviderError``) rather than reaching a network. A
|
|
121
|
+
positive cap is accepted and recorded for a future
|
|
122
|
+
provider-wiring seam; the v1 child never configures a live
|
|
123
|
+
provider either way. ``env_passthrough`` is the ONLY ambient parent
|
|
124
|
+
environment forwarded beyond the closed allow-list
|
|
125
|
+
(``PATH``/``HOME``/``LANG``) — the helper never forwards the
|
|
126
|
+
environment wholesale, so parent API keys don't leak into
|
|
127
|
+
candidate code by default. The child's ``PYTHONPATH`` is NOT an
|
|
128
|
+
ambient forward: it is computed from the parent's resolved
|
|
129
|
+
``sys.path`` so the child can import its own code (see the module
|
|
130
|
+
docstring's "two channels").
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
wall_clock_seconds: float = 120.0
|
|
134
|
+
max_rss_bytes: Optional[int] = None
|
|
135
|
+
max_events: Optional[int] = 2_000
|
|
136
|
+
max_llm_calls: Optional[int] = 0
|
|
137
|
+
env_passthrough: tuple[str, ...] = ()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class TrialReport:
|
|
142
|
+
"""What a trial produced. Store-derived numbers, child-signaled shape.
|
|
143
|
+
|
|
144
|
+
``events_appended`` and ``behavior_failures`` are re-read from the
|
|
145
|
+
fork's run in the store by the parent AFTER the child exits — the
|
|
146
|
+
stdout tail is a signal only, and any disagreement resolves in
|
|
147
|
+
favor of the store. Richer evidence (tracebacks, diffs) is read
|
|
148
|
+
from the fork's log directly: ``Runtime.load(store, run_id=
|
|
149
|
+
report.fork_run_id)`` then ``trace.failures()`` / ``diff()``.
|
|
150
|
+
|
|
151
|
+
``warnings`` (v1.7.1) carries any resource net that DEGRADED on
|
|
152
|
+
this platform — most notably the memory cap (RLIMIT_AS) on macOS,
|
|
153
|
+
which the Darwin kernel refuses. A degraded net is announced, never
|
|
154
|
+
silently skipped: the same strings are also folded into ``detail``,
|
|
155
|
+
and the parent logs each one.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
outcome: str
|
|
159
|
+
fork_run_id: str
|
|
160
|
+
events_appended: int
|
|
161
|
+
behavior_failures: int
|
|
162
|
+
detail: str
|
|
163
|
+
exit_code: Optional[int]
|
|
164
|
+
warnings: tuple[str, ...] = ()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class SandboxStartupError(RuntimeError):
|
|
168
|
+
"""A trial child could not START under the sandbox env.
|
|
169
|
+
|
|
170
|
+
Raised by :func:`preflight` when the child fails before it can run
|
|
171
|
+
any trial — the common cause is the sandbox env being unable to
|
|
172
|
+
import ``activegraph`` on this box (the exact restricted-env break
|
|
173
|
+
the explicit package-path channel exists to prevent). The message
|
|
174
|
+
carries the child's stderr tail so the cause is never opaque.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _child_code_paths() -> list[str]:
|
|
179
|
+
"""The ``sys.path`` entries a child needs to import the parent's
|
|
180
|
+
code, resolved in the parent (v1.7 explicit package-path channel).
|
|
181
|
+
|
|
182
|
+
``activegraph``'s own root comes first, resolved from
|
|
183
|
+
``activegraph.__file__`` — this is what an editable install or a
|
|
184
|
+
platform that surfaces packages via a stripped env var (Replit's
|
|
185
|
+
``REPLIT_PYTHONPATH``) would otherwise hide from the child. The
|
|
186
|
+
parent's real ``sys.path`` directories follow so third-party deps
|
|
187
|
+
resolve too. Non-directory entries (namespace-finder hooks, the
|
|
188
|
+
empty CWD entry, zip imports) are dropped: they either don't help
|
|
189
|
+
a fresh child or would import from its CWD, which we do not want.
|
|
190
|
+
"""
|
|
191
|
+
import activegraph
|
|
192
|
+
|
|
193
|
+
ordered: list[str] = []
|
|
194
|
+
ag_root = str(Path(activegraph.__file__).resolve().parent.parent)
|
|
195
|
+
ordered.append(ag_root)
|
|
196
|
+
for entry in sys.path:
|
|
197
|
+
if entry and os.path.isdir(entry) and entry not in ordered:
|
|
198
|
+
ordered.append(entry)
|
|
199
|
+
return ordered
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _child_env(limits: TrialLimits) -> dict[str, str]:
|
|
203
|
+
"""Build the child's environment: closed allow-list + the computed
|
|
204
|
+
``PYTHONPATH`` code channel. See the module docstring's "two
|
|
205
|
+
channels".
|
|
206
|
+
|
|
207
|
+
The allow-list is ``PATH``/``HOME``/``LANG`` plus the caller's
|
|
208
|
+
explicit ``env_passthrough`` — no other ambient env crosses. Then
|
|
209
|
+
``PYTHONPATH`` is set from :func:`_child_code_paths` (the parent's
|
|
210
|
+
resolved code locations), prepended before any ``PYTHONPATH`` the
|
|
211
|
+
caller opted to pass through, so the child imports the same code
|
|
212
|
+
the parent can regardless of platform package discovery.
|
|
213
|
+
"""
|
|
214
|
+
env = {
|
|
215
|
+
key: os.environ[key]
|
|
216
|
+
for key in ("PATH", "HOME", "LANG")
|
|
217
|
+
if key in os.environ
|
|
218
|
+
}
|
|
219
|
+
for key in limits.env_passthrough:
|
|
220
|
+
if key in os.environ:
|
|
221
|
+
env[key] = os.environ[key]
|
|
222
|
+
channel = os.pathsep.join(_child_code_paths())
|
|
223
|
+
passed_through = env.get("PYTHONPATH", "")
|
|
224
|
+
env["PYTHONPATH"] = (
|
|
225
|
+
channel + os.pathsep + passed_through if passed_through else channel
|
|
226
|
+
)
|
|
227
|
+
return env
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _stderr_tail(stderr: str, *, max_lines: int = 20, max_chars: int = 1500) -> str:
|
|
231
|
+
"""The last few lines of a child's stderr, capped — the actual
|
|
232
|
+
cause of a pre-``_report`` crash the DEVNULL default used to lose.
|
|
233
|
+
"""
|
|
234
|
+
text = (stderr or "").strip()
|
|
235
|
+
if not text:
|
|
236
|
+
return ""
|
|
237
|
+
lines = text.splitlines()[-max_lines:]
|
|
238
|
+
tail = "\n".join(lines)
|
|
239
|
+
return tail[-max_chars:]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _parse_report_tail(stdout: str) -> dict[str, object]:
|
|
243
|
+
"""The child's last valid JSON report line, or ``{}``."""
|
|
244
|
+
for line in reversed((stdout or "").strip().splitlines()):
|
|
245
|
+
try:
|
|
246
|
+
parsed = json.loads(line)
|
|
247
|
+
except json.JSONDecodeError:
|
|
248
|
+
continue
|
|
249
|
+
if isinstance(parsed, dict) and (
|
|
250
|
+
"outcome" in parsed or "preflight" in parsed
|
|
251
|
+
):
|
|
252
|
+
return parsed
|
|
253
|
+
return {}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _run_child(
|
|
257
|
+
job: dict[str, object],
|
|
258
|
+
*,
|
|
259
|
+
env: dict[str, str],
|
|
260
|
+
wall_clock: float,
|
|
261
|
+
python_flags: tuple[str, ...] = (),
|
|
262
|
+
) -> tuple[Optional[int], str, str, bool]:
|
|
263
|
+
"""Spawn the trial child, feed it ``job`` on stdin, and return
|
|
264
|
+
``(exit_code, stdout, stderr, timed_out)``.
|
|
265
|
+
|
|
266
|
+
Shared by :func:`run_forked_trial` and :func:`preflight`. stderr is
|
|
267
|
+
PIPED (never DEVNULL) so a pre-``_report`` crash's cause survives.
|
|
268
|
+
``python_flags`` is an interpreter-flag seam used by tests to
|
|
269
|
+
reproduce a restricted env (``-S``); production passes none.
|
|
270
|
+
"""
|
|
271
|
+
proc = subprocess.Popen(
|
|
272
|
+
[sys.executable, *python_flags, "-m", "activegraph.sandbox._child"],
|
|
273
|
+
stdin=subprocess.PIPE,
|
|
274
|
+
stdout=subprocess.PIPE,
|
|
275
|
+
stderr=subprocess.PIPE,
|
|
276
|
+
env=env,
|
|
277
|
+
text=True,
|
|
278
|
+
)
|
|
279
|
+
try:
|
|
280
|
+
stdout, stderr = proc.communicate(
|
|
281
|
+
input=json.dumps(job), timeout=wall_clock
|
|
282
|
+
)
|
|
283
|
+
return proc.returncode, stdout, stderr, False
|
|
284
|
+
except subprocess.TimeoutExpired:
|
|
285
|
+
proc.kill()
|
|
286
|
+
stdout, stderr = proc.communicate()
|
|
287
|
+
return proc.returncode, stdout, stderr, True
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
#: A representative memory cap the preflight probe applies when the
|
|
291
|
+
#: caller left ``max_rss_bytes`` unset — generous enough not to cap a
|
|
292
|
+
#: just-started child into a false ``MemoryError`` on Linux (a child
|
|
293
|
+
#: after importing activegraph sits near ~40 MiB of address space),
|
|
294
|
+
#: finite enough to actually exercise the ``setrlimit(RLIMIT_AS)`` call
|
|
295
|
+
#: that Darwin rejects. The point of the probe is to trip the platform
|
|
296
|
+
#: check, not to bound anything.
|
|
297
|
+
_PREFLIGHT_PROBE_RSS = 1024 * 2**20
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _limits_job_block(limits: TrialLimits, *, probe: bool) -> dict[str, object]:
|
|
301
|
+
"""The ``limits`` sub-dict of a child job. ``probe=True`` fills an
|
|
302
|
+
unset ``max_rss_bytes`` with :data:`_PREFLIGHT_PROBE_RSS` so
|
|
303
|
+
preflight always exercises the RLIMIT_AS path."""
|
|
304
|
+
max_rss = limits.max_rss_bytes
|
|
305
|
+
if probe and max_rss is None:
|
|
306
|
+
max_rss = _PREFLIGHT_PROBE_RSS
|
|
307
|
+
return {
|
|
308
|
+
"max_rss_bytes": max_rss,
|
|
309
|
+
"max_events": limits.max_events,
|
|
310
|
+
"max_llm_calls": limits.max_llm_calls,
|
|
311
|
+
"cpu_seconds": int(limits.wall_clock_seconds) + 5,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _preflight_with(
|
|
316
|
+
env: dict[str, str],
|
|
317
|
+
*,
|
|
318
|
+
limits: TrialLimits = TrialLimits(),
|
|
319
|
+
python_flags: tuple[str, ...] = (),
|
|
320
|
+
timeout: float = 30.0,
|
|
321
|
+
) -> tuple[str, ...]:
|
|
322
|
+
"""The preflight check against a specific ``env`` (the testable
|
|
323
|
+
seam under :func:`preflight`). Applies a representative limits block
|
|
324
|
+
so the resource-limit path is actually exercised. Returns any
|
|
325
|
+
degradation warnings (empty when every requested net applied);
|
|
326
|
+
raises :class:`SandboxStartupError` if a child cannot start and
|
|
327
|
+
report ``preflight: ok``.
|
|
328
|
+
"""
|
|
329
|
+
job = {"preflight": True, "limits": _limits_job_block(limits, probe=True)}
|
|
330
|
+
exit_code, stdout, stderr, timed_out = _run_child(
|
|
331
|
+
job, env=env, wall_clock=timeout, python_flags=python_flags
|
|
332
|
+
)
|
|
333
|
+
tail = _parse_report_tail(stdout)
|
|
334
|
+
if not timed_out and exit_code == 0 and tail.get("preflight") == "ok":
|
|
335
|
+
return tuple(str(w) for w in (tail.get("warnings") or ()))
|
|
336
|
+
cause = _stderr_tail(stderr) or (
|
|
337
|
+
"child timed out" if timed_out else "no preflight tail on stdout"
|
|
338
|
+
)
|
|
339
|
+
raise SandboxStartupError(
|
|
340
|
+
f"trial child could not start (exit {exit_code}): {cause}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def preflight(
|
|
345
|
+
*, limits: TrialLimits = TrialLimits(), timeout: float = 30.0
|
|
346
|
+
) -> tuple[str, ...]:
|
|
347
|
+
"""Verify a trial child can START under the sandbox env on this box.
|
|
348
|
+
|
|
349
|
+
Spawns the child with a null job that exercises the full startup
|
|
350
|
+
path a real trial hits — importing ``activegraph`` under the sandbox
|
|
351
|
+
env AND applying resource limits (v1.7.1: a go/no-go gate that
|
|
352
|
+
skipped limit application passed on macOS where every real trial
|
|
353
|
+
then crashed on RLIMIT_AS). No fork, no store, no candidate.
|
|
354
|
+
|
|
355
|
+
Returns a tuple of degradation warnings — empty when every net
|
|
356
|
+
applied cleanly, non-empty (and logged) when a net degraded on this
|
|
357
|
+
platform (e.g. the memory cap on macOS). Raises
|
|
358
|
+
:class:`SandboxStartupError` with the child's stderr tail if a child
|
|
359
|
+
cannot start at all. So on macOS this PASSES with a memory-net
|
|
360
|
+
warning rather than pass-then-crash; on a box that cannot import
|
|
361
|
+
activegraph it RAISES with the real cause. Consumers call it once at
|
|
362
|
+
boot to fail loud or learn what degraded before the first trial.
|
|
363
|
+
|
|
364
|
+
Pass the same ``limits`` a real trial will carry to probe that exact
|
|
365
|
+
configuration; the default probes with a representative memory cap.
|
|
366
|
+
"""
|
|
367
|
+
warnings = _preflight_with(_child_env(limits), limits=limits, timeout=timeout)
|
|
368
|
+
for w in warnings:
|
|
369
|
+
_log.warning("sandbox preflight: %s", w)
|
|
370
|
+
return warnings
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def run_forked_trial(
|
|
374
|
+
store_path: str,
|
|
375
|
+
*,
|
|
376
|
+
parent_run_id: str,
|
|
377
|
+
at_event: str,
|
|
378
|
+
pack_source: PackSource,
|
|
379
|
+
scenario: str = "",
|
|
380
|
+
limits: TrialLimits = TrialLimits(),
|
|
381
|
+
label: str = "trial",
|
|
382
|
+
extra_packs: tuple[PackSource, ...] = (),
|
|
383
|
+
) -> TrialReport:
|
|
384
|
+
"""Fork the parent at ``at_event`` and trial the candidate pack in
|
|
385
|
+
a fresh subprocess. CONTRACT v1.5 #1.
|
|
386
|
+
|
|
387
|
+
The fork is created HERE, in the parent process, with full
|
|
388
|
+
``fork()`` semantics (lineage recorded, promote-block cut guard);
|
|
389
|
+
the child receives only the fork's run id. ``scenario`` is
|
|
390
|
+
``"relative/path.py"`` or ``"relative/path.py::func"`` inside the
|
|
391
|
+
CANDIDATE's pack root — the function (default ``main``) is called
|
|
392
|
+
with the fork's ``Runtime`` and drives the trial; empty means
|
|
393
|
+
just ``run_until_idle()``. The scenario contract is
|
|
394
|
+
``def main(rt): ...``.
|
|
395
|
+
|
|
396
|
+
**The default is candidate-only isolation**: the child loads
|
|
397
|
+
nothing but the candidate, so the trial exercises it apart from
|
|
398
|
+
every other pack's behaviors. ``extra_packs`` is the opt-in for
|
|
399
|
+
cross-pack interaction trials (CONTRACT v1.5 #1 addendum 1b):
|
|
400
|
+
each entry is materialized in the child exactly like the
|
|
401
|
+
candidate — bundle hash verified before import, manifest schema
|
|
402
|
+
+ two-way surface check when required — and loaded, in order,
|
|
403
|
+
BEFORE the candidate. Any extra pack failing its pins is
|
|
404
|
+
``materialization_failed`` for the whole trial; the scenario
|
|
405
|
+
still resolves inside the candidate's root only.
|
|
406
|
+
|
|
407
|
+
Deterministic and key-free by default (``max_llm_calls=0``, empty
|
|
408
|
+
environment pass-through). Returns a :class:`TrialReport`; never
|
|
409
|
+
raises for in-trial failures — those are outcomes. Raises only
|
|
410
|
+
for parent-side setup problems (bad store, bad fork point), with
|
|
411
|
+
the same errors ``Runtime.load`` / ``fork()`` raise.
|
|
412
|
+
"""
|
|
413
|
+
from activegraph.runtime.runtime import Runtime
|
|
414
|
+
|
|
415
|
+
parent_rt = Runtime.load(store_path, run_id=parent_run_id, behaviors=[])
|
|
416
|
+
fork_rt = parent_rt.fork(at_event=at_event, label=label, behaviors=[])
|
|
417
|
+
fork_run_id = fork_rt.run_id
|
|
418
|
+
initial_events = len(fork_rt.graph.events)
|
|
419
|
+
del fork_rt # the child owns the fork from here
|
|
420
|
+
|
|
421
|
+
job = {
|
|
422
|
+
"store_path": store_path,
|
|
423
|
+
"fork_run_id": fork_run_id,
|
|
424
|
+
"initial_events": initial_events,
|
|
425
|
+
"pack_root": str(Path(pack_source.root_dir).resolve()),
|
|
426
|
+
"expected_bundle_hash": pack_source.expected_bundle_hash,
|
|
427
|
+
"manifest_required": pack_source.manifest_required,
|
|
428
|
+
"extra_packs": [
|
|
429
|
+
{
|
|
430
|
+
"pack_root": str(Path(p.root_dir).resolve()),
|
|
431
|
+
"expected_bundle_hash": p.expected_bundle_hash,
|
|
432
|
+
"manifest_required": p.manifest_required,
|
|
433
|
+
}
|
|
434
|
+
for p in extra_packs
|
|
435
|
+
],
|
|
436
|
+
"scenario": scenario,
|
|
437
|
+
"limits": _limits_job_block(limits, probe=False),
|
|
438
|
+
}
|
|
439
|
+
exit_code, stdout, stderr, timed_out = _run_child(
|
|
440
|
+
job, env=_child_env(limits), wall_clock=limits.wall_clock_seconds
|
|
441
|
+
)
|
|
442
|
+
tail = _parse_report_tail(stdout)
|
|
443
|
+
|
|
444
|
+
if timed_out:
|
|
445
|
+
outcome = "limits_exceeded"
|
|
446
|
+
detail = (
|
|
447
|
+
f"wall clock exceeded {limits.wall_clock_seconds}s; child killed"
|
|
448
|
+
)
|
|
449
|
+
elif tail:
|
|
450
|
+
outcome = str(tail.get("outcome", "crashed"))
|
|
451
|
+
if outcome not in TRIAL_OUTCOMES:
|
|
452
|
+
outcome = "crashed"
|
|
453
|
+
detail = str(tail.get("detail", ""))
|
|
454
|
+
else:
|
|
455
|
+
outcome = _EXIT_TO_OUTCOME.get(exit_code or -1, "crashed")
|
|
456
|
+
if outcome == "completed":
|
|
457
|
+
# Exit 0 with no tail is itself suspicious; say so.
|
|
458
|
+
outcome = "crashed"
|
|
459
|
+
detail = f"child exited {exit_code} with no report tail"
|
|
460
|
+
|
|
461
|
+
# The pre-execution gate must never swallow its own failure cause:
|
|
462
|
+
# when the child crashed before it could _report (module-import
|
|
463
|
+
# failure, rlimit setup, anything pre-main), the actual exception
|
|
464
|
+
# is on the child's stderr, not in any tail. Fold it into detail so
|
|
465
|
+
# it reaches the caller's recorded gate_result (v1.7 fix 1).
|
|
466
|
+
if outcome == "crashed":
|
|
467
|
+
err_tail = _stderr_tail(stderr)
|
|
468
|
+
if err_tail:
|
|
469
|
+
detail = f"{detail}; child stderr:\n{err_tail}"
|
|
470
|
+
|
|
471
|
+
# Degraded resource nets (v1.7.1): announced, never silent. A memory
|
|
472
|
+
# cap the platform refused (RLIMIT_AS on macOS) is folded into detail
|
|
473
|
+
# and logged, so a trial that ran WITHOUT the memory net it asked for
|
|
474
|
+
# is loud rather than quietly unbounded.
|
|
475
|
+
warnings = tuple(str(w) for w in (tail.get("warnings") or ()))
|
|
476
|
+
if warnings:
|
|
477
|
+
joined = "; ".join(warnings)
|
|
478
|
+
detail = f"{detail} [degraded: {joined}]" if detail else f"[degraded: {joined}]"
|
|
479
|
+
for w in warnings:
|
|
480
|
+
_log.warning("sandbox trial %s: %s", fork_run_id, w)
|
|
481
|
+
|
|
482
|
+
# The store is the record: re-read the fork run for the numbers.
|
|
483
|
+
events_appended = 0
|
|
484
|
+
behavior_failures = 0
|
|
485
|
+
try:
|
|
486
|
+
fork_view = Runtime.load(store_path, run_id=fork_run_id, behaviors=[])
|
|
487
|
+
events_appended = max(
|
|
488
|
+
0, len(fork_view.graph.events) - initial_events
|
|
489
|
+
)
|
|
490
|
+
behavior_failures = len(fork_view.trace.failures())
|
|
491
|
+
except Exception as e: # noqa: BLE001 — report, never mask the trial
|
|
492
|
+
detail = f"{detail} (store re-read failed: {e})".strip()
|
|
493
|
+
|
|
494
|
+
return TrialReport(
|
|
495
|
+
outcome=outcome,
|
|
496
|
+
fork_run_id=fork_run_id,
|
|
497
|
+
events_appended=events_appended,
|
|
498
|
+
behavior_failures=behavior_failures,
|
|
499
|
+
detail=detail,
|
|
500
|
+
exit_code=exit_code,
|
|
501
|
+
warnings=warnings,
|
|
502
|
+
)
|
|
@@ -22,6 +22,9 @@ from pathlib import Path
|
|
|
22
22
|
from typing import Any, Callable, Optional
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
_LIMIT_WARNINGS: list[str] = []
|
|
26
|
+
|
|
27
|
+
|
|
25
28
|
def _report(
|
|
26
29
|
outcome: str,
|
|
27
30
|
*,
|
|
@@ -39,6 +42,11 @@ def _report(
|
|
|
39
42
|
"events_appended": events_appended,
|
|
40
43
|
"behavior_failures": behavior_failures,
|
|
41
44
|
"detail": detail[:500],
|
|
45
|
+
# Degraded-limit warnings (v1.7.1): a resource net that
|
|
46
|
+
# could not be applied on this platform is announced,
|
|
47
|
+
# never silently skipped. The parent folds these into
|
|
48
|
+
# TrialReport.warnings and TrialReport.detail.
|
|
49
|
+
"warnings": list(_LIMIT_WARNINGS),
|
|
42
50
|
}
|
|
43
51
|
),
|
|
44
52
|
flush=True,
|
|
@@ -46,27 +54,79 @@ def _report(
|
|
|
46
54
|
raise SystemExit(exit_code)
|
|
47
55
|
|
|
48
56
|
|
|
49
|
-
def _apply_rlimits(limits: dict[str, Any]) ->
|
|
50
|
-
"""POSIX resource caps
|
|
51
|
-
|
|
57
|
+
def _apply_rlimits(limits: dict[str, Any]) -> list[str]:
|
|
58
|
+
"""Apply POSIX resource caps portably, returning a warning for any
|
|
59
|
+
cap that could not be set on this platform.
|
|
60
|
+
|
|
61
|
+
RLIMIT_AS (the memory net) is settable on Linux but rejected by the
|
|
62
|
+
Darwin kernel (``ValueError: current limit exceeds maximum limit``
|
|
63
|
+
— Darwin does not support address-space limiting). Rather than
|
|
64
|
+
crash (a trial child that can't start is useless) or silently skip
|
|
65
|
+
(a memory net that quietly does nothing is worse than one that
|
|
66
|
+
announces it is off), an unsettable cap DEGRADES: we record a loud
|
|
67
|
+
warning and let the wall-clock kill and event budget — which are
|
|
68
|
+
unaffected — remain the active nets. We never RAISE a hard limit
|
|
69
|
+
(that needs privilege and is what triggered the Darwin crash): the
|
|
70
|
+
target is clamped to the existing hard limit, so the call only ever
|
|
71
|
+
lowers, and the strong soft==hard cap is preserved where the kernel
|
|
72
|
+
allows it. Returns the list of degradation warnings (empty when all
|
|
73
|
+
requested caps applied).
|
|
74
|
+
"""
|
|
75
|
+
warnings: list[str] = []
|
|
52
76
|
try:
|
|
53
77
|
import resource
|
|
54
78
|
except ImportError: # pragma: no cover — Windows
|
|
55
|
-
|
|
79
|
+
if limits.get("max_rss_bytes"):
|
|
80
|
+
warnings.append(_mem_off("no `resource` module on this platform"))
|
|
81
|
+
return warnings
|
|
82
|
+
|
|
83
|
+
def _cap(kind: str, name: str, requested: int) -> None:
|
|
84
|
+
limit = getattr(resource, name)
|
|
85
|
+
_, hard = resource.getrlimit(limit)
|
|
86
|
+
target = (
|
|
87
|
+
requested
|
|
88
|
+
if hard == resource.RLIM_INFINITY
|
|
89
|
+
else min(requested, hard)
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
resource.setrlimit(limit, (target, target))
|
|
93
|
+
except (ValueError, OSError) as e:
|
|
94
|
+
warnings.append(
|
|
95
|
+
_mem_off(f"{sys.platform}: {type(e).__name__}: {e}")
|
|
96
|
+
if kind == "memory"
|
|
97
|
+
else (
|
|
98
|
+
f"CPU cap (RLIMIT_CPU) could not be applied on "
|
|
99
|
+
f"{sys.platform}: {type(e).__name__}: {e}"
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
56
103
|
max_rss = limits.get("max_rss_bytes")
|
|
57
104
|
if max_rss:
|
|
58
|
-
|
|
105
|
+
_cap("memory", "RLIMIT_AS", max_rss)
|
|
59
106
|
cpu = limits.get("cpu_seconds")
|
|
60
107
|
if cpu:
|
|
61
|
-
|
|
108
|
+
_cap("cpu", "RLIMIT_CPU", cpu)
|
|
109
|
+
return warnings
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _mem_off(reason: str) -> str:
|
|
113
|
+
return (
|
|
114
|
+
f"memory cap (RLIMIT_AS) could not be applied ({reason}); the "
|
|
115
|
+
f"memory net is OFF for this trial — the wall-clock kill and "
|
|
116
|
+
f"event budget still bound a runaway"
|
|
117
|
+
)
|
|
62
118
|
|
|
63
119
|
|
|
64
120
|
def _materialize_pack(job: dict[str, Any]) -> Any:
|
|
65
|
-
"""Verify pins, then import
|
|
121
|
+
"""Verify pins, then import one pack source and return its Pack.
|
|
66
122
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
123
|
+
``job`` carries ``pack_root`` / ``expected_bundle_hash`` /
|
|
124
|
+
``manifest_required`` — the top-level job spec for the candidate,
|
|
125
|
+
or one ``extra_packs`` entry (v1.7 addendum 1b), which uses the
|
|
126
|
+
identical chain. Order is the design's §3: bundle hash BEFORE any
|
|
127
|
+
import (the pin covers manifest.toml per the v1.4 amendment),
|
|
128
|
+
then manifest schema, then import, then the two-way surface check
|
|
129
|
+
against the live Pack.
|
|
70
130
|
"""
|
|
71
131
|
from activegraph.packs import Pack
|
|
72
132
|
from activegraph.packs.manifest import (
|
|
@@ -133,9 +193,29 @@ def _resolve_scenario(
|
|
|
133
193
|
|
|
134
194
|
def main() -> None:
|
|
135
195
|
job = json.loads(sys.stdin.read())
|
|
196
|
+
|
|
197
|
+
# Apply resource limits BEFORE the preflight branch (v1.7.1): the
|
|
198
|
+
# limit-application path is exactly what varies by host (Darwin
|
|
199
|
+
# rejects RLIMIT_AS), so preflight must exercise it to be a real
|
|
200
|
+
# go/no-go gate — a null job that skipped it would pass on a box
|
|
201
|
+
# where every real trial then crashed. Degradation warnings are
|
|
202
|
+
# stashed for _report and echoed on the preflight tail.
|
|
203
|
+
_LIMIT_WARNINGS.extend(_apply_rlimits(job.get("limits", {})))
|
|
204
|
+
|
|
205
|
+
# Preflight (v1.7): a null job that proves the child could START —
|
|
206
|
+
# importing activegraph under the sandbox env succeeded (before this
|
|
207
|
+
# function ran) AND resource limits could be applied (just above).
|
|
208
|
+
# Echo the marker plus any degradation warnings and exit clean. No
|
|
209
|
+
# fork, no store, no candidate touched.
|
|
210
|
+
if job.get("preflight"):
|
|
211
|
+
print(
|
|
212
|
+
json.dumps({"preflight": "ok", "warnings": list(_LIMIT_WARNINGS)}),
|
|
213
|
+
flush=True,
|
|
214
|
+
)
|
|
215
|
+
raise SystemExit(0)
|
|
216
|
+
|
|
136
217
|
fork_run_id = str(job["fork_run_id"])
|
|
137
218
|
initial = int(job.get("initial_events", 0))
|
|
138
|
-
_apply_rlimits(job.get("limits", {}))
|
|
139
219
|
|
|
140
220
|
def counts(rt: Any) -> tuple[int, int]:
|
|
141
221
|
return (
|
|
@@ -144,6 +224,9 @@ def main() -> None:
|
|
|
144
224
|
)
|
|
145
225
|
|
|
146
226
|
try:
|
|
227
|
+
# Extra packs first (addendum 1b): each pinned and verified
|
|
228
|
+
# exactly like the candidate, loaded before it below.
|
|
229
|
+
extra = [_materialize_pack(p) for p in job.get("extra_packs", [])]
|
|
147
230
|
pack = _materialize_pack(job)
|
|
148
231
|
except Exception as e: # noqa: BLE001 — outcome, not crash
|
|
149
232
|
_report(
|
|
@@ -174,6 +257,8 @@ def main() -> None:
|
|
|
174
257
|
behaviors=[],
|
|
175
258
|
budget=budget or None,
|
|
176
259
|
)
|
|
260
|
+
for trusted in extra:
|
|
261
|
+
rt.load_pack(trusted)
|
|
177
262
|
rt.load_pack(pack)
|
|
178
263
|
|
|
179
264
|
try:
|