@softspark/ai-toolkit 4.29.2 → 4.30.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/CHANGELOG.md +82 -0
- package/README.md +44 -18
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +2 -2
- package/app/mcp-templates/README.md +7 -2
- package/app/mcp-templates/rag-mcp-legal.json +11 -0
- package/app/mcp-templates/rag-mcp.json +11 -0
- package/app/surface.json +1 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +29 -17
- package/bin/ai-toolkit.js +8 -0
- package/kb/history/completed/dsh-integration-plan-superseded.md +322 -0
- package/kb/history/completed/dsh-native-install-target-plan.md +331 -0
- package/kb/procedures/ecosystem-sync-sop.md +7 -5
- package/kb/procedures/maintenance-sop.md +1 -1
- package/kb/procedures/release-verification-sop.md +35 -5
- package/kb/reference/architecture-overview.md +24 -5
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/dsh-compatibility.md +183 -0
- package/kb/reference/manifest-install.md +112 -5
- package/kb/reference/mcp-templates.md +11 -4
- package/kb/reference/plugin-pack-conventions.md +35 -18
- package/kb/reference/supported-tools-registry.md +30 -6
- package/llms-full.txt +1110 -50
- package/llms.txt +3 -0
- package/manifest.json +2 -2
- package/package.json +2 -2
- package/scripts/codex_skill_adapter.py +673 -34
- package/scripts/config_resolver.py +80 -14
- package/scripts/doctor.py +98 -20
- package/scripts/ecosystem_tools.json +51 -1
- package/scripts/generate_codex_skills.py +22 -20
- package/scripts/install.py +30 -13
- package/scripts/install_steps/ai_tools.py +97 -33
- package/scripts/install_steps/dsh.py +5063 -0
- package/scripts/install_steps/install_state.py +1645 -57
- package/scripts/mcp_editors.py +5 -2
- package/scripts/plugin.py +2495 -163
- package/scripts/plugin_mcp.py +279 -0
- package/scripts/plugin_rules.py +389 -0
- package/scripts/plugin_schema.py +139 -23
- package/scripts/uninstall.py +47 -4
- package/scripts/validate.py +421 -0
|
@@ -3,15 +3,183 @@
|
|
|
3
3
|
# Source: https://github.com/softspark/ai-toolkit
|
|
4
4
|
|
|
5
5
|
"""Track installed modules and versions in ~/.softspark/ai-toolkit/state.json."""
|
|
6
|
+
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
9
|
+
import ctypes
|
|
10
|
+
import errno
|
|
11
|
+
import hashlib
|
|
8
12
|
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import stat
|
|
9
17
|
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Callable, Iterator
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from dataclasses import dataclass
|
|
10
23
|
from datetime import datetime, timezone
|
|
11
24
|
from pathlib import Path
|
|
12
25
|
|
|
13
26
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
14
|
-
from paths import
|
|
27
|
+
from paths import EXTERNAL_HOOKS_DIR, RULES_DIR, STATE_FILE
|
|
28
|
+
|
|
29
|
+
_DSH_RECORD_KEYS = {
|
|
30
|
+
"dsh_home",
|
|
31
|
+
"profile",
|
|
32
|
+
"packages",
|
|
33
|
+
"package_trees",
|
|
34
|
+
"preset_path",
|
|
35
|
+
"preset_hash",
|
|
36
|
+
"owned",
|
|
37
|
+
"installed_at",
|
|
38
|
+
"last_updated",
|
|
39
|
+
}
|
|
40
|
+
_DSH_EXPECTED_UNSET = object()
|
|
41
|
+
_STATE_CAS_RETRIES = 5
|
|
42
|
+
_STATE_LOCK_TIMEOUT_SECONDS = 2.0
|
|
43
|
+
_STATE_LOCK_POLL_SECONDS = 0.025
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class DshStateSnapshot:
|
|
48
|
+
path: Path
|
|
49
|
+
existed: bool
|
|
50
|
+
content: bytes
|
|
51
|
+
mode: int
|
|
52
|
+
document: dict
|
|
53
|
+
profile: str
|
|
54
|
+
profile_record: dict | None
|
|
55
|
+
state_parent_device: int | None = None
|
|
56
|
+
state_parent_inode: int | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class _StateWriterContext:
|
|
61
|
+
"""One state transaction bound to the parent inode that owns its lock."""
|
|
62
|
+
|
|
63
|
+
path: Path
|
|
64
|
+
parent_descriptor: int | None
|
|
65
|
+
parent_device: int | None
|
|
66
|
+
parent_inode: int | None
|
|
67
|
+
secure: bool
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _validate_dsh_tree_inventory(value: object) -> bool:
|
|
71
|
+
if not isinstance(value, dict) or set(value) != {"digest", "entries"}:
|
|
72
|
+
return False
|
|
73
|
+
if not re.fullmatch(r"[0-9a-f]{64}", str(value.get("digest", ""))):
|
|
74
|
+
return False
|
|
75
|
+
entries = value.get("entries")
|
|
76
|
+
if not isinstance(entries, list) or not entries:
|
|
77
|
+
return False
|
|
78
|
+
paths: list[str] = []
|
|
79
|
+
for entry in entries:
|
|
80
|
+
if not isinstance(entry, dict):
|
|
81
|
+
return False
|
|
82
|
+
kind = entry.get("type")
|
|
83
|
+
expected_keys = {"type", "path", "mode"}
|
|
84
|
+
if kind == "file":
|
|
85
|
+
expected_keys.update({"size", "sha256"})
|
|
86
|
+
elif kind == "symlink":
|
|
87
|
+
expected_keys.add("target")
|
|
88
|
+
elif kind != "directory":
|
|
89
|
+
return False
|
|
90
|
+
if set(entry) != expected_keys:
|
|
91
|
+
return False
|
|
92
|
+
path = entry.get("path")
|
|
93
|
+
mode = entry.get("mode")
|
|
94
|
+
if (
|
|
95
|
+
not isinstance(path, str)
|
|
96
|
+
or not path
|
|
97
|
+
or path.startswith("/")
|
|
98
|
+
or ".." in Path(path).parts
|
|
99
|
+
or not isinstance(mode, int)
|
|
100
|
+
or isinstance(mode, bool)
|
|
101
|
+
or not 0 <= mode <= 0o7777
|
|
102
|
+
):
|
|
103
|
+
return False
|
|
104
|
+
if kind == "file" and (
|
|
105
|
+
not isinstance(entry.get("size"), int)
|
|
106
|
+
or isinstance(entry.get("size"), bool)
|
|
107
|
+
or entry["size"] < 0
|
|
108
|
+
or not re.fullmatch(r"[0-9a-f]{64}", str(entry.get("sha256", "")))
|
|
109
|
+
):
|
|
110
|
+
return False
|
|
111
|
+
if kind == "symlink" and not isinstance(entry.get("target"), str):
|
|
112
|
+
return False
|
|
113
|
+
paths.append(path)
|
|
114
|
+
try:
|
|
115
|
+
sorted_paths = sorted(paths, key=lambda item: item.encode("utf-8"))
|
|
116
|
+
except UnicodeError:
|
|
117
|
+
return False
|
|
118
|
+
return (
|
|
119
|
+
entries[0].get("path") == "."
|
|
120
|
+
and entries[0].get("type") == "directory"
|
|
121
|
+
and len(paths) == len(set(paths))
|
|
122
|
+
and paths == sorted_paths
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _validate_dsh_profiles(dsh: object) -> dict[str, dict]:
|
|
127
|
+
if not isinstance(dsh, dict) or set(dsh) != {"profiles"}:
|
|
128
|
+
raise ValueError("invalid DSH lifecycle state")
|
|
129
|
+
profiles = dsh.get("profiles")
|
|
130
|
+
if not isinstance(profiles, dict):
|
|
131
|
+
raise ValueError("invalid DSH lifecycle state")
|
|
132
|
+
for name, record in profiles.items():
|
|
133
|
+
if not isinstance(name, str) or not isinstance(record, dict):
|
|
134
|
+
raise ValueError("invalid DSH lifecycle state")
|
|
135
|
+
if (
|
|
136
|
+
set(record) == _DSH_RECORD_KEYS - {"package_trees"}
|
|
137
|
+
and record.get("profile") == name
|
|
138
|
+
):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"invalid DSH package inventory for profile '{name}'; "
|
|
141
|
+
"run 'ai-toolkit dsh doctor' and reinstall"
|
|
142
|
+
)
|
|
143
|
+
if set(record) != _DSH_RECORD_KEYS or record.get("profile") != name:
|
|
144
|
+
raise ValueError(f"invalid DSH lifecycle state for profile '{name}'")
|
|
145
|
+
packages = record.get("packages")
|
|
146
|
+
if (
|
|
147
|
+
not isinstance(packages, dict)
|
|
148
|
+
or not packages
|
|
149
|
+
or not all(
|
|
150
|
+
isinstance(package, str)
|
|
151
|
+
and package
|
|
152
|
+
and isinstance(version, str)
|
|
153
|
+
and version
|
|
154
|
+
for package, version in packages.items()
|
|
155
|
+
)
|
|
156
|
+
):
|
|
157
|
+
raise ValueError(
|
|
158
|
+
"invalid DSH ownership state for 'packages' "
|
|
159
|
+
f"in profile '{name}'"
|
|
160
|
+
)
|
|
161
|
+
package_trees = record.get("package_trees")
|
|
162
|
+
if (
|
|
163
|
+
not isinstance(package_trees, dict)
|
|
164
|
+
or set(package_trees) != set(packages)
|
|
165
|
+
or not all(
|
|
166
|
+
_validate_dsh_tree_inventory(inventory)
|
|
167
|
+
for inventory in package_trees.values()
|
|
168
|
+
)
|
|
169
|
+
):
|
|
170
|
+
raise ValueError(
|
|
171
|
+
"invalid DSH ownership state for 'package_trees' "
|
|
172
|
+
f"in profile '{name}'; "
|
|
173
|
+
"run 'ai-toolkit dsh doctor' and reinstall"
|
|
174
|
+
)
|
|
175
|
+
if record.get("owned") is not True:
|
|
176
|
+
raise ValueError(f"invalid DSH lifecycle state for profile '{name}'")
|
|
177
|
+
for key in ("dsh_home", "preset_path", "installed_at", "last_updated"):
|
|
178
|
+
if not isinstance(record.get(key), str) or not record[key]:
|
|
179
|
+
raise ValueError(f"invalid DSH lifecycle state for profile '{name}'")
|
|
180
|
+
if not re.fullmatch(r"[0-9a-f]{64}", str(record.get("preset_hash", ""))):
|
|
181
|
+
raise ValueError(f"invalid DSH lifecycle state for profile '{name}'")
|
|
182
|
+
return profiles
|
|
15
183
|
|
|
16
184
|
|
|
17
185
|
def _load_sources(sources_file: Path, key: str) -> list[tuple[str, str, str, str]]:
|
|
@@ -49,10 +217,7 @@ def _orphan_rule_files(rules_dir: Path, registered: set[str]) -> list[str]:
|
|
|
49
217
|
"""Return rule names present on disk but missing from sources.json."""
|
|
50
218
|
if not rules_dir.is_dir():
|
|
51
219
|
return []
|
|
52
|
-
return sorted(
|
|
53
|
-
f.stem for f in rules_dir.glob("*.md")
|
|
54
|
-
if f.stem not in registered
|
|
55
|
-
)
|
|
220
|
+
return sorted(f.stem for f in rules_dir.glob("*.md") if f.stem not in registered)
|
|
56
221
|
|
|
57
222
|
|
|
58
223
|
def _state_path() -> Path:
|
|
@@ -60,34 +225,1082 @@ def _state_path() -> Path:
|
|
|
60
225
|
return STATE_FILE
|
|
61
226
|
|
|
62
227
|
|
|
228
|
+
def get_state_path() -> Path:
|
|
229
|
+
"""Expose the canonical override-aware state path to lifecycle clients."""
|
|
230
|
+
return _state_path()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _unsafe_state_root() -> Path | None:
|
|
234
|
+
path = _state_path()
|
|
235
|
+
for candidate in (path.parent.parent, path.parent):
|
|
236
|
+
if candidate.is_symlink():
|
|
237
|
+
return candidate
|
|
238
|
+
if candidate.exists() and not candidate.is_dir():
|
|
239
|
+
return candidate
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _state_lock_path() -> Path:
|
|
244
|
+
return _state_path().parent / ".state.lock"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _prepare_state_parent() -> None:
|
|
248
|
+
if unsafe_root := _unsafe_state_root():
|
|
249
|
+
raise OSError(f"unsafe ai-toolkit state root: {unsafe_root}")
|
|
250
|
+
_state_path().parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
if unsafe_root := _unsafe_state_root():
|
|
252
|
+
raise OSError(f"unsafe ai-toolkit state root: {unsafe_root}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _release_state_lock(
|
|
256
|
+
lock_path: Path,
|
|
257
|
+
identity: tuple[int, int],
|
|
258
|
+
*,
|
|
259
|
+
secure: bool,
|
|
260
|
+
parent_descriptor: int | None,
|
|
261
|
+
) -> None:
|
|
262
|
+
if not secure:
|
|
263
|
+
try:
|
|
264
|
+
metadata = lock_path.stat(follow_symlinks=False)
|
|
265
|
+
except OSError as error:
|
|
266
|
+
raise OSError("ai-toolkit state lock disappeared before release") from error
|
|
267
|
+
if (
|
|
268
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
269
|
+
or (metadata.st_dev, metadata.st_ino) != identity
|
|
270
|
+
):
|
|
271
|
+
raise OSError(
|
|
272
|
+
f"ai-toolkit state lock identity changed; preserved at {lock_path}"
|
|
273
|
+
)
|
|
274
|
+
lock_path.unlink()
|
|
275
|
+
return
|
|
276
|
+
if parent_descriptor is None:
|
|
277
|
+
raise OSError("secure ai-toolkit state lock parent is unavailable")
|
|
278
|
+
parent_matches = _state_parent_path_matches(
|
|
279
|
+
lock_path.parent,
|
|
280
|
+
parent_descriptor,
|
|
281
|
+
)
|
|
282
|
+
recovery = lock_path.parent / (
|
|
283
|
+
f".state-lock-release-{os.getpid()}-{secrets.token_hex(12)}"
|
|
284
|
+
)
|
|
285
|
+
try:
|
|
286
|
+
_state_rename_operation(
|
|
287
|
+
lock_path,
|
|
288
|
+
recovery,
|
|
289
|
+
exchange=False,
|
|
290
|
+
source_parent_descriptor=parent_descriptor,
|
|
291
|
+
destination_parent_descriptor=parent_descriptor,
|
|
292
|
+
)
|
|
293
|
+
metadata = os.stat(
|
|
294
|
+
recovery.name,
|
|
295
|
+
dir_fd=parent_descriptor,
|
|
296
|
+
follow_symlinks=False,
|
|
297
|
+
)
|
|
298
|
+
except OSError as error:
|
|
299
|
+
raise OSError("ai-toolkit state lock disappeared before release") from error
|
|
300
|
+
if (
|
|
301
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
302
|
+
or (metadata.st_dev, metadata.st_ino) != identity
|
|
303
|
+
):
|
|
304
|
+
raise OSError(
|
|
305
|
+
f"ai-toolkit state lock identity changed; preserved at {recovery}"
|
|
306
|
+
)
|
|
307
|
+
os.unlink(recovery.name, dir_fd=parent_descriptor)
|
|
308
|
+
if not parent_matches or not _state_parent_path_matches(
|
|
309
|
+
lock_path.parent,
|
|
310
|
+
parent_descriptor,
|
|
311
|
+
):
|
|
312
|
+
raise OSError(
|
|
313
|
+
"ai-toolkit state parent identity changed; both roots were preserved"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@contextmanager
|
|
318
|
+
def _state_writer_lock(
|
|
319
|
+
*,
|
|
320
|
+
secure: bool = False,
|
|
321
|
+
expected_path: Path | None = None,
|
|
322
|
+
expected_parent_identity: tuple[int, int] | None = None,
|
|
323
|
+
) -> Iterator[_StateWriterContext]:
|
|
324
|
+
"""Hold the bounded cooperative lock for one complete state transaction."""
|
|
325
|
+
if secure and not _secure_state_mutation_supported():
|
|
326
|
+
raise OSError("secure ai-toolkit state mutation requires Linux, WSL, or macOS")
|
|
327
|
+
if expected_path is None:
|
|
328
|
+
_prepare_state_parent()
|
|
329
|
+
state_path = _state_path()
|
|
330
|
+
else:
|
|
331
|
+
state_path = expected_path
|
|
332
|
+
lock_path = state_path.parent / ".state.lock"
|
|
333
|
+
deadline = time.monotonic() + _STATE_LOCK_TIMEOUT_SECONDS
|
|
334
|
+
descriptor: int | None = None
|
|
335
|
+
identity: tuple[int, int] | None = None
|
|
336
|
+
parent_descriptor = (
|
|
337
|
+
_open_state_parent(lock_path.parent)
|
|
338
|
+
if _secure_state_mutation_supported()
|
|
339
|
+
else None
|
|
340
|
+
)
|
|
341
|
+
parent_metadata = (
|
|
342
|
+
os.fstat(parent_descriptor) if parent_descriptor is not None else None
|
|
343
|
+
)
|
|
344
|
+
try:
|
|
345
|
+
if expected_parent_identity is not None:
|
|
346
|
+
if parent_metadata is None or (
|
|
347
|
+
parent_metadata.st_dev,
|
|
348
|
+
parent_metadata.st_ino,
|
|
349
|
+
) != expected_parent_identity:
|
|
350
|
+
raise OSError(
|
|
351
|
+
"ai-toolkit state parent identity changed; both roots were preserved"
|
|
352
|
+
)
|
|
353
|
+
while descriptor is None:
|
|
354
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
355
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
356
|
+
flags |= os.O_NOFOLLOW
|
|
357
|
+
try:
|
|
358
|
+
if parent_descriptor is None:
|
|
359
|
+
descriptor = os.open(lock_path, flags, 0o600)
|
|
360
|
+
else:
|
|
361
|
+
descriptor = os.open(
|
|
362
|
+
lock_path.name,
|
|
363
|
+
flags,
|
|
364
|
+
0o600,
|
|
365
|
+
dir_fd=parent_descriptor,
|
|
366
|
+
)
|
|
367
|
+
metadata = os.fstat(descriptor)
|
|
368
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
369
|
+
raise OSError("ai-toolkit state lock is not a regular file")
|
|
370
|
+
identity = metadata.st_dev, metadata.st_ino
|
|
371
|
+
os.write(descriptor, b"ai-toolkit state writer\n")
|
|
372
|
+
os.fsync(descriptor)
|
|
373
|
+
except FileExistsError:
|
|
374
|
+
try:
|
|
375
|
+
if parent_descriptor is None:
|
|
376
|
+
metadata = lock_path.stat(follow_symlinks=False)
|
|
377
|
+
else:
|
|
378
|
+
metadata = os.stat(
|
|
379
|
+
lock_path.name,
|
|
380
|
+
dir_fd=parent_descriptor,
|
|
381
|
+
follow_symlinks=False,
|
|
382
|
+
)
|
|
383
|
+
except FileNotFoundError:
|
|
384
|
+
continue
|
|
385
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
386
|
+
raise OSError(f"unsafe ai-toolkit state lock: {lock_path}")
|
|
387
|
+
if time.monotonic() >= deadline:
|
|
388
|
+
raise OSError(
|
|
389
|
+
f"timed out waiting for ai-toolkit state lock: {lock_path}"
|
|
390
|
+
)
|
|
391
|
+
time.sleep(_STATE_LOCK_POLL_SECONDS)
|
|
392
|
+
except (OSError, KeyboardInterrupt):
|
|
393
|
+
if descriptor is not None:
|
|
394
|
+
os.close(descriptor)
|
|
395
|
+
descriptor = None
|
|
396
|
+
if identity is not None:
|
|
397
|
+
_release_state_lock(
|
|
398
|
+
lock_path,
|
|
399
|
+
identity,
|
|
400
|
+
secure=secure,
|
|
401
|
+
parent_descriptor=parent_descriptor,
|
|
402
|
+
)
|
|
403
|
+
raise
|
|
404
|
+
os.close(descriptor)
|
|
405
|
+
descriptor = None
|
|
406
|
+
if identity is None:
|
|
407
|
+
raise OSError("ai-toolkit state lock identity was not captured")
|
|
408
|
+
try:
|
|
409
|
+
context = _StateWriterContext(
|
|
410
|
+
path=state_path,
|
|
411
|
+
parent_descriptor=parent_descriptor,
|
|
412
|
+
parent_device=(
|
|
413
|
+
parent_metadata.st_dev if parent_metadata is not None else None
|
|
414
|
+
),
|
|
415
|
+
parent_inode=(
|
|
416
|
+
parent_metadata.st_ino if parent_metadata is not None else None
|
|
417
|
+
),
|
|
418
|
+
secure=secure,
|
|
419
|
+
)
|
|
420
|
+
if parent_descriptor is not None and not _state_parent_path_matches(
|
|
421
|
+
context.path.parent,
|
|
422
|
+
parent_descriptor,
|
|
423
|
+
):
|
|
424
|
+
raise OSError("ai-toolkit state parent identity changed")
|
|
425
|
+
yield context
|
|
426
|
+
finally:
|
|
427
|
+
_release_state_lock(
|
|
428
|
+
lock_path,
|
|
429
|
+
identity,
|
|
430
|
+
secure=parent_descriptor is not None,
|
|
431
|
+
parent_descriptor=parent_descriptor,
|
|
432
|
+
)
|
|
433
|
+
finally:
|
|
434
|
+
if descriptor is not None:
|
|
435
|
+
os.close(descriptor)
|
|
436
|
+
if parent_descriptor is not None:
|
|
437
|
+
os.close(parent_descriptor)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@dataclass(frozen=True)
|
|
441
|
+
class _OpenedStableStateFile:
|
|
442
|
+
path: Path
|
|
443
|
+
descriptor: int
|
|
444
|
+
parent_descriptor: int | None
|
|
445
|
+
content: bytes
|
|
446
|
+
metadata: os.stat_result
|
|
447
|
+
digest: str
|
|
448
|
+
secure: bool
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _state_file_signature(metadata: os.stat_result) -> tuple[int, ...]:
|
|
452
|
+
"""Return the complete stable-read signature for one state file."""
|
|
453
|
+
return (
|
|
454
|
+
metadata.st_dev,
|
|
455
|
+
metadata.st_ino,
|
|
456
|
+
metadata.st_mode,
|
|
457
|
+
metadata.st_size,
|
|
458
|
+
metadata.st_mtime_ns,
|
|
459
|
+
metadata.st_ctime_ns,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _read_declared_state_size(
|
|
464
|
+
descriptor: int,
|
|
465
|
+
*,
|
|
466
|
+
declared_size: int,
|
|
467
|
+
path: Path,
|
|
468
|
+
) -> bytes:
|
|
469
|
+
"""Read exactly the declared state size and reject early EOF or growth."""
|
|
470
|
+
remaining = declared_size
|
|
471
|
+
chunks: list[bytes] = []
|
|
472
|
+
while remaining:
|
|
473
|
+
chunk = os.read(descriptor, min(64 * 1024, remaining))
|
|
474
|
+
if not chunk:
|
|
475
|
+
raise OSError(f"ai-toolkit state changed while reading: {path}")
|
|
476
|
+
chunks.append(chunk)
|
|
477
|
+
remaining -= len(chunk)
|
|
478
|
+
if os.read(descriptor, 1):
|
|
479
|
+
raise OSError(f"ai-toolkit state changed while reading: {path}")
|
|
480
|
+
return b"".join(chunks)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _state_parent_path_matches(path: Path, descriptor: int) -> bool:
|
|
484
|
+
"""Confirm a state parent path still names the pinned directory inode."""
|
|
485
|
+
try:
|
|
486
|
+
current = _open_state_parent(path)
|
|
487
|
+
except OSError:
|
|
488
|
+
return False
|
|
489
|
+
try:
|
|
490
|
+
expected_metadata = os.fstat(descriptor)
|
|
491
|
+
current_metadata = os.fstat(current)
|
|
492
|
+
return (expected_metadata.st_dev, expected_metadata.st_ino) == (
|
|
493
|
+
current_metadata.st_dev,
|
|
494
|
+
current_metadata.st_ino,
|
|
495
|
+
)
|
|
496
|
+
finally:
|
|
497
|
+
os.close(current)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _assert_state_writer_binding(context: _StateWriterContext) -> None:
|
|
501
|
+
"""Fail before I/O if the lexical state parent no longer names the lock inode."""
|
|
502
|
+
descriptor = context.parent_descriptor
|
|
503
|
+
if descriptor is None:
|
|
504
|
+
return
|
|
505
|
+
metadata = os.fstat(descriptor)
|
|
506
|
+
if (
|
|
507
|
+
metadata.st_dev,
|
|
508
|
+
metadata.st_ino,
|
|
509
|
+
) != (
|
|
510
|
+
context.parent_device,
|
|
511
|
+
context.parent_inode,
|
|
512
|
+
) or not _state_parent_path_matches(context.path.parent, descriptor):
|
|
513
|
+
raise OSError(
|
|
514
|
+
"ai-toolkit state parent identity changed; both roots were preserved"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _state_name_exists(context: _StateWriterContext, name: str) -> bool:
|
|
519
|
+
descriptor = context.parent_descriptor
|
|
520
|
+
if descriptor is None:
|
|
521
|
+
path = context.path.parent / name
|
|
522
|
+
return path.exists() or path.is_symlink()
|
|
523
|
+
try:
|
|
524
|
+
os.stat(name, dir_fd=descriptor, follow_symlinks=False)
|
|
525
|
+
except FileNotFoundError:
|
|
526
|
+
return False
|
|
527
|
+
return True
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _named_state_metadata(
|
|
531
|
+
opened: _OpenedStableStateFile,
|
|
532
|
+
) -> os.stat_result:
|
|
533
|
+
if opened.secure:
|
|
534
|
+
if opened.parent_descriptor is None:
|
|
535
|
+
raise OSError("secure ai-toolkit state parent is unavailable")
|
|
536
|
+
return os.stat(
|
|
537
|
+
opened.path.name,
|
|
538
|
+
dir_fd=opened.parent_descriptor,
|
|
539
|
+
follow_symlinks=False,
|
|
540
|
+
)
|
|
541
|
+
return opened.path.stat(follow_symlinks=False)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@contextmanager
|
|
545
|
+
def _open_stable_state_file(
|
|
546
|
+
path: Path,
|
|
547
|
+
*,
|
|
548
|
+
secure: bool,
|
|
549
|
+
parent_descriptor: int | None = None,
|
|
550
|
+
) -> Iterator[_OpenedStableStateFile]:
|
|
551
|
+
"""Pin, validate, and read one regular state inode exactly once."""
|
|
552
|
+
opened_parent_descriptor = parent_descriptor
|
|
553
|
+
owns_parent_descriptor = False
|
|
554
|
+
descriptor: int | None = None
|
|
555
|
+
try:
|
|
556
|
+
if secure:
|
|
557
|
+
if opened_parent_descriptor is None:
|
|
558
|
+
opened_parent_descriptor = _open_state_parent(path.parent)
|
|
559
|
+
owns_parent_descriptor = True
|
|
560
|
+
if not _state_parent_path_matches(path.parent, opened_parent_descriptor):
|
|
561
|
+
raise OSError("ai-toolkit state parent identity changed")
|
|
562
|
+
named_before = os.stat(
|
|
563
|
+
path.name,
|
|
564
|
+
dir_fd=opened_parent_descriptor,
|
|
565
|
+
follow_symlinks=False,
|
|
566
|
+
)
|
|
567
|
+
descriptor = os.open(
|
|
568
|
+
path.name,
|
|
569
|
+
os.O_RDONLY | os.O_NOFOLLOW,
|
|
570
|
+
dir_fd=opened_parent_descriptor,
|
|
571
|
+
)
|
|
572
|
+
else:
|
|
573
|
+
named_before = path.stat(follow_symlinks=False)
|
|
574
|
+
flags = os.O_RDONLY
|
|
575
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
576
|
+
flags |= os.O_NOFOLLOW
|
|
577
|
+
descriptor = os.open(path, flags)
|
|
578
|
+
opened = os.fstat(descriptor)
|
|
579
|
+
signature = _state_file_signature(opened)
|
|
580
|
+
if (
|
|
581
|
+
not stat.S_ISREG(named_before.st_mode)
|
|
582
|
+
or not stat.S_ISREG(opened.st_mode)
|
|
583
|
+
or _state_file_signature(named_before) != signature
|
|
584
|
+
):
|
|
585
|
+
raise OSError("ai-toolkit state identity changed")
|
|
586
|
+
content = _read_declared_state_size(
|
|
587
|
+
descriptor,
|
|
588
|
+
declared_size=opened.st_size,
|
|
589
|
+
path=path,
|
|
590
|
+
)
|
|
591
|
+
after_read = os.fstat(descriptor)
|
|
592
|
+
if secure:
|
|
593
|
+
if opened_parent_descriptor is None:
|
|
594
|
+
raise OSError("secure ai-toolkit state parent is unavailable")
|
|
595
|
+
named_after = os.stat(
|
|
596
|
+
path.name,
|
|
597
|
+
dir_fd=opened_parent_descriptor,
|
|
598
|
+
follow_symlinks=False,
|
|
599
|
+
)
|
|
600
|
+
parent_matches = _state_parent_path_matches(
|
|
601
|
+
path.parent,
|
|
602
|
+
opened_parent_descriptor,
|
|
603
|
+
)
|
|
604
|
+
else:
|
|
605
|
+
named_after = path.stat(follow_symlinks=False)
|
|
606
|
+
parent_matches = True
|
|
607
|
+
if (
|
|
608
|
+
not stat.S_ISREG(after_read.st_mode)
|
|
609
|
+
or not stat.S_ISREG(named_after.st_mode)
|
|
610
|
+
or _state_file_signature(after_read) != signature
|
|
611
|
+
or _state_file_signature(named_after) != signature
|
|
612
|
+
or not parent_matches
|
|
613
|
+
):
|
|
614
|
+
raise OSError("ai-toolkit state identity changed")
|
|
615
|
+
yield _OpenedStableStateFile(
|
|
616
|
+
path=path,
|
|
617
|
+
descriptor=descriptor,
|
|
618
|
+
parent_descriptor=opened_parent_descriptor,
|
|
619
|
+
content=content,
|
|
620
|
+
metadata=opened,
|
|
621
|
+
digest=hashlib.sha256(content).hexdigest(),
|
|
622
|
+
secure=secure,
|
|
623
|
+
)
|
|
624
|
+
finally:
|
|
625
|
+
if descriptor is not None:
|
|
626
|
+
os.close(descriptor)
|
|
627
|
+
if owns_parent_descriptor and opened_parent_descriptor is not None:
|
|
628
|
+
os.close(opened_parent_descriptor)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _decode_state_document(content: bytes) -> dict:
|
|
632
|
+
try:
|
|
633
|
+
state = json.loads(content.decode("utf-8"))
|
|
634
|
+
except (json.JSONDecodeError, UnicodeError) as error:
|
|
635
|
+
raise ValueError("malformed ai-toolkit state file") from error
|
|
636
|
+
if not isinstance(state, dict):
|
|
637
|
+
raise ValueError("malformed ai-toolkit state file")
|
|
638
|
+
return state
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _opened_state_revision(
|
|
642
|
+
opened: _OpenedStableStateFile,
|
|
643
|
+
) -> tuple[bool, int, int, str]:
|
|
644
|
+
return (
|
|
645
|
+
True,
|
|
646
|
+
opened.metadata.st_dev,
|
|
647
|
+
opened.metadata.st_ino,
|
|
648
|
+
opened.digest,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _verify_open_state_after_mode_change(
|
|
653
|
+
opened: _OpenedStableStateFile,
|
|
654
|
+
*,
|
|
655
|
+
expected_mode: int,
|
|
656
|
+
expected_digest: str,
|
|
657
|
+
) -> bool:
|
|
658
|
+
"""Verify a mode-restored descriptor still owns the named state path."""
|
|
659
|
+
try:
|
|
660
|
+
before_read = os.fstat(opened.descriptor)
|
|
661
|
+
if (
|
|
662
|
+
not stat.S_ISREG(before_read.st_mode)
|
|
663
|
+
or (before_read.st_dev, before_read.st_ino)
|
|
664
|
+
!= (opened.metadata.st_dev, opened.metadata.st_ino)
|
|
665
|
+
or stat.S_IMODE(before_read.st_mode) != expected_mode
|
|
666
|
+
):
|
|
667
|
+
return False
|
|
668
|
+
os.lseek(opened.descriptor, 0, os.SEEK_SET)
|
|
669
|
+
content = _read_declared_state_size(
|
|
670
|
+
opened.descriptor,
|
|
671
|
+
declared_size=before_read.st_size,
|
|
672
|
+
path=opened.path,
|
|
673
|
+
)
|
|
674
|
+
after_read = os.fstat(opened.descriptor)
|
|
675
|
+
named_after = _named_state_metadata(opened)
|
|
676
|
+
signature = _state_file_signature(before_read)
|
|
677
|
+
parent_matches = not opened.secure or (
|
|
678
|
+
opened.parent_descriptor is not None
|
|
679
|
+
and _state_parent_path_matches(
|
|
680
|
+
opened.path.parent,
|
|
681
|
+
opened.parent_descriptor,
|
|
682
|
+
)
|
|
683
|
+
)
|
|
684
|
+
return (
|
|
685
|
+
stat.S_ISREG(after_read.st_mode)
|
|
686
|
+
and stat.S_ISREG(named_after.st_mode)
|
|
687
|
+
and _state_file_signature(after_read) == signature
|
|
688
|
+
and _state_file_signature(named_after) == signature
|
|
689
|
+
and stat.S_IMODE(named_after.st_mode) == expected_mode
|
|
690
|
+
and hashlib.sha256(content).hexdigest() == expected_digest
|
|
691
|
+
and parent_matches
|
|
692
|
+
)
|
|
693
|
+
except OSError:
|
|
694
|
+
return False
|
|
695
|
+
|
|
696
|
+
|
|
63
697
|
def load_state() -> dict:
|
|
64
698
|
"""Load state from ~/.softspark/ai-toolkit/state.json.
|
|
65
699
|
|
|
66
700
|
Returns an empty dict if the file does not exist or is malformed.
|
|
67
701
|
"""
|
|
68
|
-
path = _state_path()
|
|
69
|
-
if not path.is_file():
|
|
70
|
-
return {}
|
|
71
702
|
try:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if not isinstance(data, dict):
|
|
75
|
-
return {}
|
|
76
|
-
return data
|
|
77
|
-
except (json.JSONDecodeError, OSError):
|
|
703
|
+
return _load_state_strict()
|
|
704
|
+
except ValueError:
|
|
78
705
|
return {}
|
|
79
706
|
|
|
80
707
|
|
|
708
|
+
def _load_state_strict(
|
|
709
|
+
*,
|
|
710
|
+
secure: bool = False,
|
|
711
|
+
transaction: _StateWriterContext | None = None,
|
|
712
|
+
) -> dict:
|
|
713
|
+
"""Load shared state for ownership-sensitive lifecycle operations."""
|
|
714
|
+
path = transaction.path if transaction is not None else _state_path()
|
|
715
|
+
if transaction is not None:
|
|
716
|
+
_assert_state_writer_binding(transaction)
|
|
717
|
+
if unsafe_root := _unsafe_state_root():
|
|
718
|
+
raise ValueError(f"unsafe ai-toolkit state root: {unsafe_root}")
|
|
719
|
+
if transaction is not None and not _state_name_exists(transaction, path.name):
|
|
720
|
+
return {}
|
|
721
|
+
if transaction is None and not path.exists() and not path.is_symlink():
|
|
722
|
+
return {}
|
|
723
|
+
try:
|
|
724
|
+
with _open_stable_state_file(
|
|
725
|
+
path,
|
|
726
|
+
secure=secure
|
|
727
|
+
or (transaction is not None and transaction.parent_descriptor is not None),
|
|
728
|
+
parent_descriptor=(
|
|
729
|
+
transaction.parent_descriptor if transaction is not None else None
|
|
730
|
+
),
|
|
731
|
+
) as opened:
|
|
732
|
+
return _decode_state_document(opened.content)
|
|
733
|
+
except (OSError, ValueError) as error:
|
|
734
|
+
raise ValueError("malformed ai-toolkit state file") from error
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _load_state_revision_strict(
|
|
738
|
+
*,
|
|
739
|
+
secure: bool = False,
|
|
740
|
+
transaction: _StateWriterContext | None = None,
|
|
741
|
+
) -> tuple[dict, tuple[bool, int, int, str]]:
|
|
742
|
+
"""Read one stable state inode and return its compare-and-swap revision."""
|
|
743
|
+
path = transaction.path if transaction is not None else _state_path()
|
|
744
|
+
if transaction is not None:
|
|
745
|
+
_assert_state_writer_binding(transaction)
|
|
746
|
+
if unsafe_root := _unsafe_state_root():
|
|
747
|
+
raise ValueError(f"unsafe ai-toolkit state root: {unsafe_root}")
|
|
748
|
+
if transaction is not None and not _state_name_exists(transaction, path.name):
|
|
749
|
+
return {}, (False, 0, 0, "")
|
|
750
|
+
if transaction is None and not path.exists() and not path.is_symlink():
|
|
751
|
+
return {}, (False, 0, 0, "")
|
|
752
|
+
try:
|
|
753
|
+
with _open_stable_state_file(
|
|
754
|
+
path,
|
|
755
|
+
secure=secure
|
|
756
|
+
or (transaction is not None and transaction.parent_descriptor is not None),
|
|
757
|
+
parent_descriptor=(
|
|
758
|
+
transaction.parent_descriptor if transaction is not None else None
|
|
759
|
+
),
|
|
760
|
+
) as opened:
|
|
761
|
+
state = _decode_state_document(opened.content)
|
|
762
|
+
return state, _opened_state_revision(opened)
|
|
763
|
+
except (OSError, ValueError) as error:
|
|
764
|
+
raise ValueError("malformed or unsafe ai-toolkit state file") from error
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _secure_state_mutation_supported() -> bool:
|
|
768
|
+
if not (
|
|
769
|
+
os.name == "posix"
|
|
770
|
+
and hasattr(os, "O_DIRECTORY")
|
|
771
|
+
and hasattr(os, "O_NOFOLLOW")
|
|
772
|
+
and {os.stat, os.unlink, os.rename}.issubset(os.supports_dir_fd)
|
|
773
|
+
and sys.platform.startswith(("darwin", "linux"))
|
|
774
|
+
):
|
|
775
|
+
return False
|
|
776
|
+
try:
|
|
777
|
+
library = ctypes.CDLL(None, use_errno=True)
|
|
778
|
+
getattr(library, "renameatx_np" if sys.platform == "darwin" else "renameat2")
|
|
779
|
+
except (AttributeError, OSError):
|
|
780
|
+
return False
|
|
781
|
+
return True
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def secure_dsh_state_mutation_supported() -> bool:
|
|
785
|
+
"""Report whether ownership-sensitive DSH state mutation is available."""
|
|
786
|
+
return _secure_state_mutation_supported()
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _open_state_parent(path: Path) -> int:
|
|
790
|
+
if not _secure_state_mutation_supported():
|
|
791
|
+
raise OSError("secure ai-toolkit state mutation requires Linux, WSL, or macOS")
|
|
792
|
+
before = path.stat(follow_symlinks=False)
|
|
793
|
+
if not stat.S_ISDIR(before.st_mode):
|
|
794
|
+
raise OSError(f"unsafe ai-toolkit state parent: {path}")
|
|
795
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
796
|
+
opened = os.fstat(descriptor)
|
|
797
|
+
if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
|
|
798
|
+
os.close(descriptor)
|
|
799
|
+
raise OSError(f"ai-toolkit state parent identity changed: {path}")
|
|
800
|
+
return descriptor
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _state_rename_operation(
|
|
804
|
+
source: Path,
|
|
805
|
+
destination: Path,
|
|
806
|
+
*,
|
|
807
|
+
exchange: bool,
|
|
808
|
+
source_parent_descriptor: int | None = None,
|
|
809
|
+
destination_parent_descriptor: int | None = None,
|
|
810
|
+
) -> None:
|
|
811
|
+
source_parent = (
|
|
812
|
+
source_parent_descriptor
|
|
813
|
+
if source_parent_descriptor is not None
|
|
814
|
+
else _open_state_parent(source.parent)
|
|
815
|
+
)
|
|
816
|
+
destination_parent = (
|
|
817
|
+
destination_parent_descriptor
|
|
818
|
+
if destination_parent_descriptor is not None
|
|
819
|
+
else _open_state_parent(destination.parent)
|
|
820
|
+
)
|
|
821
|
+
try:
|
|
822
|
+
library = ctypes.CDLL(None, use_errno=True)
|
|
823
|
+
source_name = os.fsencode(source.name)
|
|
824
|
+
destination_name = os.fsencode(destination.name)
|
|
825
|
+
flag = (
|
|
826
|
+
0x00000002
|
|
827
|
+
if exchange
|
|
828
|
+
else (0x00000004 if sys.platform == "darwin" else 0x00000001)
|
|
829
|
+
)
|
|
830
|
+
if sys.platform == "darwin":
|
|
831
|
+
operation = library.renameatx_np
|
|
832
|
+
else:
|
|
833
|
+
operation = library.renameat2
|
|
834
|
+
operation.argtypes = [
|
|
835
|
+
ctypes.c_int,
|
|
836
|
+
ctypes.c_char_p,
|
|
837
|
+
ctypes.c_int,
|
|
838
|
+
ctypes.c_char_p,
|
|
839
|
+
ctypes.c_uint,
|
|
840
|
+
]
|
|
841
|
+
operation.restype = ctypes.c_int
|
|
842
|
+
result = operation(
|
|
843
|
+
source_parent,
|
|
844
|
+
source_name,
|
|
845
|
+
destination_parent,
|
|
846
|
+
destination_name,
|
|
847
|
+
flag,
|
|
848
|
+
)
|
|
849
|
+
if result != 0:
|
|
850
|
+
code = ctypes.get_errno()
|
|
851
|
+
if not exchange and code in {errno.EEXIST, errno.ENOTEMPTY}:
|
|
852
|
+
raise FileExistsError(code, os.strerror(code), str(destination))
|
|
853
|
+
if code in {errno.ENOSYS, errno.EINVAL, errno.ENOTSUP}:
|
|
854
|
+
raise OSError("secure state rename primitive is unavailable")
|
|
855
|
+
raise OSError(code, os.strerror(code), str(source))
|
|
856
|
+
finally:
|
|
857
|
+
if destination_parent_descriptor is None:
|
|
858
|
+
os.close(destination_parent)
|
|
859
|
+
if source_parent_descriptor is None:
|
|
860
|
+
os.close(source_parent)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _path_revision(
|
|
864
|
+
path: Path,
|
|
865
|
+
*,
|
|
866
|
+
parent_descriptor: int | None = None,
|
|
867
|
+
) -> tuple[bool, int, int, str]:
|
|
868
|
+
if parent_descriptor is None:
|
|
869
|
+
if not path.exists() and not path.is_symlink():
|
|
870
|
+
return False, 0, 0, ""
|
|
871
|
+
else:
|
|
872
|
+
try:
|
|
873
|
+
os.stat(path.name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
874
|
+
except FileNotFoundError:
|
|
875
|
+
return False, 0, 0, ""
|
|
876
|
+
with _open_stable_state_file(
|
|
877
|
+
path,
|
|
878
|
+
secure=True,
|
|
879
|
+
parent_descriptor=parent_descriptor,
|
|
880
|
+
) as opened:
|
|
881
|
+
return _opened_state_revision(opened)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _secure_publish_state(
|
|
885
|
+
temporary: Path,
|
|
886
|
+
path: Path,
|
|
887
|
+
expected_revision: tuple[bool, int, int, str],
|
|
888
|
+
*,
|
|
889
|
+
parent_descriptor: int | None = None,
|
|
890
|
+
transaction: _StateWriterContext | None = None,
|
|
891
|
+
binding_validator: Callable[[], None] | None = None,
|
|
892
|
+
) -> bool:
|
|
893
|
+
"""Publish state atomically without destroying a raced inode."""
|
|
894
|
+
if transaction is not None:
|
|
895
|
+
_assert_state_writer_binding(transaction)
|
|
896
|
+
if binding_validator is not None:
|
|
897
|
+
binding_validator()
|
|
898
|
+
if not expected_revision[0]:
|
|
899
|
+
try:
|
|
900
|
+
if transaction is not None:
|
|
901
|
+
_assert_state_writer_binding(transaction)
|
|
902
|
+
if binding_validator is not None:
|
|
903
|
+
binding_validator()
|
|
904
|
+
_state_rename_operation(
|
|
905
|
+
temporary,
|
|
906
|
+
path,
|
|
907
|
+
exchange=False,
|
|
908
|
+
source_parent_descriptor=parent_descriptor,
|
|
909
|
+
destination_parent_descriptor=parent_descriptor,
|
|
910
|
+
)
|
|
911
|
+
except FileExistsError:
|
|
912
|
+
return False
|
|
913
|
+
if transaction is not None:
|
|
914
|
+
_assert_state_writer_binding(transaction)
|
|
915
|
+
if binding_validator is not None:
|
|
916
|
+
binding_validator()
|
|
917
|
+
return True
|
|
918
|
+
try:
|
|
919
|
+
current = (
|
|
920
|
+
path.stat(follow_symlinks=False)
|
|
921
|
+
if parent_descriptor is None
|
|
922
|
+
else os.stat(
|
|
923
|
+
path.name,
|
|
924
|
+
dir_fd=parent_descriptor,
|
|
925
|
+
follow_symlinks=False,
|
|
926
|
+
)
|
|
927
|
+
)
|
|
928
|
+
except FileNotFoundError:
|
|
929
|
+
return False
|
|
930
|
+
if stat.S_ISLNK(current.st_mode):
|
|
931
|
+
return False
|
|
932
|
+
if transaction is not None:
|
|
933
|
+
_assert_state_writer_binding(transaction)
|
|
934
|
+
if binding_validator is not None:
|
|
935
|
+
binding_validator()
|
|
936
|
+
_state_rename_operation(
|
|
937
|
+
temporary,
|
|
938
|
+
path,
|
|
939
|
+
exchange=True,
|
|
940
|
+
source_parent_descriptor=parent_descriptor,
|
|
941
|
+
destination_parent_descriptor=parent_descriptor,
|
|
942
|
+
)
|
|
943
|
+
try:
|
|
944
|
+
if (
|
|
945
|
+
_path_revision(
|
|
946
|
+
temporary,
|
|
947
|
+
parent_descriptor=parent_descriptor,
|
|
948
|
+
)
|
|
949
|
+
!= expected_revision
|
|
950
|
+
):
|
|
951
|
+
_state_rename_operation(
|
|
952
|
+
temporary,
|
|
953
|
+
path,
|
|
954
|
+
exchange=True,
|
|
955
|
+
source_parent_descriptor=parent_descriptor,
|
|
956
|
+
destination_parent_descriptor=parent_descriptor,
|
|
957
|
+
)
|
|
958
|
+
return False
|
|
959
|
+
_secure_cleanup_private_file(
|
|
960
|
+
temporary,
|
|
961
|
+
(expected_revision[1], expected_revision[2]),
|
|
962
|
+
parent_descriptor=parent_descriptor,
|
|
963
|
+
)
|
|
964
|
+
if transaction is not None:
|
|
965
|
+
_assert_state_writer_binding(transaction)
|
|
966
|
+
if binding_validator is not None:
|
|
967
|
+
binding_validator()
|
|
968
|
+
return True
|
|
969
|
+
except (OSError, KeyboardInterrupt):
|
|
970
|
+
temporary_exists = (
|
|
971
|
+
temporary.exists()
|
|
972
|
+
if parent_descriptor is None
|
|
973
|
+
else _descriptor_name_exists(parent_descriptor, temporary.name)
|
|
974
|
+
)
|
|
975
|
+
path_exists = (
|
|
976
|
+
path.exists()
|
|
977
|
+
if parent_descriptor is None
|
|
978
|
+
else _descriptor_name_exists(parent_descriptor, path.name)
|
|
979
|
+
)
|
|
980
|
+
if temporary_exists and path_exists:
|
|
981
|
+
try:
|
|
982
|
+
_state_rename_operation(
|
|
983
|
+
temporary,
|
|
984
|
+
path,
|
|
985
|
+
exchange=True,
|
|
986
|
+
source_parent_descriptor=parent_descriptor,
|
|
987
|
+
destination_parent_descriptor=parent_descriptor,
|
|
988
|
+
)
|
|
989
|
+
except OSError:
|
|
990
|
+
pass
|
|
991
|
+
raise
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _descriptor_name_exists(parent_descriptor: int, name: str) -> bool:
|
|
995
|
+
try:
|
|
996
|
+
os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
997
|
+
except FileNotFoundError:
|
|
998
|
+
return False
|
|
999
|
+
return True
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _secure_cleanup_private_file(
|
|
1003
|
+
path: Path,
|
|
1004
|
+
identity: tuple[int, int],
|
|
1005
|
+
*,
|
|
1006
|
+
parent_descriptor: int | None = None,
|
|
1007
|
+
) -> None:
|
|
1008
|
+
if parent_descriptor is None:
|
|
1009
|
+
if not path.exists() and not path.is_symlink():
|
|
1010
|
+
return
|
|
1011
|
+
elif not _descriptor_name_exists(parent_descriptor, path.name):
|
|
1012
|
+
return
|
|
1013
|
+
recovery = path.parent / (
|
|
1014
|
+
f".state-private-cleanup-{os.getpid()}-{secrets.token_hex(12)}"
|
|
1015
|
+
)
|
|
1016
|
+
_state_rename_operation(
|
|
1017
|
+
path,
|
|
1018
|
+
recovery,
|
|
1019
|
+
exchange=False,
|
|
1020
|
+
source_parent_descriptor=parent_descriptor,
|
|
1021
|
+
destination_parent_descriptor=parent_descriptor,
|
|
1022
|
+
)
|
|
1023
|
+
metadata = (
|
|
1024
|
+
recovery.stat(follow_symlinks=False)
|
|
1025
|
+
if parent_descriptor is None
|
|
1026
|
+
else os.stat(
|
|
1027
|
+
recovery.name,
|
|
1028
|
+
dir_fd=parent_descriptor,
|
|
1029
|
+
follow_symlinks=False,
|
|
1030
|
+
)
|
|
1031
|
+
)
|
|
1032
|
+
if (
|
|
1033
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
1034
|
+
or (metadata.st_dev, metadata.st_ino) != identity
|
|
1035
|
+
):
|
|
1036
|
+
raise OSError(f"state temporary identity changed; preserved at {recovery}")
|
|
1037
|
+
parent = (
|
|
1038
|
+
parent_descriptor
|
|
1039
|
+
if parent_descriptor is not None
|
|
1040
|
+
else _open_state_parent(recovery.parent)
|
|
1041
|
+
)
|
|
1042
|
+
try:
|
|
1043
|
+
os.unlink(recovery.name, dir_fd=parent)
|
|
1044
|
+
finally:
|
|
1045
|
+
if parent_descriptor is None:
|
|
1046
|
+
os.close(parent)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _create_state_temporary(
|
|
1050
|
+
context: _StateWriterContext,
|
|
1051
|
+
*,
|
|
1052
|
+
prefix: str,
|
|
1053
|
+
suffix: str,
|
|
1054
|
+
mode: int = 0o600,
|
|
1055
|
+
) -> tuple[int, Path, tuple[int, int]]:
|
|
1056
|
+
"""Create a private state temporary inside the transaction's pinned parent."""
|
|
1057
|
+
_assert_state_writer_binding(context)
|
|
1058
|
+
if context.parent_descriptor is None:
|
|
1059
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
1060
|
+
prefix=prefix,
|
|
1061
|
+
suffix=suffix,
|
|
1062
|
+
dir=context.path.parent,
|
|
1063
|
+
)
|
|
1064
|
+
temporary = Path(temporary_name)
|
|
1065
|
+
else:
|
|
1066
|
+
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
|
|
1067
|
+
for _attempt in range(16):
|
|
1068
|
+
name = f"{prefix}{secrets.token_hex(12)}{suffix}"
|
|
1069
|
+
try:
|
|
1070
|
+
descriptor = os.open(
|
|
1071
|
+
name,
|
|
1072
|
+
flags,
|
|
1073
|
+
mode,
|
|
1074
|
+
dir_fd=context.parent_descriptor,
|
|
1075
|
+
)
|
|
1076
|
+
except FileExistsError:
|
|
1077
|
+
continue
|
|
1078
|
+
temporary = context.path.parent / name
|
|
1079
|
+
break
|
|
1080
|
+
else:
|
|
1081
|
+
raise OSError("unable to claim private ai-toolkit state temporary")
|
|
1082
|
+
metadata = os.fstat(descriptor)
|
|
1083
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
1084
|
+
os.close(descriptor)
|
|
1085
|
+
raise OSError("ai-toolkit state temporary is not a regular file")
|
|
1086
|
+
return descriptor, temporary, (metadata.st_dev, metadata.st_ino)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _portable_cleanup_private_file(
|
|
1090
|
+
path: Path,
|
|
1091
|
+
identity: tuple[int, int],
|
|
1092
|
+
) -> None:
|
|
1093
|
+
"""Clean a private temporary on platforms without DSH secure primitives."""
|
|
1094
|
+
if not path.exists() and not path.is_symlink():
|
|
1095
|
+
return
|
|
1096
|
+
metadata = path.stat(follow_symlinks=False)
|
|
1097
|
+
if (
|
|
1098
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
1099
|
+
or (metadata.st_dev, metadata.st_ino) != identity
|
|
1100
|
+
):
|
|
1101
|
+
raise OSError(f"state temporary identity changed; preserved at {path}")
|
|
1102
|
+
path.unlink()
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _save_state_cas(
|
|
1106
|
+
state: dict,
|
|
1107
|
+
expected_revision: tuple[bool, int, int, str],
|
|
1108
|
+
*,
|
|
1109
|
+
transaction: _StateWriterContext,
|
|
1110
|
+
binding_validator: Callable[[], None] | None = None,
|
|
1111
|
+
) -> bool:
|
|
1112
|
+
"""Atomically save only while the captured state revision is still current."""
|
|
1113
|
+
if not _secure_state_mutation_supported():
|
|
1114
|
+
raise OSError("secure ai-toolkit state mutation requires Linux, WSL, or macOS")
|
|
1115
|
+
path = transaction.path
|
|
1116
|
+
_assert_state_writer_binding(transaction)
|
|
1117
|
+
if binding_validator is not None:
|
|
1118
|
+
binding_validator()
|
|
1119
|
+
payload = json.dumps(state, indent=2) + "\n"
|
|
1120
|
+
descriptor, temporary, temporary_identity = _create_state_temporary(
|
|
1121
|
+
transaction,
|
|
1122
|
+
prefix=".state.dsh-cas-",
|
|
1123
|
+
suffix=".tmp",
|
|
1124
|
+
)
|
|
1125
|
+
try:
|
|
1126
|
+
os.fchmod(descriptor, 0o600)
|
|
1127
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
1128
|
+
stream.write(payload)
|
|
1129
|
+
stream.flush()
|
|
1130
|
+
os.fsync(stream.fileno())
|
|
1131
|
+
_assert_state_writer_binding(transaction)
|
|
1132
|
+
if binding_validator is not None:
|
|
1133
|
+
binding_validator()
|
|
1134
|
+
published = _secure_publish_state(
|
|
1135
|
+
temporary,
|
|
1136
|
+
path,
|
|
1137
|
+
expected_revision,
|
|
1138
|
+
parent_descriptor=transaction.parent_descriptor,
|
|
1139
|
+
transaction=transaction,
|
|
1140
|
+
binding_validator=binding_validator,
|
|
1141
|
+
)
|
|
1142
|
+
_assert_state_writer_binding(transaction)
|
|
1143
|
+
return published
|
|
1144
|
+
finally:
|
|
1145
|
+
_secure_cleanup_private_file(
|
|
1146
|
+
temporary,
|
|
1147
|
+
temporary_identity,
|
|
1148
|
+
parent_descriptor=transaction.parent_descriptor,
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _current_dsh_profile(state: dict, profile: str) -> dict | None:
|
|
1153
|
+
dsh = state.get("dsh")
|
|
1154
|
+
if dsh is None:
|
|
1155
|
+
return None
|
|
1156
|
+
return _validate_dsh_profiles(dsh).get(profile)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _replace_dsh_profile_cas(
|
|
1160
|
+
profile: str,
|
|
1161
|
+
*,
|
|
1162
|
+
expected_profile: object,
|
|
1163
|
+
replacement: dict | None,
|
|
1164
|
+
preserve_installed_at: bool = False,
|
|
1165
|
+
allow_already_replaced: bool = False,
|
|
1166
|
+
binding_validator: Callable[[], None] | None = None,
|
|
1167
|
+
state_snapshot: DshStateSnapshot | None = None,
|
|
1168
|
+
) -> dict | None:
|
|
1169
|
+
expected_parent_identity = (
|
|
1170
|
+
None
|
|
1171
|
+
if state_snapshot is None
|
|
1172
|
+
else (
|
|
1173
|
+
state_snapshot.state_parent_device,
|
|
1174
|
+
state_snapshot.state_parent_inode,
|
|
1175
|
+
)
|
|
1176
|
+
)
|
|
1177
|
+
if expected_parent_identity is not None and None in expected_parent_identity:
|
|
1178
|
+
raise ValueError("DSH state snapshot is missing its parent identity")
|
|
1179
|
+
with _state_writer_lock(
|
|
1180
|
+
secure=True,
|
|
1181
|
+
expected_path=state_snapshot.path if state_snapshot is not None else None,
|
|
1182
|
+
expected_parent_identity=expected_parent_identity,
|
|
1183
|
+
) as transaction:
|
|
1184
|
+
for _attempt in range(_STATE_CAS_RETRIES):
|
|
1185
|
+
if binding_validator is not None:
|
|
1186
|
+
binding_validator()
|
|
1187
|
+
state, revision = _load_state_revision_strict(
|
|
1188
|
+
secure=True,
|
|
1189
|
+
transaction=transaction,
|
|
1190
|
+
)
|
|
1191
|
+
current = _current_dsh_profile(state, profile)
|
|
1192
|
+
if allow_already_replaced and current == replacement:
|
|
1193
|
+
return current
|
|
1194
|
+
if (
|
|
1195
|
+
expected_profile is not _DSH_EXPECTED_UNSET
|
|
1196
|
+
and current != expected_profile
|
|
1197
|
+
):
|
|
1198
|
+
raise ValueError(f"concurrent DSH state change for profile '{profile}'")
|
|
1199
|
+
dsh = state.get("dsh")
|
|
1200
|
+
actual_replacement = replacement
|
|
1201
|
+
if actual_replacement is not None and preserve_installed_at:
|
|
1202
|
+
actual_replacement = dict(actual_replacement)
|
|
1203
|
+
if current is not None:
|
|
1204
|
+
actual_replacement["installed_at"] = current["installed_at"]
|
|
1205
|
+
if actual_replacement is None:
|
|
1206
|
+
if current is None:
|
|
1207
|
+
raise ValueError("invalid DSH lifecycle state")
|
|
1208
|
+
if dsh is not None:
|
|
1209
|
+
profiles = _validate_dsh_profiles(dsh)
|
|
1210
|
+
profiles.pop(profile, None)
|
|
1211
|
+
if not profiles:
|
|
1212
|
+
state.pop("dsh", None)
|
|
1213
|
+
else:
|
|
1214
|
+
if dsh is None:
|
|
1215
|
+
dsh = {"profiles": {}}
|
|
1216
|
+
state["dsh"] = dsh
|
|
1217
|
+
profiles = _validate_dsh_profiles(dsh)
|
|
1218
|
+
profiles[profile] = actual_replacement
|
|
1219
|
+
if binding_validator is not None:
|
|
1220
|
+
binding_validator()
|
|
1221
|
+
if _save_state_cas(
|
|
1222
|
+
state,
|
|
1223
|
+
revision,
|
|
1224
|
+
transaction=transaction,
|
|
1225
|
+
binding_validator=binding_validator,
|
|
1226
|
+
):
|
|
1227
|
+
if binding_validator is not None:
|
|
1228
|
+
binding_validator()
|
|
1229
|
+
return actual_replacement
|
|
1230
|
+
raise ValueError("concurrent ai-toolkit state updates prevented DSH state write")
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _write_state_locked(
|
|
1234
|
+
state: dict,
|
|
1235
|
+
*,
|
|
1236
|
+
prefix: str = ".state.",
|
|
1237
|
+
transaction: _StateWriterContext,
|
|
1238
|
+
) -> None:
|
|
1239
|
+
path = transaction.path
|
|
1240
|
+
_assert_state_writer_binding(transaction)
|
|
1241
|
+
_, revision = _load_state_revision_strict(
|
|
1242
|
+
secure=transaction.parent_descriptor is not None,
|
|
1243
|
+
transaction=transaction,
|
|
1244
|
+
)
|
|
1245
|
+
payload = json.dumps(state, indent=2) + "\n"
|
|
1246
|
+
descriptor, temporary, temporary_identity = _create_state_temporary(
|
|
1247
|
+
transaction,
|
|
1248
|
+
prefix=prefix,
|
|
1249
|
+
suffix=".tmp",
|
|
1250
|
+
)
|
|
1251
|
+
try:
|
|
1252
|
+
if callable(fchmod := getattr(os, "fchmod", None)):
|
|
1253
|
+
fchmod(descriptor, 0o600)
|
|
1254
|
+
else:
|
|
1255
|
+
os.chmod(temporary, 0o600)
|
|
1256
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
1257
|
+
stream.write(payload)
|
|
1258
|
+
stream.flush()
|
|
1259
|
+
os.fsync(stream.fileno())
|
|
1260
|
+
_assert_state_writer_binding(transaction)
|
|
1261
|
+
if transaction.parent_descriptor is not None:
|
|
1262
|
+
if not _secure_publish_state(
|
|
1263
|
+
temporary,
|
|
1264
|
+
path,
|
|
1265
|
+
revision,
|
|
1266
|
+
parent_descriptor=transaction.parent_descriptor,
|
|
1267
|
+
transaction=transaction,
|
|
1268
|
+
):
|
|
1269
|
+
raise OSError("concurrent ai-toolkit state change")
|
|
1270
|
+
else:
|
|
1271
|
+
os.replace(temporary, path)
|
|
1272
|
+
_assert_state_writer_binding(transaction)
|
|
1273
|
+
finally:
|
|
1274
|
+
if transaction.parent_descriptor is not None:
|
|
1275
|
+
_secure_cleanup_private_file(
|
|
1276
|
+
temporary,
|
|
1277
|
+
temporary_identity,
|
|
1278
|
+
parent_descriptor=transaction.parent_descriptor,
|
|
1279
|
+
)
|
|
1280
|
+
else:
|
|
1281
|
+
_portable_cleanup_private_file(temporary, temporary_identity)
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _mutate_state(mutator: Callable[[dict], None]) -> None:
|
|
1285
|
+
with _state_writer_lock() as transaction:
|
|
1286
|
+
state = _load_state_strict(
|
|
1287
|
+
secure=transaction.parent_descriptor is not None,
|
|
1288
|
+
transaction=transaction,
|
|
1289
|
+
)
|
|
1290
|
+
mutator(state)
|
|
1291
|
+
_write_state_locked(state, transaction=transaction)
|
|
1292
|
+
|
|
1293
|
+
|
|
81
1294
|
def save_state(state: dict) -> None:
|
|
82
1295
|
"""Save state to ~/.softspark/ai-toolkit/state.json.
|
|
83
1296
|
|
|
84
1297
|
Creates the parent directory if it does not exist.
|
|
85
1298
|
"""
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
1299
|
+
|
|
1300
|
+
def merge(current: dict) -> None:
|
|
1301
|
+
current.update(state)
|
|
1302
|
+
|
|
1303
|
+
_mutate_state(merge)
|
|
91
1304
|
|
|
92
1305
|
|
|
93
1306
|
def get_installed_modules() -> list[str]:
|
|
@@ -114,20 +1327,386 @@ def get_mcp_templates() -> list[str]:
|
|
|
114
1327
|
|
|
115
1328
|
def record_mcp_template(name: str) -> None:
|
|
116
1329
|
"""Add a template name to the tracked set in state.json."""
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
1330
|
+
|
|
1331
|
+
def update(state: dict) -> None:
|
|
1332
|
+
templates = set(state.get("mcp_templates", []))
|
|
1333
|
+
templates.add(name)
|
|
1334
|
+
state["mcp_templates"] = sorted(templates)
|
|
1335
|
+
|
|
1336
|
+
_mutate_state(update)
|
|
122
1337
|
|
|
123
1338
|
|
|
124
1339
|
def remove_mcp_template(name: str) -> None:
|
|
125
1340
|
"""Remove a template name from the tracked set in state.json."""
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
1341
|
+
|
|
1342
|
+
def update(state: dict) -> None:
|
|
1343
|
+
templates = set(state.get("mcp_templates", []))
|
|
1344
|
+
templates.discard(name)
|
|
1345
|
+
state["mcp_templates"] = sorted(templates)
|
|
1346
|
+
|
|
1347
|
+
_mutate_state(update)
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def get_dsh_profile(profile: str) -> dict | None:
|
|
1351
|
+
"""Return one DSH lifecycle record when its stored shape is valid."""
|
|
1352
|
+
state = _load_state_strict(secure=True)
|
|
1353
|
+
dsh = state.get("dsh")
|
|
1354
|
+
if dsh is None:
|
|
1355
|
+
return None
|
|
1356
|
+
profiles = _validate_dsh_profiles(dsh)
|
|
1357
|
+
record = profiles.get(profile)
|
|
1358
|
+
if record is None:
|
|
1359
|
+
return None
|
|
1360
|
+
return record
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def capture_dsh_profile_snapshot(
|
|
1364
|
+
profile: str,
|
|
1365
|
+
*,
|
|
1366
|
+
expected_profile: dict | None,
|
|
1367
|
+
binding_validator: Callable[[], None] | None = None,
|
|
1368
|
+
) -> DshStateSnapshot:
|
|
1369
|
+
"""Capture canonical DSH substate under the shared state writer lock."""
|
|
1370
|
+
with _state_writer_lock(secure=True) as transaction:
|
|
1371
|
+
if binding_validator is not None:
|
|
1372
|
+
binding_validator()
|
|
1373
|
+
path = transaction.path
|
|
1374
|
+
_assert_state_writer_binding(transaction)
|
|
1375
|
+
if not _state_name_exists(transaction, path.name):
|
|
1376
|
+
state: dict = {}
|
|
1377
|
+
current = _current_dsh_profile(state, profile)
|
|
1378
|
+
if current != expected_profile:
|
|
1379
|
+
raise ValueError("ai-toolkit DSH state changed before transaction")
|
|
1380
|
+
snapshot = DshStateSnapshot(
|
|
1381
|
+
path,
|
|
1382
|
+
False,
|
|
1383
|
+
b"",
|
|
1384
|
+
0o600,
|
|
1385
|
+
state,
|
|
1386
|
+
profile,
|
|
1387
|
+
current,
|
|
1388
|
+
transaction.parent_device,
|
|
1389
|
+
transaction.parent_inode,
|
|
1390
|
+
)
|
|
1391
|
+
if binding_validator is not None:
|
|
1392
|
+
binding_validator()
|
|
1393
|
+
return snapshot
|
|
1394
|
+
try:
|
|
1395
|
+
with _open_stable_state_file(
|
|
1396
|
+
path,
|
|
1397
|
+
secure=True,
|
|
1398
|
+
parent_descriptor=transaction.parent_descriptor,
|
|
1399
|
+
) as opened:
|
|
1400
|
+
state = _decode_state_document(opened.content)
|
|
1401
|
+
current = _current_dsh_profile(state, profile)
|
|
1402
|
+
if current != expected_profile:
|
|
1403
|
+
raise ValueError("ai-toolkit DSH state changed before transaction")
|
|
1404
|
+
snapshot = DshStateSnapshot(
|
|
1405
|
+
path,
|
|
1406
|
+
True,
|
|
1407
|
+
opened.content,
|
|
1408
|
+
stat.S_IMODE(opened.metadata.st_mode),
|
|
1409
|
+
state,
|
|
1410
|
+
profile,
|
|
1411
|
+
current,
|
|
1412
|
+
transaction.parent_device,
|
|
1413
|
+
transaction.parent_inode,
|
|
1414
|
+
)
|
|
1415
|
+
if binding_validator is not None:
|
|
1416
|
+
binding_validator()
|
|
1417
|
+
return snapshot
|
|
1418
|
+
except OSError as error:
|
|
1419
|
+
raise ValueError("malformed or unsafe ai-toolkit state file") from error
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def dsh_profile_matches_snapshot(snapshot: DshStateSnapshot) -> bool:
|
|
1423
|
+
"""Compare one DSH profile through the state root pinned by its snapshot."""
|
|
1424
|
+
expected_parent_identity = (
|
|
1425
|
+
snapshot.state_parent_device,
|
|
1426
|
+
snapshot.state_parent_inode,
|
|
1427
|
+
)
|
|
1428
|
+
if None in expected_parent_identity:
|
|
1429
|
+
raise ValueError("DSH state snapshot is missing its parent identity")
|
|
1430
|
+
with _state_writer_lock(
|
|
1431
|
+
secure=True,
|
|
1432
|
+
expected_path=snapshot.path,
|
|
1433
|
+
expected_parent_identity=expected_parent_identity,
|
|
1434
|
+
) as transaction:
|
|
1435
|
+
state = _load_state_strict(secure=True, transaction=transaction)
|
|
1436
|
+
return _current_dsh_profile(state, snapshot.profile) == snapshot.profile_record
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _secure_remove_state(
|
|
1440
|
+
path: Path,
|
|
1441
|
+
expected_revision: tuple[bool, int, int, str],
|
|
1442
|
+
*,
|
|
1443
|
+
transaction: _StateWriterContext,
|
|
1444
|
+
binding_validator: Callable[[], None] | None = None,
|
|
1445
|
+
) -> bool:
|
|
1446
|
+
_assert_state_writer_binding(transaction)
|
|
1447
|
+
if binding_validator is not None:
|
|
1448
|
+
binding_validator()
|
|
1449
|
+
recovery = path.parent / (
|
|
1450
|
+
f".state.dsh-remove-{os.getpid()}-{secrets.token_hex(12)}"
|
|
1451
|
+
)
|
|
1452
|
+
try:
|
|
1453
|
+
_state_rename_operation(
|
|
1454
|
+
path,
|
|
1455
|
+
recovery,
|
|
1456
|
+
exchange=False,
|
|
1457
|
+
source_parent_descriptor=transaction.parent_descriptor,
|
|
1458
|
+
destination_parent_descriptor=transaction.parent_descriptor,
|
|
1459
|
+
)
|
|
1460
|
+
except FileNotFoundError:
|
|
1461
|
+
return not expected_revision[0]
|
|
1462
|
+
try:
|
|
1463
|
+
if (
|
|
1464
|
+
_path_revision(
|
|
1465
|
+
recovery,
|
|
1466
|
+
parent_descriptor=transaction.parent_descriptor,
|
|
1467
|
+
)
|
|
1468
|
+
!= expected_revision
|
|
1469
|
+
):
|
|
1470
|
+
_state_rename_operation(
|
|
1471
|
+
recovery,
|
|
1472
|
+
path,
|
|
1473
|
+
exchange=False,
|
|
1474
|
+
source_parent_descriptor=transaction.parent_descriptor,
|
|
1475
|
+
destination_parent_descriptor=transaction.parent_descriptor,
|
|
1476
|
+
)
|
|
1477
|
+
return False
|
|
1478
|
+
if transaction.parent_descriptor is None:
|
|
1479
|
+
raise OSError("secure ai-toolkit state parent is unavailable")
|
|
1480
|
+
if binding_validator is not None:
|
|
1481
|
+
binding_validator()
|
|
1482
|
+
os.unlink(recovery.name, dir_fd=transaction.parent_descriptor)
|
|
1483
|
+
_assert_state_writer_binding(transaction)
|
|
1484
|
+
if binding_validator is not None:
|
|
1485
|
+
binding_validator()
|
|
1486
|
+
return True
|
|
1487
|
+
except (OSError, KeyboardInterrupt):
|
|
1488
|
+
if transaction.parent_descriptor is None:
|
|
1489
|
+
raise
|
|
1490
|
+
if _descriptor_name_exists(
|
|
1491
|
+
transaction.parent_descriptor,
|
|
1492
|
+
recovery.name,
|
|
1493
|
+
) and not _descriptor_name_exists(transaction.parent_descriptor, path.name):
|
|
1494
|
+
try:
|
|
1495
|
+
_state_rename_operation(
|
|
1496
|
+
recovery,
|
|
1497
|
+
path,
|
|
1498
|
+
exchange=False,
|
|
1499
|
+
source_parent_descriptor=transaction.parent_descriptor,
|
|
1500
|
+
destination_parent_descriptor=transaction.parent_descriptor,
|
|
1501
|
+
)
|
|
1502
|
+
except OSError:
|
|
1503
|
+
pass
|
|
1504
|
+
raise
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def restore_dsh_profile_snapshot(
|
|
1508
|
+
snapshot: DshStateSnapshot,
|
|
1509
|
+
*,
|
|
1510
|
+
expected_profile: dict | None,
|
|
1511
|
+
binding_validator: Callable[[], None] | None = None,
|
|
1512
|
+
) -> Path | None:
|
|
1513
|
+
"""Restore transaction-owned DSH substate without clobbering other writers."""
|
|
1514
|
+
try:
|
|
1515
|
+
restore_dsh_profile(
|
|
1516
|
+
snapshot.profile,
|
|
1517
|
+
expected_profile=expected_profile,
|
|
1518
|
+
previous_profile=snapshot.profile_record,
|
|
1519
|
+
binding_validator=binding_validator,
|
|
1520
|
+
state_snapshot=snapshot,
|
|
1521
|
+
)
|
|
1522
|
+
expected_parent_identity = (
|
|
1523
|
+
snapshot.state_parent_device,
|
|
1524
|
+
snapshot.state_parent_inode,
|
|
1525
|
+
)
|
|
1526
|
+
if None in expected_parent_identity:
|
|
1527
|
+
return snapshot.path
|
|
1528
|
+
with _state_writer_lock(
|
|
1529
|
+
secure=True,
|
|
1530
|
+
expected_path=snapshot.path,
|
|
1531
|
+
expected_parent_identity=expected_parent_identity,
|
|
1532
|
+
) as transaction:
|
|
1533
|
+
if binding_validator is not None:
|
|
1534
|
+
binding_validator()
|
|
1535
|
+
if not snapshot.existed:
|
|
1536
|
+
state, revision = _load_state_revision_strict(
|
|
1537
|
+
secure=True,
|
|
1538
|
+
transaction=transaction,
|
|
1539
|
+
)
|
|
1540
|
+
if (
|
|
1541
|
+
_current_dsh_profile(state, snapshot.profile)
|
|
1542
|
+
!= snapshot.profile_record
|
|
1543
|
+
):
|
|
1544
|
+
return snapshot.path
|
|
1545
|
+
if state != snapshot.document:
|
|
1546
|
+
return None
|
|
1547
|
+
if state or not revision[0]:
|
|
1548
|
+
return None
|
|
1549
|
+
if binding_validator is not None:
|
|
1550
|
+
binding_validator()
|
|
1551
|
+
return (
|
|
1552
|
+
None
|
|
1553
|
+
if _secure_remove_state(
|
|
1554
|
+
snapshot.path,
|
|
1555
|
+
revision,
|
|
1556
|
+
transaction=transaction,
|
|
1557
|
+
binding_validator=binding_validator,
|
|
1558
|
+
)
|
|
1559
|
+
else snapshot.path
|
|
1560
|
+
)
|
|
1561
|
+
with _open_stable_state_file(
|
|
1562
|
+
snapshot.path,
|
|
1563
|
+
secure=True,
|
|
1564
|
+
parent_descriptor=transaction.parent_descriptor,
|
|
1565
|
+
) as opened:
|
|
1566
|
+
state = _decode_state_document(opened.content)
|
|
1567
|
+
revision = _opened_state_revision(opened)
|
|
1568
|
+
if (
|
|
1569
|
+
_current_dsh_profile(state, snapshot.profile)
|
|
1570
|
+
!= snapshot.profile_record
|
|
1571
|
+
):
|
|
1572
|
+
return snapshot.path
|
|
1573
|
+
if state != snapshot.document:
|
|
1574
|
+
return None
|
|
1575
|
+
if opened.content == snapshot.content:
|
|
1576
|
+
if stat.S_IMODE(opened.metadata.st_mode) != snapshot.mode:
|
|
1577
|
+
if binding_validator is not None:
|
|
1578
|
+
binding_validator()
|
|
1579
|
+
os.fchmod(opened.descriptor, snapshot.mode)
|
|
1580
|
+
if binding_validator is not None:
|
|
1581
|
+
binding_validator()
|
|
1582
|
+
return (
|
|
1583
|
+
None
|
|
1584
|
+
if _verify_open_state_after_mode_change(
|
|
1585
|
+
opened,
|
|
1586
|
+
expected_mode=snapshot.mode,
|
|
1587
|
+
expected_digest=hashlib.sha256(
|
|
1588
|
+
snapshot.content
|
|
1589
|
+
).hexdigest(),
|
|
1590
|
+
)
|
|
1591
|
+
else snapshot.path
|
|
1592
|
+
)
|
|
1593
|
+
descriptor, temporary, temporary_identity = _create_state_temporary(
|
|
1594
|
+
transaction,
|
|
1595
|
+
prefix=".state.dsh-rollback-",
|
|
1596
|
+
suffix=".tmp",
|
|
1597
|
+
)
|
|
1598
|
+
try:
|
|
1599
|
+
os.fchmod(descriptor, snapshot.mode)
|
|
1600
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
1601
|
+
stream.write(snapshot.content)
|
|
1602
|
+
stream.flush()
|
|
1603
|
+
os.fsync(stream.fileno())
|
|
1604
|
+
if binding_validator is not None:
|
|
1605
|
+
binding_validator()
|
|
1606
|
+
return (
|
|
1607
|
+
None
|
|
1608
|
+
if _secure_publish_state(
|
|
1609
|
+
temporary,
|
|
1610
|
+
snapshot.path,
|
|
1611
|
+
revision,
|
|
1612
|
+
parent_descriptor=transaction.parent_descriptor,
|
|
1613
|
+
transaction=transaction,
|
|
1614
|
+
binding_validator=binding_validator,
|
|
1615
|
+
)
|
|
1616
|
+
else snapshot.path
|
|
1617
|
+
)
|
|
1618
|
+
finally:
|
|
1619
|
+
_secure_cleanup_private_file(
|
|
1620
|
+
temporary,
|
|
1621
|
+
temporary_identity,
|
|
1622
|
+
parent_descriptor=transaction.parent_descriptor,
|
|
1623
|
+
)
|
|
1624
|
+
except (OSError, ValueError, KeyboardInterrupt):
|
|
1625
|
+
return snapshot.path
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def record_dsh_profile(
|
|
1629
|
+
*,
|
|
1630
|
+
dsh_home: Path,
|
|
1631
|
+
profile: str,
|
|
1632
|
+
packages: dict[str, str],
|
|
1633
|
+
package_trees: dict[str, dict[str, object]],
|
|
1634
|
+
preset_path: Path,
|
|
1635
|
+
preset_hash: str,
|
|
1636
|
+
expected_profile: object = _DSH_EXPECTED_UNSET,
|
|
1637
|
+
updated_at: str | None = None,
|
|
1638
|
+
binding_validator: Callable[[], None] | None = None,
|
|
1639
|
+
state_snapshot: DshStateSnapshot | None = None,
|
|
1640
|
+
) -> dict:
|
|
1641
|
+
"""Record ownership of one successfully installed DSH profile surface."""
|
|
1642
|
+
now = updated_at or _now_iso()
|
|
1643
|
+
if isinstance(expected_profile, dict):
|
|
1644
|
+
previous = expected_profile
|
|
1645
|
+
else:
|
|
1646
|
+
previous = {}
|
|
1647
|
+
installed_at = (
|
|
1648
|
+
previous.get("installed_at", now) if isinstance(previous, dict) else now
|
|
1649
|
+
)
|
|
1650
|
+
record = {
|
|
1651
|
+
"dsh_home": str(dsh_home),
|
|
1652
|
+
"profile": profile,
|
|
1653
|
+
"packages": dict(sorted(packages.items())),
|
|
1654
|
+
"package_trees": {
|
|
1655
|
+
package: package_trees[package] for package in sorted(package_trees)
|
|
1656
|
+
},
|
|
1657
|
+
"preset_path": str(preset_path),
|
|
1658
|
+
"preset_hash": preset_hash,
|
|
1659
|
+
"owned": True,
|
|
1660
|
+
"installed_at": installed_at,
|
|
1661
|
+
"last_updated": now,
|
|
1662
|
+
}
|
|
1663
|
+
stored = _replace_dsh_profile_cas(
|
|
1664
|
+
profile,
|
|
1665
|
+
expected_profile=expected_profile,
|
|
1666
|
+
replacement=record,
|
|
1667
|
+
preserve_installed_at=expected_profile is _DSH_EXPECTED_UNSET,
|
|
1668
|
+
binding_validator=binding_validator,
|
|
1669
|
+
state_snapshot=state_snapshot,
|
|
1670
|
+
)
|
|
1671
|
+
if stored is None:
|
|
1672
|
+
raise ValueError("invalid DSH lifecycle state")
|
|
1673
|
+
return stored
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
def remove_dsh_profile(
|
|
1677
|
+
profile: str,
|
|
1678
|
+
*,
|
|
1679
|
+
expected_profile: object = _DSH_EXPECTED_UNSET,
|
|
1680
|
+
binding_validator: Callable[[], None] | None = None,
|
|
1681
|
+
state_snapshot: DshStateSnapshot | None = None,
|
|
1682
|
+
) -> None:
|
|
1683
|
+
"""Forget one DSH profile record while preserving unrelated state."""
|
|
1684
|
+
_replace_dsh_profile_cas(
|
|
1685
|
+
profile,
|
|
1686
|
+
expected_profile=expected_profile,
|
|
1687
|
+
replacement=None,
|
|
1688
|
+
binding_validator=binding_validator,
|
|
1689
|
+
state_snapshot=state_snapshot,
|
|
1690
|
+
)
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
def restore_dsh_profile(
|
|
1694
|
+
profile: str,
|
|
1695
|
+
*,
|
|
1696
|
+
expected_profile: dict | None,
|
|
1697
|
+
previous_profile: dict | None,
|
|
1698
|
+
binding_validator: Callable[[], None] | None = None,
|
|
1699
|
+
state_snapshot: DshStateSnapshot | None = None,
|
|
1700
|
+
) -> None:
|
|
1701
|
+
"""Rollback one transaction-owned DSH substate without replacing other keys."""
|
|
1702
|
+
_replace_dsh_profile_cas(
|
|
1703
|
+
profile,
|
|
1704
|
+
expected_profile=expected_profile,
|
|
1705
|
+
replacement=previous_profile,
|
|
1706
|
+
allow_already_replaced=True,
|
|
1707
|
+
binding_validator=binding_validator,
|
|
1708
|
+
state_snapshot=state_snapshot,
|
|
1709
|
+
)
|
|
131
1710
|
|
|
132
1711
|
|
|
133
1712
|
# Default global install: Claude only — no other editors unless --editors is used
|
|
@@ -165,9 +1744,11 @@ def get_global_editors() -> list[str]:
|
|
|
165
1744
|
|
|
166
1745
|
def record_global_editors(editors: list[str]) -> None:
|
|
167
1746
|
"""Record which editors are installed globally in state.json."""
|
|
168
|
-
|
|
169
|
-
state
|
|
170
|
-
|
|
1747
|
+
|
|
1748
|
+
def update(state: dict) -> None:
|
|
1749
|
+
state["global_editors"] = sorted(set(editors))
|
|
1750
|
+
|
|
1751
|
+
_mutate_state(update)
|
|
171
1752
|
|
|
172
1753
|
|
|
173
1754
|
def _now_iso() -> str:
|
|
@@ -190,32 +1771,31 @@ def record_install(
|
|
|
190
1771
|
``extends_info`` (optional) records config inheritance metadata:
|
|
191
1772
|
source, version, resolved_at, hash, overrides_applied.
|
|
192
1773
|
"""
|
|
193
|
-
state = load_state()
|
|
194
1774
|
now = _now_iso()
|
|
195
1775
|
|
|
196
|
-
state
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
1776
|
+
def update(state: dict) -> None:
|
|
1777
|
+
state["installed_version"] = version
|
|
1778
|
+
if "installed_at" not in state:
|
|
1779
|
+
state["installed_at"] = now
|
|
1780
|
+
state["last_updated"] = now
|
|
1781
|
+
state["installed_modules"] = sorted(set(modules))
|
|
1782
|
+
state["profile"] = profile
|
|
1783
|
+
if auto_detected is not None:
|
|
1784
|
+
state["auto_detected_languages"] = sorted(auto_detected)
|
|
1785
|
+
else:
|
|
1786
|
+
state.pop("auto_detected_languages", None)
|
|
206
1787
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
del state["extends"]
|
|
1788
|
+
if extends_info is not None:
|
|
1789
|
+
state["extends"] = {
|
|
1790
|
+
"source": extends_info.get("source", ""),
|
|
1791
|
+
"configs": extends_info.get("configs", []),
|
|
1792
|
+
"resolved_at": now,
|
|
1793
|
+
"overrides_applied": extends_info.get("overrides_applied", []),
|
|
1794
|
+
}
|
|
1795
|
+
else:
|
|
1796
|
+
state.pop("extends", None)
|
|
217
1797
|
|
|
218
|
-
|
|
1798
|
+
_mutate_state(update)
|
|
219
1799
|
|
|
220
1800
|
# Clear version check cache (version may have changed)
|
|
221
1801
|
cache_file = _state_path().parent / "version-check.json"
|
|
@@ -269,7 +1849,9 @@ def print_status() -> None:
|
|
|
269
1849
|
stamp = f" ({fetched_at})" if fetched_at else ""
|
|
270
1850
|
print(f" rule {name} <- {origin}{tag}{stamp}")
|
|
271
1851
|
for name in rule_orphans:
|
|
272
|
-
print(
|
|
1852
|
+
print(
|
|
1853
|
+
f" rule {name} <- (orphan, no source recorded — re-run add-rule)"
|
|
1854
|
+
)
|
|
273
1855
|
for name, origin, fetched_at, kind in ext_hooks:
|
|
274
1856
|
tag = " [local]" if kind == "local" else ""
|
|
275
1857
|
stamp = f" ({fetched_at})" if fetched_at else ""
|
|
@@ -280,20 +1862,26 @@ def print_status() -> None:
|
|
|
280
1862
|
print(f" Extends: {extends.get('source', 'unknown')}")
|
|
281
1863
|
for cfg in extends.get("configs", []):
|
|
282
1864
|
version_str = f" v{cfg['version']}" if cfg.get("version") else ""
|
|
283
|
-
print(
|
|
1865
|
+
print(
|
|
1866
|
+
f" → {cfg.get('name', cfg.get('source', '?'))}{version_str}"
|
|
1867
|
+
)
|
|
284
1868
|
if extends.get("resolved_at"):
|
|
285
1869
|
print(f" Resolved: {extends['resolved_at']}")
|
|
286
1870
|
|
|
287
1871
|
# Check for updates
|
|
288
1872
|
try:
|
|
289
1873
|
import sys as _sys
|
|
1874
|
+
|
|
290
1875
|
_sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
291
1876
|
from version_check import check
|
|
1877
|
+
|
|
292
1878
|
result = check(force=True)
|
|
293
1879
|
if result["update_available"]:
|
|
294
1880
|
print()
|
|
295
1881
|
print(f" Update available: {result['installed']} -> {result['latest']}")
|
|
296
|
-
print(
|
|
1882
|
+
print(
|
|
1883
|
+
" Run: npm install -g @softspark/ai-toolkit@latest && ai-toolkit update"
|
|
1884
|
+
)
|
|
297
1885
|
else:
|
|
298
1886
|
print(f" Latest: {result['latest']} (up to date)")
|
|
299
1887
|
except Exception:
|