dsh-crate-web 0.1.0 → 0.1.2-preview.2
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.
- package/LICENSE +21 -0
- package/README.md +11 -290
- package/README.zh-CN.md +2 -303
- package/THIRD-PARTY-LICENSE.txt +21 -0
- package/cordis.patch.yml +1 -6
- package/core/dsh_pack/_version.py +1 -0
- package/core/dsh_pack/bundle.py +74 -0
- package/core/dsh_pack/cli.py +81 -18
- package/core/dsh_pack/desktop_verify.py +304 -0
- package/core/dsh_pack/diagnostics.py +1170 -0
- package/core/dsh_pack/errors.py +16 -1
- package/core/dsh_pack/export.py +86 -103
- package/core/dsh_pack/export_plan.py +96 -0
- package/core/dsh_pack/identity.py +25 -0
- package/core/dsh_pack/importer.py +1598 -1347
- package/core/dsh_pack/network.py +5 -4
- package/core/dsh_pack/pack.py +14 -2
- package/core/dsh_pack/portability.py +392 -0
- package/core/dsh_pack/preflight.py +63 -16
- package/core/dsh_pack/troubleshooting.py +94 -0
- package/core/dsh_pack/user_cli.py +16 -0
- package/core/dsh_pack/validation.py +64 -4
- package/core/dsh_pack/verify.py +1485 -1336
- package/core/dsh_pack/windows_verify.py +177 -0
- package/desktop-patch-helper.mjs +140 -0
- package/lib/client.js +292 -102
- package/lib/index.d.ts +7 -1
- package/lib/index.js +1121 -229
- package/package.json +13 -5
- package/restart-helper.mjs +121 -121
- package/runtime/python/LICENSE.txt +676 -676
- package/runtime/python/__install__.json +81 -81
- package/runtime/python/python314._pth +6 -6
- package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
- package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
- package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
- package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
- package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
- package/verify-desktop.mjs +314 -0
package/core/dsh_pack/network.py
CHANGED
|
@@ -11,6 +11,7 @@ import re
|
|
|
11
11
|
|
|
12
12
|
from collections.abc import Mapping
|
|
13
13
|
from typing import Any
|
|
14
|
+
from .identity import expected_install_version, unknown_source
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
NETWORK_SOURCE_TYPES = frozenset({"registry", "git", "tarball", "alias"})
|
|
@@ -20,7 +21,7 @@ def network_source_details(plugin: Mapping[str, Any]) -> dict[str, str] | None:
|
|
|
20
21
|
"""Return the recorded network source, or ``None`` for local-only sources."""
|
|
21
22
|
|
|
22
23
|
requested = plugin.get("requested")
|
|
23
|
-
if not isinstance(requested, Mapping):
|
|
24
|
+
if not isinstance(requested, Mapping) or unknown_source(plugin):
|
|
24
25
|
return None
|
|
25
26
|
source_type = requested.get("sourceType")
|
|
26
27
|
specifier = requested.get("specifier")
|
|
@@ -56,14 +57,14 @@ def npm_install_specifier(plugin: Mapping[str, Any]) -> str | None:
|
|
|
56
57
|
source_type = details["sourceType"]
|
|
57
58
|
specifier = details["specifier"]
|
|
58
59
|
if source_type in {"registry", "alias"}:
|
|
59
|
-
|
|
60
|
-
exact_version = resolved.get("version") if isinstance(resolved, Mapping) else None
|
|
60
|
+
exact_version = expected_install_version(plugin)
|
|
61
61
|
if (
|
|
62
62
|
source_type == "registry"
|
|
63
63
|
and isinstance(exact_version, str)
|
|
64
64
|
and re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.\-]+)?", exact_version)
|
|
65
65
|
):
|
|
66
|
-
# Install the exact
|
|
66
|
+
# Install the exact recorded installation target (legacy resolution
|
|
67
|
+
# or observed runtime when resolution is explicitly unknown). Using the
|
|
67
68
|
# requested range alone lets npm resolve to a different (older)
|
|
68
69
|
# version when the target Profile already declares the same range.
|
|
69
70
|
return f"{name}@={exact_version}"
|
package/core/dsh_pack/pack.py
CHANGED
|
@@ -8,7 +8,7 @@ import json
|
|
|
8
8
|
import os
|
|
9
9
|
import tempfile
|
|
10
10
|
import zipfile
|
|
11
|
-
from dataclasses import dataclass
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
@@ -18,6 +18,7 @@ from .validation import (
|
|
|
18
18
|
validate_manifest,
|
|
19
19
|
validate_payload_key,
|
|
20
20
|
validate_plugins_lock,
|
|
21
|
+
validate_format_pair,
|
|
21
22
|
)
|
|
22
23
|
|
|
23
24
|
|
|
@@ -58,6 +59,7 @@ class PackData:
|
|
|
58
59
|
profile_files: dict[str, bytes | str]
|
|
59
60
|
plugins_lock: dict[str, Any]
|
|
60
61
|
plugin_artifacts: dict[str, bytes | str]
|
|
62
|
+
user_data: dict[str, bytes] = field(default_factory=dict)
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
def _normalise_data(data: PackData) -> tuple[dict[str, Any], dict[str, bytes], dict[str, Any], dict[str, bytes]]:
|
|
@@ -72,6 +74,7 @@ def _normalise_data(data: PackData) -> tuple[dict[str, Any], dict[str, bytes], d
|
|
|
72
74
|
validate_manifest(manifest)
|
|
73
75
|
artifact_paths = {f"plugins/{name}" for name in plugin_artifacts}
|
|
74
76
|
validate_plugins_lock(plugins_lock, artifact_paths=artifact_paths)
|
|
77
|
+
validate_format_pair(manifest, plugins_lock)
|
|
75
78
|
_validate_artifact_hashes(plugins_lock, plugin_artifacts)
|
|
76
79
|
return manifest, profile_files, plugins_lock, plugin_artifacts
|
|
77
80
|
|
|
@@ -122,8 +125,12 @@ def create(data: PackData, destination: str | os.PathLike[str]) -> Path:
|
|
|
122
125
|
"""
|
|
123
126
|
|
|
124
127
|
manifest, profile_files, plugins_lock, plugin_artifacts = _normalise_data(data)
|
|
128
|
+
from .portability import validate_inventory
|
|
129
|
+
user_data = _file_map(data.user_data, "user_data", "user-data")
|
|
130
|
+
validate_inventory(manifest, user_data, plugins_lock)
|
|
125
131
|
lock_bytes = _json_bytes(plugins_lock)
|
|
126
132
|
entries = _payload_entries(profile_files, lock_bytes, plugin_artifacts)
|
|
133
|
+
entries.update({f"user-data/{name}": content for name, content in user_data.items()})
|
|
127
134
|
manifest["integrity"] = {
|
|
128
135
|
"algorithm": "sha256",
|
|
129
136
|
"files": {name: sha256_bytes(content) for name, content in entries.items()},
|
|
@@ -160,7 +167,7 @@ def _validate_archive_member(name: str) -> str:
|
|
|
160
167
|
canonical = validate_archive_path(name)
|
|
161
168
|
if canonical == "manifest.json" or canonical == "plugins.lock.json":
|
|
162
169
|
return canonical
|
|
163
|
-
if canonical.startswith("profile/") or canonical.startswith("plugins/"):
|
|
170
|
+
if canonical.startswith("profile/") or canonical.startswith("plugins/") or canonical.startswith("user-data/"):
|
|
164
171
|
return canonical
|
|
165
172
|
raise PathSafetyError(f"unsupported Pack member path: {name!r}")
|
|
166
173
|
|
|
@@ -239,8 +246,13 @@ def read(source: str | os.PathLike[str]) -> PackData:
|
|
|
239
246
|
plugins_lock,
|
|
240
247
|
artifact_paths={f"plugins/{name}" for name in plugin_artifacts},
|
|
241
248
|
)
|
|
249
|
+
validate_format_pair(manifest, plugins_lock)
|
|
242
250
|
_validate_artifact_hashes(plugins_lock, plugin_artifacts)
|
|
251
|
+
from .portability import validate_inventory
|
|
252
|
+
user_data = {name[len("user-data/"):]: content for name, content in entries.items() if name.startswith("user-data/")}
|
|
253
|
+
validate_inventory(manifest, user_data, plugins_lock)
|
|
243
254
|
return PackData(
|
|
255
|
+
user_data=user_data,
|
|
244
256
|
manifest=manifest,
|
|
245
257
|
profile_files=profile_files,
|
|
246
258
|
plugins_lock=plugins_lock,
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Opt-in portable data for released v0/v4 JSONL and JSON storage layouts.
|
|
2
|
+
|
|
3
|
+
Plugin storage ownership must be explicitly selected; filenames never imply it.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import ntpath
|
|
9
|
+
import os
|
|
10
|
+
import posixpath
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Mapping, Sequence
|
|
14
|
+
from .errors import ExportError, PackImportError, SchemaValidationError
|
|
15
|
+
from .validation import validate_archive_path
|
|
16
|
+
|
|
17
|
+
KINDS = {"conversation", "plugin-config", "plugin-workflow", "plugin-data"}
|
|
18
|
+
CLASSES = {"portable", "reference-only", "secret-name-only", "excluded", "unsupported"}
|
|
19
|
+
MAX_DATA_BYTES = 32 * 1024 * 1024
|
|
20
|
+
MAX_TOTAL_BYTES = 128 * 1024 * 1024
|
|
21
|
+
_SECRET_KEY = re.compile(r"(?i)^(?:.*[_-])?(?:api[_-]?key|access[_-]?token|auth[_-]?token|secret|password|credential|cookie|authorization|private[_-]?key)s?$")
|
|
22
|
+
_SECRET_TEXT = re.compile(
|
|
23
|
+
r"(?i)(?:-----BEGIN (?:[A-Z ]*PRIVATE KEY)-----|"
|
|
24
|
+
r"\bBearer\s+[A-Za-z0-9._~+/=-]{8,}|"
|
|
25
|
+
r"\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{12,}|"
|
|
26
|
+
r"(?<![A-Za-z0-9_])(?:api[_-]?key|access[_-]?token|password|authorization|cookie)"
|
|
27
|
+
r"\s*[\"']?\s*[:=]\s*[\"']?(?!\$\{)[^\s\"',}\]]{4,})"
|
|
28
|
+
)
|
|
29
|
+
_PLACEHOLDER = re.compile(r"^\$\{[A-Za-z_][A-Za-z0-9_]*\}$")
|
|
30
|
+
_SESSION_NAMES = {"session.jsonl": 0, "session.v4.jsonl": 4}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _session_generation(filename: str) -> int | None:
|
|
34
|
+
return _SESSION_NAMES.get(filename.removesuffix(".zstd"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _safe_integer(value: Any, *, minimum: int = 0) -> bool:
|
|
38
|
+
return type(value) is int and minimum <= value <= 2**53 - 1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_v4_session(header: Mapping[str, Any], events: Sequence[Any]) -> None:
|
|
42
|
+
"""Inspect released framing and closed turn boundaries without rewriting it.
|
|
43
|
+
|
|
44
|
+
Source: official 0.2.1-rc.2 session-format-v3-to-v4 physical codec delegates
|
|
45
|
+
to released v2 framing. Empty and metadata-only sessions are valid artifacts;
|
|
46
|
+
an open turn, step, seed boundary or torn physical tail is not portable.
|
|
47
|
+
Plugin payload interpretation and runtime restoration remain Verify's job.
|
|
48
|
+
"""
|
|
49
|
+
required = {"type", "version", "id", "createdAt", "isSeeded", "delegationDepth"}
|
|
50
|
+
allowed = required | {"cwd", "parentSession", "origin", "agentPreset"}
|
|
51
|
+
if (not required.issubset(header) or not set(header).issubset(allowed)
|
|
52
|
+
or type(header.get("isSeeded")) is not bool
|
|
53
|
+
or not _safe_integer(header.get("createdAt"))
|
|
54
|
+
or not _safe_integer(header.get("delegationDepth"))
|
|
55
|
+
or any(key in header and not isinstance(header[key], str)
|
|
56
|
+
for key in ("cwd", "parentSession", "agentPreset"))
|
|
57
|
+
or ("cwd" in header and not (ntpath.isabs(header["cwd"]) or posixpath.isabs(header["cwd"])))
|
|
58
|
+
or ("origin" in header and header["origin"] != "subagent")):
|
|
59
|
+
raise ExportError("unsupported v4 session header; a matching DSH adapter is required")
|
|
60
|
+
event_required = {"type", "seq", "time", "data"}
|
|
61
|
+
event_allowed = event_required | {"ignorable", "sourceEventSeqs", "surfaceOp"}
|
|
62
|
+
turn = step = None
|
|
63
|
+
next_turn = next_step = 1
|
|
64
|
+
inherited_marker = False
|
|
65
|
+
for index, event in enumerate(events):
|
|
66
|
+
if (not isinstance(event, dict) or not event_required.issubset(event)
|
|
67
|
+
or not set(event).issubset(event_allowed)
|
|
68
|
+
or not isinstance(event["type"], str) or not event["type"]
|
|
69
|
+
or not _safe_integer(event["seq"]) or event["seq"] != index
|
|
70
|
+
or not _safe_integer(event["time"], minimum=-(2**53 - 1))
|
|
71
|
+
or not isinstance(event["data"], dict)
|
|
72
|
+
or ("ignorable" in event and event["ignorable"] is not True)):
|
|
73
|
+
raise ExportError("invalid v4 session event envelope or sequence")
|
|
74
|
+
name, data = event["type"], event["data"]
|
|
75
|
+
if name == "turn/start":
|
|
76
|
+
if turn is not None or not _safe_integer(data.get("turn"), minimum=1) or data["turn"] != next_turn:
|
|
77
|
+
raise ExportError("invalid v4 session turn boundary")
|
|
78
|
+
turn, next_step = next_turn, 1
|
|
79
|
+
elif name == "turn/end":
|
|
80
|
+
if (turn is None or not _safe_integer(data.get("turn"), minimum=1)
|
|
81
|
+
or data["turn"] != turn or step is not None
|
|
82
|
+
or not isinstance(data.get("reason"), dict)
|
|
83
|
+
or not isinstance(data["reason"].get("kind"), str)):
|
|
84
|
+
raise ExportError("invalid or unfinished v4 session turn")
|
|
85
|
+
turn, next_turn = None, next_turn + 1
|
|
86
|
+
elif name == "step/start":
|
|
87
|
+
if (turn is None or not _safe_integer(data.get("turn"), minimum=1)
|
|
88
|
+
or data["turn"] != turn or step is not None
|
|
89
|
+
or not _safe_integer(data.get("step"), minimum=1) or data["step"] != next_step):
|
|
90
|
+
raise ExportError("invalid v4 session step boundary")
|
|
91
|
+
step = next_step
|
|
92
|
+
elif name == "step/end":
|
|
93
|
+
if (turn is None or not _safe_integer(data.get("turn"), minimum=1) or data["turn"] != turn
|
|
94
|
+
or step is None or not _safe_integer(data.get("step"), minimum=1) or data["step"] != step):
|
|
95
|
+
raise ExportError("invalid v4 session step boundary")
|
|
96
|
+
step, next_step = None, next_step + 1
|
|
97
|
+
elif name == "session/end-seed" and data.get("inherited") is True:
|
|
98
|
+
inherited_marker = True
|
|
99
|
+
if turn is not None or step is not None:
|
|
100
|
+
raise ExportError("session has an unfinished turn; stop it before export")
|
|
101
|
+
if header["isSeeded"] != inherited_marker:
|
|
102
|
+
raise ExportError("v4 session seed header and inherited boundary disagree")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _hash(content: bytes) -> str:
|
|
106
|
+
return hashlib.sha256(content).hexdigest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def safe_relative(value: Any) -> str:
|
|
110
|
+
path = validate_archive_path(value)
|
|
111
|
+
forbidden = {"node_modules", "cache", "logs", "tmp", "temp", ".git", ".dsh-pack"}
|
|
112
|
+
for part in path.split("/"):
|
|
113
|
+
if (part.casefold() in forbidden or part.endswith((" ", "."))
|
|
114
|
+
or ":" in part or any(ord(c) < 32 for c in part)
|
|
115
|
+
or re.fullmatch(r"(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?", part)):
|
|
116
|
+
raise ExportError("unsafe portable data path")
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _inside(root: Path, relative: str) -> Path:
|
|
121
|
+
path = root / safe_relative(relative)
|
|
122
|
+
current = path
|
|
123
|
+
while current != root:
|
|
124
|
+
if current.is_symlink() or (hasattr(current, "is_junction") and current.is_junction()):
|
|
125
|
+
raise ExportError("portable data may not traverse symbolic links or junctions")
|
|
126
|
+
current = current.parent
|
|
127
|
+
if not path.resolve().is_relative_to(root.resolve()):
|
|
128
|
+
raise ExportError("portable data escapes its root")
|
|
129
|
+
return path
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def source_home(profile: Path) -> Path:
|
|
133
|
+
profile = profile.resolve()
|
|
134
|
+
if profile.parent.name != "profiles":
|
|
135
|
+
raise ExportError("selected Home data requires DSH_HOME/profiles/<name>")
|
|
136
|
+
return profile.parent.parent
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def assert_private_values_absent(content: bytes, label: str) -> None:
|
|
140
|
+
"""Refuse recognizable credentials; no secret value is included in errors."""
|
|
141
|
+
try:
|
|
142
|
+
text = content.decode("utf-8")
|
|
143
|
+
except UnicodeDecodeError as error:
|
|
144
|
+
raise ExportError(f"unsupported non-text data requires a dedicated adapter: {label}") from error
|
|
145
|
+
if _SECRET_TEXT.search(text):
|
|
146
|
+
raise ExportError(f"credential-like content detected: {label}")
|
|
147
|
+
values = []
|
|
148
|
+
try:
|
|
149
|
+
values.append(json.loads(text))
|
|
150
|
+
except json.JSONDecodeError:
|
|
151
|
+
for line in text.splitlines():
|
|
152
|
+
try:
|
|
153
|
+
values.append(json.loads(line))
|
|
154
|
+
except json.JSONDecodeError:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
def check(value):
|
|
158
|
+
if isinstance(value, dict):
|
|
159
|
+
for key, child in value.items():
|
|
160
|
+
normalized = re.sub(r"(?<=[a-z])(?=[A-Z])", "_", key)
|
|
161
|
+
if (_SECRET_KEY.match(normalized) and isinstance(child, str)
|
|
162
|
+
and child and not _PLACEHOLDER.fullmatch(child)):
|
|
163
|
+
raise ExportError(f"credential field detected: {label}")
|
|
164
|
+
check(child)
|
|
165
|
+
elif isinstance(value, list):
|
|
166
|
+
for child in value:
|
|
167
|
+
check(child)
|
|
168
|
+
for value in values:
|
|
169
|
+
check(value)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _logical_session(content: bytes, path: str) -> bytes:
|
|
173
|
+
if not path.endswith(".zstd"):
|
|
174
|
+
return content
|
|
175
|
+
try:
|
|
176
|
+
from compression import zstd
|
|
177
|
+
except ImportError as error:
|
|
178
|
+
raise ExportError("Zstandard inspection requires Python 3.14; compressed data was not exported") from error
|
|
179
|
+
import io
|
|
180
|
+
try:
|
|
181
|
+
with zstd.ZstdFile(io.BytesIO(content), "rb") as reader:
|
|
182
|
+
decoded = reader.read(MAX_DATA_BYTES + 1)
|
|
183
|
+
except (zstd.ZstdError, EOFError, OSError) as error:
|
|
184
|
+
raise ExportError("invalid compressed session") from error
|
|
185
|
+
if len(decoded) > MAX_DATA_BYTES:
|
|
186
|
+
raise ExportError("session exceeds the logical data size limit")
|
|
187
|
+
return decoded
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _validate_data(content: bytes, item: Mapping[str, Any]) -> None:
|
|
191
|
+
path, kind = item["path"], item["kind"]
|
|
192
|
+
parts = path.split("/")
|
|
193
|
+
if kind == "conversation":
|
|
194
|
+
generation = _session_generation(parts[-1])
|
|
195
|
+
if len(parts) != 4 or parts[0] != "sessions" or generation is None:
|
|
196
|
+
raise ExportError("unsupported session layout; select a project/session transcript")
|
|
197
|
+
logical = _logical_session(content, path)
|
|
198
|
+
assert_private_values_absent(logical, path)
|
|
199
|
+
try:
|
|
200
|
+
rows = [json.loads(line) for line in logical.decode("utf-8").splitlines()]
|
|
201
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
202
|
+
raise ExportError("invalid JSONL session") from error
|
|
203
|
+
if not rows or not isinstance(rows[0], dict):
|
|
204
|
+
raise ExportError("session header missing")
|
|
205
|
+
header = rows[0]
|
|
206
|
+
if (header.get("type") != "session" or type(header.get("version")) is not int or header["version"] != generation
|
|
207
|
+
or not isinstance(header.get("id"), str) or not header["id"]
|
|
208
|
+
or type(header.get("createdAt")) is not int
|
|
209
|
+
or type(header.get("delegationDepth")) is not int
|
|
210
|
+
or header["delegationDepth"] < 0):
|
|
211
|
+
raise ExportError("unsupported session header; a matching DSH adapter is required")
|
|
212
|
+
if generation == 4:
|
|
213
|
+
if not logical.endswith(b"\n"):
|
|
214
|
+
raise ExportError("v4 session has an incomplete JSONL record; stop its writer before export")
|
|
215
|
+
_validate_v4_session(header, rows[1:])
|
|
216
|
+
return
|
|
217
|
+
if len(rows) < 2 or not isinstance(rows[-1], dict) or rows[-1].get("type") != "turn/end":
|
|
218
|
+
raise ExportError("session has no completed turn; stop it before export")
|
|
219
|
+
else:
|
|
220
|
+
if len(parts) != 2 or parts[0] != "storages" or not parts[1].endswith(".json"):
|
|
221
|
+
raise ExportError("only explicitly owned JSON storage units are supported")
|
|
222
|
+
assert_private_values_absent(content, path)
|
|
223
|
+
try:
|
|
224
|
+
value = json.loads(content)
|
|
225
|
+
except ValueError as error:
|
|
226
|
+
raise ExportError("plugin data must be valid JSON") from error
|
|
227
|
+
if not isinstance(value, dict):
|
|
228
|
+
raise ExportError("plugin storage unit must be a JSON object")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def discover(profile: Path) -> list[dict[str, Any]]:
|
|
232
|
+
home = source_home(profile)
|
|
233
|
+
result = []
|
|
234
|
+
for root_name in ("sessions", "storages"):
|
|
235
|
+
root = home / root_name
|
|
236
|
+
if not root.exists():
|
|
237
|
+
continue
|
|
238
|
+
_inside(home, root_name)
|
|
239
|
+
for directory, dirs, files in os.walk(root, followlinks=False):
|
|
240
|
+
dirs[:] = [d for d in dirs if not (Path(directory)/d).is_symlink()
|
|
241
|
+
and not (hasattr(Path(directory)/d, "is_junction") and (Path(directory)/d).is_junction())]
|
|
242
|
+
for name in sorted(files):
|
|
243
|
+
relative = (Path(directory)/name).relative_to(home).as_posix()
|
|
244
|
+
try:
|
|
245
|
+
path = _inside(home, relative)
|
|
246
|
+
except ExportError:
|
|
247
|
+
continue
|
|
248
|
+
session = root_name == "sessions" and _session_generation(name) is not None and len(relative.split("/")) == 4
|
|
249
|
+
session_candidate = (root_name == "sessions" and len(relative.split("/")) == 4
|
|
250
|
+
and re.fullmatch(r"session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?", name) is not None)
|
|
251
|
+
unit = root_name == "storages" and len(relative.split("/")) == 2 and name.endswith(".json")
|
|
252
|
+
result.append({
|
|
253
|
+
"path": relative, "kind": "conversation" if session_candidate else "plugin-data",
|
|
254
|
+
"classification": "excluded" if session else "unsupported",
|
|
255
|
+
"selectable": session or unit,
|
|
256
|
+
"reason": ("not selected" if session else
|
|
257
|
+
"unsupported session generation; a matching DSH adapter is required" if session_candidate else
|
|
258
|
+
"explicit plugin ownership and storage contract required"),
|
|
259
|
+
"bytes": path.stat().st_size,
|
|
260
|
+
})
|
|
261
|
+
if len(result) > 2000:
|
|
262
|
+
raise ExportError("data inventory is too large; use a dedicated adapter")
|
|
263
|
+
return sorted(result, key=lambda row: row["path"])
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def collect(profile: Path, selection: Sequence[Mapping[str, Any]], owners: set[str]):
|
|
267
|
+
files: dict[str, bytes] = {}
|
|
268
|
+
items = []
|
|
269
|
+
if not selection:
|
|
270
|
+
return files, items
|
|
271
|
+
home = source_home(profile)
|
|
272
|
+
total = 0
|
|
273
|
+
seen = set()
|
|
274
|
+
for raw in selection:
|
|
275
|
+
if not isinstance(raw, dict) or set(raw) != {"path", "kind", "owner"}:
|
|
276
|
+
raise ExportError("data selections require exactly path, kind and owner")
|
|
277
|
+
path = safe_relative(raw["path"])
|
|
278
|
+
kind, owner = raw["kind"], raw["owner"]
|
|
279
|
+
if not isinstance(kind, str) or kind not in KINDS or not isinstance(owner, str):
|
|
280
|
+
raise ExportError("invalid portable data selection")
|
|
281
|
+
if kind == "conversation":
|
|
282
|
+
if owner != "@deepseek-ai/dsh-session-persistence-jsonl":
|
|
283
|
+
raise ExportError("conversation selection requires the official JSONL backend")
|
|
284
|
+
elif owner not in owners:
|
|
285
|
+
raise ExportError("selected storage owner is absent from the Profile")
|
|
286
|
+
if path.casefold() in seen:
|
|
287
|
+
raise ExportError("duplicate portable data path")
|
|
288
|
+
seen.add(path.casefold())
|
|
289
|
+
source = _inside(home, path)
|
|
290
|
+
before = source.stat()
|
|
291
|
+
if not source.is_file() or before.st_size > MAX_DATA_BYTES:
|
|
292
|
+
raise ExportError("unsupported data file or size")
|
|
293
|
+
with source.open("rb") as stream:
|
|
294
|
+
content = stream.read(MAX_DATA_BYTES + 1)
|
|
295
|
+
after = source.stat()
|
|
296
|
+
if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
|
|
297
|
+
raise ExportError("selected data changed during export; stop its writer and retry")
|
|
298
|
+
total += len(content)
|
|
299
|
+
if len(content) > MAX_DATA_BYTES or total > MAX_TOTAL_BYTES:
|
|
300
|
+
raise ExportError("selected data exceeds the export size limit")
|
|
301
|
+
_validate_data(content, raw)
|
|
302
|
+
item = {**raw, "classification": "portable", "bytes": len(content), "sha256": _hash(content)}
|
|
303
|
+
files[path] = content
|
|
304
|
+
items.append(item)
|
|
305
|
+
return files, items
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def validate_inventory(manifest: Mapping[str, Any], files: Mapping[str, bytes], plugins_lock: Mapping[str, Any] | None = None) -> None:
|
|
309
|
+
inventory = manifest.get("dataInventory", [])
|
|
310
|
+
if not isinstance(inventory, list):
|
|
311
|
+
raise SchemaValidationError("dataInventory must be an array")
|
|
312
|
+
selected = {}
|
|
313
|
+
raw_plugins = (plugins_lock or {}).get("plugins", [])
|
|
314
|
+
owners = {row["name"] for row in raw_plugins if isinstance(row, dict) and isinstance(row.get("name"), str)} if isinstance(raw_plugins, list) else set()
|
|
315
|
+
if len(inventory) > 4000:
|
|
316
|
+
raise SchemaValidationError("data inventory exceeds its entry limit")
|
|
317
|
+
for item in inventory:
|
|
318
|
+
if not isinstance(item, dict) or not isinstance(item.get("classification"), str) or item["classification"] not in CLASSES:
|
|
319
|
+
raise SchemaValidationError("invalid data inventory entry")
|
|
320
|
+
if item["classification"] != "portable":
|
|
321
|
+
continue
|
|
322
|
+
if set(item) != {"path", "kind", "owner", "classification", "bytes", "sha256"}:
|
|
323
|
+
raise SchemaValidationError("invalid portable inventory fields")
|
|
324
|
+
path = safe_relative(item["path"])
|
|
325
|
+
if not isinstance(item["kind"], str) or item["kind"] not in KINDS or not isinstance(item["owner"], str) or not item["owner"]:
|
|
326
|
+
raise SchemaValidationError("invalid portable data owner or kind")
|
|
327
|
+
if item["kind"] != "conversation" and plugins_lock is not None and item["owner"] not in owners:
|
|
328
|
+
raise SchemaValidationError("portable storage owner is absent from plugins.lock")
|
|
329
|
+
if item["kind"] == "conversation" and item["owner"] != "@deepseek-ai/dsh-session-persistence-jsonl":
|
|
330
|
+
raise SchemaValidationError("unsupported conversation owner")
|
|
331
|
+
if path.casefold() in {p.casefold() for p in selected}:
|
|
332
|
+
raise SchemaValidationError("duplicate portable inventory path")
|
|
333
|
+
selected[path] = item
|
|
334
|
+
if set(selected) != set(files):
|
|
335
|
+
raise SchemaValidationError("portable data must exactly match its inventory")
|
|
336
|
+
if files and manifest.get("schemaVersion") not in {2, 3}:
|
|
337
|
+
raise SchemaValidationError("portable data requires schema version 2 or 3")
|
|
338
|
+
if sum(len(content) for content in files.values()) > MAX_TOTAL_BYTES:
|
|
339
|
+
raise SchemaValidationError("portable data exceeds its total size limit")
|
|
340
|
+
for path, content in files.items():
|
|
341
|
+
if len(content) > MAX_DATA_BYTES:
|
|
342
|
+
raise SchemaValidationError("portable data exceeds its file size limit")
|
|
343
|
+
item = selected[path]
|
|
344
|
+
if type(item["bytes"]) is not int or item["bytes"] != len(content) or item["sha256"] != _hash(content):
|
|
345
|
+
raise SchemaValidationError("portable data identity mismatch")
|
|
346
|
+
_validate_data(content, item)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def check_targets(home: Path, files: Mapping[str, bytes], *, overwrite: bool = False) -> None:
|
|
350
|
+
if files and overwrite:
|
|
351
|
+
raise PackImportError("portable Home data requires a new Profile", stage="planning",
|
|
352
|
+
code="PORTABLE_DATA_OVERWRITE_FORBIDDEN")
|
|
353
|
+
for path in files:
|
|
354
|
+
try:
|
|
355
|
+
target = _inside(home, path)
|
|
356
|
+
except ExportError as error:
|
|
357
|
+
raise PackImportError(str(error), stage="planning", code="PORTABLE_DATA_PATH_UNSAFE") from error
|
|
358
|
+
if target.exists():
|
|
359
|
+
raise PackImportError("portable data target already exists; use a fresh DSH_HOME",
|
|
360
|
+
stage="planning", code="PORTABLE_DATA_EXISTS", details={
|
|
361
|
+
"item": path,
|
|
362
|
+
"suggestedNextStep": "请使用独立且全新的 DSH_HOME 恢复此包。会话和插件数据属于整个 Home;仅更换 Profile 名称无法避开现有数据。原 Home 不会被覆盖。",
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def restore_files(home: Path, files: Mapping[str, bytes], created: list[Path], directories: list[Path]) -> None:
|
|
367
|
+
"""Write absent files only; the importer owns rollback of this exact list."""
|
|
368
|
+
for path, content in files.items():
|
|
369
|
+
target = _inside(home, path)
|
|
370
|
+
missing = []
|
|
371
|
+
parent = target.parent
|
|
372
|
+
while not parent.exists():
|
|
373
|
+
missing.append(parent)
|
|
374
|
+
parent = parent.parent
|
|
375
|
+
for directory in reversed(missing):
|
|
376
|
+
directory.mkdir(exist_ok=False)
|
|
377
|
+
directories.append(directory)
|
|
378
|
+
with target.open("xb") as stream:
|
|
379
|
+
created.append(target)
|
|
380
|
+
stream.write(content)
|
|
381
|
+
stream.flush()
|
|
382
|
+
os.fsync(stream.fileno())
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def rollback_files(created: list[Path], directories: list[Path]) -> None:
|
|
386
|
+
for path in reversed(created):
|
|
387
|
+
path.unlink(missing_ok=True)
|
|
388
|
+
for path in reversed(directories):
|
|
389
|
+
try:
|
|
390
|
+
path.rmdir()
|
|
391
|
+
except FileNotFoundError:
|
|
392
|
+
pass
|