sediment-cli 0.1.0__tar.gz → 0.2.0__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.
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/PKG-INFO +5 -5
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/pyproject.toml +5 -5
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/cli.py +41 -1
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/client.py +50 -1
- sediment_cli-0.2.0/sediment_cli/evidence.py +195 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli_capture_authority.py +31 -1
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli_db.py +21 -5
- sediment_cli-0.2.0/tests/test_cli_evidence.py +738 -0
- sediment_cli-0.2.0/tests/test_cli_evidence_integration.py +488 -0
- sediment_cli-0.2.0/tests/testdata/help/evidence-fetch.txt +12 -0
- sediment_cli-0.2.0/tests/testdata/help/evidence-inspect.txt +11 -0
- sediment_cli-0.2.0/tests/testdata/help/evidence-inventory.txt +10 -0
- sediment_cli-0.2.0/tests/testdata/help/evidence.txt +12 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/sediment.txt +2 -1
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/.gitignore +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/LICENSE +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/__init__.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/attribution.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/delivery.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/local_postgres.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/transcript.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/sediment_cli/ui.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/conftest.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli_help.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli_libpq_unavailable.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli_remote.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_cli_ui.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_consumer_profile_cli.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_delivery_transport.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_demo_verb.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_e2e_quickstart.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_installed_wheel.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/test_local_postgres.py +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/commit.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/db-provision.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/db-status.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/db-upgrade.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/db.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/delivery-enqueue.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/delivery-replay.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/delivery-status.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/delivery.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/demo.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/derive.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/doctor.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/export-diff-sft.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/export-dpo.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/export-recovery.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/export-rlvr.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/export-sft.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/export.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/facts.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/install.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/login.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/logout.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/mirror-gc.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/quarantine-inference-calls.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/quarantine-log.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/quarantine.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/release.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-abandonment.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-attribution-share.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-dataset-diagnostics.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-label-confidence-inspection.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-lifecycle.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-merge-retention.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-model.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-precision.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report-recovery-yield.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/report.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/server.txt +0 -0
- {sediment_cli-0.1.0 → sediment_cli-0.2.0}/tests/testdata/help/uninstall.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sediment-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: The sediment command: login, install, server, facts, exports
|
|
5
5
|
Project-URL: Source, https://github.com/sediment-ai/sediment
|
|
6
6
|
Project-URL: Documentation, https://github.com/sediment-ai/sediment/tree/main/docs
|
|
@@ -10,7 +10,7 @@ License-Expression: AGPL-3.0-or-later
|
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Requires-Python: >=3.12
|
|
12
12
|
Requires-Dist: httpx>=0.27
|
|
13
|
-
Requires-Dist: sediment-api==0.
|
|
14
|
-
Requires-Dist: sediment-core==0.
|
|
15
|
-
Requires-Dist: sediment-derive==0.
|
|
16
|
-
Requires-Dist: sediment-export==0.
|
|
13
|
+
Requires-Dist: sediment-api==0.2.0
|
|
14
|
+
Requires-Dist: sediment-core==0.2.0
|
|
15
|
+
Requires-Dist: sediment-derive==0.2.0
|
|
16
|
+
Requires-Dist: sediment-export==0.2.0
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# Depends on sediment-api deliberately: the operator verbs are DB-local by
|
|
6
6
|
# design (ADR 0001) and `server`/reports/mirror-gc forward into it.
|
|
7
7
|
name = "sediment-cli"
|
|
8
|
-
version = "0.
|
|
8
|
+
version = "0.2.0"
|
|
9
9
|
description = "The sediment command: login, install, server, facts, exports"
|
|
10
10
|
requires-python = ">=3.12"
|
|
11
11
|
license = "AGPL-3.0-or-later"
|
|
@@ -14,10 +14,10 @@ license-files = ["LICENSE"]
|
|
|
14
14
|
# so an install never mixes member versions (the release workflow asserts
|
|
15
15
|
# the versions agree before publishing).
|
|
16
16
|
dependencies = [
|
|
17
|
-
"sediment-core==0.
|
|
18
|
-
"sediment-derive==0.
|
|
19
|
-
"sediment-export==0.
|
|
20
|
-
"sediment-api==0.
|
|
17
|
+
"sediment-core==0.2.0",
|
|
18
|
+
"sediment-derive==0.2.0",
|
|
19
|
+
"sediment-export==0.2.0",
|
|
20
|
+
"sediment-api==0.2.0",
|
|
21
21
|
"httpx>=0.27",
|
|
22
22
|
]
|
|
23
23
|
|
|
@@ -1506,6 +1506,46 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1506
1506
|
)
|
|
1507
1507
|
p_facts.set_defaults(func=cmd_facts)
|
|
1508
1508
|
|
|
1509
|
+
from .evidence import command as evidence_command
|
|
1510
|
+
|
|
1511
|
+
p_evidence = sub.add_parser(
|
|
1512
|
+
"evidence", help="read selected Session evidence with operator authority"
|
|
1513
|
+
)
|
|
1514
|
+
evidence_sub = p_evidence.add_subparsers(
|
|
1515
|
+
dest="evidence_operation",
|
|
1516
|
+
required=True,
|
|
1517
|
+
title="operations",
|
|
1518
|
+
metavar="<operation>",
|
|
1519
|
+
prog=p_evidence.prog,
|
|
1520
|
+
)
|
|
1521
|
+
for operation, help_text in (
|
|
1522
|
+
("inventory", "print a complete bounded Session inventory as JSON"),
|
|
1523
|
+
("inspect", "print one Inference call's part manifest as JSON"),
|
|
1524
|
+
("fetch", "write exact selected parts to a private local packet"),
|
|
1525
|
+
):
|
|
1526
|
+
command = evidence_sub.add_parser(operation, help=help_text)
|
|
1527
|
+
command.add_argument("session_id", metavar="SESSION", help="source Session ID")
|
|
1528
|
+
if operation == "inspect":
|
|
1529
|
+
command.add_argument(
|
|
1530
|
+
"inference_call_id",
|
|
1531
|
+
metavar="INFERENCE_CALL",
|
|
1532
|
+
help="Inference call Fact ID",
|
|
1533
|
+
)
|
|
1534
|
+
elif operation == "fetch":
|
|
1535
|
+
command.add_argument(
|
|
1536
|
+
"--references",
|
|
1537
|
+
required=True,
|
|
1538
|
+
metavar="PATH",
|
|
1539
|
+
help="version 1 selection JSON (64 KiB; 1–32 distinct references)",
|
|
1540
|
+
)
|
|
1541
|
+
command.add_argument(
|
|
1542
|
+
"--output",
|
|
1543
|
+
required=True,
|
|
1544
|
+
metavar="PATH",
|
|
1545
|
+
help="packet destination (0600; must not exist)",
|
|
1546
|
+
)
|
|
1547
|
+
command.set_defaults(func=evidence_command)
|
|
1548
|
+
|
|
1509
1549
|
p_demo = sub.add_parser(
|
|
1510
1550
|
"demo", help="plant one synthetic session so facts is non-zero"
|
|
1511
1551
|
)
|
|
@@ -1795,7 +1835,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1795
1835
|
|
|
1796
1836
|
# Remote verbs speak HTTP through the client seam; they never open the fact
|
|
1797
1837
|
# store and never construct Settings.
|
|
1798
|
-
_REMOTE_VERBS = {"login", "logout", "commit", "facts", "demo"}
|
|
1838
|
+
_REMOTE_VERBS = {"login", "logout", "commit", "facts", "demo", "evidence"}
|
|
1799
1839
|
|
|
1800
1840
|
# Dispatched pre-argparse to the stdlib-only attribution module.
|
|
1801
1841
|
# install/uninstall/doctor get help stubs; the hook-plumbing verbs are
|
|
@@ -21,6 +21,7 @@ from typing import Any
|
|
|
21
21
|
from urllib.parse import urlsplit
|
|
22
22
|
|
|
23
23
|
import httpx
|
|
24
|
+
from sediment_core import EVIDENCE_REQUEST_BYTES_LIMIT, EVIDENCE_RESPONSE_BYTES_LIMIT
|
|
24
25
|
|
|
25
26
|
from . import __version__, ui
|
|
26
27
|
|
|
@@ -241,7 +242,7 @@ def probe_me(base_url: str, token: str) -> dict[str, Any]:
|
|
|
241
242
|
identity = resp.json()
|
|
242
243
|
if (
|
|
243
244
|
not isinstance(identity, dict)
|
|
244
|
-
or identity.get("authority") not in {"operator", "ingest"}
|
|
245
|
+
or identity.get("authority") not in {"operator", "ingest", "retrieval"}
|
|
245
246
|
or not isinstance(identity.get("client_id"), str)
|
|
246
247
|
or not identity["client_id"]
|
|
247
248
|
or not isinstance(identity.get("org_id"), str)
|
|
@@ -290,6 +291,54 @@ def post_json(path: str, body: Any) -> Any:
|
|
|
290
291
|
return resp.json()
|
|
291
292
|
|
|
292
293
|
|
|
294
|
+
def read_evidence(
|
|
295
|
+
path: str, *, params: dict[str, str] | None = None, body: bytes | None = None
|
|
296
|
+
) -> bytes:
|
|
297
|
+
"""Read one complete bounded evidence response with operator credentials."""
|
|
298
|
+
if body is not None and len(body) > EVIDENCE_REQUEST_BYTES_LIMIT:
|
|
299
|
+
raise ClientError("evidence request exceeds the 64 KiB limit")
|
|
300
|
+
base_url, token = _resolve()
|
|
301
|
+
try:
|
|
302
|
+
with (
|
|
303
|
+
_http() as http,
|
|
304
|
+
http.stream(
|
|
305
|
+
"GET" if body is None else "POST",
|
|
306
|
+
_url(base_url, path),
|
|
307
|
+
params=params,
|
|
308
|
+
content=body,
|
|
309
|
+
headers={
|
|
310
|
+
"Authorization": f"Bearer {token}",
|
|
311
|
+
"Content-Type": "application/json",
|
|
312
|
+
"Accept-Encoding": "identity",
|
|
313
|
+
},
|
|
314
|
+
timeout=httpx.Timeout(_TIMEOUT_SECONDS, read=40.0),
|
|
315
|
+
) as response,
|
|
316
|
+
):
|
|
317
|
+
if response.status_code == 401:
|
|
318
|
+
raise ClientError("not logged in / token rejected — run sediment login")
|
|
319
|
+
if response.status_code == 403:
|
|
320
|
+
raise ClientError(
|
|
321
|
+
"operator authority required — run sediment login with an operator token"
|
|
322
|
+
)
|
|
323
|
+
if response.status_code != 200:
|
|
324
|
+
raise ClientError(f"evidence server error ({response.status_code})")
|
|
325
|
+
if (
|
|
326
|
+
response.headers.get("Content-Encoding", "identity").lower()
|
|
327
|
+
!= "identity"
|
|
328
|
+
):
|
|
329
|
+
raise ClientError("evidence response must use identity encoding")
|
|
330
|
+
content = bytearray()
|
|
331
|
+
for chunk in response.iter_bytes():
|
|
332
|
+
if len(content) + len(chunk) > EVIDENCE_RESPONSE_BYTES_LIMIT:
|
|
333
|
+
raise ClientError("evidence response exceeds the 1 MiB limit")
|
|
334
|
+
content.extend(chunk)
|
|
335
|
+
return bytes(content)
|
|
336
|
+
except httpx.HTTPError:
|
|
337
|
+
raise ClientError(
|
|
338
|
+
"evidence request failed; check the server connection"
|
|
339
|
+
) from None
|
|
340
|
+
|
|
341
|
+
|
|
293
342
|
def current_url() -> str:
|
|
294
343
|
"""The resolved server URL, for a verb that has to reason about *which*
|
|
295
344
|
server it is about to write to."""
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Operator-selected evidence reads and private packet publication."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import TypeAdapter
|
|
15
|
+
from sediment_core import (
|
|
16
|
+
EVIDENCE_INVENTORY_LIMIT,
|
|
17
|
+
EVIDENCE_REQUEST_BYTES_LIMIT,
|
|
18
|
+
EvidenceInventory,
|
|
19
|
+
EvidenceManifest,
|
|
20
|
+
EvidenceRead,
|
|
21
|
+
EvidenceReference,
|
|
22
|
+
EvidenceSchemaVersion,
|
|
23
|
+
NonEmptyId,
|
|
24
|
+
validate_evidence_references,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from .client import ClientError, read_evidence
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _object(pairs):
|
|
31
|
+
result = {}
|
|
32
|
+
for key, value in pairs:
|
|
33
|
+
if key in result:
|
|
34
|
+
raise ValueError("duplicate JSON key")
|
|
35
|
+
result[key] = value
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _finite_float(value):
|
|
40
|
+
result = float(value)
|
|
41
|
+
if not math.isfinite(result):
|
|
42
|
+
raise ValueError("non-finite JSON number")
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _reject_constant(value):
|
|
47
|
+
raise ValueError("non-finite JSON number")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _decode(content: bytes):
|
|
51
|
+
return json.loads(
|
|
52
|
+
content.decode("utf-8"),
|
|
53
|
+
object_pairs_hook=_object,
|
|
54
|
+
parse_float=_finite_float,
|
|
55
|
+
parse_constant=_reject_constant,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _selection(path: Path) -> tuple[EvidenceReference, ...]:
|
|
60
|
+
with path.open("rb") as source:
|
|
61
|
+
content = source.read(EVIDENCE_REQUEST_BYTES_LIMIT + 1)
|
|
62
|
+
if len(content) > EVIDENCE_REQUEST_BYTES_LIMIT:
|
|
63
|
+
raise ClientError("evidence selection exceeds the 64 KiB limit")
|
|
64
|
+
value = _decode(content)
|
|
65
|
+
if not isinstance(value, dict) or set(value) != {"schema_version", "references"}:
|
|
66
|
+
raise ValueError("invalid selection envelope")
|
|
67
|
+
TypeAdapter(EvidenceSchemaVersion).validate_python(value["schema_version"])
|
|
68
|
+
references = TypeAdapter(tuple[EvidenceReference, ...]).validate_python(
|
|
69
|
+
value["references"]
|
|
70
|
+
)
|
|
71
|
+
return validate_evidence_references(references)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _unchanged(source, canonical) -> bool:
|
|
75
|
+
"""Reject default-filled or normalized wire data without another part schema."""
|
|
76
|
+
if isinstance(canonical, datetime):
|
|
77
|
+
return isinstance(source, str) and datetime.fromisoformat(source) == canonical
|
|
78
|
+
if isinstance(canonical, dict):
|
|
79
|
+
return (
|
|
80
|
+
isinstance(source, dict)
|
|
81
|
+
and source.keys() == canonical.keys()
|
|
82
|
+
and all(_unchanged(source[key], value) for key, value in canonical.items())
|
|
83
|
+
)
|
|
84
|
+
if isinstance(canonical, (list, tuple)):
|
|
85
|
+
return (
|
|
86
|
+
isinstance(source, list)
|
|
87
|
+
and len(source) == len(canonical)
|
|
88
|
+
and all(_unchanged(a, b) for a, b in zip(source, canonical, strict=True))
|
|
89
|
+
)
|
|
90
|
+
return type(source) is type(canonical) and source == canonical
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _response(content: bytes, shape):
|
|
94
|
+
adapter = TypeAdapter(shape)
|
|
95
|
+
source = _decode(content)
|
|
96
|
+
result = adapter.validate_python(source)
|
|
97
|
+
if not _unchanged(source, adapter.dump_python(result)):
|
|
98
|
+
raise ValueError("noncanonical evidence response")
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _inventory(result: EvidenceInventory) -> None:
|
|
103
|
+
calls = result.calls
|
|
104
|
+
if (
|
|
105
|
+
result.visible_inference_calls != len(calls)
|
|
106
|
+
or len(calls) > EVIDENCE_INVENTORY_LIMIT
|
|
107
|
+
or len({call.inference_call_id for call in calls}) != len(calls)
|
|
108
|
+
or (not result.found and (calls or result.quarantined_inference_calls))
|
|
109
|
+
or tuple(sorted(calls, key=lambda c: (c.observed_at, c.inference_call_id)))
|
|
110
|
+
!= calls
|
|
111
|
+
):
|
|
112
|
+
raise ValueError("inconsistent evidence inventory")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _manifest(result: EvidenceManifest, inference_call_id: NonEmptyId) -> None:
|
|
116
|
+
if result.call.inference_call_id != inference_call_id:
|
|
117
|
+
raise ValueError("Inference call mismatch")
|
|
118
|
+
indices = {"input": 0, "output": 0}
|
|
119
|
+
for message in result.messages:
|
|
120
|
+
if message.message_index != indices[message.side] or (
|
|
121
|
+
message.side == "input" and indices["output"]
|
|
122
|
+
):
|
|
123
|
+
raise ValueError("inconsistent message order")
|
|
124
|
+
indices[message.side] += 1
|
|
125
|
+
for index, part in enumerate(message.parts):
|
|
126
|
+
if part.reference != EvidenceReference(
|
|
127
|
+
inference_call_id, message.side, message.message_index, index
|
|
128
|
+
):
|
|
129
|
+
raise ValueError("inconsistent part reference")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _publish(destination: Path, content: bytes) -> None:
|
|
133
|
+
"""Hard-link a complete private sibling file without replacing a destination."""
|
|
134
|
+
fd, name = tempfile.mkstemp(prefix=".sediment-evidence-", dir=destination.parent)
|
|
135
|
+
temporary = Path(name)
|
|
136
|
+
try:
|
|
137
|
+
with os.fdopen(fd, "wb") as output:
|
|
138
|
+
os.fchmod(output.fileno(), 0o600)
|
|
139
|
+
output.write(content)
|
|
140
|
+
output.flush()
|
|
141
|
+
os.fsync(output.fileno())
|
|
142
|
+
os.link(temporary, destination)
|
|
143
|
+
finally:
|
|
144
|
+
temporary.unlink()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def command(args: argparse.Namespace) -> int:
|
|
148
|
+
"""Run a remote evidence operation without opening the FactStore."""
|
|
149
|
+
try:
|
|
150
|
+
session_id = TypeAdapter(NonEmptyId).validate_python(args.session_id)
|
|
151
|
+
params = {"session_id": session_id}
|
|
152
|
+
if args.evidence_operation == "inventory":
|
|
153
|
+
content = read_evidence("/query/evidence", params=params)
|
|
154
|
+
result = _response(content, EvidenceInventory)
|
|
155
|
+
_inventory(result)
|
|
156
|
+
elif args.evidence_operation == "inspect":
|
|
157
|
+
params["inference_call_id"] = TypeAdapter(NonEmptyId).validate_python(
|
|
158
|
+
args.inference_call_id
|
|
159
|
+
)
|
|
160
|
+
content = read_evidence("/query/evidence/manifest", params=params)
|
|
161
|
+
result = _response(content, EvidenceManifest)
|
|
162
|
+
_manifest(result, params["inference_call_id"])
|
|
163
|
+
else:
|
|
164
|
+
destination = Path(args.output)
|
|
165
|
+
if os.path.lexists(destination):
|
|
166
|
+
raise ClientError("evidence output already exists")
|
|
167
|
+
references = _selection(Path(args.references))
|
|
168
|
+
body = json.dumps(
|
|
169
|
+
{
|
|
170
|
+
"schema_version": 1,
|
|
171
|
+
"session_id": session_id,
|
|
172
|
+
"references": TypeAdapter(
|
|
173
|
+
tuple[EvidenceReference, ...]
|
|
174
|
+
).dump_python(references),
|
|
175
|
+
},
|
|
176
|
+
ensure_ascii=True,
|
|
177
|
+
allow_nan=False,
|
|
178
|
+
separators=(",", ":"),
|
|
179
|
+
).encode("ascii")
|
|
180
|
+
content = read_evidence("/query/evidence/read", body=body)
|
|
181
|
+
result = _response(content, EvidenceRead)
|
|
182
|
+
if tuple(item.reference for item in result.items) != references:
|
|
183
|
+
raise ValueError("incomplete selection")
|
|
184
|
+
if result.session_id != session_id:
|
|
185
|
+
raise ValueError("Session mismatch")
|
|
186
|
+
if args.evidence_operation == "fetch":
|
|
187
|
+
_publish(destination, content)
|
|
188
|
+
print(f"{destination}: {len(result.items)} items")
|
|
189
|
+
else:
|
|
190
|
+
print(content.decode("ascii"))
|
|
191
|
+
return 0
|
|
192
|
+
except ClientError:
|
|
193
|
+
raise
|
|
194
|
+
except (OSError, ValueError, TypeError, RecursionError):
|
|
195
|
+
raise ClientError("evidence input, response, or output is invalid") from None
|
|
@@ -132,7 +132,7 @@ def test_install_uses_only_proven_ingest_in_shell_fish_and_codex(tmp_path, monke
|
|
|
132
132
|
assert path.stat().st_mode & 0o777 == 0o600
|
|
133
133
|
|
|
134
134
|
|
|
135
|
-
@pytest.mark.parametrize("authority", ["ingest", "operator", None])
|
|
135
|
+
@pytest.mark.parametrize("authority", ["ingest", "operator", "retrieval", None])
|
|
136
136
|
def test_explicit_capture_override_requires_live_ingest_authority(
|
|
137
137
|
tmp_path, monkeypatch, authority
|
|
138
138
|
):
|
|
@@ -281,3 +281,33 @@ def test_doctor_rejects_unreadable_identity_without_reproducing_response(
|
|
|
281
281
|
attribution._doctor_server(findings)
|
|
282
282
|
assert findings[0][0] == attribution.DOCTOR_FAIL
|
|
283
283
|
assert "private-response" not in str(findings)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@pytest.mark.parametrize("capture", [False, True])
|
|
287
|
+
@pytest.mark.parametrize("plural", [False, True])
|
|
288
|
+
def test_retrieval_credential_cannot_enroll_or_replace_operator_login(
|
|
289
|
+
app_transport, monkeypatch, capsys, capture, plural
|
|
290
|
+
):
|
|
291
|
+
from pydantic import SecretStr
|
|
292
|
+
from sediment_api.config import settings
|
|
293
|
+
|
|
294
|
+
token = "retrieval-test-token-long-enough"
|
|
295
|
+
monkeypatch.setattr(settings, "retrieval_token", SecretStr(token))
|
|
296
|
+
monkeypatch.setattr(
|
|
297
|
+
settings, "retrieval_session_id", None if plural else "source-session"
|
|
298
|
+
)
|
|
299
|
+
monkeypatch.setattr(
|
|
300
|
+
settings, "retrieval_session_ids", ("source-session",) if plural else None
|
|
301
|
+
)
|
|
302
|
+
original = {
|
|
303
|
+
"current": "https://testserver",
|
|
304
|
+
"servers": {"https://testserver": {"token": OPERATOR}},
|
|
305
|
+
}
|
|
306
|
+
api_client.write_config(original)
|
|
307
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(token + "\n"))
|
|
308
|
+
args = ["login", "https://testserver", "--with-token"]
|
|
309
|
+
if capture:
|
|
310
|
+
args.append("--capture")
|
|
311
|
+
assert cli.main(args) == 1
|
|
312
|
+
assert "authority required" in capsys.readouterr().err
|
|
313
|
+
assert api_client.read_config() == original
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from datetime import UTC, datetime
|
|
6
|
+
import json
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from uuid import uuid4
|
|
8
9
|
|
|
9
10
|
from psycopg import sql
|
|
10
|
-
from sqlalchemy import create_engine, text
|
|
11
|
+
from sqlalchemy import MetaData, Table, create_engine, text
|
|
11
12
|
from sqlalchemy.engine import make_url
|
|
12
13
|
|
|
13
14
|
import pytest
|
|
@@ -255,7 +256,7 @@ def test_db_upgrade_permission_failure_exits_nonzero_without_credentials(
|
|
|
255
256
|
|
|
256
257
|
|
|
257
258
|
def _behind_database_url(postgres_database_factory) -> str:
|
|
258
|
-
"""Create a database migrated to 0008 (
|
|
259
|
+
"""Create a database migrated to 0008 (before descriptive-text encoding).
|
|
259
260
|
|
|
260
261
|
The CLI one-shot quarantine verbs write ``fact_quarantine.reason`` through
|
|
261
262
|
``_SerializedText``; against a pre-0009 database the 0009 migration would
|
|
@@ -377,9 +378,13 @@ def test_preupgrade_facts_preserve_real_counts_and_show_missing_tables(
|
|
|
377
378
|
engine = create_engine(database_url)
|
|
378
379
|
try:
|
|
379
380
|
store = FactStore(engine)
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
381
|
+
# Seed the historical physical shape, without the current alias writer.
|
|
382
|
+
with engine.begin() as connection:
|
|
383
|
+
legacy = MetaData()
|
|
384
|
+
calls = Table("inference_calls", legacy, autoload_with=connection)
|
|
385
|
+
sessions = Table("sessions", legacy, autoload_with=connection)
|
|
386
|
+
for fact_id in ("visible", "hidden"):
|
|
387
|
+
call = InferenceCall(
|
|
383
388
|
inference_call_id=fact_id,
|
|
384
389
|
org_id="testorg",
|
|
385
390
|
session_id="old-session",
|
|
@@ -388,6 +393,17 @@ def test_preupgrade_facts_preserve_real_counts_and_show_missing_tables(
|
|
|
388
393
|
output_messages=[],
|
|
389
394
|
observed_at=datetime(2026, 9, 1, tzinfo=UTC),
|
|
390
395
|
)
|
|
396
|
+
values = call.model_dump(mode="python")
|
|
397
|
+
for name in ("input_messages", "output_messages", "raw"):
|
|
398
|
+
values[name] = json.dumps(values[name], ensure_ascii=True)
|
|
399
|
+
connection.execute(calls.insert().values(**values))
|
|
400
|
+
connection.execute(
|
|
401
|
+
sessions.insert().values(
|
|
402
|
+
org_id=call.org_id,
|
|
403
|
+
session_id=call.session_id,
|
|
404
|
+
first_observed_at=call.observed_at,
|
|
405
|
+
last_observed_at=call.observed_at,
|
|
406
|
+
)
|
|
391
407
|
)
|
|
392
408
|
# Seed the pre-0009 representation directly, as the prior writer did.
|
|
393
409
|
with engine.begin() as connection:
|