firedrill-run 2.0.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.
- firedrill/__init__.py +55 -0
- firedrill/activity.py +107 -0
- firedrill/artifact_downloads.py +191 -0
- firedrill/attachments.py +167 -0
- firedrill/batch_receipt.py +142 -0
- firedrill/capacity.py +91 -0
- firedrill/ci.py +263 -0
- firedrill/client.py +1033 -0
- firedrill/credentials.py +124 -0
- firedrill/device_authorization.py +317 -0
- firedrill/discovery.py +91 -0
- firedrill/endpoint.py +34 -0
- firedrill/errors.py +154 -0
- firedrill/execution.py +398 -0
- firedrill/follow.py +261 -0
- firedrill/input_files.py +74 -0
- firedrill/inspection.py +302 -0
- firedrill/interventions.py +88 -0
- firedrill/journal.py +188 -0
- firedrill/live_events.py +88 -0
- firedrill/login.py +453 -0
- firedrill/login_store.py +225 -0
- firedrill/models.py +295 -0
- firedrill/population_disclosure.py +53 -0
- firedrill/py.typed +0 -0
- firedrill/read_scope.py +28 -0
- firedrill/retained_values.py +162 -0
- firedrill/retention.py +159 -0
- firedrill/run_tools.py +145 -0
- firedrill/scoring.py +66 -0
- firedrill/sequences.py +139 -0
- firedrill/service_build_diagnostics.py +78 -0
- firedrill/service_source.py +104 -0
- firedrill/services.py +539 -0
- firedrill/submission.py +183 -0
- firedrill/task_metrics.py +134 -0
- firedrill/tls.py +56 -0
- firedrill/tool_start.py +430 -0
- firedrill/transport.py +187 -0
- firedrill_run-2.0.0.dist-info/METADATA +1751 -0
- firedrill_run-2.0.0.dist-info/RECORD +44 -0
- firedrill_run-2.0.0.dist-info/WHEEL +5 -0
- firedrill_run-2.0.0.dist-info/licenses/LICENSE.md +23 -0
- firedrill_run-2.0.0.dist-info/top_level.txt +1 -0
firedrill/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Firedrill's native, authenticated Python client."""
|
|
2
|
+
from .client import AsyncFiredrill, Firedrill, SimulationProgress, RunnerCapacity
|
|
3
|
+
from .errors import FiredrillError, RecoveryRequired, TransportError, CLIENT_DIAGNOSTICS_VERSION, DiagnosticCategory, RetrySafety, WorkStarted
|
|
4
|
+
from .models import ASSESSMENT_POLICY_VERSION, NUMERIC_CHECK_VERSION, REPORT_LINK_VERSION, TARGET_INVOCATION_VERSION, TargetScenario, ReportLink, BatchResult, CONTRACT_VERSION, SEQUENCE_VERSION, SERVICE_CONTROL_VERSION, SERVICE_CONTROL_CLOCK_VERSION, RunContext
|
|
5
|
+
from .batch_receipt import batch_receipt, BatchReceipt, BATCH_RECEIPT_VERSION
|
|
6
|
+
from .models import RESULT_LIFECYCLE_VERSION, ResultLifecycle, ResultEvidence, ResultCleanup, ResultRecovery, BatchLifecycleRead, RetentionAvailability
|
|
7
|
+
from .services import ConnectionGrant, FiredrillServices, AsyncFiredrillServices
|
|
8
|
+
from .run_tools import RunTool
|
|
9
|
+
from .credentials import CREDENTIAL_VERSION, IssuedCredential, FiredrillCredentials, AsyncFiredrillCredentials
|
|
10
|
+
from .capacity import CAPACITY_REQUEST_VERSION, CapacityResource, CapacityRequest, CapacityRequestPage, FiredrillCapacity, AsyncFiredrillCapacity
|
|
11
|
+
from .retention import RETENTION_VERSION, FiredrillRetention, AsyncFiredrillRetention, RetentionResource
|
|
12
|
+
from .interventions import RUN_INTERVENTION_VERSION, RUN_CLOCK_INTERVENTION_VERSION, FiredrillInterventions, AsyncFiredrillInterventions
|
|
13
|
+
from .login import FiredrillLogin, AsyncFiredrillLogin
|
|
14
|
+
from .device_authorization import DeviceAuthorizationError, start_device_authorization, refresh_provider_tokens
|
|
15
|
+
from .ci import CI_CONTEXT_VERSION, CI_REPORT_VERSION, CiContext, CiReportReceipt
|
|
16
|
+
from .activity import ACTIVITY_VERSION, ActivityFilters, ActivityPageMetadata
|
|
17
|
+
from .scoring import COMPOSITE_VERSION, CompositePolicy, CompositeComponent, CompositeResult
|
|
18
|
+
from .task_metrics import (TASK_METRIC_VERSION, TaskMetricDefinition, TaskMetricDirection,
|
|
19
|
+
TaskMetricResult, TaskMetricSummary)
|
|
20
|
+
from .follow import FOLLOW_VERSION, FollowObservation, FollowResult
|
|
21
|
+
from .submission import SUBMISSION_RECEIPT_VERSION, SubmissionReceipt, SubmissionError
|
|
22
|
+
from .artifact_downloads import ArtifactReference, ArtifactManifest, ArtifactContent, ArtifactDownload, ArtifactRetention, UnavailableArtifact, RetainedArtifact
|
|
23
|
+
from .service_build_diagnostics import SERVICE_BUILD_DIAGNOSTICS_VERSION, ServiceBuild, ServiceBuildFailure, ServiceBuildDiagnostic, ServiceBuildDiagnosticCode
|
|
24
|
+
from .read_scope import READ_SCOPE_VERSION
|
|
25
|
+
from .population_disclosure import (POPULATION_PURPOSE_VERSION, POPULATION_DISCLOSURE_VERSION,
|
|
26
|
+
PopulationPurpose, PopulationDisclosure, PopulationProvenance)
|
|
27
|
+
from .discovery import (DISCOVERY_VERSION, AUTHORING_SCHEMA_IDS, AuthoringSchemaId,
|
|
28
|
+
ProductCapability, ProductDiscovery, AuthoringSchemaDocument)
|
|
29
|
+
from .retained_values import (RETAINED_VALUE_VERSION, OPERATOR_DOCUMENT_VERSION, RetainedValueOptions, RetainedValueChunk,
|
|
30
|
+
RetainedValueReference, RetainedResource, RetainedValueSource,
|
|
31
|
+
RunValueSource, RetainedValueType, OperatorDocument)
|
|
32
|
+
|
|
33
|
+
__all__ = ["Firedrill", "AsyncFiredrill", "SimulationProgress", "RunnerCapacity", "BatchResult", "BatchReceipt", "batch_receipt", "BATCH_RECEIPT_VERSION", "RunContext", "TARGET_INVOCATION_VERSION", "TargetScenario", "FiredrillError",
|
|
34
|
+
"TransportError", "RecoveryRequired", "CONTRACT_VERSION", "SEQUENCE_VERSION", "SERVICE_CONTROL_VERSION", "SERVICE_CONTROL_CLOCK_VERSION", "ASSESSMENT_POLICY_VERSION", "ConnectionGrant", "FiredrillServices", "AsyncFiredrillServices", "RunTool",
|
|
35
|
+
"CLIENT_DIAGNOSTICS_VERSION", "DiagnosticCategory", "RetrySafety", "WorkStarted",
|
|
36
|
+
"RESULT_LIFECYCLE_VERSION", "ResultLifecycle", "ResultEvidence", "ResultCleanup", "ResultRecovery", "BatchLifecycleRead", "RetentionAvailability",
|
|
37
|
+
"CREDENTIAL_VERSION", "IssuedCredential", "FiredrillCredentials", "AsyncFiredrillCredentials",
|
|
38
|
+
"CAPACITY_REQUEST_VERSION", "CapacityResource", "CapacityRequest", "CapacityRequestPage", "FiredrillCapacity", "AsyncFiredrillCapacity",
|
|
39
|
+
"RETENTION_VERSION", "FiredrillRetention", "AsyncFiredrillRetention", "RetentionResource",
|
|
40
|
+
"RUN_INTERVENTION_VERSION", "RUN_CLOCK_INTERVENTION_VERSION", "FiredrillInterventions", "AsyncFiredrillInterventions",
|
|
41
|
+
"FiredrillLogin", "AsyncFiredrillLogin", "DeviceAuthorizationError", "start_device_authorization", "refresh_provider_tokens",
|
|
42
|
+
"RETAINED_VALUE_VERSION", "OPERATOR_DOCUMENT_VERSION", "RetainedValueOptions", "RetainedValueChunk", "RetainedValueReference",
|
|
43
|
+
"RetainedResource", "RetainedValueSource", "RunValueSource", "RetainedValueType", "OperatorDocument",
|
|
44
|
+
"DISCOVERY_VERSION", "AUTHORING_SCHEMA_IDS", "AuthoringSchemaId", "ProductCapability", "ProductDiscovery", "AuthoringSchemaDocument", "NUMERIC_CHECK_VERSION",
|
|
45
|
+
"CI_CONTEXT_VERSION", "CI_REPORT_VERSION", "CiContext", "CiReportReceipt", "REPORT_LINK_VERSION", "ReportLink",
|
|
46
|
+
"ACTIVITY_VERSION", "ActivityFilters", "ActivityPageMetadata",
|
|
47
|
+
"COMPOSITE_VERSION", "CompositePolicy", "CompositeComponent", "CompositeResult",
|
|
48
|
+
"TASK_METRIC_VERSION", "TaskMetricDefinition", "TaskMetricDirection", "TaskMetricResult", "TaskMetricSummary",
|
|
49
|
+
"FOLLOW_VERSION", "FollowObservation", "FollowResult",
|
|
50
|
+
"SUBMISSION_RECEIPT_VERSION", "SubmissionReceipt", "SubmissionError",
|
|
51
|
+
"ArtifactReference", "ArtifactManifest", "ArtifactContent", "ArtifactDownload",
|
|
52
|
+
"ArtifactRetention", "UnavailableArtifact", "RetainedArtifact",
|
|
53
|
+
"SERVICE_BUILD_DIAGNOSTICS_VERSION", "ServiceBuild", "ServiceBuildFailure", "ServiceBuildDiagnostic", "ServiceBuildDiagnosticCode",
|
|
54
|
+
"POPULATION_PURPOSE_VERSION", "POPULATION_DISCLOSURE_VERSION",
|
|
55
|
+
"PopulationPurpose", "PopulationDisclosure", "PopulationProvenance"]
|
firedrill/activity.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Literal retained-activity filters and bounded page queries; no target actions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import Literal, NotRequired, TypedDict
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
from .errors import FiredrillError
|
|
9
|
+
from .transport import segment
|
|
10
|
+
|
|
11
|
+
ACTIVITY_VERSION = "2026-10-16-activity.1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ActivityFilters(TypedDict):
|
|
15
|
+
"""Wire field names retained in report.activity.filters."""
|
|
16
|
+
eventKind: NotRequired[str]
|
|
17
|
+
eventProducer: NotRequired[str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ActivityPageMetadata(TypedDict):
|
|
21
|
+
schemaVersion: Literal["2026-10-16-activity.1"]
|
|
22
|
+
filters: ActivityFilters
|
|
23
|
+
filtered: bool
|
|
24
|
+
afterSequence: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def confirm_activity_selection(response, *, event_kind: str | None = None,
|
|
28
|
+
event_producer: str | None = None, operator: bool = False):
|
|
29
|
+
"""An older service may ignore query fields: never call that a filtered page."""
|
|
30
|
+
expected = {}
|
|
31
|
+
if event_kind is not None:
|
|
32
|
+
expected["eventKind"] = event_kind
|
|
33
|
+
if event_producer is not None:
|
|
34
|
+
expected["eventProducer"] = event_producer
|
|
35
|
+
if not expected:
|
|
36
|
+
return response # Historical unfiltered reports remain readable.
|
|
37
|
+
body = response.get("value") if operator and isinstance(response, dict) else response
|
|
38
|
+
metadata = body.get("activity") if isinstance(body, dict) else None
|
|
39
|
+
if (not isinstance(metadata, dict)
|
|
40
|
+
or set(metadata) != {"schemaVersion", "filters", "filtered", "afterSequence"}
|
|
41
|
+
or metadata.get("schemaVersion") != ACTIVITY_VERSION or metadata.get("filtered") is not True
|
|
42
|
+
or type(metadata.get("afterSequence")) is not int or not 0 <= metadata["afterSequence"] <= 2147483647
|
|
43
|
+
or not isinstance(metadata.get("filters"), dict) or metadata["filters"] != expected):
|
|
44
|
+
raise FiredrillError("The service did not confirm the requested activity filters",
|
|
45
|
+
code="ACTIVITY_SELECTION_UNCONFIRMED",
|
|
46
|
+
repair="Use matching client/service versions, or deliberately clear the activity filters for an unfiltered read. No automatic retry or target execution occurred.")
|
|
47
|
+
return response
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _invalid(message: str):
|
|
51
|
+
raise FiredrillError(message, code="ACTIVITY_QUERY_INVALID",
|
|
52
|
+
repair="Omit cursor for the first page. Reuse nextCursor with the same event_kind and event_producer; clear it when changing filters. No request was sent.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _literal(value, maximum, name):
|
|
56
|
+
valid = isinstance(value, str)
|
|
57
|
+
if valid:
|
|
58
|
+
try:
|
|
59
|
+
valid = (1 <= len(value.encode("utf-16-le")) // 2 <= maximum
|
|
60
|
+
and not any(ord(char) < 32 or ord(char) == 127 for char in value))
|
|
61
|
+
value.encode("utf-8")
|
|
62
|
+
except UnicodeEncodeError:
|
|
63
|
+
valid = False
|
|
64
|
+
if not valid:
|
|
65
|
+
_invalid(f"{name} must contain 1–{maximum} characters without control characters or unpaired Unicode surrogates; preserve case and spaces")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def activity_report_query(*, cursor: str | None = None, limit: int = 100,
|
|
70
|
+
event_kind: str | None = None, event_producer: str | None = None,
|
|
71
|
+
operator: bool = False, check_cursor: str | None = None) -> dict[str, str | int]:
|
|
72
|
+
"""Do not decode opaque cursors or reinterpret their server-owned bindings."""
|
|
73
|
+
maximum = 20 if operator else 100
|
|
74
|
+
if type(limit) is not int or not 1 <= limit <= maximum:
|
|
75
|
+
_invalid(f"Report limit must be an integer between 1 and {maximum}")
|
|
76
|
+
query: dict[str, str | int] = {"limit": limit}
|
|
77
|
+
if operator:
|
|
78
|
+
query["view"] = "operator"
|
|
79
|
+
if event_kind is not None:
|
|
80
|
+
query["eventKind"] = _literal(event_kind, 120, "event_kind")
|
|
81
|
+
if event_producer is not None:
|
|
82
|
+
query["eventProducer"] = _literal(event_producer, 200, "event_producer")
|
|
83
|
+
filtered = event_kind is not None or event_producer is not None
|
|
84
|
+
if cursor is not None:
|
|
85
|
+
if not isinstance(cursor, str) or len(cursor) > 1024:
|
|
86
|
+
_invalid("Activity cursor must be a returned string of at most 1024 characters")
|
|
87
|
+
if cursor not in ("", "0"):
|
|
88
|
+
if filtered:
|
|
89
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", cursor) or re.fullmatch(r"[0-9]+", cursor):
|
|
90
|
+
_invalid("Filtered activity requires the returned opaque cursor, not a positive numeric offset")
|
|
91
|
+
elif not re.fullmatch(r"[1-9][0-9]{0,8}", cursor) or int(cursor) > 100000000:
|
|
92
|
+
_invalid("Unfiltered activity requires a nonnegative decimal cursor up to 100000000")
|
|
93
|
+
query["cursor"] = cursor
|
|
94
|
+
if check_cursor is not None:
|
|
95
|
+
if (not operator or not isinstance(check_cursor, str)
|
|
96
|
+
or not re.fullmatch(r"0|[1-9][0-9]{0,6}", check_cursor) or int(check_cursor) > 1000000):
|
|
97
|
+
_invalid("check_cursor is only valid for operator reports and must be the returned decimal cursor up to 1000000")
|
|
98
|
+
query["checkCursor"] = check_cursor
|
|
99
|
+
return query
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def report_path(run_id: str, *, cursor: str | None = None, limit: int = 100,
|
|
103
|
+
event_kind: str | None = None, event_producer: str | None = None) -> str:
|
|
104
|
+
if not isinstance(run_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,160}", run_id):
|
|
105
|
+
_invalid("Use the exact retained run ID")
|
|
106
|
+
query = activity_report_query(cursor=cursor, limit=limit, event_kind=event_kind, event_producer=event_producer)
|
|
107
|
+
return f"/v1/runs/{segment(run_id)}/report?" + urlencode(query)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Read captured evidence without target execution or operator-token disclosure."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Literal, NotRequired, TypedDict
|
|
11
|
+
|
|
12
|
+
from .errors import FiredrillError, TransportError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ArtifactRetention(TypedDict):
|
|
16
|
+
schemaVersion: Literal["2026-10-15-retention.1"]
|
|
17
|
+
policyVersion: Literal["2026-10-15-retention.1", "historical_preserved"]
|
|
18
|
+
state: Literal["available", "historical_preserved", "expired", "deletion_pending", "deleted"]
|
|
19
|
+
payloadExpiresAt: str | None
|
|
20
|
+
metadataExpiresAt: str | None
|
|
21
|
+
removalId: str | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ArtifactReference(TypedDict):
|
|
25
|
+
id: str
|
|
26
|
+
name: str
|
|
27
|
+
kind: Literal["screenshot", "trace", "browser_events", "recording", "file"]
|
|
28
|
+
mediaType: str
|
|
29
|
+
bytes: int
|
|
30
|
+
sha256: str
|
|
31
|
+
capturedAt: str
|
|
32
|
+
metadata: dict
|
|
33
|
+
runId: str
|
|
34
|
+
attemptId: str
|
|
35
|
+
state: Literal["pending", "available"]
|
|
36
|
+
uploadPath: str
|
|
37
|
+
downloadPath: str
|
|
38
|
+
metadataOnly: NotRequired[Literal[False]]
|
|
39
|
+
retention: NotRequired[ArtifactRetention]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UnavailableArtifact(TypedDict):
|
|
43
|
+
id: str
|
|
44
|
+
runId: str
|
|
45
|
+
metadataOnly: Literal[True]
|
|
46
|
+
retention: ArtifactRetention
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
RetainedArtifact = ArtifactReference | UnavailableArtifact
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ArtifactManifest(TypedDict):
|
|
53
|
+
items: list[RetainedArtifact]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ArtifactContent(TypedDict):
|
|
57
|
+
artifact: ArtifactReference
|
|
58
|
+
content: bytes
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ArtifactDownload(TypedDict):
|
|
62
|
+
artifact: ArtifactReference
|
|
63
|
+
path: str
|
|
64
|
+
verified: Literal[True]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def artifact_path(run_id: str, artifact_id: str | None = None) -> str:
|
|
68
|
+
if any(not isinstance(value, str) or re.fullmatch(r"[A-Za-z0-9_-]{1,160}", value) is None
|
|
69
|
+
for value in [run_id] + ([] if artifact_id is None else [artifact_id])):
|
|
70
|
+
raise FiredrillError("Select an exact retained run and artifact ID", code="ARTIFACT_ID_INVALID",
|
|
71
|
+
repair="Use IDs from artifacts(run_id); a URL or local filename is not an artifact ID.")
|
|
72
|
+
return f"/v1/runs/{run_id}/artifacts" + ("" if artifact_id is None else f"/{artifact_id}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _retention(value) -> ArtifactRetention | None:
|
|
76
|
+
if (not isinstance(value, dict) or value.get("schemaVersion") != "2026-10-15-retention.1"
|
|
77
|
+
or value.get("policyVersion") not in ("2026-10-15-retention.1", "historical_preserved")
|
|
78
|
+
or value.get("state") not in ("available", "historical_preserved", "expired", "deletion_pending", "deleted")):
|
|
79
|
+
return None
|
|
80
|
+
for key in ("payloadExpiresAt", "metadataExpiresAt"):
|
|
81
|
+
if key not in value:
|
|
82
|
+
return None
|
|
83
|
+
if value[key] is not None:
|
|
84
|
+
if not isinstance(value[key], str) or len(value[key]) > 64:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
datetime.fromisoformat(value[key].replace("Z", "+00:00"))
|
|
88
|
+
except ValueError:
|
|
89
|
+
return None
|
|
90
|
+
if "removalId" not in value or value["removalId"] is not None and (not isinstance(value["removalId"], str) or re.fullmatch(r"removal_[a-f0-9]{24}", value["removalId"]) is None):
|
|
91
|
+
return None
|
|
92
|
+
return {key: value[key] for key in ArtifactRetention.__annotations__}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _reference(value, run_id, artifact_id=None) -> RetainedArtifact:
|
|
96
|
+
def invalid():
|
|
97
|
+
raise FiredrillError("The retained artifact metadata could not be confirmed", code="ARTIFACT_RESPONSE_MISMATCH",
|
|
98
|
+
run_id=run_id, repair="Read this same run and artifact again in its original scope. No target is rerun and no file is written.")
|
|
99
|
+
if not isinstance(value, dict):
|
|
100
|
+
invalid()
|
|
101
|
+
if (value.get("runId") != run_id or artifact_id is not None and value.get("id") != artifact_id
|
|
102
|
+
or not isinstance(value.get("id"), str) or re.fullmatch(r"[A-Za-z0-9_-]{1,160}", value["id"]) is None):
|
|
103
|
+
invalid()
|
|
104
|
+
availability = _retention(value.get("retention"))
|
|
105
|
+
if "retention" in value and availability is None:
|
|
106
|
+
invalid()
|
|
107
|
+
if value.get("metadataOnly") is True:
|
|
108
|
+
if availability is None or availability["state"] not in ("expired", "deletion_pending", "deleted"):
|
|
109
|
+
invalid()
|
|
110
|
+
return {"id": value["id"], "runId": run_id, "metadataOnly": True, "retention": availability}
|
|
111
|
+
if ("metadataOnly" in value and value["metadataOnly"] is not False
|
|
112
|
+
or availability is not None and availability["state"] not in ("available", "historical_preserved")):
|
|
113
|
+
invalid()
|
|
114
|
+
if (value.get("runId") != run_id or artifact_id is not None and value.get("id") != artifact_id
|
|
115
|
+
or any(not isinstance(value.get(key), str) or re.fullmatch(r"[A-Za-z0-9_-]{1,160}", value[key]) is None for key in ("id", "attemptId"))
|
|
116
|
+
or not isinstance(value.get("name"), str) or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._ -]{0,159}", value["name"]) is None
|
|
117
|
+
or value.get("kind") not in ("screenshot", "trace", "browser_events", "recording", "file")
|
|
118
|
+
or value.get("mediaType") not in ("image/png", "image/jpeg", "image/webp", "application/zip", "application/json", "text/plain", "video/webm", "text/markdown", "application/pdf", "application/octet-stream")
|
|
119
|
+
or type(value.get("bytes")) is not int or not 1 <= value["bytes"] <= 16 * 1024 * 1024
|
|
120
|
+
or not isinstance(value.get("sha256"), str) or re.fullmatch(r"[a-f0-9]{64}", value["sha256"]) is None
|
|
121
|
+
or not isinstance(value.get("capturedAt"), str) or not isinstance(value.get("metadata"), dict)
|
|
122
|
+
or value.get("state") not in ("pending", "available")):
|
|
123
|
+
invalid()
|
|
124
|
+
path = artifact_path(run_id, value["id"]) + "/content"
|
|
125
|
+
if value.get("uploadPath") != path or value.get("downloadPath") != path:
|
|
126
|
+
invalid()
|
|
127
|
+
return {**{key: value[key] for key in ArtifactReference.__annotations__ if key in value and key != "retention"},
|
|
128
|
+
**({"retention": availability} if availability is not None else {})}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read(transport, path):
|
|
132
|
+
try:
|
|
133
|
+
return transport.request("GET", path)
|
|
134
|
+
except TransportError as error:
|
|
135
|
+
error.repair = "Retry this same retained artifact read. It does not execute a target or request evaluation."
|
|
136
|
+
raise
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def artifacts(transport, run_id: str) -> ArtifactManifest:
|
|
140
|
+
value = _read(transport, artifact_path(run_id))
|
|
141
|
+
if not isinstance(value, dict) or not isinstance(value.get("items"), list) or len(value["items"]) > 128:
|
|
142
|
+
raise FiredrillError("The artifact manifest is not a bounded retained listing", code="ARTIFACT_RESPONSE_MISMATCH", run_id=run_id)
|
|
143
|
+
items = [_reference(item, run_id) for item in value["items"]]
|
|
144
|
+
if len({item["id"] for item in items}) != len(items):
|
|
145
|
+
raise FiredrillError("The artifact manifest repeats an identity", code="ARTIFACT_RESPONSE_MISMATCH", run_id=run_id)
|
|
146
|
+
return {"items": items}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def artifact_content(transport, run_id: str, artifact_id: str) -> ArtifactContent:
|
|
150
|
+
path = artifact_path(run_id, artifact_id)
|
|
151
|
+
artifact = _reference(_read(transport, path), run_id, artifact_id)
|
|
152
|
+
if artifact.get("metadataOnly") is True:
|
|
153
|
+
raise FiredrillError(f"The capture is {artifact['retention']['state']}; only its history remains", code="ARTIFACT_UNAVAILABLE", run_id=run_id,
|
|
154
|
+
repair="Inspect retained metadata. Do not recreate removed evidence by rerunning the target.")
|
|
155
|
+
if artifact["state"] != "available":
|
|
156
|
+
raise FiredrillError("This capture has not finished uploading", code="ARTIFACT_PENDING", run_id=run_id,
|
|
157
|
+
repair="Inspect the original run or let its runner reconcile the pending upload. Do not repeat the target to create replacement evidence.")
|
|
158
|
+
content, media_type = transport.download(path + "/content", artifact["bytes"])
|
|
159
|
+
if (len(content) != artifact["bytes"] or hashlib.sha256(content).hexdigest() != artifact["sha256"]
|
|
160
|
+
or not isinstance(media_type, str) or media_type.split(";", 1)[0].strip().lower() != artifact["mediaType"]):
|
|
161
|
+
raise FiredrillError("Downloaded evidence does not match its retained size, digest or media type", code="ARTIFACT_CONTENT_MISMATCH",
|
|
162
|
+
run_id=run_id, repair="Read the same retained artifact again. Unverified bytes are not returned or written; the target is not rerun.")
|
|
163
|
+
return {"artifact": artifact, "content": content}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def download_artifact(transport, run_id: str, artifact_id: str, output: str | Path) -> ArtifactDownload:
|
|
167
|
+
if not isinstance(output, (str, Path)) or not str(output).strip() or "\0" in str(output):
|
|
168
|
+
raise FiredrillError("Choose an explicit new local file for this capture", code="ARTIFACT_OUTPUT_INVALID",
|
|
169
|
+
repair="Use an existing output directory and a new filename. Existing files are never overwritten.")
|
|
170
|
+
value = artifact_content(transport, run_id, artifact_id)
|
|
171
|
+
path = Path(os.path.abspath(output))
|
|
172
|
+
temporary = path.parent / (".firedrill-artifact-" + str(uuid.uuid4()))
|
|
173
|
+
created = False
|
|
174
|
+
try:
|
|
175
|
+
fd = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
|
|
176
|
+
created = True
|
|
177
|
+
with os.fdopen(fd, "wb") as file:
|
|
178
|
+
file.write(value["content"])
|
|
179
|
+
file.flush()
|
|
180
|
+
os.fsync(file.fileno())
|
|
181
|
+
os.link(temporary, path) # Publish atomically without replacing a file or symlink.
|
|
182
|
+
return {"artifact": value["artifact"], "path": str(path), "verified": True}
|
|
183
|
+
except OSError:
|
|
184
|
+
raise FiredrillError("The verified capture could not be saved to that new path", code="ARTIFACT_OUTPUT_UNAVAILABLE",
|
|
185
|
+
run_id=run_id, repair="Choose a new filename in an existing writable directory. Existing files are preserved; downloading again never executes the target.") from None
|
|
186
|
+
finally:
|
|
187
|
+
if created:
|
|
188
|
+
try:
|
|
189
|
+
temporary.unlink()
|
|
190
|
+
except OSError:
|
|
191
|
+
pass # A filesystem failure can leave only the private staging file.
|
firedrill/attachments.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Journaled artifact transport; captures remain customer-runner evidence.
|
|
2
|
+
|
|
3
|
+
Wire fields and recovery ordering follow the root-owned Artifact contract and
|
|
4
|
+
the sibling TypeScript attachment coordinator; this is a native Python adapter.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
import stat
|
|
16
|
+
import uuid
|
|
17
|
+
|
|
18
|
+
from .errors import FiredrillError, RecoveryRequired
|
|
19
|
+
from .models import json_bytes
|
|
20
|
+
from .transport import segment
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def metadata(value):
|
|
24
|
+
def require(condition):
|
|
25
|
+
if not condition:
|
|
26
|
+
raise ValueError("Invalid artifact field")
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
item = json.loads(json_bytes(value))
|
|
30
|
+
required = {"id", "name", "kind", "mediaType", "bytes", "sha256", "capturedAt"}
|
|
31
|
+
require(isinstance(item, dict) and required <= item.keys() and not item.keys() - required - {"metadata"})
|
|
32
|
+
require(re.fullmatch(r"[a-zA-Z0-9_-]{1,160}", item["id"]))
|
|
33
|
+
require(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._ -]{0,159}", item["name"]))
|
|
34
|
+
require(item["kind"] in {"screenshot", "trace", "browser_events", "recording", "file"})
|
|
35
|
+
require(item["mediaType"] in {"image/png", "image/jpeg", "image/webp", "application/zip", "application/json", "text/plain", "video/webm", "text/markdown", "application/pdf", "application/octet-stream"})
|
|
36
|
+
require(type(item["bytes"]) is int and 1 <= item["bytes"] <= 16 * 1024 * 1024)
|
|
37
|
+
require(re.fullmatch(r"[a-f0-9]{64}", item["sha256"]))
|
|
38
|
+
require(re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z", item["capturedAt"]))
|
|
39
|
+
datetime.fromisoformat(item["capturedAt"].replace("Z", "+00:00"))
|
|
40
|
+
item.setdefault("metadata", {})
|
|
41
|
+
require(isinstance(item["metadata"], dict) and len(json_bytes(item["metadata"])) <= 16384)
|
|
42
|
+
return item
|
|
43
|
+
except (ValueError, TypeError, KeyError, OverflowError):
|
|
44
|
+
raise FiredrillError("Attachment metadata does not satisfy the artifact contract", code="ARTIFACT_INVALID",
|
|
45
|
+
repair="Use supported media, bounded size, SHA-256, UTC capturedAt and a stable artifact ID.") from None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def private_bytes(intent):
|
|
49
|
+
try:
|
|
50
|
+
fd = os.open(intent["path"], os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
|
|
51
|
+
with os.fdopen(fd, "rb") as stream:
|
|
52
|
+
info = os.fstat(stream.fileno())
|
|
53
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_mode & 0o077
|
|
54
|
+
or info.st_size != intent["artifact"]["bytes"] or info.st_size > 16 * 1024 * 1024
|
|
55
|
+
or (hasattr(os, "getuid") and info.st_uid != os.getuid())):
|
|
56
|
+
raise OSError("Unsafe file")
|
|
57
|
+
content = stream.read(16 * 1024 * 1024 + 1)
|
|
58
|
+
except OSError:
|
|
59
|
+
raise FiredrillError("Attachment is not the expected private regular file", code="ARTIFACT_FILE_UNSAFE",
|
|
60
|
+
repair="Preserve the capture; use a current-user-owned 0600 file without links.") from None
|
|
61
|
+
if len(content) != intent["artifact"]["bytes"] or hashlib.sha256(content).hexdigest() != intent["artifact"]["sha256"]:
|
|
62
|
+
raise FiredrillError("Attachment bytes changed from their saved digest", code="ARTIFACT_CONTENT_CHANGED",
|
|
63
|
+
repair="Restore the exact original bytes; never replace evidence under an existing ID.")
|
|
64
|
+
return content
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AttachmentCoordinator:
|
|
68
|
+
def __init__(self, entry, save, transport):
|
|
69
|
+
self.entry, self.save, self.transport = entry, save, transport
|
|
70
|
+
self.loop = asyncio.get_running_loop()
|
|
71
|
+
self.lock = asyncio.Lock()
|
|
72
|
+
|
|
73
|
+
def _active_invocation(self):
|
|
74
|
+
if self.entry["phase"] == "running":
|
|
75
|
+
return "single"
|
|
76
|
+
step = self.entry.get("sequenceStep", {})
|
|
77
|
+
if self.entry["phase"] == "sequence" and step.get("phase") == "running":
|
|
78
|
+
return ("sequence", step["ordinal"])
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def for_callback(self, value):
|
|
82
|
+
"""Sync native threads block; async callbacks await on their owning loop."""
|
|
83
|
+
try:
|
|
84
|
+
running = asyncio.get_running_loop()
|
|
85
|
+
except RuntimeError:
|
|
86
|
+
running = None
|
|
87
|
+
if running is self.loop:
|
|
88
|
+
return self.attach(value)
|
|
89
|
+
if self.loop.is_closed() or not self.loop.is_running():
|
|
90
|
+
raise FiredrillError("Attachment runner lifecycle ended", code="ARTIFACT_LEASE_INACTIVE")
|
|
91
|
+
return asyncio.run_coroutine_threadsafe(self.attach(value), self.loop).result()
|
|
92
|
+
|
|
93
|
+
async def attach(self, value):
|
|
94
|
+
async with self.lock:
|
|
95
|
+
invocation = self._active_invocation()
|
|
96
|
+
if invocation is None or not self.entry.get("claim", {}).get("leaseToken"):
|
|
97
|
+
raise FiredrillError("Attachments require the invocation's active lifecycle", code="ARTIFACT_LEASE_INACTIVE",
|
|
98
|
+
repair="Complete context.attach inside the callback before it returns.")
|
|
99
|
+
if not isinstance(value, dict) or set(value) != {"path", "artifact"}:
|
|
100
|
+
raise FiredrillError("Use context.attach({path, artifact})", code="ARTIFACT_INVALID")
|
|
101
|
+
artifact = metadata(value["artifact"])
|
|
102
|
+
intents = self.entry.setdefault("attachments", [])
|
|
103
|
+
intent = next((item for item in intents if item["artifact"]["id"] == artifact["id"]), None)
|
|
104
|
+
if intent and intent["artifact"] != artifact:
|
|
105
|
+
raise FiredrillError("Artifact ID already describes different evidence", code="ARTIFACT_ID_CONFLICT",
|
|
106
|
+
repair="Use its original metadata and capture; new evidence needs a new ID.")
|
|
107
|
+
if intent is None:
|
|
108
|
+
intent = {"path": str(Path(value["path"]).expanduser().absolute()), "artifact": artifact,
|
|
109
|
+
"key": str(uuid.uuid4()), "state": "pending"}
|
|
110
|
+
await asyncio.to_thread(private_bytes, intent)
|
|
111
|
+
# Lifecycle may have ended while the file was checked.
|
|
112
|
+
if self._active_invocation() != invocation:
|
|
113
|
+
raise FiredrillError("Attachment runner lifecycle ended", code="ARTIFACT_LEASE_INACTIVE")
|
|
114
|
+
intents.append(intent)
|
|
115
|
+
self.save() # Durable exact content/key intent precedes either network write.
|
|
116
|
+
return await self._upload(intent)
|
|
117
|
+
|
|
118
|
+
def _checked(self, ref, intent):
|
|
119
|
+
claim = self.entry["claim"]
|
|
120
|
+
if (not isinstance(ref, dict) or ref.get("runId") != claim["run"]["id"]
|
|
121
|
+
or ref.get("attemptId") != claim["attemptId"]
|
|
122
|
+
or any(ref.get(key) != value for key, value in intent["artifact"].items())):
|
|
123
|
+
raise FiredrillError("Attachment acknowledgement mismatches exact run, attempt or content",
|
|
124
|
+
code="ARTIFACT_RESPONSE_MISMATCH", repair="Preserve the journal; do not rerun the target.")
|
|
125
|
+
return ref
|
|
126
|
+
|
|
127
|
+
async def _upload(self, intent):
|
|
128
|
+
if intent["state"] == "available" and intent.get("retained"):
|
|
129
|
+
return json.loads(json_bytes(intent["retained"]))
|
|
130
|
+
claim = self.entry["claim"]
|
|
131
|
+
route = f"/v1/runs/{segment(claim['run']['id'])}/artifacts"
|
|
132
|
+
phase = "declaration"
|
|
133
|
+
try:
|
|
134
|
+
content = await asyncio.to_thread(private_bytes, intent)
|
|
135
|
+
ref = self._checked(await asyncio.to_thread(self.transport.request, "POST", route,
|
|
136
|
+
{"attemptId": claim["attemptId"], "leaseToken": claim["leaseToken"], "artifact": intent["artifact"]}, intent["key"]), intent)
|
|
137
|
+
if ref["state"] != "available":
|
|
138
|
+
phase = "upload"
|
|
139
|
+
ref = self._checked(await asyncio.to_thread(self.transport.upload,
|
|
140
|
+
route + f"/{segment(intent['artifact']['id'])}/content", content,
|
|
141
|
+
intent["artifact"]["mediaType"], claim["attemptId"], claim["leaseToken"]), intent)
|
|
142
|
+
if ref["state"] != "available":
|
|
143
|
+
raise ValueError("Upload not confirmed")
|
|
144
|
+
intent.update(state="available", retained=ref)
|
|
145
|
+
self.save()
|
|
146
|
+
return json.loads(json_bytes(ref))
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
if isinstance(exc, FiredrillError) and exc.code in {"ARTIFACT_FILE_UNSAFE", "ARTIFACT_CONTENT_CHANGED", "ARTIFACT_RESPONSE_MISMATCH"}:
|
|
149
|
+
self.save()
|
|
150
|
+
raise
|
|
151
|
+
cause = exc.code if isinstance(exc, FiredrillError) else "transport_uncertain"
|
|
152
|
+
request_id = getattr(exc, "request_id", None)
|
|
153
|
+
if not isinstance(request_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,160}", request_id):
|
|
154
|
+
request_id = None
|
|
155
|
+
failure = RecoveryRequired("Artifact upload acknowledgement is unconfirmed; exact intent and capture were preserved",
|
|
156
|
+
code="ARTIFACT_UPLOAD_PENDING", request_id=request_id,
|
|
157
|
+
status=getattr(exc, "status", None), cause_code=cause, phase=phase,
|
|
158
|
+
repair="Resume the saved operation while its lease is active. Preserve the original capture; never rerun the target to recover an attachment.")
|
|
159
|
+
intent["lastFailure"] = {name: value for name, value in failure.to_dict()["error"].items()
|
|
160
|
+
if name in ("causeCode", "status", "phase", "requestId")}
|
|
161
|
+
self.save()
|
|
162
|
+
raise failure from None
|
|
163
|
+
|
|
164
|
+
async def reconcile(self):
|
|
165
|
+
async with self.lock:
|
|
166
|
+
for intent in self.entry.get("attachments", []):
|
|
167
|
+
await self._upload(intent)
|