diff --git a/docs/api-reference.md b/docs/api-reference.md index 62dd2bc..b932080 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -758,7 +758,7 @@ base_cli.TelemetryOptions(...) ### `TelemetrySession` **Kind:** class
-**Signature:** `TelemetrySession(span: 'Any', started_monotonic_ns: 'int') -> None` +**Signature:** `TelemetrySession(span: 'Any', started_monotonic_ns: 'int', context_api: 'Any | None' = None, context_token: 'Any | None' = None) -> None` **Behavior:** Best-effort state for a single lifecycle span. diff --git a/docs/integrations.md b/docs/integrations.md index 0d43124..72f4cde 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -62,6 +62,43 @@ exceptions. Interrupts and other non-success outcomes are therefore visible as errors while retaining the existing `base_cli.outcome` attribute for detailed dashboard filtering. +## Child-process run and trace propagation + +Use the active context when a command launches another CLI: + +```python +import subprocess + +subprocess.run(["other-cli", "sync"], env=ctx.child_environment(), check=True) +``` + +`Context.child_environment()` returns a copy of the environment containing the +following language-neutral contract: + +| Variable | Meaning | +| --- | --- | +| `BASE_CLI_RUN_ID` | The current run identity. It is validated as one safe path component. | +| `BASE_CLI_PARENT_RUN_ID` | The launching run identity. | +| `BASE_CLI_RUN_ROOT` | The absolute, framework-owned bundle to reuse when it exists. | +| `BASE_CLI_RUN_SCOPE` | `internal` for a propagated child, or `primary` for a deliberate top-level record. | +| `BASE_CLI_PROPAGATE` | Set to `0` to opt out of runtime and trace propagation. | +| `TRACEPARENT` / `TRACESTATE` | W3C trace context for OpenTelemetry-enabled consumers. | + +The generic profile honors these variables without a custom `RuntimeResolver`. +An inherited `BASE_CLI_RUN_ROOT` must be absolute, non-symlinked, and contain +valid `run.json` metadata; malformed IDs, traversal, absolute IDs, and invalid +scopes fail with an actionable configuration error before any path is created. +The generic profile's explicit `RuntimeResolver`, when supplied, is +authoritative and is not overridden by environment values. + +An inherited child reuses the parent's bundle and is marked `internal`; only +the process that created the bundle owns its lifecycle metadata and retention. +The framework cannot intercept arbitrary subprocess APIs, so callers must pass +the returned mapping (or selectively copy its documented variables). Use +`ctx.child_environment(propagate=False)` for an intentionally independent +child. OpenTelemetry extraction/injection remains best-effort and never makes +the optional dependency required. + ## Logger ownership The lifecycle uses `base_cli.` and owns only the handlers it creates. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index f008bbc..d877886 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -77,6 +77,7 @@ not a replacement for explicit `sensitive=True` declarations. | A plugin executes unwanted code or changes command behavior | Lazy discovery, deterministic ordering, duplicate detection, explicit `load`/`load_all`, canonical `group:name` or `dist:distribution` allowlists, and `disabled`; `tests/test_extensions.py` | Loaded extensions have the process's authority and are not sandboxed. Capability extras are informational metadata, not authorization. Pin and review distributions, disable discovery by default where possible, and use an allowlist. | | Concurrency corrupts logs, history, metadata, or retention state | Atomic replacement, sidecar/process locks, per-run leases, bounded complete-bundle retention, and concurrency/adversarial tests | The framework cannot make consumer databases or external stores transactional. Use a transactional writer and define recovery for multi-process consumers. Unknown run liveness fails closed. | | Child/inherited runs mutate or delete a parent's artifacts | Inherited runtimes do not own a new bundle, do not finalize parent metadata, and are excluded from cleanup/retention; inherited-run tests cover startup, success, and failure | Parent and child processes still share the user's filesystem authority. Consumers must validate parent provenance and avoid inheriting from untrusted paths. | +| Untrusted process environment redirects a child into an arbitrary run bundle | The generic propagation contract validates single-component run IDs, absolute non-symlinked bundle roots, active `run.json` metadata, and `primary`/`internal` scopes before constructing a layout; `Context.child_environment()` emits a copied, opt-out-capable contract | Environment values are still same-user input. Use the helper rather than forwarding arbitrary paths, keep cache roots private, and provide an explicit `RuntimeResolver` when a consumer has a stronger provenance policy. | | Telemetry leaks argv, config, paths, or secrets | Telemetry is opt-in; only run ID, CLI name, environment, dry-run, outcome, exit code, and duration are attached; missing/broken exporters are no-ops; `tests/test_integrations.py` | A consumer-supplied tracer/exporter can add arbitrary attributes or send to an untrusted endpoint. Review provider configuration, use TLS/authentication, and never attach raw command data. | | Optional integrations or dependencies broaden the attack surface | Rich, Typer, and OpenTelemetry are optional extras; import/use is lazy; quality CI runs Bandit and pip-audit | A vulnerable or malicious dependency remains a supply-chain risk. Pin/lock deployments, review updates, and install only required extras. | | Secondary persistence failure hides the real command result or leaves false state | Transactional startup/teardown, terminal outcome finalization, best-effort history, and rollback tests | A crash can still lose diagnostics. Treat logs/history as evidence, not an authorization or billing source, and alert on persistence warnings. | diff --git a/lib/python/base_cli/_app_core.py b/lib/python/base_cli/_app_core.py index d20e9e4..9d1594d 100644 --- a/lib/python/base_cli/_app_core.py +++ b/lib/python/base_cli/_app_core.py @@ -1115,14 +1115,14 @@ def wrapper(**kwargs: Any) -> Any: recorder = RunRecorder(context, started_at, started_monotonic_ns) except BaseException as exc: # pylint: disable=broad-exception-caught _warn_lifecycle_failure(context, "Run recorder construction failed", exc) + finish_telemetry( + telemetry_session, + context, + outcome, + ended_monotonic_ns=ended_monotonic_ns, + exception=exception, + ) if recorder is not None: - finish_telemetry( - telemetry_session, - context, - outcome, - ended_monotonic_ns=ended_monotonic_ns, - exception=exception, - ) _finish_run_recorder( recorder, outcome, diff --git a/lib/python/base_cli/_propagation.py b/lib/python/base_cli/_propagation.py new file mode 100644 index 0000000..442a94f --- /dev/null +++ b/lib/python/base_cli/_propagation.py @@ -0,0 +1,176 @@ +"""Validated process-boundary propagation for runtime identity. + +The environment is a transport, not an authority. Values received from it are +validated before they influence runtime state or filesystem paths. +""" + +from __future__ import annotations + +import json +import os +import stat +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +from ._runtime import _open_absolute_directory +from .errors import ConfigurationError + +RUN_ID_ENV = "BASE_CLI_RUN_ID" +PARENT_RUN_ID_ENV = "BASE_CLI_PARENT_RUN_ID" +RUN_ROOT_ENV = "BASE_CLI_RUN_ROOT" +RUN_SCOPE_ENV = "BASE_CLI_RUN_SCOPE" +PROPAGATE_ENV = "BASE_CLI_PROPAGATE" +TRACEPARENT_ENV = "TRACEPARENT" +TRACESTATE_ENV = "TRACESTATE" + +_SAFE_RUN_ID_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") +_VALID_SCOPES = frozenset({"primary", "internal"}) + + +@dataclass(frozen=True) +class RuntimePropagation: + """Validated values received across a process boundary.""" + + run_id: str | None = None + parent_run_id: str | None = None + run_root: Path | None = None + scope: str | None = None + + +def read_runtime_propagation(environ: Mapping[str, str] | None = None) -> RuntimePropagation: + """Read and validate the base-cli runtime environment contract.""" + + values = os.environ if environ is None else environ + if not _is_enabled(values.get(PROPAGATE_ENV)): + return RuntimePropagation() + + run_id = _optional_run_id(values.get(RUN_ID_ENV), RUN_ID_ENV) + parent_run_id = _optional_run_id(values.get(PARENT_RUN_ID_ENV), PARENT_RUN_ID_ENV) + raw_root = _optional_text(values.get(RUN_ROOT_ENV)) + scope = _optional_text(values.get(RUN_SCOPE_ENV)) + if scope is not None: + scope = scope.lower() + if scope not in _VALID_SCOPES: + raise ConfigurationError(f"{RUN_SCOPE_ENV} must be 'primary' or 'internal', not {scope!r}.") + if raw_root is not None and run_id is None and parent_run_id is None: + raise ConfigurationError( + f"{RUN_ROOT_ENV} requires {RUN_ID_ENV} or {PARENT_RUN_ID_ENV}; " + "a runtime path cannot be trusted without an identity." + ) + run_root = _validate_run_root(raw_root) if raw_root is not None else None + return RuntimePropagation( + run_id=run_id, + parent_run_id=parent_run_id, + run_root=run_root, + scope=scope, + ) + + +def validate_run_root(run_root: Path, expected_run_id: str | None = None) -> Path: + """Validate a parent run bundle supplied by a process boundary. + + The path must be absolute, an actual directory (not a symlink), and carry + a private ``run.json`` whose identity matches the supplied run ID when one + is available. The returned path is resolved and safe to use as an + inherited runtime root. + """ + + candidate = Path(run_root).expanduser() + if not candidate.is_absolute(): + raise ConfigurationError(f"{RUN_ROOT_ENV} must be an absolute path.") + if candidate != candidate.resolve(strict=False): + raise ConfigurationError(f"{RUN_ROOT_ENV} must not contain symlinked path components.") + try: + resolved = candidate.resolve(strict=True) + except OSError as exc: + raise ConfigurationError(f"{RUN_ROOT_ENV} does not point to a readable directory: {exc}") from exc + if not resolved.is_dir() or resolved.is_symlink(): + raise ConfigurationError(f"{RUN_ROOT_ENV} must point to a real directory.") + + try: + payload = _read_run_metadata(resolved) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ConfigurationError(f"{RUN_ROOT_ENV} has no valid run metadata.") from exc + metadata_run_id = payload.get("run_id") if isinstance(payload, dict) else None + if not isinstance(metadata_run_id, str) or not _is_safe_run_id(metadata_run_id): + raise ConfigurationError(f"{RUN_ROOT_ENV} has invalid run metadata.") + if expected_run_id is not None and metadata_run_id != expected_run_id: + raise ConfigurationError(f"{RUN_ROOT_ENV} belongs to run {metadata_run_id!r}, not {expected_run_id!r}.") + return resolved + + +def _read_run_metadata(run_root: Path) -> object: + """Read run metadata through a stable directory handle when available.""" + + if _supports_secure_directory_reads(): + root_fd = _open_absolute_directory(run_root) + metadata_fd: int | None = None + try: + flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0) + metadata_fd = os.open("run.json", flags, dir_fd=root_fd) + metadata_stat = os.fstat(metadata_fd) + if not stat.S_ISREG(metadata_stat.st_mode): + raise OSError("run.json is not a regular file") + with os.fdopen(metadata_fd, "r", encoding="utf-8") as stream: + metadata_fd = None + return json.load(stream) + finally: + if metadata_fd is not None: + os.close(metadata_fd) + os.close(root_fd) + + metadata_path = run_root / "run.json" + if metadata_path.is_symlink() or not metadata_path.is_file(): + raise OSError("run.json is not a regular file") + return json.loads(metadata_path.read_text(encoding="utf-8")) + + +def _supports_secure_directory_reads() -> bool: + return ( + os.name != "nt" and hasattr(os, "O_DIRECTORY") and hasattr(os, "O_NOFOLLOW") and os.open in os.supports_dir_fd + ) + + +def _validate_run_root(raw_root: str) -> Path: + return validate_run_root(Path(raw_root)) + + +def _optional_run_id(value: str | None, variable: str) -> str | None: + text = _optional_text(value) + if text is None: + return None + if not _is_safe_run_id(text): + raise ConfigurationError( + f"{variable} must be a single filesystem-safe run ID containing only letters, digits, '.', '_' or '-'." + ) + return text + + +def _optional_text(value: str | None) -> str | None: + if value is None: + return None + text = value.strip() + return text or None + + +def _is_safe_run_id(value: str) -> bool: + return bool(value) and ".." not in value and all(character in _SAFE_RUN_ID_CHARS for character in value) + + +def _is_enabled(value: str | None) -> bool: + return value is None or value.strip().lower() not in {"0", "false", "no", "off"} + + +__all__ = [ + "PARENT_RUN_ID_ENV", + "PROPAGATE_ENV", + "RUN_ID_ENV", + "RUN_ROOT_ENV", + "RUN_SCOPE_ENV", + "TRACEPARENT_ENV", + "TRACESTATE_ENV", + "RuntimePropagation", + "read_runtime_propagation", + "validate_run_root", +] diff --git a/lib/python/base_cli/context.py b/lib/python/base_cli/context.py index 59b54d0..59f5215 100644 --- a/lib/python/base_cli/context.py +++ b/lib/python/base_cli/context.py @@ -10,8 +10,18 @@ from typing import Any, Generic, TypeVar from ._cleanup import remove_owned_temp_directory +from ._propagation import ( + PARENT_RUN_ID_ENV, + PROPAGATE_ENV, + RUN_ID_ENV, + RUN_ROOT_ENV, + RUN_SCOPE_ENV, + TRACEPARENT_ENV, + TRACESTATE_ENV, +) from .config import FrameworkConfig from .history import display_command as _default_history_display_command +from .integrations import inject_trace_context _current_context: contextvars.ContextVar[Context[Any, Any, Any] | None] = contextvars.ContextVar( "base_cli_current_context", @@ -108,6 +118,46 @@ def bind_project(self, project_name: str | None, project_root: Path, manifest_pa self.project_root = project_root.resolve() self.manifest_path = manifest_path.resolve() if manifest_path is not None else None + def child_environment( + self, + *, + environ: Mapping[str, str] | None = None, + propagate: bool = True, + ) -> dict[str, str]: + """Return an environment for a child process correlated to this run. + + The returned copy carries the run identity, parent identity, shared + bundle path, and internal history scope. When the optional telemetry + extra is installed, the current W3C trace context is injected too. + Set ``propagate=False`` for an intentionally independent child. + """ + + child_env = dict(os.environ if environ is None else environ) + if not propagate: + for variable in ( + RUN_ID_ENV, + PARENT_RUN_ID_ENV, + RUN_ROOT_ENV, + RUN_SCOPE_ENV, + TRACEPARENT_ENV, + TRACESTATE_ENV, + ): + child_env.pop(variable, None) + child_env[PROPAGATE_ENV] = "0" + return child_env + + child_env[PROPAGATE_ENV] = "1" + child_env[RUN_ID_ENV] = self.run_id + child_env[PARENT_RUN_ID_ENV] = self.run_id + child_env[RUN_SCOPE_ENV] = "internal" + if self.run_root is not None: + child_env[RUN_ROOT_ENV] = str(self.run_root.resolve()) + else: + child_env.pop(RUN_ROOT_ENV, None) + + inject_trace_context(child_env) + return child_env + def _warn_cleanup_failure(self, message: str, *args: object) -> None: try: self.log.warning(message, *args) diff --git a/lib/python/base_cli/integrations.py b/lib/python/base_cli/integrations.py index b920f05..e6691f4 100644 --- a/lib/python/base_cli/integrations.py +++ b/lib/python/base_cli/integrations.py @@ -10,11 +10,14 @@ from __future__ import annotations +import os import time from collections.abc import Sequence from dataclasses import dataclass from typing import Any, TextIO +from ._propagation import TRACEPARENT_ENV, TRACESTATE_ENV + @dataclass(frozen=True) class TelemetryOptions: @@ -37,6 +40,8 @@ class TelemetrySession: span: Any started_monotonic_ns: int + context_api: Any | None = None + context_token: Any | None = None def try_render_rich_table( @@ -102,23 +107,69 @@ def start_telemetry(options: TelemetryOptions | None, context: Any) -> Telemetry try: tracer = options.tracer + otel_context_api: Any | None = None + parent_context: Any | None = None + otel_trace_api: Any | None = None + try: + from opentelemetry import context as otel_context + from opentelemetry import propagate + from opentelemetry import trace as otel_trace + + # W3C carriers are lower-case; process environments are commonly + # upper-case. Normalize only the documented trace variables, never + # the complete environment (which could accidentally carry W3C + # baggage from an unrelated variable). + carrier: dict[str, str] = {} + for variable in (TRACEPARENT_ENV, TRACESTATE_ENV): + value = os.environ.get(variable) + if value is None: + value = os.environ.get(variable.lower()) + if value is not None: + carrier[variable.lower()] = value + if carrier: + parent_context = propagate.extract(carrier) + otel_context_api = otel_context + otel_trace_api = otel_trace + except ImportError: + pass if tracer is None: - from opentelemetry import trace + if otel_trace_api is None: + from opentelemetry import trace as otel_trace - tracer = trace.get_tracer( + otel_trace_api = otel_trace + tracer = otel_trace_api.get_tracer( options.tracer_name, tracer_provider=options.tracer_provider, ) attributes = _start_attributes(context) try: - span = tracer.start_span("base_cli.run", attributes=attributes) + if parent_context is None: + span = tracer.start_span("base_cli.run", attributes=attributes) + else: + span = tracer.start_span("base_cli.run", attributes=attributes, context=parent_context) except TypeError: - # Small test/demonstration tracers may only accept a span name. - span = tracer.start_span("base_cli.run") + try: + # Small test/demonstration tracers may reject context or + # attributes; preserve the historical name-only fallback. + span = tracer.start_span("base_cli.run", attributes=attributes) + except TypeError: + span = tracer.start_span("base_cli.run") if span is None: return None _safe_span_call(span, "add_event", "base_cli.run.started", attributes=attributes) - return TelemetrySession(span=span, started_monotonic_ns=time.monotonic_ns()) + context_token = None + if otel_context_api is not None and otel_trace_api is not None and hasattr(span, "get_span_context"): + try: + active_context = otel_trace_api.set_span_in_context(span, parent_context) + context_token = otel_context_api.attach(active_context) + except Exception: + context_token = None + return TelemetrySession( + span=span, + started_monotonic_ns=time.monotonic_ns(), + context_api=otel_context_api, + context_token=context_token, + ) except Exception as exc: # pragma: no cover - optional package/runtime dependent _debug_integration_failure(context, "OpenTelemetry start failed", exc) return None @@ -164,6 +215,30 @@ def finish_telemetry( # command result even if an exporter or logger raises process-control # exceptions while the span is being closed. _debug_integration_failure(context, "OpenTelemetry finish failed", exc) + finally: + if session.context_api is not None and session.context_token is not None: + try: + session.context_api.detach(session.context_token) + except Exception: + pass + + +def inject_trace_context(environ: dict[str, str]) -> None: + """Inject the current OpenTelemetry span into a child process environment.""" + + try: + from opentelemetry import propagate + + carrier: dict[str, str] = {} + propagate.inject(carrier) + for key, value in carrier.items(): + if key.lower() == TRACEPARENT_ENV.lower(): + environ[TRACEPARENT_ENV] = value + elif key.lower() == TRACESTATE_ENV.lower(): + environ[TRACESTATE_ENV] = value + except Exception: + # Trace propagation is optional and must never change command behavior. + pass def _start_attributes(context: Any) -> dict[str, Any]: @@ -216,6 +291,7 @@ def _debug_integration_failure(context: Any, message: str, exc: BaseException) - "TelemetryOptions", "TelemetrySession", "finish_telemetry", + "inject_trace_context", "start_telemetry", "try_render_rich_table", ] diff --git a/lib/python/base_cli/profile.py b/lib/python/base_cli/profile.py index ffd2fe3..2d53628 100644 --- a/lib/python/base_cli/profile.py +++ b/lib/python/base_cli/profile.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Any, Protocol, cast +from ._propagation import read_runtime_propagation from ._runtime import runtime_layout from .config import ( BatteriesIncludedConfigLoader, @@ -324,7 +325,16 @@ def _generic_runtime_resolver( ) -> RuntimeResolver: def resolve_runtime(cli_name: str, project: ProjectInfo | None) -> RuntimeBinding: root = (cache_root if cache_root is not None else default_cache_root()).expanduser().resolve() - run_id = make_run_id() + propagated = read_runtime_propagation() + run_id = propagated.run_id or make_run_id() + inherited_path = propagated.run_root + parent_run_id = propagated.parent_run_id + if inherited_path is not None: + # A run root always identifies the parent invocation. When a + # caller forwards only BASE_CLI_RUN_ID, the shared bundle's run ID + # is the parent identity as well. + parent_run_id = parent_run_id or propagated.run_id + history_scope = propagated.scope or ("internal" if parent_run_id is not None else "primary") project_root = project.root if project is not None else None project_name = project.name if project is not None else None return RuntimeBinding( @@ -335,14 +345,16 @@ def resolve_runtime(cli_name: str, project: ProjectInfo | None) -> RuntimeBindin run_id, namespace=cli_name, project_name=project_name, + inherited_run_root=inherited_path, ), application_home=application_home, runtime_owner="default", project_root=project_root, project_name=project_name, - inherited_path=None, - history_parent_run_id=None, + inherited_path=inherited_path, + history_parent_run_id=parent_run_id, run_id=run_id, + history_scope=history_scope, ) return resolve_runtime diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 70529bf..bff026e 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -46,6 +46,15 @@ def start_span(self, name: str, *, attributes: dict[str, object]) -> _Span: return self.span +class _NameOnlyTracer: + def __init__(self) -> None: + self.span = _Span() + + def start_span(self, name: str) -> _Span: + self.span.name = name + return self.span + + class _BrokenTracer: def start_span(self, *_args: object, **_kwargs: object) -> object: raise RuntimeError("exporter unavailable") @@ -129,6 +138,14 @@ def main(ctx: base_cli.Context) -> None: self.assertEqual(tracer.span.exceptions, []) self.assertIsNotNone(tracer.span.status) + def test_name_only_tracer_remains_supported(self) -> None: + tracer = _NameOnlyTracer() + + session = start_telemetry(base_cli.TelemetryOptions(tracer=tracer), object()) + + self.assertIsNotNone(session) + self.assertEqual(tracer.span.name, "base_cli.run") + def test_telemetry_marks_failures_and_records_exception(self) -> None: tracer = _Tracer() app = base_cli.App( diff --git a/tests/test_propagation.py b/tests/test_propagation.py new file mode 100644 index 0000000..e3897c2 --- /dev/null +++ b/tests/test_propagation.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +from unittest import mock + +import base_cli +import pytest +from base_cli._propagation import read_runtime_propagation +from base_cli.integrations import inject_trace_context, start_telemetry +from base_cli.testing import invoke + + +def _run_metadata(run_root: Path, run_id: str) -> None: + run_root.mkdir(parents=True) + (run_root / "run.json").write_text( + json.dumps({"schema_version": 1, "run_id": run_id, "status": "running"}), + encoding="utf-8", + ) + + +def test_generic_profile_inherits_a_valid_run_bundle_and_marks_child_internal(tmp_path: Path) -> None: + run_root = tmp_path / "cache" / "parent" / "runs" / "parent-run__parent" + _run_metadata(run_root, "parent-run") + seen: dict[str, object] = {} + app = base_cli.App(name="child", profile=base_cli.CliProfile.generic(cache_root=tmp_path / "cache")) + + @app.command() + def main(ctx: base_cli.Context) -> None: + seen.update( + { + "run_id": ctx.run_id, + "parent": ctx.history_parent_run_id, + "scope": ctx.history_scope, + "run_root": ctx.run_root, + } + ) + + result = invoke( + app, + home=tmp_path / "home", + env={ + "BASE_CLI_RUN_ID": "child-run", + "BASE_CLI_PARENT_RUN_ID": "parent-run", + "BASE_CLI_RUN_ROOT": str(run_root), + }, + ) + + assert result.exit_code == 0, result.output + assert seen == { + "run_id": "child-run", + "parent": "parent-run", + "scope": "internal", + "run_root": run_root, + } + assert not (run_root / "run.json").read_text(encoding="utf-8").__contains__("child-run") + + +@pytest.mark.parametrize("value", ["../escape", "/absolute", "parent/run", "bad\x00id", ".."]) +def test_inbound_run_id_is_rejected_before_runtime_resolution(value: str) -> None: + with mock.patch.dict("os.environ", {}, clear=True): + with pytest.raises(base_cli.ConfigurationError, match="BASE_CLI_RUN_ID"): + read_runtime_propagation({"BASE_CLI_RUN_ID": value}) + + +def test_inbound_run_root_must_be_a_real_active_bundle(tmp_path: Path) -> None: + with mock.patch.dict( + os.environ, + { + "BASE_CLI_RUN_ID": "parent-run", + "BASE_CLI_RUN_ROOT": str(tmp_path / "missing"), + }, + clear=True, + ): + with pytest.raises(base_cli.ConfigurationError, match="BASE_CLI_RUN_ROOT"): + read_runtime_propagation() + + +def test_explicit_runtime_resolver_takes_precedence_over_inbound_environment(tmp_path: Path) -> None: + layout = base_cli.RuntimeLayout( + owner_root=tmp_path / "owner", + run_root=tmp_path / "owner" / "run", + state_dir=tmp_path / "owner", + log_dir=tmp_path / "owner" / "logs", + cache_dir=tmp_path / "owner" / "cache", + temp_dir=tmp_path / "owner" / "tmp", + ) + + def resolve_runtime( + _cli_name: str, + _project: base_cli.ProjectInfo | None, + ) -> base_cli.RuntimeBinding: + return base_cli.RuntimeBinding( + cache_root=tmp_path, + layout=layout, + application_home=None, + runtime_owner="explicit", + project_root=None, + project_name=None, + inherited_path=None, + history_parent_run_id=None, + run_id="explicit-run", + ) + + profile = base_cli.CliProfile.generic(resolve_runtime=resolve_runtime) + with mock.patch.dict("os.environ", {"BASE_CLI_RUN_ID": "../escape"}, clear=True): + assert profile.resolve_runtime("tool", None).run_id == "explicit-run" + + +def test_child_environment_exports_runtime_identity_and_supports_opt_out(tmp_path: Path) -> None: + ctx = base_cli.Context( + cli_name="parent", + run_id="parent-run", + state_dir=tmp_path, + log_dir=tmp_path, + cache_dir=tmp_path, + temp_dir=tmp_path, + log_file=None, + config={}, + environment="dev", + debug=False, + keep_temp=False, + log=logging.getLogger("test"), + run_root=tmp_path / "bundle", + ) + child = ctx.child_environment(environ={"KEEP": "yes"}) + assert child["KEEP"] == "yes" + assert child["BASE_CLI_RUN_ID"] == "parent-run" + assert child["BASE_CLI_PARENT_RUN_ID"] == "parent-run" + assert child["BASE_CLI_RUN_ROOT"] == str((tmp_path / "bundle").resolve()) + assert child["BASE_CLI_RUN_SCOPE"] == "internal" + + independent = ctx.child_environment(environ=child, propagate=False) + assert independent["BASE_CLI_PROPAGATE"] == "0" + assert "BASE_CLI_RUN_ID" not in independent + assert "BASE_CLI_RUN_ROOT" not in independent + + +def test_otel_trace_context_is_extracted_and_injected_for_a_child() -> None: + otel_trace = pytest.importorskip("opentelemetry.trace") + otel_propagate = pytest.importorskip("opentelemetry.propagate") + otel_baggage = pytest.importorskip("opentelemetry.baggage") + from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags, TraceState + + parent = SpanContext( + trace_id=0x1, + span_id=0x3, + is_remote=False, + trace_flags=TraceFlags(1), + trace_state=TraceState(), + ) + carrier: dict[str, str] = {} + otel_propagate.inject(carrier, context=otel_trace.set_span_in_context(NonRecordingSpan(parent))) + received_context: object | None = None + + class Tracer: + def start_span(self, name: str, *, attributes: dict[str, object], context: object) -> object: + nonlocal received_context + del name, attributes + received_context = context + received = otel_trace.get_current_span(context).get_span_context() + assert received.trace_id == parent.trace_id + return NonRecordingSpan( + SpanContext( + trace_id=received.trace_id, + span_id=0x4, + is_remote=False, + trace_flags=TraceFlags(1), + trace_state=TraceState(), + ) + ) + + with mock.patch.dict( + "os.environ", + {"TRACEPARENT": carrier["traceparent"], "BAGGAGE": "unrelated=environment"}, + clear=True, + ): + session = start_telemetry(base_cli.TelemetryOptions(tracer=Tracer()), object()) + child_env: dict[str, str] = {} + inject_trace_context(child_env) + + assert session is not None + assert received_context is not None + assert otel_baggage.get_all(received_context) == {} + assert child_env["TRACEPARENT"].endswith("0000000000000004-01")