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.
- package/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -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 +807 -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 +269 -0
- package/ccx/claude/host/session.py +5244 -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 +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -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 +1558 -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 +2424 -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/history_store.py +131 -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 +1454 -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/models.py +225 -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 +296 -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 +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -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 +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -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 +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -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/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- 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()
|
package/ccx/rpc/view.py
ADDED
|
@@ -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
|