openbox-sdk-python 0.2.0__py3-none-any.whl

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.
Files changed (52) hide show
  1. openbox_core/__init__.py +59 -0
  2. openbox_core/adapters/__init__.py +3 -0
  3. openbox_core/adapters/base.py +123 -0
  4. openbox_core/approvals.py +106 -0
  5. openbox_core/client.py +298 -0
  6. openbox_core/config.py +260 -0
  7. openbox_core/conformance/__init__.py +3 -0
  8. openbox_core/conformance/fake_core.py +169 -0
  9. openbox_core/conformance/hook_preflight.py +87 -0
  10. openbox_core/conformance/instrumentation.py +91 -0
  11. openbox_core/context.py +205 -0
  12. openbox_core/contracts/__init__.py +3 -0
  13. openbox_core/contracts/context.py +79 -0
  14. openbox_core/contracts/events.py +401 -0
  15. openbox_core/contracts/otel_spans.py +325 -0
  16. openbox_core/contracts/results.py +287 -0
  17. openbox_core/errors.py +287 -0
  18. openbox_core/gate.py +185 -0
  19. openbox_core/hooks/__init__.py +3 -0
  20. openbox_core/hooks/events.py +64 -0
  21. openbox_core/hooks/preflight.py +292 -0
  22. openbox_core/hooks/wrappers.py +105 -0
  23. openbox_core/identity.py +231 -0
  24. openbox_core/instrumentation/__init__.py +3 -0
  25. openbox_core/instrumentation/db.py +689 -0
  26. openbox_core/instrumentation/file.py +239 -0
  27. openbox_core/instrumentation/function.py +121 -0
  28. openbox_core/instrumentation/http.py +840 -0
  29. openbox_core/instrumentation/llm.py +3 -0
  30. openbox_core/instrumentation/manager.py +135 -0
  31. openbox_core/instrumentation/shared.py +27 -0
  32. openbox_core/otel/__init__.py +3 -0
  33. openbox_core/otel/propagation.py +45 -0
  34. openbox_core/otel/provider.py +35 -0
  35. openbox_core/otel/setup.py +36 -0
  36. openbox_core/otel/span_processor.py +62 -0
  37. openbox_core/otel/trace_context.py +71 -0
  38. openbox_core/py.typed +0 -0
  39. openbox_core/runtime.py +138 -0
  40. openbox_core/sdk_version.py +79 -0
  41. openbox_core/serialization.py +129 -0
  42. openbox_core/validation/__init__.py +3 -0
  43. openbox_core/validation/diagnostics.py +60 -0
  44. openbox_core/validation/event_rules.py +164 -0
  45. openbox_core/validation/registry.py +31 -0
  46. openbox_core/validation/span_normalization.py +107 -0
  47. openbox_core/wire/__init__.py +3 -0
  48. openbox_core/wire/core_span.py +130 -0
  49. openbox_core/wire/evaluate_payload.py +56 -0
  50. openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
  51. openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
  52. openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
openbox_core/config.py ADDED
@@ -0,0 +1,260 @@
1
+ """OpenBoxConfig and nested config groups with layered env resolution.
2
+
3
+ Resolution order (highest wins):
4
+
5
+ 1. explicit arguments
6
+ 2. SDK-specific environment variables via ``env_prefix`` (e.g.
7
+ ``OPENBOX_FRAMEWORK_API_KEY`` for ``env_prefix="OPENBOX_FRAMEWORK"``)
8
+ 3. global ``OPENBOX_*`` environment variables
9
+ 4. defaults
10
+ 5. validation and normalization
11
+
12
+ Common framework configuration fields map onto the nested groups here:
13
+
14
+ skip_workflow_types / skip_activity_types / skip_signals /
15
+ enforce_task_queues / send_start_event / send_activity_start_event -> gate
16
+ hitl_enabled / skip_hitl_activity_types / hitl_poll_interval_ms -> hitl
17
+ max_body_size -> privacy
18
+ on_api_error / api_timeout -> top level
19
+
20
+ No heavy imports; safe outside sandbox paths (env access happens only inside
21
+ ``resolve()``, never at import time).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from collections.abc import Mapping
28
+ from dataclasses import dataclass, field
29
+ from typing import Any
30
+
31
+ from .errors import OpenBoxAuthError, OpenBoxConfigError, OpenBoxInsecureURLError
32
+ from .sdk_version import DEFAULT_SDK_ENGINE, DEFAULT_SDK_LANGUAGE
33
+
34
+ __all__ = [
35
+ "GLOBAL_ENV_PREFIX",
36
+ "HitlConfig",
37
+ "TelemetryConfig",
38
+ "InstrumentationConfig",
39
+ "GateConfig",
40
+ "PrivacyConfig",
41
+ "OpenBoxConfig",
42
+ ]
43
+
44
+ # API key format pattern (obx_live_... or obx_test_...)
45
+ API_KEY_PATTERN = re.compile(r"^obx_(live|test)_\w+$")
46
+
47
+ GLOBAL_ENV_PREFIX = "OPENBOX"
48
+
49
+ # Config fields resolvable from the environment (suffix -> coercion).
50
+ _ENV_FIELDS: dict[str, str] = {
51
+ "api_url": "API_URL",
52
+ "api_key": "API_KEY",
53
+ "timeout_seconds": "TIMEOUT_SECONDS",
54
+ "on_api_error": "ON_API_ERROR",
55
+ "agent_name": "AGENT_NAME",
56
+ "agent_did": "AGENT_DID",
57
+ "agent_private_key": "AGENT_PRIVATE_KEY",
58
+ }
59
+
60
+
61
+ @dataclass
62
+ class HitlConfig:
63
+ """Human-in-the-loop approval polling configuration."""
64
+
65
+ enabled: bool = True
66
+ poll_interval_ms: int = 5000
67
+ max_wait_ms: int | None = None # None = poll indefinitely (framework decides)
68
+ # Activity types to skip approval checks for (avoids infinite loops).
69
+ skip_activity_types: set[str] = field(default_factory=lambda: {"send_governance_event"})
70
+
71
+
72
+ @dataclass
73
+ class TelemetryConfig:
74
+ """Telemetry emission toggles."""
75
+
76
+ enabled: bool = True
77
+
78
+
79
+ @dataclass
80
+ class InstrumentationConfig:
81
+ """Generic instrumentation install toggles."""
82
+
83
+ enabled: bool = True
84
+ http_enabled: bool = True
85
+ db_enabled: bool = True
86
+ # Safe to default-on: interpreter-owned paths bypass governance and a
87
+ # re-entrancy guard passes through evaluation-time opens.
88
+ file_enabled: bool = True
89
+ function_enabled: bool = True
90
+ llm_enabled: bool = False # Reserved; disabled until provider hooks are implemented.
91
+ install_opentelemetry: bool = True
92
+ preflight_enabled: bool = True
93
+ completed_telemetry_enabled: bool = True
94
+
95
+
96
+ @dataclass
97
+ class GateConfig:
98
+ """Event-level gate toggles (which lifecycle events are evaluated).
99
+
100
+ Gate mode is not configurable: event/runtime contracts are always strict.
101
+ These fields control only which events are emitted.
102
+ """
103
+
104
+ skip_workflow_types: set[str] = field(default_factory=set)
105
+ skip_signals: set[str] = field(default_factory=set)
106
+ # By default skip the governance event activity itself to avoid loops.
107
+ skip_activity_types: set[str] = field(default_factory=lambda: {"send_governance_event"})
108
+ enforce_task_queues: set[str] | None = None # None = all
109
+ send_start_event: bool = True
110
+ send_activity_start_event: bool = True
111
+
112
+
113
+ @dataclass
114
+ class PrivacyConfig:
115
+ """Redaction/truncation applied BEFORE signing."""
116
+
117
+ redact_keys: set[str] = field(default_factory=set)
118
+ max_body_size: int = 65536 # chars
119
+
120
+
121
+ @dataclass
122
+ class OpenBoxConfig:
123
+ """Resolved base-SDK configuration.
124
+
125
+ Build via :meth:`resolve` for layered env resolution + validation, or
126
+ construct directly in tests (no validation on direct construction).
127
+ """
128
+
129
+ api_url: str = ""
130
+ api_key: str = ""
131
+ timeout_seconds: float = 30.0
132
+ on_api_error: str = "fail_open" # "fail_open" | "fail_closed"
133
+ on_fallback: Any = None # reserved passthrough for fallback callbacks
134
+ agent_name: str | None = None
135
+ agent_did: str | None = None
136
+ agent_private_key: str | None = field(default=None, repr=False) # never in repr
137
+ sdk_version: str | None = None
138
+ sdk_engine: str = DEFAULT_SDK_ENGINE
139
+ sdk_language: str = DEFAULT_SDK_LANGUAGE
140
+ env_prefix: str | None = None
141
+ hitl: HitlConfig = field(default_factory=HitlConfig)
142
+ telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
143
+ instrumentation: InstrumentationConfig = field(default_factory=InstrumentationConfig)
144
+ gate: GateConfig = field(default_factory=GateConfig)
145
+ privacy: PrivacyConfig = field(default_factory=PrivacyConfig)
146
+ metadata: dict[str, Any] = field(default_factory=dict)
147
+
148
+ # ── Resolution ───────────────────────────────────────────────────────
149
+
150
+ @classmethod
151
+ def resolve(
152
+ cls,
153
+ *,
154
+ env_prefix: str | None = None,
155
+ environ: Mapping[str, str] | None = None,
156
+ validate: bool = True,
157
+ **explicit: Any,
158
+ ) -> OpenBoxConfig:
159
+ """Layered resolution: explicit > env_prefix > OPENBOX_* > defaults.
160
+
161
+ Args:
162
+ env_prefix: SDK-specific env namespace (e.g. ``OPENBOX_FRAMEWORK``).
163
+ environ: Environment mapping (defaults to ``os.environ``; injectable
164
+ for tests).
165
+ validate: Run validation/normalization (step 5). Disable only in
166
+ tests that need partial configs.
167
+ **explicit: Explicit values for any OpenBoxConfig field. ``None``
168
+ means "not provided" and falls through to the next layer.
169
+ """
170
+ if environ is None:
171
+ import os
172
+
173
+ environ = os.environ
174
+
175
+ unknown = set(explicit) - {f.name for f in cls.__dataclass_fields__.values()} # type: ignore[attr-defined]
176
+ if unknown:
177
+ raise OpenBoxConfigError(f"Unknown config fields: {sorted(unknown)}")
178
+
179
+ resolved: dict[str, Any] = {}
180
+ for field_name, suffix in _ENV_FIELDS.items():
181
+ value: Any = explicit.get(field_name)
182
+ if value is None and env_prefix:
183
+ value = environ.get(f"{env_prefix}_{suffix}")
184
+ if value is None:
185
+ value = environ.get(f"{GLOBAL_ENV_PREFIX}_{suffix}")
186
+ if value is not None:
187
+ resolved[field_name] = value
188
+
189
+ # Non-env fields pass through explicitly only.
190
+ for field_name, value in explicit.items():
191
+ if field_name not in _ENV_FIELDS and value is not None:
192
+ resolved[field_name] = value
193
+
194
+ config = cls(env_prefix=env_prefix, **resolved)
195
+ return config.normalized() if validate else config
196
+
197
+ def normalized(self) -> OpenBoxConfig:
198
+ """Validate + normalize in place (step 5). Returns self for chaining."""
199
+ if not self.api_url:
200
+ raise OpenBoxConfigError("api_url is required")
201
+ if not self.api_key:
202
+ raise OpenBoxConfigError("api_key is required")
203
+
204
+ self.api_url = str(self.api_url).rstrip("/")
205
+ _validate_url_security(self.api_url)
206
+
207
+ if not API_KEY_PATTERN.match(self.api_key):
208
+ raise OpenBoxAuthError(
209
+ f"Invalid API key format. Expected 'obx_live_*' or 'obx_test_*', "
210
+ f"got: '{self.api_key[:15]}...' (showing first 15 chars)"
211
+ )
212
+
213
+ try:
214
+ self.timeout_seconds = float(self.timeout_seconds)
215
+ except (TypeError, ValueError):
216
+ raise OpenBoxConfigError(
217
+ f"timeout_seconds must be numeric, got {self.timeout_seconds!r}"
218
+ ) from None
219
+
220
+ if self.on_api_error not in ("fail_open", "fail_closed"):
221
+ raise OpenBoxConfigError(
222
+ f"on_api_error must be 'fail_open' or 'fail_closed', got {self.on_api_error!r}"
223
+ )
224
+
225
+ # DID + private key: both-or-neither; format-validate the DID eagerly.
226
+ if bool(self.agent_did) != bool(self.agent_private_key):
227
+ raise OpenBoxConfigError(
228
+ "agent_did and agent_private_key must be provided together "
229
+ "(got only one). Provide both to enable signed requests, or neither."
230
+ )
231
+ if self.agent_did:
232
+ from .identity import validate_agent_did
233
+
234
+ validate_agent_did(self.agent_did)
235
+ return self
236
+
237
+ def load_identity(self) -> Any:
238
+ """Load an :class:`~openbox_core.identity.AgentIdentity` (or None).
239
+
240
+ Decodes + loads the Ed25519 seed exactly once; callers keep the
241
+ returned identity and never re-touch the raw key string.
242
+ """
243
+ if not (self.agent_did and self.agent_private_key):
244
+ return None
245
+ from .identity import AgentIdentity
246
+
247
+ return AgentIdentity.from_private_key(self.agent_did, self.agent_private_key)
248
+
249
+
250
+ def _validate_url_security(api_url: str) -> None:
251
+ """HTTPS required for non-localhost URLs (protects API keys in transit)."""
252
+ from urllib.parse import urlparse
253
+
254
+ parsed = urlparse(api_url)
255
+ is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
256
+ if parsed.scheme == "http" and not is_localhost:
257
+ raise OpenBoxInsecureURLError(
258
+ f"Insecure HTTP URL detected: {api_url}. "
259
+ "Use HTTPS for non-localhost URLs to protect API keys in transit."
260
+ )
@@ -0,0 +1,3 @@
1
+ """Reusable conformance fixtures importable by framework SDKs."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,169 @@
1
+ """Programmable fake OpenBox Core — verdict/approval queues + payload capture.
2
+
3
+ No live network: requests terminate in an ``httpx.MockTransport``. Serves both
4
+ sync and async clients. Importable from any framework SDK repo.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from typing import Any
12
+
13
+ from ..client import EvaluationClient
14
+
15
+ __all__ = ["FakeCore", "fake_client", "assert_hook_wire_shape"]
16
+
17
+ _SPAN_ID_RE = re.compile(r"^[0-9a-f]{16}$")
18
+ _TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
19
+
20
+ # Common root fields every flat hook span must carry (present even when null).
21
+ _COMMON_ROOT_FIELDS = (
22
+ "span_id",
23
+ "trace_id",
24
+ "parent_span_id",
25
+ "name",
26
+ "kind",
27
+ "stage",
28
+ "start_time",
29
+ "end_time",
30
+ "duration_ns",
31
+ "attributes",
32
+ "status",
33
+ "events",
34
+ "hook_type",
35
+ "error",
36
+ )
37
+
38
+ # Family-specific root fields that must exist (present even when null) per type.
39
+ _FAMILY_ROOT_FIELDS = {
40
+ "http_request": (
41
+ "http_method",
42
+ "http_url",
43
+ "http_status_code",
44
+ "request_headers",
45
+ "response_headers",
46
+ "request_body",
47
+ "response_body",
48
+ ),
49
+ "db_query": (
50
+ "db_system",
51
+ "db_name",
52
+ "db_operation",
53
+ "db_statement",
54
+ "server_address",
55
+ "server_port",
56
+ "rowcount",
57
+ ),
58
+ "file_operation": (
59
+ "file_path",
60
+ "file_mode",
61
+ "file_operation",
62
+ "bytes_read",
63
+ "bytes_written",
64
+ ),
65
+ "function_call": ("function", "module", "args", "result"),
66
+ }
67
+
68
+
69
+ class FakeCore:
70
+ """Queue verdict/approval responses; capture every outgoing payload.
71
+
72
+ Responses pop FIFO from ``queue``; an empty queue answers ALLOW. Approval
73
+ polls share the same queue (enqueue in call order).
74
+ """
75
+
76
+ def __init__(self, *responses: dict[str, Any]):
77
+ self.queue: list[dict[str, Any]] = list(responses)
78
+ self.payloads: list[dict[str, Any]] = []
79
+ self.approval_requests: list[dict[str, Any]] = []
80
+
81
+ # ── transport ─────────────────────────────────────────────────────────
82
+
83
+ def handler(self, request: Any) -> Any:
84
+ import httpx
85
+
86
+ if request.url.path.endswith("/governance/evaluate"):
87
+ self.payloads.append(json.loads(request.content))
88
+ return httpx.Response(200, json=self._next({"verdict": "allow"}))
89
+ if request.url.path.endswith("/governance/approval"):
90
+ self.approval_requests.append(json.loads(request.content))
91
+ return httpx.Response(200, json=self._next({"action": "allow"}))
92
+ return httpx.Response(200, json={})
93
+
94
+ def _next(self, default: dict[str, Any]) -> dict[str, Any]:
95
+ return self.queue.pop(0) if self.queue else default
96
+
97
+ # ── capture views ─────────────────────────────────────────────────────
98
+
99
+ @property
100
+ def started_payloads(self) -> list[dict[str, Any]]:
101
+ return [
102
+ p for p in self.payloads
103
+ if p.get("spans") and p["spans"][0].get("stage") == "started"
104
+ ]
105
+
106
+ @property
107
+ def completed_payloads(self) -> list[dict[str, Any]]:
108
+ return [
109
+ p for p in self.payloads
110
+ if p.get("spans") and p["spans"][0].get("stage") == "completed"
111
+ ]
112
+
113
+ @property
114
+ def lifecycle_payloads(self) -> list[dict[str, Any]]:
115
+ return [p for p in self.payloads if not p.get("hook_trigger")]
116
+
117
+
118
+ def fake_client(fake_core: FakeCore, **kwargs: Any) -> EvaluationClient:
119
+ """EvaluationClient wired to the fake Core (sync + async transports)."""
120
+ import httpx
121
+
122
+ transport = httpx.MockTransport(fake_core.handler)
123
+ return EvaluationClient(
124
+ kwargs.pop("api_url", "https://core.test"),
125
+ kwargs.pop("api_key", "obx_test_conformance"),
126
+ transport=transport,
127
+ async_transport=transport,
128
+ **kwargs,
129
+ )
130
+
131
+
132
+ def assert_hook_wire_shape(payload: dict[str, Any]) -> None:
133
+ """Assert one captured hook payload matches the flat Core wire contract:
134
+
135
+ - ``event_type=ActivityStarted`` + ``hook_trigger=true`` + non-empty spans
136
+ - hex-string ids (regex, not truthiness)
137
+ - flat ``SpanData`` dicts — the nested ``otel``/``openbox`` envelope and any
138
+ opt-in ``data`` blob must never reach the wire
139
+ - every common root field present (``stage``/``hook_type``/``error``/…)
140
+ - every family-specific root field present for the span's ``hook_type``
141
+ - ``semantic_type`` never set by the SDK (Core computes it)
142
+ """
143
+ assert payload.get("event_type") == "ActivityStarted", payload.get("event_type")
144
+ assert payload.get("hook_trigger") is True
145
+ spans = payload.get("spans")
146
+ assert spans, "hook payload must carry non-empty spans"
147
+ assert payload.get("span_count") == len(spans)
148
+ for span in spans:
149
+ assert "otel" not in span and "openbox" not in span, (
150
+ "nested span envelope leaked to the wire"
151
+ )
152
+ assert "data" not in span, "flat hook spans must not carry a data blob"
153
+ assert "semantic_type" not in span, "semantic_type is computed by Core, not the SDK"
154
+ for field_name in _COMMON_ROOT_FIELDS:
155
+ assert field_name in span, f"missing common root field: {field_name}"
156
+ assert _SPAN_ID_RE.fullmatch(span.get("span_id", "")), span.get("span_id")
157
+ assert _TRACE_ID_RE.fullmatch(span.get("trace_id", "")), span.get("trace_id")
158
+ parent = span.get("parent_span_id")
159
+ if parent is not None:
160
+ assert _SPAN_ID_RE.fullmatch(parent), parent
161
+ assert span.get("stage") in ("started", "completed")
162
+ assert span.get("hook_type"), "hook spans must carry hook_type at the root"
163
+ for field_name in _FAMILY_ROOT_FIELDS.get(span.get("hook_type", ""), ()):
164
+ assert field_name in span, (
165
+ f"missing {span.get('hook_type')} root field: {field_name}"
166
+ )
167
+ if span.get("stage") == "started":
168
+ assert span["end_time"] is None
169
+ assert span["duration_ns"] is None
@@ -0,0 +1,87 @@
1
+ """Adapter/context-binding conformance pieces: recording adapter + env builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..config import InstrumentationConfig, OpenBoxConfig
8
+ from ..context import ContextStore
9
+ from ..contracts.context import ActivityContext
10
+ from ..contracts.results import EvaluationResult, Verdict
11
+ from ..errors import ApprovalRejectedError, GovernanceBlockedError, GovernanceHaltError
12
+ from ..runtime import OpenBoxRuntime
13
+ from .fake_core import FakeCore, fake_client
14
+
15
+ __all__ = ["CONFORMANCE_CONTEXT", "RecordingHookAdapter", "build_conformance_runtime"]
16
+
17
+ # The reference activity context every conformance case binds.
18
+ CONFORMANCE_CONTEXT = ActivityContext(
19
+ workflow_id="wf-conformance",
20
+ run_id="run-conformance",
21
+ workflow_type="ConformanceWorkflow",
22
+ task_queue="conformance-queue",
23
+ activity_id="act-conformance",
24
+ activity_type="conformance_activity",
25
+ )
26
+
27
+
28
+ class RecordingHookAdapter:
29
+ """Reference FrameworkAdapter: records every delegation, raises core errors.
30
+
31
+ ``approve_next`` drives the async approval outcome (True ⇒ approved).
32
+ """
33
+
34
+ name = "conformance"
35
+
36
+ def __init__(self) -> None:
37
+ self.hook_blocked: list[EvaluationResult] = []
38
+ self.lifecycle_blocked: list[EvaluationResult] = []
39
+ self.completed_results: list[EvaluationResult] = []
40
+ self.completed_contexts: list[ActivityContext | None] = []
41
+ self.approvals: list[EvaluationResult] = []
42
+ self.approve_next = True
43
+
44
+ async def handle_approval(self, result: EvaluationResult) -> None:
45
+ self.approvals.append(result)
46
+ if not self.approve_next:
47
+ raise ApprovalRejectedError("rejected by conformance adapter")
48
+
49
+ def raise_lifecycle_blocked(self, result: EvaluationResult) -> None:
50
+ self.lifecycle_blocked.append(result)
51
+ self._raise(result)
52
+
53
+ def raise_hook_blocked(self, result: EvaluationResult) -> None:
54
+ self.hook_blocked.append(result)
55
+ self._raise(result)
56
+
57
+ def on_completed_hook_result(
58
+ self, result: EvaluationResult, context: ActivityContext | None = None
59
+ ) -> None:
60
+ self.completed_results.append(result)
61
+ self.completed_contexts.append(context)
62
+
63
+ @staticmethod
64
+ def _raise(result: EvaluationResult) -> None:
65
+ if result.verdict is Verdict.HALT:
66
+ raise GovernanceHaltError(result.reason or "halted")
67
+ raise GovernanceBlockedError(result.verdict, result.reason or "blocked")
68
+
69
+
70
+ def build_conformance_runtime(
71
+ fake_core: FakeCore,
72
+ adapter: Any | None = None,
73
+ store: ContextStore | None = None,
74
+ **instrumentation_overrides: Any,
75
+ ) -> OpenBoxRuntime:
76
+ """OpenBoxRuntime wired to the fake Core with an isolated ContextStore."""
77
+ config = OpenBoxConfig(
78
+ api_url="https://core.test",
79
+ api_key="obx_test_conformance",
80
+ instrumentation=InstrumentationConfig(**instrumentation_overrides),
81
+ )
82
+ return OpenBoxRuntime(
83
+ config,
84
+ adapter if adapter is not None else RecordingHookAdapter(),
85
+ client=fake_client(fake_core),
86
+ context_store=store if store is not None else ContextStore(),
87
+ )
@@ -0,0 +1,91 @@
1
+ """Instrumentation conformance environment — real wrappers, fake Core.
2
+
3
+ Framework SDKs import this to prove their integration keeps every behavioral
4
+ guarantee: the checks assert the REAL operation ran / did not run, not merely
5
+ payload shape. No live network (governance terminates in the fake Core; HTTP
6
+ cases hit a local counting server).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import http.server
13
+ import threading
14
+ from collections.abc import Iterator
15
+ from typing import Any
16
+
17
+ from ..context import ContextStore, activity_scope
18
+ from ..instrumentation.manager import InstrumentationManager
19
+ from ..runtime import OpenBoxRuntime
20
+ from .fake_core import FakeCore
21
+ from .hook_preflight import (
22
+ CONFORMANCE_CONTEXT,
23
+ RecordingHookAdapter,
24
+ build_conformance_runtime,
25
+ )
26
+
27
+ __all__ = [
28
+ "LocalCountingServer",
29
+ "installed_conformance_runtime",
30
+ "bound_conformance_activity",
31
+ ]
32
+
33
+
34
+ class LocalCountingServer:
35
+ """Loopback HTTP server counting hits — proves a request was/wasn't sent."""
36
+
37
+ def __init__(self) -> None:
38
+ self.hits = 0
39
+ outer = self
40
+
41
+ class Handler(http.server.BaseHTTPRequestHandler):
42
+ def _respond(self) -> None:
43
+ outer.hits += 1
44
+ body = b'{"ok": true}'
45
+ self.send_response(200)
46
+ self.send_header("Content-Type", "application/json")
47
+ self.send_header("Content-Length", str(len(body)))
48
+ self.end_headers()
49
+ self.wfile.write(body)
50
+
51
+ do_GET = _respond
52
+ do_POST = _respond
53
+
54
+ def log_message(self, *args: Any) -> None: # keep test output clean
55
+ pass
56
+
57
+ self._server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
58
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
59
+ self._thread.start()
60
+ self.url = f"http://127.0.0.1:{self._server.server_port}/echo"
61
+
62
+ def stop(self) -> None:
63
+ self._server.shutdown()
64
+ self._server.server_close()
65
+
66
+
67
+ @contextlib.contextmanager
68
+ def installed_conformance_runtime(
69
+ fake_core: FakeCore,
70
+ adapter: Any | None = None,
71
+ store: ContextStore | None = None,
72
+ **instrumentation_overrides: Any,
73
+ ) -> Iterator[OpenBoxRuntime]:
74
+ """Runtime with REAL instrumentation installed; guaranteed uninstall."""
75
+ adapter = adapter if adapter is not None else RecordingHookAdapter()
76
+ store = store if store is not None else ContextStore()
77
+ runtime = build_conformance_runtime(fake_core, adapter, store, **instrumentation_overrides)
78
+ manager = InstrumentationManager(runtime)
79
+ runtime._instrumentation_manager = manager
80
+ manager.install()
81
+ try:
82
+ yield runtime
83
+ finally:
84
+ manager.uninstall()
85
+
86
+
87
+ @contextlib.contextmanager
88
+ def bound_conformance_activity(store: ContextStore) -> Iterator[None]:
89
+ """Bind the reference ActivityContext with guaranteed reset."""
90
+ with activity_scope(CONFORMANCE_CONTEXT, store=store):
91
+ yield