ccx-native 0.0.0-stage → 0.1.0

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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -2
  3. package/bin/ccx +56 -0
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +40 -4
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,804 @@
1
+ from __future__ import annotations
2
+ import errno
3
+ import fcntl
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import stat
9
+ import time
10
+ from contextlib import ExitStack, contextmanager
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Callable, Iterator, Mapping
14
+ from uuid import uuid4
15
+ from ccx.system import oslayer
16
+ from ccx.store.identity import (
17
+ OwnerConflict,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from ccx.store.store import GenerationStore
22
+
23
+
24
+ def canonical_root(value: Path | str) -> Path:
25
+ """Absolute canonical root spelling without following a symlink at its leaf.
26
+
27
+ The descriptor-pinned generation walk owns the actual trust decision. This
28
+ function only gives every caller (including offline migrations) one stable
29
+ spelling, even for symlinked HOME and macOS /tmp ancestors.
30
+ """
31
+ text = os.path.abspath(os.path.expanduser(os.fspath(value)))
32
+ parent, leaf = os.path.split(os.path.normpath(text))
33
+ return Path(os.path.realpath(parent), leaf)
34
+
35
+
36
+ _GENERATION = re.compile(r"g-[0-9a-f]{32}\Z")
37
+
38
+
39
+ _HASH = re.compile(r"[0-9a-f]{64}\Z")
40
+
41
+
42
+ _FAMILIES = frozenset({"claude-delivery", "codex-send", "answer",
43
+ "cancel", "codex-compact", "compact", "steer"})
44
+
45
+
46
+ _NATIVE_WRITES = frozenset({"send", "resume", "start", "compact", "steer", "answer", "cancel"})
47
+
48
+
49
+ _POINTER_LAYOUT = 2
50
+
51
+
52
+ _OWNER_SCHEMA = 2
53
+
54
+
55
+ _CLAIM_SCHEMA = 2
56
+
57
+
58
+ _RECEIPT_SCHEMA = 2
59
+
60
+
61
+ _FILE_LIMIT = 4 * 1024 * 1024
62
+
63
+
64
+ _RUNTIME_SEAL_LIMIT = 64 * 1024 # never parse the full migration plan each request
65
+
66
+
67
+ _DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
68
+
69
+
70
+ _FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC
71
+
72
+
73
+ class GenerationError(ValueError):
74
+ """Unsafe, malformed or inaccessible full-UUID generation."""
75
+
76
+
77
+ class _StrictFileError(ValueError):
78
+ """Unsafe or changing descriptor-pinned strict file."""
79
+
80
+
81
+ class GenerationUnavailable(GenerationError):
82
+ """Absent, non-ACTIVE or otherwise unusable generation pointer."""
83
+
84
+
85
+ class StaleGeneration(GenerationError):
86
+ """Pinned generation/root changed before an operation's effect."""
87
+
88
+
89
+ class GenerationBusy(GenerationError):
90
+ """Stable lock unavailable within a bounded non-blocking attempt."""
91
+
92
+
93
+ class StorageIntegrityError(GenerationError):
94
+ """Owned record/manifest/receipt shape or disk identity disagrees."""
95
+
96
+
97
+ class RecordConflict(GenerationError):
98
+ """Fresh record version/field precondition failed under per-UUID CAS."""
99
+
100
+
101
+ class CreationConflict(OwnerConflict):
102
+ """Caller reused a full UUID for a different immutable specification."""
103
+
104
+
105
+ class CreationIndeterminate(GenerationError):
106
+ """Existing reservation/claim lacks proof for safe initial retry."""
107
+
108
+
109
+ class NativeActionDenied(GenerationError):
110
+ """Unbound or unresolved worker cannot invoke a native operation."""
111
+
112
+
113
+ class NativeEffectIndeterminate(GenerationError):
114
+ """Prior native submission may have reached provider; do not replay."""
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class GenerationPointer:
119
+ root: Path
120
+ generation: str
121
+ manifest_sha256: str
122
+ phase: str
123
+ layout: int = _POINTER_LAYOUT
124
+
125
+ @property
126
+ def generation_root(self) -> Path:
127
+ return self.root / "identity" / "generations" / self.generation
128
+
129
+
130
+ def _root_text(root: Path | str) -> Path:
131
+ text = os.path.expanduser(os.fspath(root))
132
+ if not os.path.isabs(text):
133
+ raise GenerationError("explicit CCX root must be absolute")
134
+ return canonical_root(text)
135
+
136
+
137
+ @contextmanager
138
+ def _root_dir_fd(path: Path):
139
+ """Pin every explicit root component, preserving errors from with-body.
140
+
141
+ Symlink/open errors in the *root traversal* are wrapped in _StrictFileError.
142
+ Errors raised by the caller after yield retain their own domain/errno.
143
+ """
144
+ # Translate only errors from opening ancestors, not with-body OSErrors.
145
+ with ExitStack() as stack:
146
+ try:
147
+ absolute, fd = stack.enter_context(oslayer.open_root_dir(path))
148
+ except oslayer.RootOpenError as exc:
149
+ raise _StrictFileError(str(exc)) from exc.__cause__
150
+ yield absolute, fd
151
+
152
+
153
+ def _read_bounded_at(parent_fd: int, name: str, max_bytes: int) -> tuple[bytes, str]:
154
+ """Read a regular child relative to pinned parent fd, no-follow at both ends."""
155
+ try:
156
+ before = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
157
+ if not stat.S_ISREG(before.st_mode):
158
+ raise _StrictFileError("not a regular inventory file")
159
+ if before.st_size > max_bytes:
160
+ raise _StrictFileError("file exceeds read-only inventory byte limit")
161
+ fd = os.open(name, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW, dir_fd=parent_fd)
162
+ try:
163
+ inside = os.fstat(fd)
164
+ fields = lambda st: (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns)
165
+ if not stat.S_ISREG(inside.st_mode) or fields(before) != fields(inside):
166
+ raise _StrictFileError("source file replaced during scan")
167
+ parts: list[bytes] = []
168
+ remaining = max_bytes + 1
169
+ while remaining:
170
+ block = os.read(fd, min(65536, remaining))
171
+ if not block:
172
+ break
173
+ parts.append(block)
174
+ remaining -= len(block)
175
+ data = b"".join(parts)
176
+ if len(data) > max_bytes:
177
+ raise _StrictFileError("file grew beyond inventory byte limit")
178
+ after_fd = os.fstat(fd)
179
+ finally:
180
+ os.close(fd)
181
+ after = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
182
+ if fields(before) != fields(inside) or fields(inside) != fields(after_fd) or fields(after_fd) != fields(after):
183
+ raise _StrictFileError("source file modified during scan")
184
+ return data, hashlib.sha256(data).hexdigest()
185
+ except OSError as exc:
186
+ raise _StrictFileError("source file unavailable without following links") from exc
187
+
188
+
189
+ @contextmanager
190
+ def _open_child_dir(parent_fd: int, part: str) -> Iterator[int]:
191
+ """One nofollow, owner-private child directory relative to a pinned fd."""
192
+ if not part or part in (".", "..") or "/" in part or "\x00" in part:
193
+ raise StorageIntegrityError("unsafe generation path component")
194
+ fd = os.open(part, _DIR_FLAGS, dir_fd=parent_fd)
195
+ try:
196
+ st = os.fstat(fd)
197
+ if st.st_uid != os.getuid() or st.st_mode & 0o077:
198
+ raise StorageIntegrityError("generation directory must be owner-private")
199
+ yield fd
200
+ finally:
201
+ os.close(fd)
202
+
203
+
204
+ @contextmanager
205
+ def _open_dir(root: Path, *parts: str, create: bool = False) -> Iterator[int]:
206
+ """OS-owned file descriptor walk; each component is nofollow."""
207
+ for part in parts:
208
+ if not part or part in (".", "..") or "/" in part or "\x00" in part:
209
+ raise StorageIntegrityError("unsafe generation path component")
210
+ with _root_dir_fd(root) as (_pinned_root, root_fd):
211
+ root_st = os.fstat(root_fd)
212
+ if root_st.st_uid != os.getuid() or root_st.st_mode & 0o077:
213
+ raise StorageIntegrityError("CCX generation root must be owned by UID and private")
214
+ fds: list[int] = []
215
+ fd = root_fd
216
+ try:
217
+ for part in parts:
218
+ if create:
219
+ try:
220
+ os.mkdir(part, 0o700, dir_fd=fd)
221
+ os.fsync(fd)
222
+ except FileExistsError:
223
+ pass
224
+ next_fd = os.open(part, _DIR_FLAGS, dir_fd=fd)
225
+ st = os.fstat(next_fd)
226
+ if st.st_uid != os.getuid() or st.st_mode & 0o077:
227
+ os.close(next_fd)
228
+ raise StorageIntegrityError("generation directory must be owner-private")
229
+ fds.append(next_fd)
230
+ fd = next_fd
231
+ yield fd
232
+ finally:
233
+ for held in reversed(fds):
234
+ os.close(held)
235
+
236
+
237
+ def _read_json(fd: int, name: str, *, max_bytes: int = _FILE_LIMIT) -> dict | None:
238
+ """Read one consistent live-store inode, even during atomic replacement.
239
+
240
+ The strict `_read_bounded_at` deliberately rejects a
241
+ *pathname* changed mid-scan. Runtime writers instead fsync a new inode and
242
+ atomically replace that pathname: an already-open old inode is a valid
243
+ immutable snapshot. Verify only that same open fd remains internally
244
+ consistent. Sealed-manifest reads also require the pathname to stay fixed.
245
+ """
246
+ try:
247
+ # O_NONBLOCK prevents a substituted FIFO from blocking *before* fstat
248
+ # can reject its nonregular file type. Never follow a symlink.
249
+ child = os.open(name, _FILE_FLAGS | os.O_NONBLOCK, dir_fd=fd)
250
+ except FileNotFoundError:
251
+ return None
252
+ except OSError as exc:
253
+ # Keep the established security diagnostic for an O_NOFOLLOW symlink
254
+ # or a changed non-directory path component. Never follow either.
255
+ if exc.errno in (errno.ELOOP, errno.ENOTDIR):
256
+ raise StorageIntegrityError(
257
+ "owned JSON path must be owner-private regular file"
258
+ ) from exc
259
+ raise StorageIntegrityError("unreadable or unsafe owned JSON") from exc
260
+ try:
261
+ try:
262
+ before = os.fstat(child)
263
+ if (not stat.S_ISREG(before.st_mode) or before.st_uid != os.getuid()
264
+ or before.st_mode & 0o077):
265
+ raise StorageIntegrityError("owned JSON path must be owner-private regular file")
266
+ if before.st_size < 0 or before.st_size > max_bytes:
267
+ raise StorageIntegrityError("owned JSON exceeds bounded read size")
268
+ parts: list[bytes] = []
269
+ remaining = max_bytes + 1
270
+ while remaining:
271
+ block = os.read(child, min(65536, remaining))
272
+ if not block:
273
+ break
274
+ parts.append(block)
275
+ remaining -= len(block)
276
+ raw = b"".join(parts)
277
+ after = os.fstat(child)
278
+ except OSError as exc:
279
+ raise StorageIntegrityError("unreadable or changing owned JSON") from exc
280
+ # ctime and st_nlink can change when os.replace unlinks the OLD
281
+ # still-open inode, without modifying its data. Do not compare them.
282
+ fields = lambda st: (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns,
283
+ st.st_uid, st.st_mode)
284
+ if (fields(before) != fields(after) or len(raw) != before.st_size
285
+ or len(raw) > max_bytes):
286
+ raise StorageIntegrityError("owned JSON inode changed during read")
287
+ finally:
288
+ try:
289
+ os.close(child)
290
+ except OSError as exc:
291
+ raise StorageIntegrityError("could not close owned JSON descriptor") from exc
292
+ try:
293
+ val = json.loads(raw)
294
+ except (UnicodeError, ValueError) as exc:
295
+ raise StorageIntegrityError("malformed JSON in owned store") from exc
296
+ if not isinstance(val, dict):
297
+ raise StorageIntegrityError("owned store JSON must be an object")
298
+ return val
299
+
300
+
301
+ def _write_json(
302
+ fd: int, name: str, value: Mapping, *, rendered_transcript: bool = False,
303
+ ) -> None:
304
+ """Fsync new 0600 inode, same-directory atomic replace and directory fsync.
305
+
306
+ A cleaned Claude transcript is the one legacy JSON exception: its public
307
+ schema retains indented turns and may exceed the generic 4 MiB metadata
308
+ limit. Keep this explicit filename-scoped producer mode; all other store
309
+ writes retain their original byte format and strict metadata bound. The
310
+ runtime sidecar reader remains independently bounded to 4 MiB.
311
+ """
312
+ if not name or "/" in name or name in (".", ".."):
313
+ raise StorageIntegrityError("unsafe owned output basename")
314
+ if rendered_transcript:
315
+ if name != "transcript.json":
316
+ raise StorageIntegrityError("pretty unlimited JSON only allowed for owned transcript")
317
+ # The previous renderer used json.dumps(..., indent=2)'s default
318
+ # ensure_ascii=True. Preserve its surrogate-safe escaping and Unicode
319
+ # roundtrip; only this rendered-transcript branch changes, not the
320
+ # generic compact metadata writer below.
321
+ raw = json.dumps(value, indent=2, ensure_ascii=True, allow_nan=False).encode("utf-8")
322
+ else:
323
+ raw = (json.dumps(value, sort_keys=True, separators=(",", ":"),
324
+ ensure_ascii=False, allow_nan=False) + "\n").encode("utf-8")
325
+ if len(raw) > _FILE_LIMIT:
326
+ raise StorageIntegrityError("owned JSON exceeds bounded writer size")
327
+ name_tmp = ".new-" + uuid4().hex
328
+ output_fd = os.open(
329
+ name_tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
330
+ 0o600, dir_fd=fd,
331
+ )
332
+ try:
333
+ with os.fdopen(output_fd, "wb") as out:
334
+ out.write(raw)
335
+ out.flush()
336
+ os.fsync(out.fileno())
337
+ # No symlink/unknown owner should be overwritten. Existing targets must
338
+ # already be regular owner-private files on same pinned filesystem.
339
+ try:
340
+ prior = os.stat(name, dir_fd=fd, follow_symlinks=False)
341
+ except FileNotFoundError:
342
+ pass
343
+ else:
344
+ if (not stat.S_ISREG(prior.st_mode) or prior.st_uid != os.getuid()
345
+ or prior.st_mode & 0o077):
346
+ raise StorageIntegrityError("refuse replacement of unsafe owned JSON target")
347
+ os.replace(name_tmp, name, src_dir_fd=fd, dst_dir_fd=fd)
348
+ os.fsync(fd)
349
+ finally:
350
+ try:
351
+ os.unlink(name_tmp, dir_fd=fd)
352
+ except FileNotFoundError:
353
+ pass
354
+
355
+
356
+ @contextmanager
357
+ def _flock_at(fd: int, name: str, *, exclusive: bool, create: bool = False,
358
+ timeout: float = 0.5) -> Iterator[None]:
359
+ if timeout < 0 or timeout > 3:
360
+ raise GenerationError("lock window must be bounded (<= 3 seconds)")
361
+ flags = (_FILE_FLAGS | (os.O_RDWR | os.O_CREAT if create else 0))
362
+ if create:
363
+ flags &= ~os.O_RDONLY
364
+ # On Monterey, concurrent openat(O_CREAT|O_NOFOLLOW) against the same
365
+ # pinned directory can transiently return ENOENT while another process
366
+ # creates the very same lock. This was reproduced on the test Mac with
367
+ # unchanged parent st_dev/st_ino and 3 of 4 opens succeeding. Retry only
368
+ # CREATING a lock, on that one errno, for a short bounded interval; a
369
+ # missing PREEXISTING lock must still fail closed immediately.
370
+ for attempt in range(6):
371
+ try:
372
+ lock_fd = os.open(name, flags, 0o600, dir_fd=fd)
373
+ break
374
+ except FileNotFoundError as exc:
375
+ if not create:
376
+ raise GenerationUnavailable("required preinstalled stable lock is missing") from exc
377
+ if attempt == 5:
378
+ raise GenerationUnavailable(
379
+ "stable lock creation failed on pinned owner directory"
380
+ ) from exc
381
+ time.sleep(.005)
382
+ try:
383
+ st = os.fstat(lock_fd)
384
+ if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & 0o077:
385
+ raise StorageIntegrityError("activation/creation lock inode is not private regular file")
386
+ deadline = time.monotonic() + timeout
387
+ op = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
388
+ while True:
389
+ try:
390
+ fcntl.flock(lock_fd, op | fcntl.LOCK_NB)
391
+ break
392
+ except BlockingIOError:
393
+ if time.monotonic() >= deadline:
394
+ raise GenerationBusy("bounded generation or owner lock unavailable")
395
+ time.sleep(min(.01, max(0, deadline - time.monotonic())))
396
+ current = os.stat(name, dir_fd=fd, follow_symlinks=False)
397
+ if (current.st_dev, current.st_ino) != (st.st_dev, st.st_ino):
398
+ raise StorageIntegrityError("stable activation/creation lock inode was replaced")
399
+ try:
400
+ yield
401
+ finally:
402
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
403
+ finally:
404
+ os.close(lock_fd)
405
+
406
+
407
+ @dataclass(frozen=True)
408
+ class RootStatus:
409
+ """One read-only classification of a canonical CCX root."""
410
+ root: Path
411
+ kind: str # missing | empty | initialized | unsafe
412
+ pointer: GenerationPointer | None = None
413
+ reason: str | None = None
414
+
415
+ @property
416
+ def generation(self) -> str | None:
417
+ return self.pointer.generation if self.pointer else None
418
+
419
+ @property
420
+ def phase(self) -> str | None:
421
+ return self.pointer.phase if self.pointer else None
422
+
423
+
424
+ _ROOT_REASONS = {
425
+ "symlink-leaf": "root is a symlink; select a real, owner-private directory (do not follow the link)",
426
+ "dangling-link": "root is a dangling symlink; replace it with a real, owner-private directory",
427
+ "not-dir": "root is not a directory; choose a directory path",
428
+ "foreign-owner": "root has a foreign owner; select a directory owned by this UID",
429
+ "perms": "root must be owner-private (mode 0700); correct its permissions offline",
430
+ "unreadable": "root or ancestor cannot be safely read; check directory traversal permissions and path components",
431
+ "pointer-invalid": "generation pointer is missing, malformed or unsafe; inspect identity/current.json offline",
432
+ "seal-mismatch": "sealed generation manifest is missing, unsafe or does not match its SHA seal; inspect offline",
433
+ "nonempty-uninitialized": "root is nonempty without a valid generation pointer; inspect partial state offline, do not reinitialize",
434
+ }
435
+
436
+
437
+ def root_status_message(status: RootStatus) -> str:
438
+ """One actionable wording table, independent of CLI/API error wrappers."""
439
+ if status.kind in ("missing", "empty"):
440
+ return (f"no CCX store at {status.root}; run any ccx command to create it, "
441
+ "or ccx init")
442
+ if status.kind == "unsafe":
443
+ if status.reason and status.reason.startswith("spelling-alias:"):
444
+ spelling = status.reason.removeprefix("spelling-alias:")
445
+ return (f"CCX root {status.root} was initialized as {spelling}; "
446
+ "select it with that exact spelling")
447
+ if status.reason == "perms":
448
+ import shlex
449
+ return (f"CCX root {status.root}: {_ROOT_REASONS['perms']}; "
450
+ f"if you own it: chmod 700 {shlex.quote(str(status.root))}")
451
+ return f"CCX root {status.root}: {_ROOT_REASONS[status.reason]}"
452
+ raise ValueError("initialized root has no unavailability message")
453
+
454
+
455
+ def _unsafe_root(root: Path, reason: str) -> RootStatus:
456
+ return RootStatus(root, "unsafe", reason=reason)
457
+
458
+
459
+ def root_status(root: Path | str) -> RootStatus:
460
+ """Inspect one root once: descriptor-pinned, no-follow, read-only and sealed.
461
+
462
+ Never infer a safe root from Path.exists() or an exception's English text.
463
+ Symlink leaves (including dangling links) are always refused; checking
464
+ whether their targets exist would follow an untrusted path, so both use
465
+ symlink-leaf rather than claiming to prove a dangling target.
466
+ """
467
+ base = _root_text(root)
468
+ try:
469
+ # Pin the canonical parent before lstat/openat of the untrusted LEAF.
470
+ # Ancestor symlinks were normalized by canonical_root, not the leaf.
471
+ with oslayer.open_root_dir(base.parent) as (_parent, parent_fd):
472
+ name = base.name or "."
473
+ try:
474
+ before = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
475
+ except FileNotFoundError:
476
+ return RootStatus(base, "missing")
477
+ if stat.S_ISLNK(before.st_mode):
478
+ return _unsafe_root(base, "symlink-leaf")
479
+ if not stat.S_ISDIR(before.st_mode):
480
+ return _unsafe_root(base, "not-dir")
481
+ if before.st_uid != os.getuid():
482
+ return _unsafe_root(base, "foreign-owner")
483
+ if before.st_mode & 0o077:
484
+ return _unsafe_root(base, "perms")
485
+ try:
486
+ root_fd = os.open(name, _DIR_FLAGS, dir_fd=parent_fd)
487
+ except OSError:
488
+ return _unsafe_root(base, "unreadable")
489
+ try:
490
+ pinned = os.fstat(root_fd)
491
+ if ((pinned.st_dev, pinned.st_ino) != (before.st_dev, before.st_ino)
492
+ or pinned.st_uid != os.getuid() or pinned.st_mode & 0o077):
493
+ return _unsafe_root(base, "unreadable")
494
+ try:
495
+ with _open_child_dir(root_fd, "identity") as identity:
496
+ try:
497
+ obj = _read_json(identity, "current.json")
498
+ except (StorageIntegrityError, OSError, ValueError):
499
+ return _unsafe_root(base, "pointer-invalid")
500
+ if obj is None:
501
+ return _unsafe_root(base, "nonempty-uninitialized")
502
+ if (not isinstance(obj, dict) or obj.get("layout") != _POINTER_LAYOUT
503
+ or obj.get("phase") != "ACTIVE"
504
+ or not isinstance(obj.get("rootRealpath"), str)):
505
+ return _unsafe_root(base, "pointer-invalid")
506
+ original_spelling = obj["rootRealpath"]
507
+ if original_spelling != str(base):
508
+ # Diagnostic-only inode test: refuse the alias even
509
+ # when both paths name the same pinned directory.
510
+ try:
511
+ alias_stat = os.stat(original_spelling)
512
+ except OSError:
513
+ return _unsafe_root(base, "pointer-invalid")
514
+ if (alias_stat.st_dev, alias_stat.st_ino) != (
515
+ pinned.st_dev, pinned.st_ino
516
+ ):
517
+ return _unsafe_root(base, "pointer-invalid")
518
+ generation = obj.get("generation")
519
+ checksum = obj.get("manifestSha256")
520
+ if (not isinstance(generation, str) or not _GENERATION.fullmatch(generation)
521
+ or not isinstance(checksum, str) or not _HASH.fullmatch(checksum)):
522
+ return _unsafe_root(base, "pointer-invalid")
523
+ try:
524
+ with _open_child_dir(identity, "generations") as generations:
525
+ with _open_child_dir(generations, generation) as gfd:
526
+ raw, observed = _read_bounded_at(gfd, "manifest.json", _RUNTIME_SEAL_LIMIT)
527
+ except (OSError, StorageIntegrityError, ValueError):
528
+ return _unsafe_root(base, "seal-mismatch")
529
+ except FileNotFoundError:
530
+ # Scandir only for roots without an identity directory;
531
+ # keep the hot initialized path to bounded pointer + seal.
532
+ try:
533
+ with os.scandir(root_fd) as entries:
534
+ is_empty = not any(True for _ in entries)
535
+ except OSError:
536
+ return _unsafe_root(base, "unreadable")
537
+ return RootStatus(base, "empty") if is_empty else _unsafe_root(base, "nonempty-uninitialized")
538
+ except (OSError, StorageIntegrityError, ValueError):
539
+ return _unsafe_root(base, "pointer-invalid")
540
+ if observed != checksum:
541
+ return _unsafe_root(base, "seal-mismatch")
542
+ try:
543
+ manifest = json.loads(raw)
544
+ except (UnicodeError, ValueError):
545
+ return _unsafe_root(base, "seal-mismatch")
546
+ if (not isinstance(manifest, dict) or manifest.get("schemaVersion") != 1
547
+ or manifest.get("generation") != generation
548
+ or not isinstance(manifest.get("rootRealpath"), str)
549
+ or manifest.get("sealed") is not True):
550
+ return _unsafe_root(base, "seal-mismatch")
551
+ if manifest["rootRealpath"] != original_spelling:
552
+ return _unsafe_root(base, "seal-mismatch")
553
+ if original_spelling != str(base):
554
+ return _unsafe_root(base, "spelling-alias:" + original_spelling)
555
+ return RootStatus(base, "initialized", GenerationPointer(
556
+ base, generation, checksum, obj["phase"]
557
+ ))
558
+ finally:
559
+ os.close(root_fd)
560
+ except oslayer.RootOpenError as exc:
561
+ # oslayer deliberately preserves the original errno as __cause__.
562
+ cause = exc.__cause__
563
+ if isinstance(cause, OSError) and cause.errno == errno.ENOENT:
564
+ return RootStatus(base, "missing")
565
+ return _unsafe_root(base, "unreadable")
566
+ except OSError:
567
+ return _unsafe_root(base, "unreadable")
568
+
569
+
570
+ def inspect_generation(root: Path | str) -> GenerationPointer:
571
+ """Read-only pointer and sealed manifest check."""
572
+ status = root_status(root)
573
+ if status.pointer is None:
574
+ message = root_status_message(status)
575
+ # Preserve the existing integrity-exception family for an insecure
576
+ # root owner/mode, while sharing exactly the same diagnosis text.
577
+ if status.reason in ("perms", "foreign-owner"):
578
+ raise StorageIntegrityError(message)
579
+ raise GenerationUnavailable(message)
580
+ return status.pointer
581
+
582
+
583
+ def require_ccx_active(root: Path | str, *, expected_generation: str | None = None) -> "GenerationStore":
584
+ """Validate and pin the active store without incidental writes."""
585
+ pointer = inspect_generation(root)
586
+ if expected_generation is not None and pointer.generation != expected_generation:
587
+ raise StaleGeneration("expected generation no longer active")
588
+ # Crucially never ensure_dirs or create activation.lock here. The
589
+ # constructor performs the same check even if called directly.
590
+ from ccx.store.store import GenerationStore
591
+ return GenerationStore(pointer)
592
+
593
+
594
+ def create_missing_root_leaf(root: Path | str) -> tuple[int, int]:
595
+ """Create only a missing root leaf using a pinned parent; never mkdir -p.
596
+
597
+ Return the newly created inode identity for initialize_empty_root's initial
598
+ descriptor check. Existing roots, links and replacement races fail closed.
599
+ """
600
+ base = _root_text(root)
601
+ try:
602
+ with oslayer.open_root_dir(base.parent) as (_parent, parent_fd):
603
+ try:
604
+ os.mkdir(base.name, 0o700, dir_fd=parent_fd)
605
+ except FileNotFoundError:
606
+ raise GenerationUnavailable(f"parent directory of {base} does not exist") from None
607
+ except FileExistsError:
608
+ raise GenerationUnavailable(root_status_message(_unsafe_root(base, "unreadable"))) from None
609
+ os.fsync(parent_fd)
610
+ fd = os.open(base.name, _DIR_FLAGS, dir_fd=parent_fd)
611
+ try:
612
+ st = os.fstat(fd)
613
+ named = os.stat(base.name, dir_fd=parent_fd, follow_symlinks=False)
614
+ if (not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid()
615
+ or st.st_mode & 0o077
616
+ or (st.st_dev, st.st_ino) != (named.st_dev, named.st_ino)):
617
+ raise GenerationUnavailable(root_status_message(_unsafe_root(base, "unreadable")))
618
+ return st.st_dev, st.st_ino
619
+ finally:
620
+ os.close(fd)
621
+ except oslayer.RootOpenError as exc:
622
+ if isinstance(exc.__cause__, OSError) and exc.__cause__.errno == errno.ENOENT:
623
+ raise GenerationUnavailable(f"parent directory of {base} does not exist") from None
624
+ raise GenerationUnavailable(root_status_message(_unsafe_root(base, "unreadable"))) from exc
625
+
626
+
627
+ def initialize_empty_root(root: Path | str, *,
628
+ generation: str | None = None,
629
+ _failpoint: Callable[[str], None] | None = None,
630
+ _expected_inode: tuple[int, int] | None = None) -> GenerationStore:
631
+ """Explicit fresh install on an EXISTING EMPTY 0700 owned directory only.
632
+
633
+ No implicit legacy CCX initialization, no provider calls, no migration.
634
+ A process crash leaves a protected immutable marker/partial tree:
635
+ a second initializer must NOT repair or adopt it automatically.
636
+ Everything is written relative to the ONE held root inode, avoiding a
637
+ re-opening by path after the initial empty-root eligibility check.
638
+ """
639
+ base = _root_text(root)
640
+ generation = "g-" + uuid4().hex if generation is None else generation
641
+ if not _GENERATION.fullmatch(generation):
642
+ raise GenerationError("invalid fresh-install generation ID")
643
+ try:
644
+ with _root_dir_fd(base) as (_canonical, root_fd):
645
+ st = os.fstat(root_fd)
646
+ if _expected_inode is not None and (st.st_dev, st.st_ino) != _expected_inode:
647
+ raise GenerationUnavailable(root_status_message(_unsafe_root(base, "unreadable")))
648
+ if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & 0o077:
649
+ raise GenerationUnavailable("new installation root must exist, be owner-private 0700")
650
+ with os.scandir(root_fd) as entries:
651
+ if any(True for _ in entries):
652
+ raise GenerationUnavailable(root_status_message(_unsafe_root(base, "nonempty-uninitialized")))
653
+ try:
654
+ marker_fd = os.open(
655
+ ".ccx-init.lock",
656
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
657
+ 0o600, dir_fd=root_fd,
658
+ )
659
+ except FileExistsError as exc:
660
+ raise GenerationUnavailable("another fresh initialization already claimed root") from exc
661
+ try:
662
+ os.fsync(marker_fd)
663
+ finally:
664
+ os.close(marker_fd)
665
+ os.fsync(root_fd)
666
+ if _failpoint:
667
+ _failpoint("after-init-marker")
668
+ os.mkdir("identity", 0o700, dir_fd=root_fd)
669
+ os.fsync(root_fd)
670
+ identity = os.open("identity", _DIR_FLAGS, dir_fd=root_fd)
671
+ try:
672
+ with os.fdopen(os.open(
673
+ "activation.lock",
674
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
675
+ 0o600, dir_fd=identity,
676
+ ), "wb") as lock:
677
+ lock.flush()
678
+ os.fsync(lock.fileno())
679
+ os.fsync(identity)
680
+ if _failpoint:
681
+ _failpoint("after-activation-lock")
682
+ os.mkdir("generations", 0o700, dir_fd=identity)
683
+ os.fsync(identity)
684
+ gens_fd = os.open("generations", _DIR_FLAGS, dir_fd=identity)
685
+ try:
686
+ os.mkdir(generation, 0o700, dir_fd=gens_fd)
687
+ os.fsync(gens_fd)
688
+ gen_fd = os.open(generation, _DIR_FLAGS, dir_fd=gens_fd)
689
+ try:
690
+ _write_json(gen_fd, "manifest.json", {
691
+ "schemaVersion": 1,
692
+ "generation": generation,
693
+ "rootRealpath": str(base),
694
+ "sealed": True,
695
+ "newEmptyInstall": True,
696
+ })
697
+ _raw, seal_sha = _read_bounded_at(
698
+ gen_fd, "manifest.json", _RUNTIME_SEAL_LIMIT,
699
+ )
700
+ finally:
701
+ os.close(gen_fd)
702
+ finally:
703
+ os.close(gens_fd)
704
+ if _failpoint:
705
+ _failpoint("after-sealed-generation")
706
+ _write_json(identity, "current.json", {
707
+ "layout": 2,
708
+ "generation": generation,
709
+ "manifestSha256": seal_sha,
710
+ "phase": "ACTIVE",
711
+ "rootRealpath": str(base),
712
+ })
713
+ finally:
714
+ os.close(identity)
715
+ except _StrictFileError as exc:
716
+ raise GenerationUnavailable("explicit empty installation root has an unsafe symlink or component") from exc
717
+ return require_ccx_active(base)
718
+
719
+
720
+ def open_or_initialize(root: Path | str) -> tuple["GenerationStore", bool]:
721
+ """Open a sealed store, initializing only a missing or owner-private empty root.
722
+
723
+ A concurrent initializer may own the exclusive init marker. Observe its
724
+ result for at most five seconds; never repair or replace partial state.
725
+ Daemons/hosts use require_ccx_active instead of this user-facing entry gate.
726
+ """
727
+ base = _root_text(root)
728
+
729
+ def claimed() -> bool:
730
+ # The marker is only evidence to *wait*, never evidence of a valid
731
+ # generation. An ACTIVE pointer still has to pass require_ccx_active.
732
+ try:
733
+ with _root_dir_fd(base) as (_path, fd):
734
+ marker = os.stat(".ccx-init.lock", dir_fd=fd, follow_symlinks=False)
735
+ return (stat.S_ISREG(marker.st_mode) and marker.st_uid == os.getuid()
736
+ and marker.st_mode & 0o077 == 0)
737
+ except (OSError, GenerationError, _StrictFileError):
738
+ return False
739
+
740
+ def join() -> tuple["GenerationStore", bool]:
741
+ deadline = time.monotonic() + 5.0
742
+ while True:
743
+ current = root_status(base)
744
+ if current.kind == "initialized":
745
+ return require_ccx_active(base), False
746
+ # Only a known transient init marker or the short mkdir/claim
747
+ # gap can precede a winning initializer. No unsafe root is
748
+ # reinitialized, modified or adopted without its sealed pointer.
749
+ waiting = (current.kind == "empty"
750
+ or (current.kind == "unsafe"
751
+ and current.reason in {"nonempty-uninitialized", "pointer-invalid"}
752
+ and claimed()))
753
+ if not waiting or time.monotonic() >= deadline:
754
+ raise GenerationUnavailable(root_status_message(current))
755
+ time.sleep(min(.025, max(0., deadline - time.monotonic())))
756
+
757
+ status = root_status(base)
758
+ if status.kind == "initialized":
759
+ return require_ccx_active(base), False
760
+ if status.kind == "unsafe":
761
+ if (status.reason in {"nonempty-uninitialized", "pointer-invalid"}
762
+ and claimed()):
763
+ return join()
764
+ message = root_status_message(status)
765
+ if status.reason in ("perms", "foreign-owner"):
766
+ raise StorageIntegrityError(message)
767
+ raise GenerationUnavailable(message)
768
+
769
+ inode = None
770
+ if status.kind == "missing":
771
+ try:
772
+ inode = create_missing_root_leaf(base)
773
+ except GenerationUnavailable:
774
+ # Another initializer may have won mkdir between status and our
775
+ # attempt. Do not wait for a genuinely missing parent or unsafe
776
+ # replacement.
777
+ current = root_status(base)
778
+ if (current.kind == "initialized" or current.kind == "empty"
779
+ or (current.kind == "unsafe"
780
+ and current.reason in {"nonempty-uninitialized", "pointer-invalid"}
781
+ and claimed())):
782
+ return join()
783
+ raise
784
+ try:
785
+ return initialize_empty_root(base, _expected_inode=inode), True
786
+ except GenerationUnavailable:
787
+ current = root_status(base)
788
+ if not (current.kind == "initialized" or current.kind == "empty"
789
+ or (current.kind == "unsafe"
790
+ and current.reason in {"nonempty-uninitialized", "pointer-invalid"}
791
+ and claimed())):
792
+ raise
793
+ if inode is not None:
794
+ # Another process can see the leaf we just mkdir'd and claim its
795
+ # exclusive init marker first. Join ONLY that same original inode,
796
+ # never a replaced root (even if the replacement has a good seal).
797
+ try:
798
+ with _root_dir_fd(base) as (_path, fd):
799
+ st = os.fstat(fd)
800
+ if (st.st_dev, st.st_ino) != inode:
801
+ raise GenerationUnavailable("new installation root inode changed")
802
+ except _StrictFileError as exc:
803
+ raise GenerationUnavailable("new installation root inode changed") from exc
804
+ return join()