@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +44 -18
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/ARCHITECTURE.md +2 -2
  5. package/app/mcp-templates/README.md +7 -2
  6. package/app/mcp-templates/rag-mcp-legal.json +11 -0
  7. package/app/mcp-templates/rag-mcp.json +11 -0
  8. package/app/surface.json +1 -0
  9. package/benchmarks/ecosystem-doctor-snapshot.json +29 -17
  10. package/bin/ai-toolkit.js +8 -0
  11. package/kb/history/completed/dsh-integration-plan-superseded.md +322 -0
  12. package/kb/history/completed/dsh-native-install-target-plan.md +331 -0
  13. package/kb/procedures/ecosystem-sync-sop.md +7 -5
  14. package/kb/procedures/maintenance-sop.md +1 -1
  15. package/kb/procedures/release-verification-sop.md +35 -5
  16. package/kb/reference/architecture-overview.md +24 -5
  17. package/kb/reference/cli-reference.md +1 -1
  18. package/kb/reference/dsh-compatibility.md +183 -0
  19. package/kb/reference/manifest-install.md +112 -5
  20. package/kb/reference/mcp-templates.md +11 -4
  21. package/kb/reference/plugin-pack-conventions.md +35 -18
  22. package/kb/reference/supported-tools-registry.md +30 -6
  23. package/llms-full.txt +1110 -50
  24. package/llms.txt +3 -0
  25. package/manifest.json +2 -2
  26. package/package.json +2 -2
  27. package/scripts/codex_skill_adapter.py +673 -34
  28. package/scripts/config_resolver.py +80 -14
  29. package/scripts/doctor.py +98 -20
  30. package/scripts/ecosystem_tools.json +51 -1
  31. package/scripts/generate_codex_skills.py +22 -20
  32. package/scripts/install.py +30 -13
  33. package/scripts/install_steps/ai_tools.py +97 -33
  34. package/scripts/install_steps/dsh.py +5063 -0
  35. package/scripts/install_steps/install_state.py +1645 -57
  36. package/scripts/mcp_editors.py +5 -2
  37. package/scripts/plugin.py +2495 -163
  38. package/scripts/plugin_mcp.py +279 -0
  39. package/scripts/plugin_rules.py +389 -0
  40. package/scripts/plugin_schema.py +139 -23
  41. package/scripts/uninstall.py +47 -4
  42. 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 TOOLKIT_DATA_DIR, STATE_FILE, RULES_DIR, EXTERNAL_HOOKS_DIR
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
- with open(path, encoding="utf-8") as f:
73
- data = json.load(f)
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
- path = _state_path()
87
- path.parent.mkdir(parents=True, exist_ok=True)
88
- with open(path, "w", encoding="utf-8") as f:
89
- json.dump(state, f, indent=2)
90
- f.write("\n")
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
- state = load_state()
118
- templates = set(state.get("mcp_templates", []))
119
- templates.add(name)
120
- state["mcp_templates"] = sorted(templates)
121
- save_state(state)
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
- state = load_state()
127
- templates = set(state.get("mcp_templates", []))
128
- templates.discard(name)
129
- state["mcp_templates"] = sorted(templates)
130
- save_state(state)
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
- state = load_state()
169
- state["global_editors"] = sorted(set(editors))
170
- save_state(state)
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["installed_version"] = version
197
- if "installed_at" not in state:
198
- state["installed_at"] = now
199
- state["last_updated"] = now
200
- state["installed_modules"] = sorted(set(modules))
201
- state["profile"] = profile
202
- if auto_detected is not None:
203
- state["auto_detected_languages"] = sorted(auto_detected)
204
- else:
205
- state.pop("auto_detected_languages", None)
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
- if extends_info is not None:
208
- state["extends"] = {
209
- "source": extends_info.get("source", ""),
210
- "configs": extends_info.get("configs", []),
211
- "resolved_at": now,
212
- "overrides_applied": extends_info.get("overrides_applied", []),
213
- }
214
- elif "extends" in state:
215
- # Clear extends if no longer using it
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
- save_state(state)
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(f" rule {name} <- (orphan, no source recorded — re-run add-rule)")
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(f" → {cfg.get('name', cfg.get('source', '?'))}{version_str}")
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(f" Run: npm install -g @softspark/ai-toolkit@latest && ai-toolkit update")
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: