devlyn-cli 4.3.21 → 4.3.24

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 (51) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +1 -1
  4. package/bin/devlyn.js +26 -1
  5. package/bin/harness-activation.js +11 -13
  6. package/bin/instruction-templates.json +26 -0
  7. package/bin/instructions.js +1 -1
  8. package/bin/skill-history.json +15 -1
  9. package/config/skills/_shared/bring-in.py +810 -0
  10. package/config/skills/_shared/harness-store.py +18 -6
  11. package/config/skills/_shared/runtime-principles.md +2 -2
  12. package/config/skills/_shared/storage-checkout.py +117 -0
  13. package/config/skills/_shared/storage-client.py +1180 -0
  14. package/config/skills/_shared/task-complete.py +900 -88
  15. package/config/skills/_shared/task-completion.md +78 -6
  16. package/config/skills/_shared/warm-build-dirs.py +53 -3
  17. package/config/skills/devlyn-ideate/SKILL.md +2 -2
  18. package/config/skills/devlyn-ideate/references/loop.md +4 -4
  19. package/config/skills/devlyn-ideate/scripts/queue.py +70 -19
  20. package/config/storage-client-contract.json +7 -0
  21. package/harness/AGENTS.md +2 -2
  22. package/harness/CLAUDE.md +2 -2
  23. package/native/claude/.claude-plugin/plugin.json +1 -1
  24. package/native/claude/skills/_shared/bring-in.py +810 -0
  25. package/native/claude/skills/_shared/harness-store.py +18 -6
  26. package/native/claude/skills/_shared/runtime-principles.md +2 -2
  27. package/native/claude/skills/_shared/storage-checkout.py +117 -0
  28. package/native/claude/skills/_shared/storage-client.py +1180 -0
  29. package/native/claude/skills/_shared/task-complete.py +900 -88
  30. package/native/claude/skills/_shared/task-completion.md +78 -6
  31. package/native/claude/skills/_shared/warm-build-dirs.py +53 -3
  32. package/native/claude/skills/devlyn-ideate/SKILL.md +2 -2
  33. package/native/claude/skills/devlyn-ideate/references/loop.md +4 -4
  34. package/native/claude/skills/devlyn-ideate/scripts/queue.py +70 -19
  35. package/native/codex/.agents/plugins/marketplace.json +1 -1
  36. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  37. package/native/codex/plugins/devlyn/skills/_shared/bring-in.py +810 -0
  38. package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +18 -6
  39. package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +2 -2
  40. package/native/codex/plugins/devlyn/skills/_shared/storage-checkout.py +117 -0
  41. package/native/codex/plugins/devlyn/skills/_shared/storage-client.py +1180 -0
  42. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +900 -88
  43. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +78 -6
  44. package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +53 -3
  45. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +2 -2
  46. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +4 -4
  47. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +70 -19
  48. package/optional-skills/better-auth-setup/SKILL.md +9 -3
  49. package/optional-skills/better-auth-setup/references/proxy-setup.md +10 -3
  50. package/package.json +1 -1
  51. package/release.json +93 -43
@@ -0,0 +1,1180 @@
1
+ #!/usr/bin/env python3
2
+ """Authenticated storage protocol 1. No registry access or allocation side effects.
3
+
4
+ Source of record: devlyn-os/storage-reservations/crates/devlynd/src/:
5
+ storage/rpc.rs:21-119 (wire), 798-835 (busy), 870-933 (authentication/errors);
6
+ storage/model.rs:9-350 (all records and decimal fields);
7
+ control_service/protocol.rs:71-74 (limits), unix.rs:19-27 (endpoint);
8
+ control_service/windows.rs:91-106 (pipe digest);
9
+ tool_channel/peer.rs:188-249 (peer credentials);
10
+ supervisor.rs:253-271,3753-3886,3933-3957 (process identities);
11
+ util/windows.rs:65-112,308-326,876-901,977-1000 (SID/canonical path).
12
+
13
+ Connections carry one request. Hello is negotiated on a separate authenticated
14
+ connection; the operation connection must have that same server incarnation.
15
+ No transport error authorizes downgrade, a new operation ID, or an automatic
16
+ retry. Only an explicit busy reply permits bounded replay of identical bytes.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import ctypes
22
+ import errno
23
+ import hashlib
24
+ import json
25
+ import math
26
+ import os
27
+ from pathlib import Path
28
+ import re
29
+ import selectors
30
+ import socket
31
+ import stat
32
+ import struct
33
+ import sys
34
+ import threading
35
+ import time
36
+ import uuid
37
+
38
+ MAX_REQUEST = 128 * 1024
39
+ MAX_REPLY = 4 * 1024 * 1024
40
+ EXCHANGE_SECONDS = 15.0
41
+ BUSY_SECONDS = 2.0
42
+ MANAGED_PENDING = "managed_pending"
43
+ MANAGED_OWNED = "managed_owned"
44
+ MANAGED_STATES = frozenset({MANAGED_PENDING, MANAGED_OWNED})
45
+
46
+ # Quoted sentences are exactly plan §4. Rows without a quoted sentence use
47
+ # conservative receipt-based reconciliation instructions, never raw diagnostics.
48
+ REFUSAL_TEXT = {
49
+ "storage_insufficient": "Not enough disk space to complete this operation safely. Free up space and retry.",
50
+ "allocation_size_unknown": "Devlyn can't check the space needed to create a separate workspace. You can work in an existing project folder instead.",
51
+ "storage_io": "Couldn't verify available disk space. Retry the operation.",
52
+ "storage_unavailable": "Devlyn storage is unavailable. Open Devlyn and retry using this receipt.",
53
+ "storage_unsupported": "Update Devlyn and devlyn-cli, then resume using this receipt.",
54
+ "storage_exhausted": "Finish outstanding work, then retry using this receipt. If this continues, repair Devlyn.",
55
+ "storage_expired": "This attempt needs reconciliation; retain the workspace and resume from its receipt.",
56
+ "storage_busy": "Devlyn storage is busy. Retry using this receipt.",
57
+ "storage_in_use": "This cache is still in use. Stop the processes using this cache. Then resume cleanup.",
58
+ "storage_protected": "Ownership or identity changed. Retain the workspace and inspect using this receipt.",
59
+ "storage_missing": "Ownership or identity changed. Retain the workspace and inspect using this receipt.",
60
+ "storage_conflict": "Ownership or identity changed. Retain the workspace and inspect using this receipt.",
61
+ "storage_invalid": "The storage request needs repair. Retain the workspace and inspect using this receipt.",
62
+ "storage_not_found": "This attempt needs reconciliation; retain the workspace and resume from its receipt.",
63
+ }
64
+
65
+
66
+ class StorageRefusal(Exception):
67
+ """Safe prose in str(); untrusted details only in diagnostic."""
68
+
69
+ def __init__(self, code, diagnostic="", *, operation=None, phase=None, message=None, resume=None):
70
+ self.code = "storage_busy" if code == "busy" else code
71
+ if self.code not in REFUSAL_TEXT:
72
+ diagnostic = f"unknown refusal {self.code!r}: {diagnostic}"
73
+ self.code = "storage_unavailable"
74
+ self.diagnostic = diagnostic
75
+ self.operation = operation
76
+ self.phase = phase
77
+ self.resume = resume
78
+ self.retryable = self.code == "storage_busy"
79
+ super().__init__(message or REFUSAL_TEXT[self.code])
80
+
81
+ def result(self, *, receipt=None, resume=None):
82
+ # Callers construct resume from their saved receipt and verified helper;
83
+ # no path, shell command or instruction from the server is interpolated.
84
+ return {"code": self.code, "message": str(self), "diagnostic": self.diagnostic,
85
+ "operation": self.operation, "phase": self.phase,
86
+ "retryable": self.retryable, "receipt": receipt, "resume": resume or self.resume}
87
+
88
+
89
+ def require(condition, diagnostic, code="storage_unavailable"):
90
+ if not condition:
91
+ raise StorageRefusal(code, diagnostic)
92
+
93
+
94
+ def _pairs(pairs):
95
+ value = {}
96
+ for key, item in pairs:
97
+ require(key not in value, f"duplicate JSON key {key!r}")
98
+ value[key] = item
99
+ return value
100
+
101
+
102
+ def _constant(value):
103
+ raise StorageRefusal("storage_unavailable", f"nonfinite JSON constant {value}")
104
+
105
+
106
+ def loads(data):
107
+ try:
108
+ value = json.loads(data.decode("utf-8", "strict"), object_pairs_hook=_pairs,
109
+ parse_constant=_constant)
110
+ _json_values(value)
111
+ return value
112
+ except (UnicodeError, ValueError, RecursionError) as exc:
113
+ raise StorageRefusal("storage_unavailable", f"invalid JSON: {exc}") from exc
114
+
115
+
116
+ def _json_values(value):
117
+ if isinstance(value, str):
118
+ value.encode("utf-8", "strict") # Reject escaped unpaired surrogates too.
119
+ elif isinstance(value, float):
120
+ require(math.isfinite(value), "overflowing JSON float")
121
+ elif isinstance(value, dict):
122
+ for key, item in value.items():
123
+ require(isinstance(key, str), "JSON key must be text")
124
+ _json_values(key)
125
+ _json_values(item)
126
+ elif isinstance(value, list):
127
+ for item in value:
128
+ _json_values(item)
129
+
130
+
131
+ # Declarative required shapes are deliberately separate from policy. Replies
132
+ # allow additive fields; requests reject misspelled/unknown fields before I/O.
133
+ # '?' marks serde Option/default fields. Decimal is ONLY used at Rust #[with].
134
+ SHAPES = {
135
+ "identity": {"volume": "str", "file": "str"},
136
+ "stamp": {"pid": "i64", "start": "str"},
137
+ "owner": {"repository": "identity", "allocation_id": "str", "receipt": "str"},
138
+ "creator": {"product": "str", "protocol_version": "u32", "operation_id": "str"},
139
+ "declaration": {"creator": "creator", "owner": "owner", "parent": "str", "entry": "str"},
140
+ "location": {"parent": "str", "parent_identity": "identity", "entry": "str",
141
+ "directory_identity?": "identity?", "volume_id": "str", "pool_id?": "str?"},
142
+ "cache": {"schema_version": "u32", "cache_id": "str", "kind": ("cargo_build_cache",),
143
+ "disposable": "bool", "creator": "creator", "owner": "owner", "location": "location",
144
+ "created_at": "str", "last_release_at?": "str?", "coverage": ("unproven", "managed"),
145
+ "state": ("preparing", "active", "retired", "removing", "evicting", "partial", "deleted", "protected"),
146
+ "reason?": "str?", "protection?": (None, "missing", "io"), "supervisor": "stamp",
147
+ "removal_generation?": "u64", "removal_id?": "str?", "retire_intent": "bool",
148
+ "retirement_authorized_by?": "stamp?"},
149
+ "lease": {"lease_id": "str", "cache_id": "str", "operation_id": "str", "supervisor": "stamp",
150
+ "consumers": ["consumer"], "acquired_at": "str", "leaf?": "bool",
151
+ "descendants_reconciled": "bool", "released_at?": "str?"},
152
+ "snapshot": {"cache": "cache", "leases": ["lease"]},
153
+ "removal_entry": {"cache_id": "str", "generation": "u64"},
154
+ "removal": {"removal_id": "str", "operation_id": "str", "supervisor": "stamp",
155
+ "entries": ["removal_entry"], "finished_at?": "str?"},
156
+ "write": {"parent": "str", "relative": "str", "phase": "str", "bytes": "decimal"},
157
+ "charge": {"id": "str", "bytes": "decimal"},
158
+ "destination": {"parent_identity": "identity", "existing_ancestors": ["identity"],
159
+ "volume_id": "str", "pool_id": "str"},
160
+ "worker": {"operation_id": "str", "consumer": "consumer", "containment": "containment",
161
+ "descendants_reconciled": "bool"},
162
+ "reservation": {"reservation_id": "str", "operation_id": "str", "generation": "decimal",
163
+ "boot_id?": "str?", "owner": "owner", "holder": "stamp", "plan": "plan",
164
+ "destinations": ["destination"], "pools": ["charge"], "volumes": ["charge"],
165
+ "workers": ["worker"], "granted_at": "str", "released_at?": "str?"},
166
+ "hello": {"storage_protocol": "u32", "capabilities": ["str"], "schema": "u32"},
167
+ "containment": ("unix_group", "unix_checkout", "windows_job", "supervised"),
168
+ }
169
+ REQUESTS = {
170
+ "hello": ({}, "hello"),
171
+ "reserve": ({"operation_id": "str", "owner": "owner", "plan": "plan"}, "reservation"),
172
+ "enroll_reservation_worker": ({"operation_id": "str", "reservation_id": "str", "generation": "decimal",
173
+ "consumer": "consumer", "containment": "containment"}, "reservation"),
174
+ "release_reservation": ({"reservation_id": "str", "generation": "decimal", "descendants_reconciled": "bool"}, "reservation"),
175
+ "record_reservation_completion": ({"reservation_id": "str", "generation": "decimal"}, "reservation"),
176
+ "query_reservation": ({"operation_id": "str"}, "reservation"),
177
+ "locate": ({"parent": "str", "entry": "str"}, "cache"),
178
+ "begin_removal": ({"operation_id": "str", "cache_ids": ["str"]}, "removal"),
179
+ "finish_removal": ({"removal_id": "str"}, "removal"),
180
+ "declare": ({"declaration": "declaration"}, "cache"),
181
+ "bind": ({"cache_id": "str", "consumers": ["consumer"]}, "published"),
182
+ "bind_publication": ({"cache_id": "str"}, "published"),
183
+ "lease": ({"cache_id": "str", "operation_id": "str", "consumers": ["consumer"], "leaf?": "bool"}, "published"),
184
+ "release": ({"lease_id": "str", "descendants_reconciled": "bool"}, "released"),
185
+ "retire_intent": ({"cache_id": "str"}, "cache"),
186
+ "query": ({"cache_id": "str"}, "snapshot"),
187
+ }
188
+ SHAPES["published"] = {"cache": "cache", "lease": "lease"}
189
+ SHAPES["released"] = SHAPES["lease"]
190
+ SHAPES["parent_evidence"] = {"identity": "identity", "mount": "str"}
191
+ SHAPES["declaration"]["parent_evidence?"] = "parent_evidence?"
192
+ SHAPES["location"]["parent_evidence?"] = "parent_evidence?"
193
+ SHAPES["warning"] = {"code": ("exclusion_failed", "cargo_ineligible", "cargo_unverifiable", "no_cargo_manifest"),
194
+ "diagnostic": "str"}
195
+ SHAPES["cache"]["warnings?"] = ["warning"]
196
+ SHAPES["outcome"] = {"attempt": "str", "resolves?": "str?", "project": "str", "taskId": "str",
197
+ "code?": (None, "storage_insufficient", "allocation_size_unknown", "storage_io",
198
+ "storage_unavailable", "storage_unsupported", "storage_exhausted", "storage_protected"),
199
+ "retryable": "bool"}
200
+ SHAPES["storage_refused"] = {"generation": "i64", "revision": "str", "outcome": "outcome"}
201
+ SHAPES["allocation_outcome"] = "storage_refused?"
202
+ REQUESTS.update({"allocation_outcome": ({}, "allocation_outcome"),
203
+ "report_allocation_outcome": ({"outcome": "outcome"}, "reported")})
204
+
205
+
206
+ def validate(value, shape, *, exact=False, windows=None):
207
+ if windows is None:
208
+ windows = os.name == "nt"
209
+ if isinstance(shape, dict):
210
+ require(type(value) is dict, "expected JSON object")
211
+ for key, child in shape.items():
212
+ name = key.removesuffix("?")
213
+ if name not in value and key.endswith("?"):
214
+ continue
215
+ require(name in value, f"missing field {name}")
216
+ validate(value[name], child, exact=exact, windows=windows)
217
+ if exact:
218
+ require(not value.keys() - {k.removesuffix('?') for k in shape}, "unknown request field")
219
+ elif isinstance(shape, list):
220
+ require(type(value) is list, "expected JSON array")
221
+ for item in value:
222
+ validate(item, shape[0], exact=exact, windows=windows)
223
+ elif isinstance(shape, tuple):
224
+ require(value in shape and (value is None or type(value) is str), "invalid enum")
225
+ elif shape.endswith("?"):
226
+ if value is not None:
227
+ validate(value, shape[:-1], exact=exact, windows=windows)
228
+ elif shape == "str":
229
+ require(type(value) is str, "expected string")
230
+ elif shape == "bool":
231
+ require(type(value) is bool, "expected boolean")
232
+ elif shape in {"u32", "u64", "i64"}:
233
+ lo, hi = {"u32": (0, 2**32-1), "u64": (0, 2**64-1), "i64": (-2**63, 2**63-1)}[shape]
234
+ require(type(value) is int and lo <= value <= hi, f"expected {shape} integer")
235
+ elif shape == "decimal":
236
+ require(type(value) is str and re.fullmatch(r"0|[1-9][0-9]{0,19}", value) is not None
237
+ and int(value) <= 2**64-1, "expected canonical decimal u64 string")
238
+ elif shape == "consumer":
239
+ fields = {"pid": "i64", "created": "u64"} if windows else {"pid": "i64", "pgid": "i64", "start?": "str?"}
240
+ validate(value, fields, exact=exact, windows=windows)
241
+ elif shape == "plan":
242
+ require(type(value) is dict, "expected write plan")
243
+ if value.get("kind") == "known":
244
+ fields = {"kind": ("known",), "writes": ["write"], "digest": "str"}
245
+ else:
246
+ fields = {"kind": ("unknown",), "reason": ("external_filter", "lfs_extension", "encoding", "attributes",
247
+ "lfs_transfer", "missing_object", "plan_changed", "unsupported_git", "overflow")}
248
+ validate(value, fields, exact=exact, windows=windows)
249
+ else:
250
+ validate(value, SHAPES[shape], exact=exact, windows=windows)
251
+
252
+
253
+ def encode_request(request):
254
+ try:
255
+ require(type(request) is dict and request.get("op") in REQUESTS, "unsupported request operation", "storage_invalid")
256
+ fields, _ = REQUESTS[request["op"]]
257
+ validate(request, {"op": (request["op"],), **fields}, exact=True)
258
+ _json_values(request)
259
+ data = json.dumps({"storage_protocol": 1, "request": request}, ensure_ascii=False,
260
+ allow_nan=False, separators=(",", ":")).encode("utf-8")
261
+ require(len(data) <= MAX_REQUEST, "request exceeds 128 KiB", "storage_invalid")
262
+ return data + b"\n"
263
+ except (ValueError, TypeError, UnicodeError, RecursionError) as exc:
264
+ raise StorageRefusal("storage_invalid", str(exc)) from exc
265
+
266
+
267
+ def decode_reply(frame, expected):
268
+ require(frame.endswith(b"\n") and b"\n" not in frame[:-1], "truncated or multiple reply frames")
269
+ require(len(frame) <= MAX_REPLY, "reply exceeds 4 MiB including LF")
270
+ response = loads(frame[:-1])
271
+ require(type(response) is dict and ("ok" in response) != ("error" in response), "invalid response envelope")
272
+ if "error" in response:
273
+ validate(response["error"], {"code": "str", "message": "str"})
274
+ raise StorageRefusal(response["error"]["code"], response["error"]["message"])
275
+ reply = response["ok"]
276
+ require(type(reply) is dict and reply.get("kind") == expected, "unexpected reply kind")
277
+ if expected == "reported":
278
+ return None
279
+ require("value" in reply, "missing reply value")
280
+ validate(reply["value"], expected)
281
+ return reply["value"]
282
+
283
+
284
+ def remaining(deadline):
285
+ left = deadline - time.monotonic()
286
+ require(left > 0, "storage exchange deadline exceeded")
287
+ return left
288
+
289
+
290
+ def unix_start(pid):
291
+ require(type(pid) is int and 0 < pid < 2**31, "invalid peer pid")
292
+ if sys.platform.startswith("linux"):
293
+ fields = Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split()
294
+ require(fields[0] not in {"Z", "X"}, "peer exited")
295
+ ticks = int(fields[19])
296
+ boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
297
+ require(re.fullmatch(r"[0-9a-f-]{36}", boot) is not None and ticks >= 0, "invalid Linux process identity")
298
+ return f"linux:{boot}:{ticks}"
299
+ require(sys.platform == "darwin" and struct.calcsize("P") == 8, "unsupported process identity", "storage_unsupported")
300
+ # Same size/offsets as supervisor.rs:3810-3886; no ps/libproc approximation.
301
+ libc = ctypes.CDLL(None, use_errno=True)
302
+ libc.sysctl.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_uint, ctypes.c_void_p,
303
+ ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p, ctypes.c_size_t]
304
+ libc.sysctl.restype = ctypes.c_int
305
+ mib = (ctypes.c_int * 4)(1, 14, 1, pid)
306
+ record = ctypes.create_string_buffer(648)
307
+ size = ctypes.c_size_t(648)
308
+ require(libc.sysctl(mib, 4, record, ctypes.byref(size), None, 0) == 0,
309
+ f"sysctl failed: errno {ctypes.get_errno()}")
310
+ require(size.value == 648, f"unexpected kinfo_proc size {size.value}")
311
+ raw = record.raw
312
+ seconds, micros = struct.unpack_from("=qi", raw)
313
+ require(struct.unpack_from("=i", raw, 40)[0] == pid and raw[36] != 5
314
+ and seconds >= 0 and 0 <= micros < 1_000_000, "invalid macOS process identity")
315
+ return f"macos:{seconds}:{micros}"
316
+
317
+
318
+ def unix_peer(stream):
319
+ if sys.platform.startswith("linux"):
320
+ raw = stream.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("=iII"))
321
+ pid, uid, _ = struct.unpack("=iII", raw)
322
+ else:
323
+ require(sys.platform == "darwin", "unsupported peer credentials", "storage_unsupported")
324
+ libc = ctypes.CDLL(None, use_errno=True)
325
+ libc.getpeereid.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint)]
326
+ libc.getpeereid.restype = ctypes.c_int
327
+ uid_out, gid_out = ctypes.c_uint(), ctypes.c_uint()
328
+ require(libc.getpeereid(stream.fileno(), ctypes.byref(uid_out), ctypes.byref(gid_out)) == 0,
329
+ f"getpeereid failed: errno {ctypes.get_errno()}")
330
+ uid = uid_out.value
331
+ pid = struct.unpack("=i", stream.getsockopt(0, 2, 4))[0] # SOL_LOCAL / LOCAL_PEERPID
332
+ require(uid == os.geteuid(), f"foreign endpoint uid {uid}")
333
+ return pid, uid, unix_start(pid)
334
+
335
+
336
+ class UnixStream:
337
+ def __init__(self, directory, deadline):
338
+ self.stream = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
339
+ try:
340
+ directory = Path(directory).resolve(strict=True)
341
+ self.directory = directory
342
+ self.directory_stat = directory.lstat()
343
+ require(stat.S_ISDIR(self.directory_stat.st_mode) and self.directory_stat.st_uid == os.geteuid()
344
+ and self.directory_stat.st_mode & 0o077 == 0, "control directory is not private")
345
+ self.path = directory / "control.sock"
346
+ self.socket_stat = self.path.lstat()
347
+ require(stat.S_ISSOCK(self.socket_stat.st_mode) and self.socket_stat.st_uid == os.geteuid(), "invalid control socket")
348
+ self.stream.setblocking(False)
349
+ while True:
350
+ remaining(deadline)
351
+ result = self.stream.connect_ex(str(self.path))
352
+ if result in (0, errno.EISCONN):
353
+ break
354
+ if result == errno.EAGAIN: # Linux's full Unix backlog: retry connect itself.
355
+ time.sleep(min(0.02, remaining(deadline)))
356
+ continue
357
+ if result in (errno.EINPROGRESS, errno.EALREADY, errno.EINTR):
358
+ with selectors.DefaultSelector() as selector:
359
+ selector.register(self.stream, selectors.EVENT_WRITE)
360
+ require(selector.select(remaining(deadline)), "connect deadline exceeded")
361
+ result = self.stream.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
362
+ if result == 0:
363
+ break
364
+ raise OSError(result, os.strerror(result))
365
+ self.identity = self.authenticate()
366
+ except BaseException:
367
+ self.close()
368
+ raise
369
+
370
+ def authenticate(self):
371
+ directory, endpoint = self.directory.lstat(), self.path.lstat()
372
+ require((directory.st_dev, directory.st_ino) == (self.directory_stat.st_dev, self.directory_stat.st_ino)
373
+ and directory.st_uid == os.geteuid() and directory.st_mode & 0o077 == 0
374
+ and (endpoint.st_dev, endpoint.st_ino) == (self.socket_stat.st_dev, self.socket_stat.st_ino)
375
+ and stat.S_ISSOCK(endpoint.st_mode), "control endpoint changed")
376
+ first = unix_peer(self.stream)
377
+ require(first == unix_peer(self.stream), "server incarnation changed during authentication")
378
+ return first
379
+
380
+ def exchange(self, data, deadline):
381
+ require(self.authenticate() == self.identity, "server incarnation changed before first byte")
382
+ sent, reply = 0, bytearray()
383
+ write_error = None
384
+ with selectors.DefaultSelector() as selector:
385
+ selector.register(self.stream, selectors.EVENT_READ | selectors.EVENT_WRITE)
386
+ while True:
387
+ events = selector.select(remaining(deadline))
388
+ require(events, "exchange deadline exceeded")
389
+ mask = 0
390
+ for _, ready in events: # kqueue may emit read/write separately.
391
+ mask |= ready
392
+ # Read first: an early busy reply survives a concurrent EPIPE.
393
+ if mask & selectors.EVENT_READ:
394
+ try:
395
+ chunk = self.stream.recv(min(65536, MAX_REPLY + 2 - len(reply)))
396
+ except BlockingIOError:
397
+ chunk = None
398
+ if chunk is not None:
399
+ require(chunk, f"reply truncated; write error={write_error}")
400
+ reply.extend(chunk)
401
+ require(len(reply) <= MAX_REPLY, "oversized reply")
402
+ if b"\n" in reply:
403
+ return bytes(reply)
404
+ if mask & selectors.EVENT_WRITE and sent < len(data) and write_error is None:
405
+ try:
406
+ count = self.stream.send(data[sent:])
407
+ require(count > 0, "zero-byte write")
408
+ sent += count
409
+ except BlockingIOError:
410
+ continue
411
+ except OSError as exc:
412
+ write_error = exc
413
+ if sent == len(data) or write_error is not None:
414
+ selector.modify(self.stream, selectors.EVENT_READ)
415
+
416
+ def close(self):
417
+ self.stream.close()
418
+
419
+
420
+ def pipe_name(sid, canonical_directory):
421
+ digest = hashlib.sha256((sid + "\0" + canonical_directory).encode("utf-8")).hexdigest()[:32]
422
+ return "\\\\.\\pipe\\devlyn-control-" + digest
423
+
424
+
425
+ def rust_windows_path(path):
426
+ """Rust util/windows.rs:876-901 strips ONLY safe verbatim disk prefixes."""
427
+ if re.match(r"^\\\\\?\\[A-Za-z]:\\", path):
428
+ names = path[7:].split("\\")
429
+ reserved = re.compile(r"^(CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|$)", re.I | re.ASCII)
430
+ if all(not reserved.match(name) and not name.endswith((".", " "))
431
+ and not any(c in '<>:"|?*' or ord(c) < 32 or 127 <= ord(c) <= 159 for c in name)
432
+ for name in names if name):
433
+ return path[4:]
434
+ return path
435
+
436
+
437
+ class Overlapped(ctypes.Structure):
438
+ _fields_ = [("Internal", ctypes.c_size_t), ("InternalHigh", ctypes.c_size_t),
439
+ ("Offset", ctypes.c_uint32), ("OffsetHigh", ctypes.c_uint32), ("hEvent", ctypes.c_void_p)]
440
+
441
+
442
+ class WindowsAPI:
443
+ """Typed Win32 boundary; constructed only on native Windows."""
444
+
445
+ def __init__(self):
446
+ self.kernel = ctypes.WinDLL("kernel32", use_last_error=True)
447
+ self.advapi = ctypes.WinDLL("advapi32", use_last_error=True)
448
+ self.cleanup_diagnostics = []
449
+ H, D, B, P, W = ctypes.c_void_p, ctypes.c_uint32, ctypes.c_int32, ctypes.c_void_p, ctypes.c_wchar_p
450
+ self._bind(self.kernel, "CreateFileW", H, [W, D, D, P, D, D, H])
451
+ self._bind(self.kernel, "CloseHandle", B, [H])
452
+ self._bind(self.kernel, "DuplicateHandle", B, [H, H, H, ctypes.POINTER(H), D, B, D])
453
+ self._bind(self.kernel, "GetCurrentProcess", H, [])
454
+ self._bind(self.kernel, "OpenProcess", H, [D, B, D])
455
+ self._bind(self.kernel, "GetProcessTimes", B, [H, P, P, P, P])
456
+ self._bind(self.kernel, "GetNamedPipeServerProcessId", B, [H, ctypes.POINTER(D)])
457
+ self._bind(self.kernel, "GetFinalPathNameByHandleW", D, [H, W, D, D])
458
+ self._bind(self.kernel, "GetFileInformationByHandleEx", B, [H, ctypes.c_int, P, D])
459
+ self._bind(self.kernel, "LocalFree", H, [H])
460
+ self._bind(self.kernel, "WaitNamedPipeW", B, [W, D])
461
+ self._bind(self.kernel, "CreateEventW", H, [P, B, B, W])
462
+ self._bind(self.kernel, "WaitForSingleObject", D, [H, D])
463
+ self._bind(self.kernel, "ReadFile", B, [H, P, D, ctypes.POINTER(D), ctypes.POINTER(Overlapped)])
464
+ self._bind(self.kernel, "WriteFile", B, [H, P, D, ctypes.POINTER(D), ctypes.POINTER(Overlapped)])
465
+ self._bind(self.kernel, "GetOverlappedResult", B, [H, ctypes.POINTER(Overlapped), ctypes.POINTER(D), B])
466
+ self._bind(self.kernel, "CancelIoEx", B, [H, ctypes.POINTER(Overlapped)])
467
+ self._bind(self.advapi, "OpenProcessToken", B, [H, D, ctypes.POINTER(H)])
468
+ self._bind(self.advapi, "GetTokenInformation", B, [H, ctypes.c_int, P, D, ctypes.POINTER(D)])
469
+ self._bind(self.advapi, "ConvertSidToStringSidW", B, [P, ctypes.POINTER(H)])
470
+
471
+ @staticmethod
472
+ def _bind(library, name, result, args):
473
+ function = getattr(library, name)
474
+ function.restype, function.argtypes = result, args
475
+
476
+ @staticmethod
477
+ def check(value):
478
+ if not value:
479
+ raise ctypes.WinError(ctypes.get_last_error())
480
+ return value
481
+
482
+ def close(self, handle):
483
+ self.check(self.kernel.CloseHandle(handle))
484
+
485
+ def sid(self, process=None):
486
+ token = ctypes.c_void_p()
487
+ self.check(self.advapi.OpenProcessToken(process or self.kernel.GetCurrentProcess(), 8, ctypes.byref(token)))
488
+ try:
489
+ size = ctypes.c_uint32()
490
+ self.advapi.GetTokenInformation(token, 1, None, 0, ctypes.byref(size))
491
+ require(0 < size.value <= 65536, "invalid token information size")
492
+ buffer = (ctypes.c_uint64 * ((size.value + 7)//8))()
493
+ self.check(self.advapi.GetTokenInformation(token, 1, buffer, size, ctypes.byref(size)))
494
+ sid_pointer = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_void_p))[0]
495
+ text = ctypes.c_void_p()
496
+ self.check(self.advapi.ConvertSidToStringSidW(sid_pointer, ctypes.byref(text)))
497
+ try:
498
+ return ctypes.wstring_at(text)
499
+ finally:
500
+ self.kernel.LocalFree(text)
501
+ finally:
502
+ self.close(token)
503
+
504
+ def directory(self, path):
505
+ handle = self.kernel.CreateFileW(str(path), 0, 7, None, 3, 0x02000000, None)
506
+ require(handle not in (None, ctypes.c_void_p(-1).value), "cannot open control directory")
507
+ try:
508
+ buffer = ctypes.create_unicode_buffer(32768)
509
+ count = self.check(self.kernel.GetFinalPathNameByHandleW(handle, buffer, len(buffer), 0))
510
+ require(count < len(buffer), "canonical directory path exceeds buffer")
511
+ # FileIdInfo: volume serial + 128-bit file ID. Never coerce ID to float.
512
+ identity = ctypes.create_string_buffer(24)
513
+ self.check(self.kernel.GetFileInformationByHandleEx(handle, 18, identity, 24))
514
+ volume = struct.unpack_from("=Q", identity.raw)[0]
515
+ file_id = identity.raw[8:24].hex()
516
+ return rust_windows_path(buffer.value), (str(volume), file_id)
517
+ finally:
518
+ self.close(handle)
519
+
520
+ def server(self, pipe):
521
+ pid = ctypes.c_uint32()
522
+ self.check(self.kernel.GetNamedPipeServerProcessId(pipe, ctypes.byref(pid)))
523
+ process = self.check(self.kernel.OpenProcess(0x1000 | 0x100000, False, pid))
524
+ try:
525
+ times = [ctypes.c_uint64() for _ in range(4)]
526
+ self.check(self.kernel.GetProcessTimes(process, *(ctypes.byref(t) for t in times)))
527
+ require(self.kernel.WaitForSingleObject(process, 0) == 258, "server process exited")
528
+ return pid.value, self.sid(process), f"windows:{times[0].value}"
529
+ finally:
530
+ self.close(process)
531
+
532
+ def open_pipe(self, name, deadline):
533
+ while True:
534
+ remaining(deadline)
535
+ # FILE_FLAG_OVERLAPPED | SECURITY_SQOS_PRESENT | SECURITY_ANONYMOUS
536
+ handle = self.kernel.CreateFileW(name, 0xC0000000, 0, None, 3, 0x40000000 | 0x00100000, None)
537
+ if handle not in (None, ctypes.c_void_p(-1).value):
538
+ return handle
539
+ error = ctypes.get_last_error()
540
+ if error != 231: # ERROR_PIPE_BUSY only; absent/access-denied never downgrades.
541
+ raise ctypes.WinError(error)
542
+ wait = max(1, min(50, int(remaining(deadline)*1000)))
543
+ if not self.kernel.WaitNamedPipeW(name, wait) and ctypes.get_last_error() not in (121, 231):
544
+ raise ctypes.WinError(ctypes.get_last_error())
545
+
546
+ def start_io(self, handle, data=None):
547
+ event = self.check(self.kernel.CreateEventW(None, True, False, None))
548
+ overlap = Overlapped(hEvent=event)
549
+ buffer = ctypes.create_string_buffer(data, len(data)) if data is not None else ctypes.create_string_buffer(65536)
550
+ operation = [overlap, buffer, event, False]
551
+ function = self.kernel.WriteFile if data is not None else self.kernel.ReadFile
552
+ if not function(handle, buffer, len(buffer), None, ctypes.byref(overlap)):
553
+ error = ctypes.get_last_error()
554
+ if error != 997: # ERROR_IO_PENDING
555
+ self.close(event)
556
+ raise ctypes.WinError(error)
557
+ return operation
558
+
559
+ def poll_io(self, handle, operation):
560
+ count = ctypes.c_uint32()
561
+ if not self.kernel.GetOverlappedResult(handle, ctypes.byref(operation[0]), ctypes.byref(count), False):
562
+ error = ctypes.get_last_error()
563
+ if error == 996: # ERROR_IO_INCOMPLETE
564
+ return None
565
+ if error in (995, 109, 232, 234):
566
+ operation[3] = True
567
+ raise ctypes.WinError(error)
568
+ operation[3] = True
569
+ return count.value, operation[1].raw[:count.value]
570
+
571
+ def _cancel_completed(self, handle, operation, wait):
572
+ count = ctypes.c_uint32()
573
+ success = self.kernel.GetOverlappedResult(handle, ctypes.byref(operation[0]), ctypes.byref(count), wait)
574
+ if success or ctypes.get_last_error() in (995, 109, 232):
575
+ operation[3] = True
576
+ return operation[3]
577
+
578
+ def _drain_cancelled(self, handle, operation, close_handle=True):
579
+ # Unexpected errors are not positive completion. Keep all native state
580
+ # alive and retry; never free a buffer the kernel may still reference.
581
+ while not operation[3]:
582
+ try:
583
+ if not self._cancel_completed(handle, operation, True):
584
+ time.sleep(0.01)
585
+ except OSError as exc:
586
+ self.cleanup_diagnostics.append(str(exc))
587
+ time.sleep(0.01)
588
+ try:
589
+ self.close(operation[2])
590
+ finally:
591
+ if close_handle:
592
+ self.close(handle)
593
+
594
+ def _retain_cancelled(self, handle, operation):
595
+ duplicate = ctypes.c_void_p()
596
+ try:
597
+ process = self.kernel.GetCurrentProcess()
598
+ self.check(self.kernel.DuplicateHandle(process, handle, process, ctypes.byref(duplicate), 0, False, 2))
599
+ except BaseException:
600
+ # No duplicate exists to own a background wait. The caller must
601
+ # retain the original handle and synchronously await completion.
602
+ self._drain_cancelled(handle, operation, False)
603
+ raise
604
+ cleanup = None
605
+ try:
606
+ lock = threading.Lock()
607
+ reclaimed = False
608
+ def cleanup():
609
+ nonlocal reclaimed
610
+ # start() may launch a worker and then be interrupted. Both
611
+ # paths share one owner of the event and duplicated handle.
612
+ with lock:
613
+ if not reclaimed:
614
+ try:
615
+ self._drain_cancelled(duplicate, operation)
616
+ finally:
617
+ reclaimed = operation[3]
618
+ worker = threading.Thread(target=cleanup, daemon=True)
619
+ worker.start()
620
+ except BaseException:
621
+ if cleanup is None:
622
+ self._drain_cancelled(duplicate, operation)
623
+ else:
624
+ cleanup()
625
+ raise
626
+ self.cleanup_diagnostics.append('cancellation completion pending; native I/O resources retained until completion')
627
+
628
+ def finish_io(self, handle, operation, deadline):
629
+ try:
630
+ if not operation[3]:
631
+ if not self.kernel.CancelIoEx(handle, ctypes.byref(operation[0])):
632
+ require(ctypes.get_last_error() == 1168, 'CancelIoEx failed')
633
+ wait_ms = max(0, int((deadline-time.monotonic())*1000))
634
+ result = self.kernel.WaitForSingleObject(operation[2], wait_ms)
635
+ require(result in (0, 258), 'cancellation wait failed')
636
+ if result == 0:
637
+ self._cancel_completed(handle, operation, False)
638
+ finally:
639
+ if operation[3]:
640
+ self.close(operation[2])
641
+ else:
642
+ self._retain_cancelled(handle, operation)
643
+
644
+
645
+ class WindowsStream:
646
+ def __init__(self, directory, deadline, *, api=None):
647
+ self.api = api or WindowsAPI()
648
+ self.directory, self.directory_identity = self.api.directory(directory)
649
+ self.sid = self.api.sid()
650
+ self.handle = self.api.open_pipe(pipe_name(self.sid, self.directory), deadline)
651
+ try:
652
+ self.identity = self.authenticate()
653
+ except BaseException:
654
+ self.close()
655
+ raise
656
+
657
+ def authenticate(self):
658
+ require(self.api.directory(self.directory) == (self.directory, self.directory_identity), "control directory changed")
659
+ first = self.api.server(self.handle)
660
+ require(first[1] == self.sid and first == self.api.server(self.handle), "foreign or reused pipe server")
661
+ return first
662
+
663
+ def exchange(self, data, deadline):
664
+ require(self.authenticate() == self.identity, "pipe server changed before first byte")
665
+ reply, sent, write_error = bytearray(), 0, None
666
+ with contextlib.ExitStack() as pending:
667
+ read = self.api.start_io(self.handle)
668
+ read_cleanup = pending.enter_context(contextlib.ExitStack())
669
+ read_cleanup.callback(self.api.finish_io, self.handle, read, deadline)
670
+ write_cleanup = pending.enter_context(contextlib.ExitStack())
671
+ write = None
672
+ while True:
673
+ remaining(deadline)
674
+ try:
675
+ result = self.api.poll_io(self.handle, read)
676
+ except OSError as exc:
677
+ raise StorageRefusal("storage_unavailable", f"pipe reply failed: {exc}; write={write_error}") from exc
678
+ if result is not None:
679
+ count, chunk = result
680
+ require(count, "pipe reply truncated")
681
+ reply.extend(chunk)
682
+ require(len(reply) <= MAX_REPLY, "oversized reply")
683
+ if b"\n" in reply:
684
+ return bytes(reply)
685
+ read_cleanup.close()
686
+ read = self.api.start_io(self.handle)
687
+ read_cleanup.callback(self.api.finish_io, self.handle, read, deadline)
688
+ if sent < len(data) and write_error is None:
689
+ try:
690
+ if write is None:
691
+ write = self.api.start_io(self.handle, data[sent:])
692
+ write_cleanup.callback(self.api.finish_io, self.handle, write, deadline)
693
+ result = self.api.poll_io(self.handle, write)
694
+ if result is not None:
695
+ require(result[0] > 0, "pipe zero-byte write")
696
+ sent += result[0]
697
+ write_cleanup.close()
698
+ write = None
699
+ except OSError as exc:
700
+ write_error = exc # Continue reading an early busy reply.
701
+ time.sleep(min(0.005, remaining(deadline)))
702
+
703
+ def close(self):
704
+ self.api.close(self.handle)
705
+
706
+
707
+ def connect(directory, deadline):
708
+ return WindowsStream(directory, deadline) if os.name == "nt" else UnixStream(directory, deadline)
709
+
710
+
711
+ class Client:
712
+ def __init__(self, control_directory, *, required_capabilities=("reserve",)):
713
+ self.directory = control_directory
714
+ self.required = frozenset(required_capabilities)
715
+
716
+ def _exchange(self, data, expected, deadline, server=None):
717
+ with contextlib.closing(connect(self.directory, deadline)) as stream:
718
+ require(server is None or stream.identity == server, "server changed after hello")
719
+ value = decode_reply(stream.exchange(data, deadline), expected)
720
+ return value, stream.identity
721
+
722
+ def call(self, request):
723
+ data = encode_request(request) # Freeze validated bytes before retry.
724
+ expected = REQUESTS[request["op"]][1]
725
+ busy_deadline, backoff = time.monotonic()+BUSY_SECONDS, 0.01
726
+ try:
727
+ while True:
728
+ try:
729
+ # A new server incarnation always gets fresh hello. Never
730
+ # send an operation to a successor without renegotiation.
731
+ hello, server = self._exchange(encode_request({"op": "hello"}), "hello", time.monotonic()+EXCHANGE_SECONDS)
732
+ required = self.required | ({"leaf_leases"} if request.get("leaf") is True else set())
733
+ require(hello["storage_protocol"] == 1 and required <= set(hello["capabilities"]),
734
+ "incompatible protocol or missing capabilities", "storage_unsupported")
735
+ if request["op"] == "hello":
736
+ return hello
737
+ return self._exchange(data, expected, time.monotonic()+EXCHANGE_SECONDS, server)[0]
738
+ except StorageRefusal as exc:
739
+ if exc.code != "storage_busy" or time.monotonic() >= busy_deadline:
740
+ raise
741
+ time.sleep(max(0.0, min(backoff, busy_deadline-time.monotonic())))
742
+ backoff = min(backoff*2, 0.2)
743
+ require(time.monotonic() < busy_deadline, "busy retry window exhausted", "storage_busy")
744
+ except (OSError, ValueError, IndexError, struct.error) as exc:
745
+ raise StorageRefusal("storage_unavailable", str(exc), operation=request["op"], phase="exchange") from exc
746
+ except StorageRefusal as exc:
747
+ exc.operation, exc.phase = request["op"], "exchange"
748
+ raise
749
+
750
+
751
+ def _evidence(path):
752
+ """Only ENOENT is absence; reject redirected evidence below the canonical root."""
753
+ try:
754
+ info = path.lstat()
755
+ except FileNotFoundError:
756
+ return None
757
+ require(not stat.S_ISLNK(info.st_mode) and not getattr(info, "st_file_attributes", 0) & 0x400,
758
+ f"redirected enrollment evidence: {path}")
759
+ return info
760
+
761
+
762
+ def _same_file(left, right, handles=False):
763
+ def identity(info):
764
+ if os.name == 'nt' and not handles:
765
+ # Windows path stat synthesizes executable bits from the extension, and its
766
+ # st_ctime is the creation time while handle stat reports the change time
767
+ # (CPython 3.12, Windows Server 2025). Neither compares across the two;
768
+ # two stats of one handle (`handles`) compare every field.
769
+ return (info.st_dev, info.st_ino, stat.S_IFMT(info.st_mode), info.st_size, info.st_mtime_ns)
770
+ return (info.st_dev, info.st_ino, info.st_mode, info.st_size, info.st_mtime_ns, info.st_ctime_ns)
771
+ return right is not None and identity(left) == identity(right)
772
+
773
+
774
+ def managed_receipt(receipt):
775
+ """Rust obligations::Discriminators, including future managed custody states."""
776
+ require(type(receipt) is dict, "invalid saved receipt")
777
+ for name in ("mode", "allocation", "allocation_state"):
778
+ require(receipt.get(name) is None or type(receipt[name]) is str, "invalid receipt discriminator")
779
+ return receipt.get("mode") == "managed" or any(
780
+ (receipt.get(name) or "").startswith("managed") for name in ("allocation", "allocation_state"))
781
+
782
+
783
+ def operation_id():
784
+ # RFC 9562 UUIDv7, also on Python versions without uuid.uuid7.
785
+ bits = (int(time.time()*1000) << 80) | (int.from_bytes(os.urandom(10), "big") & ((1 << 80)-1))
786
+ return str(uuid.UUID(int=(bits & ~(15 << 76) & ~(3 << 62)) | (7 << 76) | (2 << 62)))
787
+
788
+
789
+ def canonical_operation(value, *, now=None):
790
+ try:
791
+ parsed = uuid.UUID(value)
792
+ except (ValueError, TypeError, AttributeError) as exc:
793
+ raise StorageRefusal("storage_invalid", "operation ID must be canonical UUIDv7") from exc
794
+ require(parsed.version == 7 and str(parsed) == value, "operation ID must be canonical UUIDv7", "storage_invalid")
795
+ if now is not None:
796
+ seconds = (parsed.int >> 80)//1000
797
+ require(now-604800 <= seconds <= now+300, "operation ID outside replay window", "storage_expired")
798
+ return parsed
799
+
800
+
801
+ def persistent_identity(path):
802
+ """Same persistent identity as Rust, without boot-dependent device spelling."""
803
+ path = Path(path)
804
+ info = _evidence(path)
805
+ require(info is not None, "identity path absent")
806
+ if os.name == "nt":
807
+ api = WindowsAPI()
808
+ handle = api.kernel.CreateFileW(str(path), 0, 7, None, 3, 0x02200000, None)
809
+ require(handle not in (None, ctypes.c_void_p(-1).value), "identity open failed")
810
+ try:
811
+ identity = ctypes.create_string_buffer(24)
812
+ api.check(api.kernel.GetFileInformationByHandleEx(handle, 18, identity, 24))
813
+ return {"volume": str(struct.unpack_from("=Q", identity.raw)[0]),
814
+ "file": str(int.from_bytes(identity.raw[8:24], "little"))}
815
+ finally:
816
+ api.close(handle)
817
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
818
+ try:
819
+ require(_same_file(info, os.fstat(fd)), "identity changed during open")
820
+ volume = str(info.st_dev)
821
+ if sys.platform == "darwin":
822
+ class AttrList(ctypes.Structure):
823
+ _fields_ = [("count", ctypes.c_uint16), ("reserved", ctypes.c_uint16),
824
+ ("common", ctypes.c_uint32), ("volume", ctypes.c_uint32),
825
+ ("directory", ctypes.c_uint32), ("file", ctypes.c_uint32), ("fork", ctypes.c_uint32)]
826
+ libc = ctypes.CDLL(None, use_errno=True)
827
+ mount = ctypes.create_string_buffer(2168) # Darwin statfs64, SDK sys/mount.h.
828
+ if libc.fstatfs64(fd, mount) != 0:
829
+ raise OSError(ctypes.get_errno(), "mount observation failed")
830
+ require(not struct.unpack_from("=I", mount.raw, 64)[0] & 0x40000000,
831
+ "snapshot volume identity unsupported", "storage_unsupported")
832
+ asked = AttrList(5, 0, 0x80000000, 0x80040000, 0, 0, 0)
833
+ answer = ctypes.create_string_buffer(40)
834
+ if libc.fgetattrlist(fd, ctypes.byref(asked), answer, 40, 0) != 0:
835
+ raise OSError(ctypes.get_errno(), "volume UUID observation failed")
836
+ require(struct.unpack("=6I", answer.raw[:24]) == (40, 0x80000000, 0x40000, 0, 0, 0)
837
+ and any(answer.raw[24:]), "volume UUID unavailable")
838
+ volume = str(uuid.UUID(bytes=answer.raw[24:]))
839
+ return {"volume": volume, "file": str(info.st_ino)}
840
+ finally:
841
+ os.close(fd)
842
+
843
+
844
+ def regular_digest(path):
845
+ path = Path(path)
846
+ before = _evidence(path)
847
+ require(before is not None and stat.S_ISREG(before.st_mode), "retained artifact is not a regular file")
848
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0))
849
+ with os.fdopen(fd, "rb") as stream:
850
+ opened = os.fstat(stream.fileno())
851
+ require(_same_file(before, opened), "retained artifact changed")
852
+ digest = hashlib.sha256()
853
+ for chunk in iter(lambda: stream.read(1024*1024), b""):
854
+ digest.update(chunk)
855
+ require(_same_file(opened, os.fstat(stream.fileno()), handles=True) and _same_file(before, _evidence(path)),
856
+ "retained artifact changed while hashing")
857
+ return digest.hexdigest()
858
+
859
+
860
+ def package_digest(root):
861
+ digest = hashlib.sha256()
862
+ root = Path(root)
863
+ def visit(directory):
864
+ directory_info = _evidence(directory)
865
+ require(directory_info is not None and stat.S_ISDIR(directory_info.st_mode), "invalid retained package directory")
866
+ for path in sorted(directory.iterdir(), key=lambda p: p.name.encode("utf-8")):
867
+ info = _evidence(path)
868
+ require(info is not None, "retained package entry disappeared")
869
+ name = str(path.relative_to(root)).encode()
870
+ digest.update(struct.pack("<Q", len(name)))
871
+ digest.update(name)
872
+ if stat.S_ISDIR(info.st_mode):
873
+ digest.update(b"directory")
874
+ visit(path)
875
+ else:
876
+ require(stat.S_ISREG(info.st_mode), "special retained package entry")
877
+ digest.update(b"file")
878
+ digest.update(regular_digest(path).encode())
879
+ visit(root)
880
+ return digest.hexdigest()
881
+
882
+
883
+ ARTIFACT_SHAPE = {"path": "str", "identity": "identity", "sha256": "str"}
884
+ ENROLLMENT_SHAPE = {"schema": "u32", "control_directory": "str", "control_identity": "identity",
885
+ "helper_path": "str", "helper_release": {"version": "str", "target": "str", "sha256": "str"},
886
+ "recovery_release": {"package": "str", "version": "str", "digest": "str", "root": "str"},
887
+ "retained_helper": ARTIFACT_SHAPE, "retained_recovery": ARTIFACT_SHAPE}
888
+
889
+
890
+ def verify_artifact(artifact, *, package=False):
891
+ path = Path(artifact['path'])
892
+ require(path.is_absolute() and str(path.resolve(strict=True)) == artifact['path'], 'noncanonical retained path')
893
+ require(persistent_identity(artifact['path']) == artifact['identity'], 'retained identity changed')
894
+ digest = package_digest(artifact['path']) if package else regular_digest(artifact['path'])
895
+ require(digest == artifact['sha256'], 'retained digest changed')
896
+
897
+
898
+ def verify_registration(record):
899
+ try:
900
+ return _verify_registration(record)
901
+ except OSError as exc:
902
+ raise StorageRefusal('storage_unavailable', f'registration inspection failed: {exc}', phase='enrollment') from exc
903
+
904
+
905
+ def _verify_registration(record):
906
+ validate(record, ENROLLMENT_SHAPE, exact=True)
907
+ helper, recovery = record["helper_release"], record["recovery_release"]
908
+ require(record["schema"] == 1 and re.fullmatch(
909
+ r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?", helper["version"])
910
+ and 0 < len(helper["target"]) <= 128 and recovery["package"] == "devlyn-cli"
911
+ and recovery["version"] and recovery["digest"], "unsupported or incomplete enrollment", "storage_unsupported")
912
+ for value in (record['control_directory'], record['helper_path']):
913
+ path = Path(value)
914
+ require(path.is_absolute() and str(path.resolve(strict=True)) == value, "noncanonical enrollment path")
915
+ require(persistent_identity(record["control_directory"]) == record["control_identity"], "control identity changed")
916
+ require(regular_digest(record["helper_path"]) == helper["sha256"], "helper digest changed")
917
+ verify_artifact(record['retained_helper'])
918
+ verify_artifact(record['retained_recovery'], package=True)
919
+ require(record["retained_helper"]["sha256"] == helper["sha256"]
920
+ and record["retained_recovery"]["path"] == recovery["root"], "retained release binding changed")
921
+ return record
922
+
923
+
924
+ def read_evidence_json(path, observed):
925
+ info = _evidence(path)
926
+ require(info is not None and stat.S_ISREG(info.st_mode), f'missing/invalid {path}')
927
+ fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)
928
+ | getattr(os, 'O_BINARY', 0))
929
+ with os.fdopen(fd, 'rb') as stream:
930
+ require(_same_file(info, os.fstat(stream.fileno())), f'{path} changed while opening')
931
+ raw = stream.read(MAX_REQUEST + 1)
932
+ require(len(raw) <= MAX_REQUEST, f'oversized {path}')
933
+ observed.append((path, info))
934
+ return loads(raw)
935
+
936
+
937
+ def recovery_registration():
938
+ """Current retained pins survive replacement of the app and unenrollment."""
939
+ try:
940
+ home = user_home()
941
+ require(home is not None, 'recovery home unavailable')
942
+ root = (home / '.devlyn').resolve(strict=True)
943
+ marker = root / 'storage-client-recovery'
944
+ info = _evidence(marker)
945
+ require(info is not None and stat.S_ISDIR(info.st_mode), 'recovery pins directory missing/invalid')
946
+ observed = [(marker, info)]
947
+ pins = read_evidence_json(marker / 'pins.json', observed)
948
+ validate(pins, {'schema': 'u32', 'enrollment': ENROLLMENT_SHAPE,
949
+ 'runtime': ARTIFACT_SHAPE, 'node': 'str'}, exact=True)
950
+ record = pins['enrollment']
951
+ node, runtime = Path(pins['node']), Path(pins['runtime']['path'])
952
+ require(pins['schema'] == record['schema'] == 1
953
+ and record['recovery_release']['root'] == record['retained_recovery']['path']
954
+ and node.is_absolute() and str(node.resolve(strict=True)) == pins['node']
955
+ and node.is_relative_to(runtime), 'invalid recovery pin paths')
956
+ verify_artifact(record['retained_helper'])
957
+ verify_artifact(record['retained_recovery'], package=True)
958
+ verify_artifact(pins['runtime'], package=True)
959
+ for path, before in observed:
960
+ require(_same_file(before, _evidence(path)), f'recovery pins changed: {path}')
961
+ return record
962
+ except (StorageRefusal, OSError, ValueError, RuntimeError) as exc:
963
+ detail = exc.diagnostic if isinstance(exc, StorageRefusal) else str(exc)
964
+ raise StorageRefusal('storage_unavailable', f'recovery pins invalid: {detail}', phase='enrollment',
965
+ message='Retain the workspace and receipt; repair Devlyn recovery pins and retry') from exc
966
+
967
+
968
+ def windows_profile():
969
+ """The known-folder fallback used by Rust dirs::home_dir on Windows."""
970
+ import uuid
971
+ class GUID(ctypes.Structure):
972
+ _fields_ = [('bytes', ctypes.c_ubyte * 16)]
973
+ profile = GUID((ctypes.c_ubyte * 16).from_buffer_copy(
974
+ uuid.UUID('5e6c858f-0e22-4760-9afe-ea3317b67173').bytes_le))
975
+ shell = ctypes.WinDLL('shell32', use_last_error=True)
976
+ ole = ctypes.WinDLL('ole32', use_last_error=True)
977
+ shell.SHGetKnownFolderPath.argtypes = [ctypes.POINTER(GUID), ctypes.c_uint32, ctypes.c_void_p,
978
+ ctypes.POINTER(ctypes.c_wchar_p)]
979
+ shell.SHGetKnownFolderPath.restype = ctypes.c_long
980
+ ole.CoTaskMemFree.argtypes = [ctypes.c_void_p]
981
+ ole.CoTaskMemFree.restype = None
982
+ value = ctypes.c_wchar_p()
983
+ try:
984
+ result = shell.SHGetKnownFolderPath(ctypes.byref(profile), 0, None, ctypes.byref(value))
985
+ require(result == 0 and bool(value.value), 'Windows profile unavailable; repair the user profile')
986
+ return Path(value.value)
987
+ finally:
988
+ if value:
989
+ ole.CoTaskMemFree(ctypes.cast(value, ctypes.c_void_p))
990
+
991
+
992
+ def user_home():
993
+ """Mirror devlynd util::home_dir; HOMEDRIVE/HOMEPATH are not authority."""
994
+ try:
995
+ if os.name == 'nt':
996
+ profile = os.environ.get('USERPROFILE')
997
+ return Path(profile) if profile else windows_profile()
998
+ home = os.environ.get('HOME')
999
+ if home:
1000
+ return Path(home)
1001
+ import pwd
1002
+ try:
1003
+ home = pwd.getpwuid(os.getuid()).pw_dir
1004
+ except KeyError:
1005
+ return None
1006
+ return Path(home) if home else None
1007
+ except (OSError, ValueError, RuntimeError, ImportError, AttributeError) as exc:
1008
+ raise StorageRefusal('storage_unavailable', f'home resolution failed; repair the user profile: {exc}',
1009
+ phase='enrollment') from exc
1010
+
1011
+
1012
+ def enrollment(*, receipt=None, verify_registration=verify_registration):
1013
+ """Three-state read: absent/standalone, enrolled/managed, or invalid/refusal.
1014
+
1015
+ Canonical lifecycle root and retained artifact checks mirror Rust evidence.rs.
1016
+ Registry contents never participate. Receipt custody cannot downgrade.
1017
+ """
1018
+ try:
1019
+ # A positively absent home has no record. Invalid/inaccessible evidence
1020
+ # still fails below; receipt custody must never downgrade.
1021
+ home = user_home()
1022
+ if home is None:
1023
+ require(not managed_receipt(receipt or {}), 'managed receipt has no home; restore the user profile')
1024
+ return {'mode': 'standalone', 'evidence': []}
1025
+ home = home.resolve(strict=False)
1026
+ # Rust canonical_root resolves the lifecycle root at the user boundary.
1027
+ named_root = home / ".devlyn"
1028
+ try:
1029
+ named_root.lstat()
1030
+ except FileNotFoundError:
1031
+ root = named_root
1032
+ else:
1033
+ root = named_root.resolve(strict=True)
1034
+ evidence = []
1035
+ observations = []
1036
+
1037
+ def probe(path):
1038
+ info = _evidence(path)
1039
+ observations.append((path, info))
1040
+ return info
1041
+
1042
+ def finish(result):
1043
+ for path, before in observations:
1044
+ after = _evidence(path)
1045
+ require(after is None if before is None else _same_file(before, after),
1046
+ f"enrollment evidence changed during inspection: {path}")
1047
+ return result
1048
+
1049
+ managed = False
1050
+ if receipt is not None:
1051
+ require(type(receipt) is dict, "invalid saved receipt")
1052
+ managed = managed_receipt(receipt)
1053
+ if managed:
1054
+ evidence.append("managed_receipt")
1055
+ expectation = root / "storage-client.json"
1056
+ info = probe(expectation)
1057
+ if info is not None:
1058
+ evidence.append("storage-client.json")
1059
+ require(stat.S_ISREG(info.st_mode), "expectation is not a regular file")
1060
+ fd = os.open(expectation, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
1061
+ | getattr(os, "O_BINARY", 0))
1062
+ with os.fdopen(fd, "rb") as stream:
1063
+ require(_same_file(info, os.fstat(stream.fileno())), "expectation changed while opening")
1064
+ raw = stream.read(MAX_REQUEST+1)
1065
+ require(len(raw) <= MAX_REQUEST, "oversized expectation")
1066
+ record = loads(raw)
1067
+ require(type(record) is dict and type(record.get("schema")) is int, "malformed expectation schema")
1068
+ require(record["schema"] == 1, "unsupported expectation schema", "storage_unsupported")
1069
+ require(verify_registration is not None, "enrollment registration verifier unavailable", "storage_unsupported")
1070
+ registration = verify_registration(record)
1071
+ require(type(registration) is dict and bool(registration), "registration verification failed")
1072
+ return finish({"mode": "managed", "evidence": evidence, "evidence_version": 1,
1073
+ "expectation": str(expectation), "registration": registration})
1074
+ require(not evidence, "Devlyn enrollment evidence exists without a valid helper registration")
1075
+ return finish({"mode": "standalone", "evidence": []})
1076
+ except (OSError, ValueError, RuntimeError) as exc:
1077
+ raise StorageRefusal("storage_unavailable", f"enrollment inspection failed: {exc}", phase="enrollment") from exc
1078
+
1079
+
1080
+ def recovery_marker():
1081
+ # Marker existence (including a redirected marker) matches Rust retained().
1082
+ try:
1083
+ home = user_home()
1084
+ if home is None:
1085
+ return False
1086
+ (home / '.devlyn/storage-client-recovery').lstat()
1087
+ return True
1088
+ except FileNotFoundError:
1089
+ return False
1090
+ except (OSError, ValueError, RuntimeError) as exc:
1091
+ raise StorageRefusal('storage_unavailable', f'recovery marker inspection failed; repair the user profile: {exc}',
1092
+ phase='enrollment') from exc
1093
+
1094
+
1095
+ def operation_enrollment():
1096
+ """New operations use enrollment alone; existing receipts keep their mode."""
1097
+ decision = enrollment()
1098
+ if decision['mode'] == 'managed' or not recovery_marker():
1099
+ return decision
1100
+ repair = 'Open Devlyn to repair its storage registration'
1101
+ try:
1102
+ root = (user_home() / '.devlyn').resolve(strict=True)
1103
+ observed = []
1104
+
1105
+ journal = read_evidence_json(root / 'storage-client-unenroll.json', observed)
1106
+ fence = read_evidence_json(root / 'storage-client-fence.json', observed)
1107
+ binding = {'schema': 'u32', 'operation_id': 'str', 'root_identity': 'identity', 'lock_identity': 'identity'}
1108
+ validate(journal, {**binding, 'archives': [{'source': 'str', 'destination': 'str',
1109
+ 'identity': 'identity', 'sha256': 'str'}], 'retired': 'bool'})
1110
+ validate(fence, binding)
1111
+ # Journal, Fence and Archive deny unknown fields in Rust; Identity does not.
1112
+ require(set(journal) == set(binding) | {'archives', 'retired'} and set(fence) == set(binding),
1113
+ 'unknown retirement field')
1114
+ require(journal['schema'] == fence['schema'] == 1 and journal['retired'] is True,
1115
+ 'unenrollment is not complete')
1116
+ operation = journal['operation_id']
1117
+ parsed = uuid.UUID(operation)
1118
+ require(parsed.version == 7 and str(parsed) == operation and fence['operation_id'] == operation,
1119
+ 'retirement operation ID differs')
1120
+ stale = False
1121
+ for key, path in (('root_identity', root), ('lock_identity', root / 'storage-client.lock')):
1122
+ info = _evidence(path)
1123
+ require(info is not None and (stat.S_ISDIR(info.st_mode) if path == root else stat.S_ISREG(info.st_mode)),
1124
+ 'invalid retirement root/lock kind')
1125
+ identities = [{field: record[key][field] for field in ('volume', 'file')} for record in (journal, fence)]
1126
+ require(identities[0] == identities[1], 'retirement journal/fence identity differs')
1127
+ stale |= identities[0] != persistent_identity(path)
1128
+ sources = ('storage', 'control', 'harness/state.json', 'storage-client.json')
1129
+ names = ('storage', 'control', 'harness-state.json', 'enrollment.json')
1130
+ previous = -1
1131
+ archive_root = None
1132
+ for entry in journal['archives']:
1133
+ require(set(entry) == {'source', 'destination', 'identity', 'sha256'}, 'unknown retirement archive field')
1134
+ source = Path(entry['source'])
1135
+ matches = [i for i, name in enumerate(sources) if source.parts[-len(Path(name).parts):] == Path(name).parts]
1136
+ require(source.is_absolute() and '..' not in source.parts and len(matches) == 1, 'invalid retirement source')
1137
+ index = matches[0]
1138
+ origin = source.parents[len(Path(sources[index]).parts) - 1]
1139
+ require(archive_root is None or origin == archive_root, 'retirement archive roots differ')
1140
+ archive_root = origin
1141
+ require(index > previous, 'duplicate or reordered retirement archive')
1142
+ previous = index
1143
+ require(Path(entry['destination']) == origin / 'storage-client-retired' / operation / names[index],
1144
+ 'invalid retirement archive path')
1145
+ stale |= origin != root
1146
+ if stale:
1147
+ for name in sources:
1148
+ try:
1149
+ (root / name).lstat()
1150
+ except FileNotFoundError:
1151
+ continue
1152
+ raise StorageRefusal('storage_unavailable', 'stale retirement evidence has live inventory')
1153
+ for path, info in observed:
1154
+ require(_same_file(info, _evidence(path)), f'retirement evidence changed: {path}')
1155
+ require(_evidence(root / 'storage-client.json') is None, 'enrollment appeared during retirement inspection')
1156
+ return decision
1157
+ except (StorageRefusal, OSError, ValueError, RuntimeError) as exc:
1158
+ detail = exc.diagnostic if isinstance(exc, StorageRefusal) else str(exc)
1159
+ raise StorageRefusal('storage_unavailable', f'registration repair required: {detail}', phase='enrollment',
1160
+ message=repair, resume=repair) from exc
1161
+
1162
+
1163
+ def sync_directory(path):
1164
+ """Native durability barrier; failure is visible, never treated as success."""
1165
+ path = Path(path)
1166
+ if os.name != 'nt':
1167
+ fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
1168
+ try:
1169
+ os.fsync(fd)
1170
+ finally:
1171
+ os.close(fd)
1172
+ else:
1173
+ api = WindowsAPI()
1174
+ api._bind(api.kernel, 'FlushFileBuffers', ctypes.c_int, [ctypes.c_void_p])
1175
+ handle = api.kernel.CreateFileW(str(path), 0xC0000000, 3, None, 3, 0x02200000, None)
1176
+ require(handle not in (None, ctypes.c_void_p(-1).value), 'directory durability open failed')
1177
+ try:
1178
+ api.check(api.kernel.FlushFileBuffers(handle))
1179
+ finally:
1180
+ api.close(handle)