mainsequence 9.0.8.dev44__tar.gz → 9.0.9__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.
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/PKG-INFO +1 -1
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/browser_auth.py +3 -2
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/config.py +3 -7
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/agent_runtime_models.py +3 -1
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/models_foundry.py +5 -1
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/models_helpers.py +6 -2
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/utils.py +69 -24
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/code_repository_context.py +3 -3
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/logconf.py +29 -10
- mainsequence-9.0.9/mainsequence/runtime_credential_exchange.py +168 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence.egg-info/PKG-INFO +1 -1
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence.egg-info/SOURCES.txt +3 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/pyproject.toml +1 -1
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_auth_precedence.py +1 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_cli_secure_storage.py +17 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_code_repository_runtime_context.py +13 -2
- mainsequence-9.0.9/tests/test_credential_repr.py +172 -0
- mainsequence-9.0.9/tests/test_runtime_credential_exchange.py +383 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/LICENSE +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/README.md +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/AGENTS.md +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/__init__.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/__main__.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/_request_identity.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/bootstrap.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/__init__.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/api.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/cli.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/code_repository_status.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/docker_utils.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/doctor.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/local_ops.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/model_filters.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/pydantic_cli.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/sdk_utils.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/ssh_utils.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/cli/ui.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/__init__.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/_transport.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/base.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/client.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/compute_validation.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/exceptions.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/github_issues.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/inference.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/models_user.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/observability.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/client/value_sets.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/code_repository_skills.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/defaults.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/instrumentation/__init__.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/instrumentation/utils.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/repository_identity_security.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/runtime_flags.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/scaffold_skills.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/server/__init__.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/server/caller_assertions.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence/server/fastapi.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence.egg-info/dependency_links.txt +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence.egg-info/entry_points.txt +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence.egg-info/requires.txt +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/mainsequence.egg-info/top_level.txt +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/setup.cfg +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_auth_bootstrap.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_caller_assertions.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_canonical_api_route_contract.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_cli_auth_contract.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_cli_browser_auth.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_client.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_code_repository_branch_contract.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_code_repository_documentation_contract.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_code_repository_skills.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_dependency_extras.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_dev_release_version.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_environment_resource_context.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_environment_response_contracts.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_fastapi_request_identity.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_github_issues_client.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_http_retry_budget.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_independent_context.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_inference_client.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_instrumentation.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_job_client.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_local_ops.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_logconf.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_models_user_request_bound_auth.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_notification_uid_contract.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_observability_client.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_scaffold_skills.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_secret_client_model.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_source_ownership.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_ssh_utils.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_tolerant_response_reading.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_tolerant_value_sets.py +0 -0
- {mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_user_application_facts.py +0 -0
|
@@ -15,7 +15,7 @@ import threading
|
|
|
15
15
|
import time
|
|
16
16
|
import webbrowser
|
|
17
17
|
from collections.abc import Callable
|
|
18
|
-
from dataclasses import dataclass
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
19
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
20
20
|
from urllib.parse import parse_qs, urlparse
|
|
21
21
|
|
|
@@ -37,7 +37,8 @@ class BrowserAuthError(RuntimeError):
|
|
|
37
37
|
class BrowserAuthCallback:
|
|
38
38
|
"""Authorization callback payload captured from the local loopback receiver."""
|
|
39
39
|
|
|
40
|
-
code:
|
|
40
|
+
# The authorization code is a bearer credential: it stays out of repr() and str().
|
|
41
|
+
code: str | None = field(default=None, repr=False)
|
|
41
42
|
state: str | None = None
|
|
42
43
|
error: str | None = None
|
|
43
44
|
error_description: str | None = None
|
|
@@ -29,6 +29,7 @@ from keyring import core as keyring_core
|
|
|
29
29
|
from keyring import errors as keyring_errors
|
|
30
30
|
|
|
31
31
|
from mainsequence.defaults import CANONICAL_BACKEND_ENV, STANDARD_BACKEND_URL
|
|
32
|
+
from mainsequence.runtime_credential_exchange import runtime_credential_configured
|
|
32
33
|
|
|
33
34
|
APP_NAME = "MainSequenceCLI"
|
|
34
35
|
|
|
@@ -804,13 +805,8 @@ def session_report() -> dict:
|
|
|
804
805
|
refresh_expires_at = token_expiry(refresh)
|
|
805
806
|
|
|
806
807
|
if auth_mode == "runtime_credential":
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
or (
|
|
810
|
-
(os.environ.get("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID") or "").strip()
|
|
811
|
-
and (os.environ.get("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET") or "").strip()
|
|
812
|
-
)
|
|
813
|
-
)
|
|
808
|
+
# The credential is an ID with a bootstrap secret or an identity token file.
|
|
809
|
+
authenticated = bool(access or runtime_credential_configured())
|
|
814
810
|
session_expires_at = None
|
|
815
811
|
elif refresh:
|
|
816
812
|
authenticated = refresh_expires_at is None or refresh_expires_at > now
|
|
@@ -353,7 +353,8 @@ class AgentRuntimePresence(BasePydanticModel):
|
|
|
353
353
|
|
|
354
354
|
|
|
355
355
|
class AgentSessionRuntimeAccess(BasePydanticModel):
|
|
356
|
-
|
|
356
|
+
# A validation error must not echo the input: it holds the bearer token.
|
|
357
|
+
model_config = ConfigDict(extra="allow", hide_input_in_errors=True)
|
|
357
358
|
|
|
358
359
|
coding_agent_service_uid: str | None = Field(
|
|
359
360
|
None,
|
|
@@ -373,6 +374,7 @@ class AgentSessionRuntimeAccess(BasePydanticModel):
|
|
|
373
374
|
token: str | None = Field(
|
|
374
375
|
None,
|
|
375
376
|
description="Bearer token that authorizes calls to the coding-agent gateway.",
|
|
377
|
+
repr=False,
|
|
376
378
|
)
|
|
377
379
|
expires_at: str | None = Field(
|
|
378
380
|
None,
|
|
@@ -7,7 +7,7 @@ from uuid import UUID
|
|
|
7
7
|
|
|
8
8
|
import yaml
|
|
9
9
|
from cachetools import TTLCache, cachedmethod
|
|
10
|
-
from pydantic import Field, SecretStr
|
|
10
|
+
from pydantic import ConfigDict, Field, SecretStr
|
|
11
11
|
|
|
12
12
|
from .base import (
|
|
13
13
|
BaseObjectOrm,
|
|
@@ -891,6 +891,10 @@ class Secret(
|
|
|
891
891
|
BasePydanticModel,
|
|
892
892
|
BaseObjectOrm,
|
|
893
893
|
):
|
|
894
|
+
# `value` is a SecretStr once read, but a validation error would echo the
|
|
895
|
+
# plaintext it was given.
|
|
896
|
+
model_config = ConfigDict(hide_input_in_errors=True)
|
|
897
|
+
|
|
894
898
|
HYDRATE_FILTER_GET_FROM_DETAIL: ClassVar[bool] = True
|
|
895
899
|
FILTERSET_FIELDS: ClassVar[dict[str, list[str]]] = {
|
|
896
900
|
"uid": ["in", "exact"],
|
|
@@ -6,7 +6,7 @@ from decimal import Decimal
|
|
|
6
6
|
from typing import Any, ClassVar, Literal
|
|
7
7
|
from uuid import UUID
|
|
8
8
|
|
|
9
|
-
from pydantic import BaseModel, Field, PositiveInt
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, PositiveInt
|
|
10
10
|
|
|
11
11
|
from .base import (
|
|
12
12
|
BaseObjectOrm,
|
|
@@ -798,6 +798,10 @@ class ResourceReleaseRuntimePresence(BasePydanticModel):
|
|
|
798
798
|
|
|
799
799
|
|
|
800
800
|
class ResourceReleaseRuntimeAccess(BasePydanticModel):
|
|
801
|
+
# `access` holds the runtime's bearer token, so neither repr() nor a
|
|
802
|
+
# validation error shows it.
|
|
803
|
+
model_config = ConfigDict(hide_input_in_errors=True)
|
|
804
|
+
|
|
801
805
|
resource_release_uid: str
|
|
802
806
|
# The canonical vocabulary ResourceRelease.release_kind reads (#119): this
|
|
803
807
|
# payload names a release kind, so it names it the same way.
|
|
@@ -805,7 +809,7 @@ class ResourceReleaseRuntimeAccess(BasePydanticModel):
|
|
|
805
809
|
routing: ResourceReleaseRuntimeRouting
|
|
806
810
|
runtime_access: ResourceReleaseRuntimeAdmission
|
|
807
811
|
runtime_presence: ResourceReleaseRuntimePresence
|
|
808
|
-
access: dict[str, Any] | None = None
|
|
812
|
+
access: dict[str, Any] | None = Field(default=None, repr=False)
|
|
809
813
|
|
|
810
814
|
|
|
811
815
|
class ResourceRelease(
|
|
@@ -21,6 +21,14 @@ from requests.structures import CaseInsensitiveDict
|
|
|
21
21
|
|
|
22
22
|
from mainsequence.defaults import resolve_backend_endpoint
|
|
23
23
|
from mainsequence.logconf import logger
|
|
24
|
+
from mainsequence.runtime_credential_exchange import (
|
|
25
|
+
RUNTIME_CREDENTIAL_ID_ENV,
|
|
26
|
+
RUNTIME_CREDENTIAL_SECRET_ENV,
|
|
27
|
+
RUNTIME_IDENTITY_TOKEN_FILE_ENV,
|
|
28
|
+
RuntimeIdentityTokenError,
|
|
29
|
+
exchange_runtime_credential,
|
|
30
|
+
identity_token_file_from_environment,
|
|
31
|
+
)
|
|
24
32
|
|
|
25
33
|
from ._transport import DEFAULT_ALLOWED_METHODS as DEFAULT_ALLOWED_METHODS
|
|
26
34
|
from ._transport import DEFAULT_STATUS_FORCELIST as DEFAULT_STATUS_FORCELIST
|
|
@@ -270,8 +278,9 @@ class BaseAuthProvider:
|
|
|
270
278
|
|
|
271
279
|
@dataclass
|
|
272
280
|
class SessionJWTAuthProvider(BaseAuthProvider):
|
|
273
|
-
|
|
274
|
-
|
|
281
|
+
# Tokens stay out of repr() and str(), so logging the provider never shows them.
|
|
282
|
+
access_token: str | None = field(default=None, repr=False)
|
|
283
|
+
refresh_token: str | None = field(default=None, repr=False)
|
|
275
284
|
header_keyword: str = "Bearer"
|
|
276
285
|
|
|
277
286
|
def __post_init__(self):
|
|
@@ -309,20 +318,37 @@ class SessionJWTAuthProvider(BaseAuthProvider):
|
|
|
309
318
|
|
|
310
319
|
@dataclass
|
|
311
320
|
class RuntimeCredentialAuthProvider(BaseAuthProvider):
|
|
321
|
+
"""
|
|
322
|
+
Exchange the runtime credential the platform injected for short-lived access tokens.
|
|
323
|
+
|
|
324
|
+
The proof is the projected workload identity token when
|
|
325
|
+
`MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE` is set: the file is read for every
|
|
326
|
+
exchange, and `MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET` is never read or sent.
|
|
327
|
+
Otherwise the proof is that bootstrap secret. The token is never kept on the
|
|
328
|
+
provider, and the secret is left out of `repr()` and `str()`. Throttled
|
|
329
|
+
(`429`) and temporarily unavailable (`503`) exchanges are retried within the
|
|
330
|
+
timeout budget; a rejected one (`401`) fails at once.
|
|
331
|
+
"""
|
|
332
|
+
|
|
312
333
|
credential_id: str | None = None
|
|
313
|
-
credential_secret: str | None = None
|
|
334
|
+
credential_secret: str | None = field(default=None, repr=False)
|
|
314
335
|
token_url: str | None = None
|
|
315
336
|
token_type: str = "Bearer"
|
|
316
337
|
refresh_skew_seconds: int = 30
|
|
317
338
|
timeout: tuple[float, float] = DEFAULT_TIMEOUT
|
|
318
339
|
expires_at: float | None = None
|
|
340
|
+
identity_token_file: str | None = None
|
|
319
341
|
_lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
|
|
320
342
|
|
|
321
343
|
def __post_init__(self):
|
|
322
344
|
if self.credential_id is None:
|
|
323
|
-
self.credential_id = os.getenv(
|
|
324
|
-
if self.
|
|
325
|
-
self.
|
|
345
|
+
self.credential_id = os.getenv(RUNTIME_CREDENTIAL_ID_ENV)
|
|
346
|
+
if self.identity_token_file is None:
|
|
347
|
+
self.identity_token_file = identity_token_file_from_environment()
|
|
348
|
+
else:
|
|
349
|
+
self.identity_token_file = str(self.identity_token_file).strip() or None
|
|
350
|
+
if self.credential_secret is None and not self.identity_token_file:
|
|
351
|
+
self.credential_secret = os.getenv(RUNTIME_CREDENTIAL_SECRET_ENV)
|
|
326
352
|
if not self.token_url:
|
|
327
353
|
self.token_url = f"{API_ENDPOINT}/runtime-credentials/token/"
|
|
328
354
|
|
|
@@ -344,20 +370,48 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
|
|
|
344
370
|
|
|
345
371
|
return exp <= int(time.time()) + self.refresh_skew_seconds
|
|
346
372
|
|
|
347
|
-
|
|
373
|
+
@staticmethod
|
|
374
|
+
def _log_exchange_retry(status: int, delay: float) -> None:
|
|
375
|
+
logger.warning(f"Runtime credential exchange returned {status}; retrying in {delay:.1f} s.")
|
|
376
|
+
|
|
377
|
+
def _exchange(self) -> requests.Response:
|
|
348
378
|
credential_id = (self.credential_id or "").strip()
|
|
349
|
-
|
|
379
|
+
identity_token_file = self.identity_token_file
|
|
380
|
+
credential_secret = None if identity_token_file else (self.credential_secret or "").strip()
|
|
350
381
|
if not credential_id:
|
|
351
382
|
raise AuthError(
|
|
352
383
|
"MAINSEQUENCE_RUNTIME_CREDENTIAL_ID is required when "
|
|
353
384
|
"MAINSEQUENCE_AUTH_MODE=runtime_credential."
|
|
354
385
|
)
|
|
355
|
-
if not credential_secret:
|
|
386
|
+
if not identity_token_file and not credential_secret:
|
|
356
387
|
raise AuthError(
|
|
357
388
|
"MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET is required when "
|
|
358
|
-
"MAINSEQUENCE_AUTH_MODE=runtime_credential
|
|
389
|
+
"MAINSEQUENCE_AUTH_MODE=runtime_credential and "
|
|
390
|
+
f"{RUNTIME_IDENTITY_TOKEN_FILE_ENV} is not set."
|
|
359
391
|
)
|
|
360
|
-
|
|
392
|
+
try:
|
|
393
|
+
# One budget for every attempt and wait, inside the caller's budget if any.
|
|
394
|
+
with request_budget(self.timeout) as budget:
|
|
395
|
+
|
|
396
|
+
def post(body: dict[str, str]) -> requests.Response:
|
|
397
|
+
return requests.post(
|
|
398
|
+
self.token_url,
|
|
399
|
+
json=body,
|
|
400
|
+
headers={"Content-Type": "application/json"},
|
|
401
|
+
timeout=budget.timeout(),
|
|
402
|
+
allow_redirects=False,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
return exchange_runtime_credential(
|
|
406
|
+
post,
|
|
407
|
+
credential_id=credential_id,
|
|
408
|
+
credential_secret=credential_secret,
|
|
409
|
+
identity_token_file=identity_token_file,
|
|
410
|
+
deadline=budget.deadline,
|
|
411
|
+
on_retry=self._log_exchange_retry,
|
|
412
|
+
)
|
|
413
|
+
except RuntimeIdentityTokenError as exc:
|
|
414
|
+
raise AuthError(str(exc)) from None
|
|
361
415
|
|
|
362
416
|
def refresh(
|
|
363
417
|
self,
|
|
@@ -370,17 +424,7 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
|
|
|
370
424
|
if not force and not self._needs_exchange():
|
|
371
425
|
return
|
|
372
426
|
|
|
373
|
-
|
|
374
|
-
response = requests.post(
|
|
375
|
-
self.token_url,
|
|
376
|
-
json={
|
|
377
|
-
"credential_id": credential_id,
|
|
378
|
-
"credential_secret": credential_secret,
|
|
379
|
-
},
|
|
380
|
-
headers={"Content-Type": "application/json"},
|
|
381
|
-
timeout=remaining_timeout(self.timeout),
|
|
382
|
-
allow_redirects=False,
|
|
383
|
-
)
|
|
427
|
+
response = self._exchange()
|
|
384
428
|
if response.status_code < 200 or response.status_code >= 300:
|
|
385
429
|
raise AuthError(
|
|
386
430
|
f"Runtime credential exchange failed with status {response.status_code}."
|
|
@@ -439,8 +483,9 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
|
|
|
439
483
|
|
|
440
484
|
@dataclass
|
|
441
485
|
class JWTAuthProvider(BaseAuthProvider):
|
|
442
|
-
|
|
443
|
-
|
|
486
|
+
# Tokens stay out of repr() and str(), so logging the provider never shows them.
|
|
487
|
+
access_token: str | None = field(default=None, repr=False)
|
|
488
|
+
refresh_token: str | None = field(default=None, repr=False)
|
|
444
489
|
refresh_url: str | None = None
|
|
445
490
|
obtain_url: str | None = None
|
|
446
491
|
header_keyword: str = "Bearer"
|
|
@@ -10,6 +10,8 @@ from dataclasses import dataclass, replace
|
|
|
10
10
|
from typing import Any, Literal
|
|
11
11
|
from urllib.parse import urlsplit
|
|
12
12
|
|
|
13
|
+
from mainsequence.runtime_credential_exchange import runtime_credential_configured
|
|
14
|
+
|
|
13
15
|
CodeRepositoryContextStatus = Literal[
|
|
14
16
|
"resolved",
|
|
15
17
|
"code_repository_branch_not_registered",
|
|
@@ -204,9 +206,7 @@ def _exchange_authenticated_runtime_context_if_configured() -> None:
|
|
|
204
206
|
return
|
|
205
207
|
if (os.getenv("MAINSEQUENCE_AUTH_MODE") or "").strip().lower() != "runtime_credential":
|
|
206
208
|
return
|
|
207
|
-
if not (
|
|
208
|
-
return
|
|
209
|
-
if not (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET") or "").strip():
|
|
209
|
+
if not runtime_credential_configured():
|
|
210
210
|
return
|
|
211
211
|
|
|
212
212
|
from mainsequence.client.utils import AuthError, loaders
|
|
@@ -5,6 +5,7 @@ import logging
|
|
|
5
5
|
import logging.config
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
+
import time
|
|
8
9
|
import traceback
|
|
9
10
|
from collections.abc import Mapping
|
|
10
11
|
from importlib.metadata import PackageNotFoundError
|
|
@@ -22,6 +23,13 @@ from structlog.stdlib import BoundLogger
|
|
|
22
23
|
from .defaults import resolve_backend_endpoint
|
|
23
24
|
from .instrumentation import OTelJSONRenderer
|
|
24
25
|
from .repository_identity_security import UNSUPPORTED_SOURCE_IDENTITY_ENV_NAMES
|
|
26
|
+
from .runtime_credential_exchange import (
|
|
27
|
+
RUNTIME_CREDENTIAL_ID_ENV,
|
|
28
|
+
RUNTIME_CREDENTIAL_SECRET_ENV,
|
|
29
|
+
exchange_runtime_credential,
|
|
30
|
+
identity_token_file_from_environment,
|
|
31
|
+
runtime_credential_configured,
|
|
32
|
+
)
|
|
25
33
|
from .runtime_flags import is_running_in_pod
|
|
26
34
|
|
|
27
35
|
logger = None
|
|
@@ -94,20 +102,31 @@ def _request_job_startup_state(*, timeout_s: float = 10.0) -> dict[str, Any]:
|
|
|
94
102
|
return headers, False
|
|
95
103
|
|
|
96
104
|
def _exchange_runtime_credential() -> bool:
|
|
97
|
-
|
|
98
|
-
credential_secret = (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET") or "").strip()
|
|
99
|
-
if not credential_id or not credential_secret:
|
|
105
|
+
if not runtime_credential_configured():
|
|
100
106
|
return False
|
|
107
|
+
identity_token_file = identity_token_file_from_environment()
|
|
108
|
+
token_url = f"{_backend_base_url()}/api/v1/runtime-credentials/token/"
|
|
101
109
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
def _post(body: dict[str, str]) -> requests.Response:
|
|
111
|
+
return requests.post(
|
|
112
|
+
token_url,
|
|
105
113
|
headers={"Content-Type": "application/json"},
|
|
106
|
-
json=
|
|
107
|
-
"credential_id": credential_id,
|
|
108
|
-
"credential_secret": credential_secret,
|
|
109
|
-
},
|
|
114
|
+
json=body,
|
|
110
115
|
timeout=timeout_s,
|
|
116
|
+
allow_redirects=False,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
token_resp = exchange_runtime_credential(
|
|
121
|
+
_post,
|
|
122
|
+
credential_id=(os.getenv(RUNTIME_CREDENTIAL_ID_ENV) or "").strip(),
|
|
123
|
+
credential_secret=(
|
|
124
|
+
None
|
|
125
|
+
if identity_token_file
|
|
126
|
+
else (os.getenv(RUNTIME_CREDENTIAL_SECRET_ENV) or "").strip()
|
|
127
|
+
),
|
|
128
|
+
identity_token_file=identity_token_file,
|
|
129
|
+
deadline=time.monotonic() + timeout_s,
|
|
111
130
|
)
|
|
112
131
|
except Exception:
|
|
113
132
|
return False
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The runtime credential exchange, shared by the auth provider and the logger.
|
|
3
|
+
|
|
4
|
+
A runtime started with ``MAINSEQUENCE_AUTH_MODE=runtime_credential`` exchanges its
|
|
5
|
+
credential at ``POST /api/v1/runtime-credentials/token/`` for a short-lived access
|
|
6
|
+
token. The request carries ``credential_id`` and exactly one proof:
|
|
7
|
+
|
|
8
|
+
* ``workload_identity_token`` when ``MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE`` is
|
|
9
|
+
set: the projected token read from that file for every exchange, because the
|
|
10
|
+
file is rotated. The bootstrap secret is then never read or sent, and a missing,
|
|
11
|
+
unreadable or empty file is an error, never a fallback to the secret.
|
|
12
|
+
* ``credential_secret`` otherwise: ``MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET``.
|
|
13
|
+
|
|
14
|
+
The token exists only inside one exchange request. It is not kept on any object,
|
|
15
|
+
written to the environment or a file, logged, or included in an error message.
|
|
16
|
+
|
|
17
|
+
The logger exchanges a runtime credential while it is being configured, so this
|
|
18
|
+
module imports nothing from the SDK.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from urllib3.exceptions import InvalidHeader
|
|
29
|
+
from urllib3.util import Retry
|
|
30
|
+
|
|
31
|
+
RUNTIME_CREDENTIAL_ID_ENV = "MAINSEQUENCE_RUNTIME_CREDENTIAL_ID"
|
|
32
|
+
RUNTIME_CREDENTIAL_SECRET_ENV = "MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET"
|
|
33
|
+
RUNTIME_IDENTITY_TOKEN_FILE_ENV = "MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE"
|
|
34
|
+
|
|
35
|
+
# 429: the exchange is throttled. 503: verification is temporarily unavailable.
|
|
36
|
+
# Neither exchanged anything, so the same request may be sent again.
|
|
37
|
+
RETRYABLE_EXCHANGE_STATUSES = frozenset({429, 503})
|
|
38
|
+
EXCHANGE_RETRIES = 3
|
|
39
|
+
EXCHANGE_BACKOFF_FACTOR = 0.5
|
|
40
|
+
|
|
41
|
+
_RETRY_AFTER = Retry(total=0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RuntimeIdentityTokenError(Exception):
|
|
45
|
+
"""The runtime identity token file cannot be used. The message never holds its content."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _environment_value(name: str) -> str:
|
|
49
|
+
return (os.getenv(name) or "").strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def identity_token_file_from_environment() -> str | None:
|
|
53
|
+
"""Return the path in ``MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE``, or None when unset."""
|
|
54
|
+
return _environment_value(RUNTIME_IDENTITY_TOKEN_FILE_ENV) or None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def runtime_credential_configured() -> bool:
|
|
58
|
+
"""
|
|
59
|
+
Say whether the environment holds a runtime credential to exchange.
|
|
60
|
+
|
|
61
|
+
That is a credential ID and one proof: the identity token file, or else the
|
|
62
|
+
bootstrap secret. Only the presence of the setting is checked. The token file
|
|
63
|
+
is read by the exchange itself, which reports a missing or empty file.
|
|
64
|
+
"""
|
|
65
|
+
if not _environment_value(RUNTIME_CREDENTIAL_ID_ENV):
|
|
66
|
+
return False
|
|
67
|
+
if identity_token_file_from_environment():
|
|
68
|
+
return True
|
|
69
|
+
return bool(_environment_value(RUNTIME_CREDENTIAL_SECRET_ENV))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_identity_token(path: str) -> str:
|
|
73
|
+
source = f"The runtime identity token file {path} ({RUNTIME_IDENTITY_TOKEN_FILE_ENV})"
|
|
74
|
+
try:
|
|
75
|
+
with open(path, "rb") as token_file:
|
|
76
|
+
raw = token_file.read()
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
raise RuntimeIdentityTokenError(f"{source} does not exist.") from None
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
raise RuntimeIdentityTokenError(
|
|
81
|
+
f"{source} could not be read ({type(exc).__name__})."
|
|
82
|
+
) from None
|
|
83
|
+
try:
|
|
84
|
+
token = raw.decode("utf-8").strip()
|
|
85
|
+
except UnicodeDecodeError:
|
|
86
|
+
# Raised below, outside this handler: the decode error holds the bytes.
|
|
87
|
+
token = None
|
|
88
|
+
if token is None:
|
|
89
|
+
raise RuntimeIdentityTokenError(f"{source} does not hold a token.")
|
|
90
|
+
if not token:
|
|
91
|
+
raise RuntimeIdentityTokenError(f"{source} is empty.")
|
|
92
|
+
return token
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _request_body(
|
|
96
|
+
credential_id: str,
|
|
97
|
+
credential_secret: str | None,
|
|
98
|
+
identity_token_file: str | None,
|
|
99
|
+
) -> dict[str, str]:
|
|
100
|
+
if identity_token_file:
|
|
101
|
+
return {
|
|
102
|
+
"credential_id": credential_id,
|
|
103
|
+
"workload_identity_token": _read_identity_token(identity_token_file),
|
|
104
|
+
}
|
|
105
|
+
return {"credential_id": credential_id, "credential_secret": credential_secret or ""}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _retry_delay(response: Any, attempt: int) -> float:
|
|
109
|
+
delay = min(EXCHANGE_BACKOFF_FACTOR * 2**attempt, Retry.DEFAULT_BACKOFF_MAX)
|
|
110
|
+
retry_after = (getattr(response, "headers", None) or {}).get("Retry-After")
|
|
111
|
+
if retry_after is not None:
|
|
112
|
+
try:
|
|
113
|
+
delay = max(delay, _RETRY_AFTER.parse_retry_after(str(retry_after)))
|
|
114
|
+
except InvalidHeader:
|
|
115
|
+
pass
|
|
116
|
+
return delay
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def exchange_runtime_credential(
|
|
120
|
+
send: Callable[[dict[str, str]], Any],
|
|
121
|
+
*,
|
|
122
|
+
credential_id: str,
|
|
123
|
+
credential_secret: str | None,
|
|
124
|
+
identity_token_file: str | None,
|
|
125
|
+
deadline: float,
|
|
126
|
+
on_retry: Callable[[int, float], None] | None = None,
|
|
127
|
+
) -> Any:
|
|
128
|
+
"""
|
|
129
|
+
Send one runtime credential exchange and return the last response.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
send:
|
|
134
|
+
Posts the JSON body it is given to the exchange route and returns the
|
|
135
|
+
response. It is called once per attempt.
|
|
136
|
+
credential_id:
|
|
137
|
+
The runtime credential ID.
|
|
138
|
+
credential_secret:
|
|
139
|
+
The bootstrap secret. Ignored when ``identity_token_file`` is set.
|
|
140
|
+
identity_token_file:
|
|
141
|
+
The projected token file. It is read again for every attempt.
|
|
142
|
+
deadline:
|
|
143
|
+
``time.monotonic()`` value after which no retry starts.
|
|
144
|
+
on_retry:
|
|
145
|
+
Called with the status and the delay before each retry.
|
|
146
|
+
|
|
147
|
+
A ``429`` or ``503`` answer is retried up to ``EXCHANGE_RETRIES`` times. The
|
|
148
|
+
wait is the exponential backoff or the answer's ``Retry-After``, whichever is
|
|
149
|
+
longer; a wait that would not end before ``deadline`` ends the retries. Any
|
|
150
|
+
other answer, ``401`` included, is returned at once and the proof is never
|
|
151
|
+
switched. ``RuntimeIdentityTokenError`` is raised when the token file cannot
|
|
152
|
+
be used.
|
|
153
|
+
"""
|
|
154
|
+
for attempt in range(EXCHANGE_RETRIES + 1):
|
|
155
|
+
response = send(_request_body(credential_id, credential_secret, identity_token_file))
|
|
156
|
+
status = response.status_code
|
|
157
|
+
if status not in RETRYABLE_EXCHANGE_STATUSES or attempt == EXCHANGE_RETRIES:
|
|
158
|
+
return response
|
|
159
|
+
delay = _retry_delay(response, attempt)
|
|
160
|
+
if delay >= deadline - time.monotonic():
|
|
161
|
+
return response
|
|
162
|
+
if on_retry is not None:
|
|
163
|
+
on_retry(status, delay)
|
|
164
|
+
close = getattr(response, "close", None)
|
|
165
|
+
if callable(close):
|
|
166
|
+
close()
|
|
167
|
+
time.sleep(delay)
|
|
168
|
+
raise AssertionError("Unreachable retry state")
|
|
@@ -20,6 +20,7 @@ mainsequence/code_repository_skills.py
|
|
|
20
20
|
mainsequence/defaults.py
|
|
21
21
|
mainsequence/logconf.py
|
|
22
22
|
mainsequence/repository_identity_security.py
|
|
23
|
+
mainsequence/runtime_credential_exchange.py
|
|
23
24
|
mainsequence/runtime_flags.py
|
|
24
25
|
mainsequence/scaffold_skills.py
|
|
25
26
|
mainsequence.egg-info/PKG-INFO
|
|
@@ -74,6 +75,7 @@ tests/test_code_repository_branch_contract.py
|
|
|
74
75
|
tests/test_code_repository_documentation_contract.py
|
|
75
76
|
tests/test_code_repository_runtime_context.py
|
|
76
77
|
tests/test_code_repository_skills.py
|
|
78
|
+
tests/test_credential_repr.py
|
|
77
79
|
tests/test_dependency_extras.py
|
|
78
80
|
tests/test_dev_release_version.py
|
|
79
81
|
tests/test_environment_resource_context.py
|
|
@@ -90,6 +92,7 @@ tests/test_logconf.py
|
|
|
90
92
|
tests/test_models_user_request_bound_auth.py
|
|
91
93
|
tests/test_notification_uid_contract.py
|
|
92
94
|
tests/test_observability_client.py
|
|
95
|
+
tests/test_runtime_credential_exchange.py
|
|
93
96
|
tests/test_scaffold_skills.py
|
|
94
97
|
tests/test_secret_client_model.py
|
|
95
98
|
tests/test_source_ownership.py
|
|
@@ -150,6 +150,7 @@ def test_logconf_runtime_credential_exchanges_before_startup_state_request(monke
|
|
|
150
150
|
"credential_secret": "cred-secret",
|
|
151
151
|
},
|
|
152
152
|
"timeout": 10.0,
|
|
153
|
+
"allow_redirects": False,
|
|
153
154
|
}
|
|
154
155
|
]
|
|
155
156
|
assert os.environ["MAINSEQUENCE_ACCESS_TOKEN"] == "runtime-access"
|
|
@@ -1021,3 +1021,20 @@ def test_session_report_for_a_runtime_credential_has_no_session_expiry(monkeypat
|
|
|
1021
1021
|
assert report["session_expires_at"] is None
|
|
1022
1022
|
assert report["auth_mode"] == "runtime_credential"
|
|
1023
1023
|
assert "cred-secret" not in json.dumps(report)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def test_session_report_accepts_a_runtime_identity_token_file(monkeypatch, tmp_path):
|
|
1027
|
+
_isolate_auth(monkeypatch, tmp_path, None)
|
|
1028
|
+
monkeypatch.setattr(config, "auth_persistence_label", lambda: "test store")
|
|
1029
|
+
token_file = tmp_path / "token"
|
|
1030
|
+
token_file.write_text("projected-token", encoding="utf-8")
|
|
1031
|
+
monkeypatch.setenv("MAINSEQUENCE_AUTH_MODE", "runtime_credential")
|
|
1032
|
+
monkeypatch.setenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID", "cred-id")
|
|
1033
|
+
monkeypatch.delenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET", raising=False)
|
|
1034
|
+
monkeypatch.setenv("MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE", str(token_file))
|
|
1035
|
+
|
|
1036
|
+
report = config.session_report()
|
|
1037
|
+
|
|
1038
|
+
assert report["authenticated"] is True
|
|
1039
|
+
assert report["session_expires_at"] is None
|
|
1040
|
+
assert "projected-token" not in json.dumps(report)
|
{mainsequence-9.0.8.dev44 → mainsequence-9.0.9}/tests/test_code_repository_runtime_context.py
RENAMED
|
@@ -368,10 +368,21 @@ def test_authenticated_runtime_context_detects_git_drift(monkeypatch):
|
|
|
368
368
|
code_repository_context.validate_code_repository_source_context(context=context)
|
|
369
369
|
|
|
370
370
|
|
|
371
|
-
|
|
371
|
+
@pytest.mark.parametrize(
|
|
372
|
+
("proof_variable", "proof_value"),
|
|
373
|
+
[
|
|
374
|
+
("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET", "credential-secret"),
|
|
375
|
+
("MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE", "/var/run/secrets/runtime-identity/token"),
|
|
376
|
+
],
|
|
377
|
+
)
|
|
378
|
+
def test_configured_runtime_credential_exchanges_context_before_resolution(
|
|
379
|
+
monkeypatch, proof_variable, proof_value
|
|
380
|
+
):
|
|
372
381
|
monkeypatch.setenv("MAINSEQUENCE_AUTH_MODE", "runtime_credential")
|
|
373
382
|
monkeypatch.setenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID", "credential-id")
|
|
374
|
-
monkeypatch.
|
|
383
|
+
monkeypatch.delenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET", raising=False)
|
|
384
|
+
monkeypatch.delenv("MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE", raising=False)
|
|
385
|
+
monkeypatch.setenv(proof_variable, proof_value)
|
|
375
386
|
events = []
|
|
376
387
|
monkeypatch.setattr(
|
|
377
388
|
code_repository_context,
|