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.
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/bin/ccx +56 -0
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +40 -4
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- 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()
|