ccx-native 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +807 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +269 -0
  16. package/ccx/claude/host/session.py +5244 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,409 @@
1
+ """Bounded AF_UNIX transport plans and exact root/generation/owner peer checks.
2
+
3
+ Socket filenames and hashes never select workers. This module validates private
4
+ paths, proves peer identity, and owns daemon lifetime leases; the actual staged
5
+ socket bind is performed by paths.bind_private_unix_socket.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import fcntl
10
+ import hashlib
11
+ import os
12
+ import socket
13
+ import stat
14
+ from contextlib import contextmanager
15
+ from uuid import uuid4
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Mapping
19
+
20
+ from ccx.system import oslayer
21
+ from ccx.store.store import GenerationStore
22
+ from ccx.store.root import GenerationError, GenerationUnavailable, StorageIntegrityError, _open_dir, _flock_at, _read_json, _write_json
23
+ from ccx.store.liveness import proc_start_token
24
+
25
+ _STAGE_SUFFIX = "/.XXXXXXXX/s" # existing tempfile.mkdtemp(prefix=".", dir=parent)
26
+ _ALLOWED_ROLES = frozenset({"worker", "codexd", "notifyd"})
27
+
28
+
29
+ class TransportError(StorageIntegrityError):
30
+ """Socket transport cannot securely bind or prove full owner identity."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class DaemonLease:
35
+ """One process's generation/role singleton socket lifetime fence."""
36
+ role: str
37
+ generation: str
38
+ nonce: str
39
+ pid: int
40
+ start_token: str
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class TransportPlan:
45
+ root: Path
46
+ generation: str
47
+ parent: Path
48
+ final: Path
49
+ worker_id: str | None
50
+ engine: str | None
51
+ role: str
52
+ fallback: bool
53
+
54
+ def evidence(self) -> dict:
55
+ return {
56
+ "rootRealpath": str(self.root), "rootHash": hashlib.sha256(
57
+ os.fsencode(self.root)).hexdigest(),
58
+ "generation": self.generation, "workerId": self.worker_id,
59
+ "engine": self.engine, "role": self.role,
60
+ }
61
+
62
+
63
+ def _fits(path: Path) -> bool:
64
+ return oslayer.fits_unix_path(path)
65
+
66
+
67
+ def _budget(parent: Path, basename: str) -> tuple[Path, Path]:
68
+ final = parent / basename
69
+ staged = Path(str(parent) + _STAGE_SUFFIX)
70
+ return final, staged
71
+
72
+
73
+ def _trust_dir(path: Path, *, may_be_missing: bool = False) -> None:
74
+ """Reject a symlink in any existing path component; writable base private."""
75
+ absolute = Path(os.path.abspath(path))
76
+ component = Path(absolute.anchor)
77
+ for idx, item in enumerate(absolute.parts[1:]):
78
+ component = component / item
79
+ try:
80
+ st = os.lstat(component)
81
+ except FileNotFoundError:
82
+ if may_be_missing and idx == len(absolute.parts[1:]) - 1:
83
+ return
84
+ raise TransportError("trusted short socket parent does not exist")
85
+ if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
86
+ raise TransportError("socket root contains symlink/non-directory")
87
+ st = os.lstat(absolute)
88
+ if st.st_uid != os.getuid() or st.st_mode & 0o077:
89
+ raise TransportError("transport directory must be user-owned private 0700")
90
+
91
+
92
+ def _default_runtime_dir(*, suffix_bytes: int = 0) -> oslayer.RuntimeChoice | None:
93
+ """One read-only base choice on both Linux and Darwin."""
94
+ return oslayer.runtime_base(purpose="transport", suffix_bytes=suffix_bytes)
95
+
96
+
97
+ def _transport_suffix(discriminator: str, basename: str) -> int:
98
+ """Worst of the actual final and staged socket suffixes, including NUL."""
99
+ parent = f"/ccx-uuid-{discriminator}"
100
+ return max(len(os.fsencode(parent + "/" + basename)) + 1,
101
+ len(os.fsencode(parent + _STAGE_SUFFIX)) + 1)
102
+
103
+
104
+ def _ensure_private_runtime_directory(
105
+ path: Path, choice: oslayer.RuntimeChoice,
106
+ ) -> None:
107
+ """The shared checked-create path for transport and live-var sockets.
108
+
109
+ An existing XDG base is never created here. A missing short per-UID base
110
+ on Linux or Darwin is created only after the owner has confirmed ACTIVE; every
111
+ write uses a no-follow pinned parent FD, then revalidates the created leaf.
112
+ """
113
+ base = choice.path
114
+ if choice.may_create:
115
+ if base.name != f"ccx-u{os.getuid()}":
116
+ raise TransportError("untrusted short runtime creation candidate")
117
+ with oslayer.open_root_dir(base.parent) as (_parent, fd):
118
+ st = os.fstat(fd)
119
+ if (not stat.S_ISDIR(st.st_mode) or st.st_uid != 0
120
+ or not st.st_mode & stat.S_ISVTX or not st.st_mode & 0o002):
121
+ raise TransportError("short runtime parent is not trusted sticky root")
122
+ try:
123
+ os.mkdir(base.name, 0o700, dir_fd=fd)
124
+ os.fsync(fd)
125
+ except FileExistsError:
126
+ pass
127
+ _trust_dir(base)
128
+ if path != base:
129
+ if path.parent != base:
130
+ raise TransportError("untrusted runtime child path")
131
+ if choice.may_create:
132
+ with oslayer.open_root_dir(base) as (_, fd):
133
+ try:
134
+ os.mkdir(path.name, 0o700, dir_fd=fd)
135
+ os.fsync(fd)
136
+ except FileExistsError:
137
+ pass
138
+ _trust_dir(path)
139
+ else:
140
+ # Preserve the existing Linux per-UID live-vars mkdir + chmod path.
141
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
142
+ elif not choice.may_create:
143
+ _trust_dir(base)
144
+
145
+
146
+ def plan_transport(store: GenerationStore, *, target: str | None = None,
147
+ role: str = "worker",
148
+ trusted_runtime: Path | str | None = None) -> TransportPlan:
149
+ """No filesystem writes. Select shallow CCX_ROOT/run or private UID fallback.
150
+
151
+ Socket basename for worker is the full 36-char worker UUID; a daemon role
152
+ is a separately checked singleton, never a worker alias.
153
+ """
154
+ if role not in _ALLOWED_ROLES:
155
+ raise TransportError("unknown CCX transport role")
156
+ if role == "worker":
157
+ if target is None:
158
+ raise TransportError("worker socket requires an owned full UUID")
159
+ owner = store.resolve(target)
160
+ worker_id, engine = owner.worker_id, owner.engine
161
+ basename = worker_id + ".sock"
162
+ else:
163
+ if target is not None:
164
+ raise TransportError("daemon singleton cannot use worker target alias")
165
+ store.revalidate()
166
+ worker_id, engine = None, None
167
+ basename = role + ".sock"
168
+ primary = store.root / "run"
169
+ final, stage = _budget(primary, basename)
170
+ if _fits(final) and _fits(stage):
171
+ _trust_dir(primary, may_be_missing=True)
172
+ parent, fallback = primary, False
173
+ else:
174
+ discriminator = hashlib.sha256(os.fsencode(store.root)).hexdigest()[:16]
175
+ suffix = _transport_suffix(discriminator, basename)
176
+ if trusted_runtime is None:
177
+ choice = _default_runtime_dir(suffix_bytes=suffix)
178
+ else:
179
+ choice = oslayer.RuntimeChoice(
180
+ Path(os.path.abspath(os.fspath(trusted_runtime))))
181
+ if choice is None:
182
+ raise TransportError(
183
+ f"CCX root {store.root} is too long for Unix socket paths (108 bytes); "
184
+ "use a shorter CCX_ROOT or a private XDG_RUNTIME_DIR")
185
+ runtime = choice.path
186
+ _trust_dir(runtime, may_be_missing=choice.may_create)
187
+ if not _fits(runtime / "x.sock"):
188
+ raise TransportError("trusted runtime root too deep for pathname AF_UNIX")
189
+ parent = runtime / ("ccx-uuid-" + discriminator)
190
+ if not choice.may_create or os.path.lexists(runtime):
191
+ _trust_dir(parent, may_be_missing=True)
192
+ final, stage = _budget(parent, basename)
193
+ fallback = True
194
+ if not _fits(final) or not _fits(stage):
195
+ raise TransportError("even trusted short runtime root exceeds socket budget")
196
+ if store.pointer.generation_root in final.parents:
197
+ raise TransportError("socket path would live under forbidden generation directory")
198
+ return TransportPlan(store.root, store.generation, parent, final,
199
+ worker_id, engine, role, fallback)
200
+
201
+
202
+ def ensure_transport_parent(store: GenerationStore, plan: TransportPlan,
203
+ *, trusted_runtime: Path | str | None = None) -> None:
204
+ """Create only post-ACTIVE + exact validated plan (never at bootstrap)."""
205
+ store.revalidate()
206
+ required = plan_transport(
207
+ store, target=(f"{plan.engine}:{plan.worker_id}" if plan.role == "worker" else None),
208
+ role=plan.role, trusted_runtime=trusted_runtime,
209
+ )
210
+ if required != plan:
211
+ raise TransportError("transport root, generation or owner plan changed")
212
+ with store.lease():
213
+ if plan.fallback:
214
+ # The original selector is read-only; the post-ACTIVE writer alone
215
+ # creates a missing short UID leaf through a pinned FD on either OS.
216
+ basename = plan.worker_id + ".sock" if plan.worker_id else plan.role + ".sock"
217
+ discriminator = hashlib.sha256(os.fsencode(store.root)).hexdigest()[:16]
218
+ if trusted_runtime is None:
219
+ choice = _default_runtime_dir(
220
+ suffix_bytes=_transport_suffix(discriminator, basename))
221
+ else:
222
+ choice = oslayer.RuntimeChoice(
223
+ Path(os.path.abspath(os.fspath(trusted_runtime))))
224
+ base = plan.parent.parent
225
+ if choice is None or choice.path != base:
226
+ raise TransportError("fallback runtime base changed before creation")
227
+ _ensure_private_runtime_directory(base, choice)
228
+ with _open_dir(base) as fd:
229
+ name = plan.parent.name
230
+ try:
231
+ os.mkdir(name, 0o700, dir_fd=fd)
232
+ os.fsync(fd)
233
+ except FileExistsError:
234
+ pass
235
+ _trust_dir(plan.parent)
236
+ else:
237
+ with _open_dir(store.root) as fd:
238
+ try:
239
+ os.mkdir("run", 0o700, dir_fd=fd)
240
+ os.fsync(fd)
241
+ except FileExistsError:
242
+ pass
243
+ _trust_dir(plan.parent)
244
+
245
+
246
+ @contextmanager
247
+ def daemon_process_lease(store: GenerationStore, plan: TransportPlan):
248
+ """Own a singleton daemon role across its actual socket lifetime.
249
+
250
+ A stable role .lease.lock is held exclusively until the daemon exits;
251
+ only short local writes take the shared activation lease. Native turn
252
+ or subprocess waits must never run under the activation lock. A crash
253
+ leaves the metadata but releases the role flock, so subsequent
254
+ adoption refuses stale peer info even if PID has been recycled.
255
+ """
256
+ if plan.role not in ("codexd", "notifyd") or plan.worker_id is not None:
257
+ raise TransportError("daemon lifetime lease needs a singleton role")
258
+ if (plan.root, plan.generation) != (store.root, store.generation):
259
+ raise TransportError("daemon lease targets a stale generation")
260
+ if plan_transport(
261
+ store, role=plan.role,
262
+ trusted_runtime=plan.parent.parent if plan.fallback else None,
263
+ ) != plan:
264
+ raise TransportError("daemon lease plan is not its verified root/role transport")
265
+ pid = os.getpid()
266
+ start = proc_start_token(pid)
267
+ if not start:
268
+ raise TransportError("cannot bind daemon lease to positive /proc start time")
269
+ nonce = uuid4().hex
270
+ binding = DaemonLease(plan.role, store.generation, nonce, pid, str(start))
271
+ # All inaugural directory and lock-file creation requires a *short*
272
+ # activation lease; do not create dirs on a stale pointer then attempt
273
+ # to revalidate only when writing metadata. The lifetime role lock is
274
+ # acquired separately so no activation lease spans a daemon's lifetime.
275
+ with store.lease():
276
+ with store._gen("runtime-metadata", "pid", create=True) as fd:
277
+ with _flock_at(fd, plan.role + ".lease.lock", exclusive=True,
278
+ create=True, timeout=.5):
279
+ pass
280
+ with store._gen("runtime-metadata", "pid") as fd:
281
+ with _flock_at(fd, plan.role + ".lease.lock", exclusive=True,
282
+ timeout=.5):
283
+ store.revalidate()
284
+ with store.lease():
285
+ _write_json(fd, plan.role + ".json", {
286
+ "schemaVersion": 2, "state": "active",
287
+ "role": plan.role, "rootRealpath": str(store.root),
288
+ "generation": store.generation, "pid": pid,
289
+ "startToken": str(start), "instanceNonce": nonce,
290
+ })
291
+ try:
292
+ yield binding
293
+ finally:
294
+ # Release the role file lock even if the generation changed.
295
+ # The next verifier checks lock occupancy and fresh /proc
296
+ # start token, not just the lingering active JSON.
297
+ try:
298
+ with store.lease(timeout=.1):
299
+ prior = _read_json(fd, plan.role + ".json")
300
+ if (prior and prior.get("instanceNonce") == nonce
301
+ and prior.get("pid") == pid
302
+ and prior.get("generation") == store.generation):
303
+ released = dict(prior)
304
+ released["state"] = "released"
305
+ _write_json(fd, plan.role + ".json", released)
306
+ except (OSError, GenerationError):
307
+ pass
308
+
309
+
310
+ def handshake_payload(plan: TransportPlan, *,
311
+ daemon_nonce: str | None = None) -> dict:
312
+ """Exact root/generation/owner payload; no native-ID routing aliases."""
313
+ if plan.role in ("codexd", "notifyd"):
314
+ if (not isinstance(daemon_nonce, str) or len(daemon_nonce) != 32
315
+ or any(x not in "0123456789abcdef" for x in daemon_nonce)):
316
+ raise TransportError("singleton handshake needs exact active daemon lease nonce")
317
+ return {"protocolVersion": 2, **plan.evidence(),
318
+ "daemonLeaseNonce": daemon_nonce}
319
+ if daemon_nonce is not None:
320
+ raise TransportError("worker socket cannot use daemon role lease")
321
+ return {"protocolVersion": 2, **plan.evidence()}
322
+
323
+
324
+ def verify_handshake(store: GenerationStore, plan: TransportPlan, *,
325
+ received: Mapping, connected_socket: socket.socket,
326
+ peer_uid: int | None = None) -> None:
327
+ """Exact Linux peer UID/PID/start plus global full owner or daemon lease."""
328
+ if not isinstance(received, Mapping):
329
+ raise TransportError("invalid socket handshake")
330
+ store.revalidate()
331
+ if (plan.root, plan.generation) != (store.root, store.generation):
332
+ raise TransportError("socket plan root/generation does not match active owner store")
333
+ expected_plan = plan_transport(
334
+ store,
335
+ target=(f"{plan.engine}:{plan.worker_id}" if plan.role == "worker" else None),
336
+ role=plan.role,
337
+ trusted_runtime=plan.parent.parent if plan.fallback else None,
338
+ )
339
+ if expected_plan != plan:
340
+ raise TransportError("socket plan is not the verified full-ID/root/role address")
341
+ expected = {"protocolVersion": 2, **plan.evidence()}
342
+ if any(received.get(key) != val for key, val in expected.items()):
343
+ raise TransportError("socket handshake root/generation/full owner mismatch")
344
+ uid = os.getuid() if peer_uid is None else peer_uid
345
+ try:
346
+ credentials = oslayer.peer_credentials(connected_socket)
347
+ except oslayer.PeerIdentityUnavailable as exc:
348
+ raise TransportError("socket peer credentials unavailable") from exc
349
+ pid, actual_uid = credentials.pid, credentials.uid
350
+ if pid <= 0 or actual_uid != uid:
351
+ raise TransportError("socket peer UID mismatch")
352
+ start = proc_start_token(pid)
353
+ if not start:
354
+ raise TransportError("socket peer live start time could not be attested")
355
+ if plan.role == "worker":
356
+ if dict(received) != expected:
357
+ raise TransportError("worker handshake has unexpected unowned fields")
358
+ owner = store.resolve(f"{plan.engine}:{plan.worker_id}")
359
+ if (owner.worker_id, owner.engine) != (plan.worker_id, plan.engine):
360
+ raise TransportError("socket global full-ID worker owner mismatch")
361
+ rec = store.record(owner.worker)
362
+ lifecycle = rec.get("lifecycle")
363
+ if (not isinstance(lifecycle, dict)
364
+ or lifecycle.get("hostPid") != pid
365
+ or str(lifecycle.get("hostStart")) != str(start)):
366
+ raise TransportError("worker socket peer is not current PID/start-token owner")
367
+ return
368
+ if plan.role not in ("codexd", "notifyd"):
369
+ raise TransportError("unknown singleton transport role")
370
+ nonce = received.get("daemonLeaseNonce")
371
+ if not isinstance(nonce, str) or len(nonce) != 32:
372
+ raise TransportError("missing daemon lease nonce in peer handshake")
373
+ if dict(received) != {**expected, "daemonLeaseNonce": nonce}:
374
+ raise TransportError("unexpected daemon handshake identity field")
375
+ try:
376
+ with store._gen("runtime-metadata", "pid") as fd:
377
+ metadata = _read_json(fd, plan.role + ".json")
378
+ if (not metadata or metadata.get("schemaVersion") != 2
379
+ or metadata.get("state") != "active"
380
+ or metadata.get("role") != plan.role
381
+ or metadata.get("rootRealpath") != str(store.root)
382
+ or metadata.get("generation") != store.generation
383
+ or metadata.get("instanceNonce") != nonce
384
+ or metadata.get("pid") != pid
385
+ or str(metadata.get("startToken")) != str(start)):
386
+ raise TransportError("daemon socket is not bound to current runtime PID/lease")
387
+ # Positive proof another open file description holds the ROLE
388
+ # flock exclusively. Merely having the metadata is insufficient:
389
+ # if the daemon crashed its kernel lock is no longer held.
390
+ guard = os.open(plan.role + ".lease.lock",
391
+ os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC,
392
+ dir_fd=fd)
393
+ try:
394
+ st = os.fstat(guard)
395
+ if (not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid()
396
+ or st.st_mode & 0o077):
397
+ raise TransportError("daemon singleton lease inode is unsafe")
398
+ try:
399
+ fcntl.flock(guard, fcntl.LOCK_SH | fcntl.LOCK_NB)
400
+ except BlockingIOError:
401
+ pass # active exclusive lease held; required
402
+ else:
403
+ fcntl.flock(guard, fcntl.LOCK_UN)
404
+ raise TransportError("daemon metadata is stale (no live role lease)")
405
+ finally:
406
+ os.close(guard)
407
+ except FileNotFoundError as exc:
408
+ raise TransportError("daemon socket lacks generation PID/lease proof") from exc
409
+ store.revalidate()
@@ -0,0 +1,125 @@
1
+ """Pure V2 Local JSON worker/result presentation; never a native state adapter.
2
+
3
+ This module deliberately owns no store, host, or daemon. Its callers provide a
4
+ verified owned record and any already-read (bounded) enrichment. In particular,
5
+ nothing here changes WorkerClient, the CLI, or protocol_ops return contracts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ccx.store.worker_state import normalize_native_state
13
+
14
+ WORKER_KEYS = (
15
+ "worker", "engine", "name", "cwd", "model", "effort", "state",
16
+ "native_state", "state_source", "loaded", "native_id", "forked_from",
17
+ "created_at", "last_turn_at", "managed_turns", "active_turn_ref",
18
+ "last_settled_turn", "pending_input", "pending_input_observed", "context",
19
+ "background_tasks",
20
+ )
21
+ CONTEXT_KEYS = (
22
+ "tokens_used", "window_size", "percent_used", "reason", "as_of_ms",
23
+ "as_of_turn_ref",
24
+ )
25
+
26
+
27
+ def context_view(value: dict[str, Any] | None) -> dict[str, Any]:
28
+ """A completed recorded read has an object even without a token signal."""
29
+ value = value if isinstance(value, dict) else {}
30
+ out = {key: value.get(key) for key in CONTEXT_KEYS}
31
+ if out["tokens_used"] is None and not out["reason"]:
32
+ out["reason"] = "pending" if value.get("state") == "pending" else "no-signal"
33
+ return out
34
+
35
+
36
+ def background_view(value: dict[str, Any] | None) -> dict[str, Any] | None:
37
+ """Group the existing bounded protocol_ops background facts once."""
38
+ if value is None:
39
+ return None
40
+ if not value.get("background_tasks_supported", False):
41
+ return {"supported": False, "tasks": [], "running_count": None,
42
+ "truncated": False}
43
+ result = {
44
+ "supported": True,
45
+ "tasks": list(value.get("background_tasks") or []),
46
+ "running_count": value.get("running_background_task_count"),
47
+ "truncated": bool(value.get("background_tasks_truncated") or
48
+ value.get("background_task_details_truncated")),
49
+ }
50
+ if value.get("background_task_summary") is not None:
51
+ result["summary"] = value["background_task_summary"]
52
+ return result
53
+
54
+
55
+ def project_worker_view(
56
+ handle: str,
57
+ record: dict[str, Any],
58
+ *,
59
+ context: dict[str, Any] | None = None,
60
+ background: dict[str, Any] | None = None,
61
+ last_settled_turn: dict[str, Any] | None = None,
62
+ active_turn_ref: str | None = None,
63
+ pending_input: dict[str, Any] | None = None,
64
+ ) -> dict[str, Any]:
65
+ """Serialize one recorded owned worker with exactly 21 fixed keys.
66
+
67
+ The handle has already been proven by the caller's generation/owner lookup.
68
+ An observed native verdict remains separate: V2 wait intentionally uses
69
+ this recorded worker view, never a second host/daemon observation.
70
+ """
71
+ from ccx.ops.forking import public_lineage
72
+
73
+ engine, _, _worker_id = handle.partition(":")
74
+ lifecycle = record.get("lifecycle")
75
+ lifecycle = lifecycle if isinstance(lifecycle, dict) else {}
76
+ native = record.get("native")
77
+ native = native if isinstance(native, dict) else {}
78
+ create_args = record.get("createArgs")
79
+ create_args = create_args if isinstance(create_args, dict) else {}
80
+ stats = record.get("stats")
81
+ stats = stats if isinstance(stats, dict) else {}
82
+ raw_state = lifecycle.get("state")
83
+ lineage = public_lineage(record)
84
+ if lineage is not None:
85
+ lineage = {
86
+ "worker": lineage.get("worker"),
87
+ "native_id": (lineage.get("session_id") if engine == "claude"
88
+ else lineage.get("thread_id")),
89
+ "forked_at_ms": lineage.get("forked_at_ms"),
90
+ }
91
+ if last_settled_turn is not None:
92
+ last_settled_turn = {
93
+ "turn_ref": last_settled_turn.get("turn_ref"),
94
+ "turn_id": last_settled_turn.get("turn_id"),
95
+ "outcome": last_settled_turn.get("outcome"),
96
+ "completed_at": last_settled_turn.get("completed_at"),
97
+ "context": (context_view(last_settled_turn["context"])
98
+ if isinstance(last_settled_turn.get("context"), dict) else None),
99
+ }
100
+ view = {
101
+ "worker": handle,
102
+ "engine": engine,
103
+ "name": record.get("name"),
104
+ "cwd": record.get("cwd"),
105
+ "model": create_args.get("model"),
106
+ "effort": create_args.get("effort"),
107
+ "state": normalize_native_state(raw_state),
108
+ "native_state": raw_state,
109
+ "state_source": "recorded",
110
+ "loaded": None,
111
+ "native_id": (native.get("sessionId") if engine == "claude"
112
+ else native.get("threadId")),
113
+ "forked_from": lineage,
114
+ "created_at": record.get("createdAt"),
115
+ "last_turn_at": record.get("lastTurnAt"),
116
+ "managed_turns": stats.get("turns"),
117
+ "active_turn_ref": active_turn_ref,
118
+ "last_settled_turn": last_settled_turn,
119
+ "pending_input": pending_input,
120
+ "pending_input_observed": False,
121
+ "context": context,
122
+ "background_tasks": background_view(background),
123
+ }
124
+ assert tuple(view) == WORKER_KEYS
125
+ return view
File without changes