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.
Files changed (44) hide show
  1. firedrill/__init__.py +55 -0
  2. firedrill/activity.py +107 -0
  3. firedrill/artifact_downloads.py +191 -0
  4. firedrill/attachments.py +167 -0
  5. firedrill/batch_receipt.py +142 -0
  6. firedrill/capacity.py +91 -0
  7. firedrill/ci.py +263 -0
  8. firedrill/client.py +1033 -0
  9. firedrill/credentials.py +124 -0
  10. firedrill/device_authorization.py +317 -0
  11. firedrill/discovery.py +91 -0
  12. firedrill/endpoint.py +34 -0
  13. firedrill/errors.py +154 -0
  14. firedrill/execution.py +398 -0
  15. firedrill/follow.py +261 -0
  16. firedrill/input_files.py +74 -0
  17. firedrill/inspection.py +302 -0
  18. firedrill/interventions.py +88 -0
  19. firedrill/journal.py +188 -0
  20. firedrill/live_events.py +88 -0
  21. firedrill/login.py +453 -0
  22. firedrill/login_store.py +225 -0
  23. firedrill/models.py +295 -0
  24. firedrill/population_disclosure.py +53 -0
  25. firedrill/py.typed +0 -0
  26. firedrill/read_scope.py +28 -0
  27. firedrill/retained_values.py +162 -0
  28. firedrill/retention.py +159 -0
  29. firedrill/run_tools.py +145 -0
  30. firedrill/scoring.py +66 -0
  31. firedrill/sequences.py +139 -0
  32. firedrill/service_build_diagnostics.py +78 -0
  33. firedrill/service_source.py +104 -0
  34. firedrill/services.py +539 -0
  35. firedrill/submission.py +183 -0
  36. firedrill/task_metrics.py +134 -0
  37. firedrill/tls.py +56 -0
  38. firedrill/tool_start.py +430 -0
  39. firedrill/transport.py +187 -0
  40. firedrill_run-2.0.0.dist-info/METADATA +1751 -0
  41. firedrill_run-2.0.0.dist-info/RECORD +44 -0
  42. firedrill_run-2.0.0.dist-info/WHEEL +5 -0
  43. firedrill_run-2.0.0.dist-info/licenses/LICENSE.md +23 -0
  44. 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.
@@ -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)