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,1706 @@
|
|
|
1
|
+
"""Shared/durable Python variables for CCX-managed workers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from collections.abc import Mapping, MutableMapping
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
import importlib
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import keyword
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import queue
|
|
16
|
+
import shlex
|
|
17
|
+
import shutil
|
|
18
|
+
import socket
|
|
19
|
+
import stat
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import sysconfig
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ccx import constants
|
|
29
|
+
from ccx.system import oslayer
|
|
30
|
+
from ccx.errors import CapabilityUnsupported, InvalidRequest, SessionUnavailable
|
|
31
|
+
from ccx.system.executable import current_executable_path
|
|
32
|
+
from ccx.controllers.live_vars_runner import execute_target, strict_json, STATE_FILE_LIMIT
|
|
33
|
+
from ccx.store.paths import accept_same_user, bind_private_unix_socket, _require_unix_socket_budget
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
VARIABLE_SCHEMA_VERSION = 2
|
|
37
|
+
VARIABLE_STATE_SCHEMA_VERSION = 1
|
|
38
|
+
_WIRE_DUMPS = json.dumps
|
|
39
|
+
_WIRE_LOADS = json.loads
|
|
40
|
+
_NATIVE_FALLTHROUGH_MODULES = frozenset({"pip", "venv", "ensurepip"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _root() -> Path:
|
|
44
|
+
from ccx.store.root import require_ccx_active
|
|
45
|
+
return require_ccx_active(constants.CCX_ROOT).pointer.generation_root / "variables"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sidecar_path(engine: str, short: str) -> Path:
|
|
49
|
+
store, owner = _env_worker(engine, short)
|
|
50
|
+
return store.pointer.generation_root / "variables" / engine / f"{owner.worker_id}.json"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sidecar_lock_path(engine: str, short: str) -> Path:
|
|
54
|
+
store, owner = _env_worker(engine, short)
|
|
55
|
+
return store.pointer.generation_root / "variables" / engine / f"{owner.worker_id}.lock"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def states_dir() -> Path:
|
|
59
|
+
return _root() / "states"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def state_path(state_id: str) -> Path:
|
|
63
|
+
return states_dir() / f"{_valid_state_id(state_id)}.json"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def state_lock_path(state_id: str) -> Path:
|
|
67
|
+
return states_dir() / f"{_valid_state_id(state_id)}.lock"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _env_worker(engine: str, worker_id: str):
|
|
71
|
+
from ccx.store.root import require_ccx_active
|
|
72
|
+
from ccx.store.identity import EngineMismatch
|
|
73
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
74
|
+
owner = store.resolve(worker_id)
|
|
75
|
+
if owner.engine != engine:
|
|
76
|
+
raise EngineMismatch("Worker uses a different engine; check the handle")
|
|
77
|
+
return store, owner
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def shell_env_path(engine: str, short: str) -> Path:
|
|
81
|
+
store, owner = _env_worker(engine, short)
|
|
82
|
+
return (store.pointer.generation_root / "variables" / "env" /
|
|
83
|
+
engine / f"{owner.worker_id}.sh")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def claude_bridge_path(short: str) -> Path:
|
|
87
|
+
store, owner = _env_worker("claude", short)
|
|
88
|
+
return (store.pointer.generation_root / "variables" / "env" /
|
|
89
|
+
"claude" / f"{owner.worker_id}.bridge.json")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _owner_runtime(
|
|
93
|
+
basename: str | None = None,
|
|
94
|
+
) -> tuple[Path, oslayer.RuntimeChoice]:
|
|
95
|
+
"""Plan the full live-owner socket suffix before any filesystem writes."""
|
|
96
|
+
dirname = f"ccx-live-vars-{os.getuid()}"
|
|
97
|
+
suffix = (max(
|
|
98
|
+
len(os.fsencode(f"/{dirname}/{basename}")) + 1,
|
|
99
|
+
len(os.fsencode(f"/{dirname}/.XXXXXXXX/s")) + 1,
|
|
100
|
+
) if basename else 0)
|
|
101
|
+
choice = oslayer.runtime_base(purpose="live_vars", suffix_bytes=suffix)
|
|
102
|
+
if choice is None:
|
|
103
|
+
raise PermissionError("no private owner-socket runtime base available")
|
|
104
|
+
return choice.path / dirname, choice
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _owners_path() -> Path:
|
|
108
|
+
return _owner_runtime()[0]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def owners_dir(
|
|
112
|
+
path: Path | None = None, *, runtime: oslayer.RuntimeChoice | None = None,
|
|
113
|
+
) -> Path:
|
|
114
|
+
if path is None:
|
|
115
|
+
path, runtime = _owner_runtime()
|
|
116
|
+
if runtime is None:
|
|
117
|
+
runtime = oslayer.RuntimeChoice(path.parent)
|
|
118
|
+
from ccx.rpc.transport import _ensure_private_runtime_directory
|
|
119
|
+
_ensure_private_runtime_directory(path, runtime)
|
|
120
|
+
st = os.lstat(path)
|
|
121
|
+
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid():
|
|
122
|
+
raise PermissionError(
|
|
123
|
+
f"live-vars owner directory is not private and user-owned: {path}"
|
|
124
|
+
)
|
|
125
|
+
os.chmod(path, 0o700)
|
|
126
|
+
return path
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _atomic_json(path: Path, value: dict[str, Any]) -> None:
|
|
130
|
+
"""Main's atomic state replacement inside the validated private generation."""
|
|
131
|
+
text = strict_json(value)
|
|
132
|
+
if len(text.encode("utf-8")) > STATE_FILE_LIMIT:
|
|
133
|
+
raise InvalidRequest("worker variables exceed the 4 MiB state limit")
|
|
134
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
135
|
+
tmp.write_text(text, encoding="utf-8")
|
|
136
|
+
try:
|
|
137
|
+
os.chmod(tmp, 0o600)
|
|
138
|
+
os.replace(tmp, path)
|
|
139
|
+
finally:
|
|
140
|
+
tmp.unlink(missing_ok=True)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@contextmanager
|
|
144
|
+
def _worker_lock(engine: str, short: str):
|
|
145
|
+
from ccx.store.root import _flock_at
|
|
146
|
+
store, owner = _env_worker(engine, short)
|
|
147
|
+
with store.lease():
|
|
148
|
+
with store._gen("variables", engine, create=True) as directory:
|
|
149
|
+
with _flock_at(directory, owner.worker_id + ".lock",
|
|
150
|
+
exclusive=True, create=True):
|
|
151
|
+
yield
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@contextmanager
|
|
155
|
+
def _state_lock(state_id: str, *, create: bool = False):
|
|
156
|
+
from ccx.store.root import _flock_at, require_ccx_active
|
|
157
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
158
|
+
with store.lease():
|
|
159
|
+
with store._gen("variables", "states", create=create) as directory:
|
|
160
|
+
with _flock_at(directory, _valid_state_id(state_id) + ".lock",
|
|
161
|
+
exclusive=True, create=create):
|
|
162
|
+
yield
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _valid_state_id(state_id: Any) -> str:
|
|
166
|
+
value = str(state_id or "")
|
|
167
|
+
if len(value) != 32 or any(ch not in "0123456789abcdef" for ch in value):
|
|
168
|
+
raise SessionUnavailable(f"invalid durable variable state id: {value!r}")
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _new_state_id() -> str:
|
|
173
|
+
return uuid.uuid4().hex
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def read_sidecar(engine: str, short: str) -> dict[str, Any] | None:
|
|
177
|
+
"""Read a binding only after resolving its exact global full-UUID owner."""
|
|
178
|
+
from ccx.store.root import _read_json, StorageIntegrityError
|
|
179
|
+
store, owner = _env_worker(engine, short)
|
|
180
|
+
with store.lease():
|
|
181
|
+
try:
|
|
182
|
+
with store._gen("variables", engine) as directory:
|
|
183
|
+
value = _read_json(directory, owner.worker_id + ".json")
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
return None
|
|
186
|
+
if value is not None and (
|
|
187
|
+
value.get("schemaVersion") != VARIABLE_SCHEMA_VERSION
|
|
188
|
+
or value.get("engine") != owner.engine
|
|
189
|
+
or value.get("workerId") != owner.worker_id
|
|
190
|
+
or value.get("mode") not in {"live", "durable"}
|
|
191
|
+
):
|
|
192
|
+
raise StorageIntegrityError("Worker variable reference is incomplete; use the full handle")
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def read_state(state_id: str) -> dict[str, Any]:
|
|
197
|
+
from ccx.store.root import _read_json, require_ccx_active, StorageIntegrityError
|
|
198
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
199
|
+
with store.lease():
|
|
200
|
+
try:
|
|
201
|
+
with store._gen("variables", "states") as directory:
|
|
202
|
+
value = _read_json(directory, _valid_state_id(state_id) + ".json")
|
|
203
|
+
except FileNotFoundError:
|
|
204
|
+
value = None
|
|
205
|
+
if (value is None or value.get("schemaVersion") != VARIABLE_STATE_SCHEMA_VERSION
|
|
206
|
+
or value.get("stateId") != state_id
|
|
207
|
+
or not isinstance(value.get("names"), list)
|
|
208
|
+
or not isinstance(value.get("variables"), dict)):
|
|
209
|
+
raise StorageIntegrityError("Worker variables are unavailable; inspect the worker")
|
|
210
|
+
names, variables = value["names"], value["variables"]
|
|
211
|
+
if (any(not isinstance(name, str) or not name.isidentifier()
|
|
212
|
+
or keyword.iskeyword(name) for name in names)
|
|
213
|
+
or len(set(names)) != len(names)
|
|
214
|
+
or set(names) != set(variables)):
|
|
215
|
+
raise StorageIntegrityError("Worker variable names do not match saved values; inspect them")
|
|
216
|
+
return value
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _new_state_document(
|
|
220
|
+
state_id: str,
|
|
221
|
+
*,
|
|
222
|
+
names: list[str],
|
|
223
|
+
variables: dict[str, Any],
|
|
224
|
+
) -> dict[str, Any]:
|
|
225
|
+
return {
|
|
226
|
+
"schemaVersion": VARIABLE_STATE_SCHEMA_VERSION,
|
|
227
|
+
"stateId": _valid_state_id(state_id),
|
|
228
|
+
"names": sorted(names),
|
|
229
|
+
"variables": _json_clone(variables, "durable variables"),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def durable_state_id(
|
|
234
|
+
engine: str,
|
|
235
|
+
short: str,
|
|
236
|
+
value: dict[str, Any] | None = None,
|
|
237
|
+
) -> str:
|
|
238
|
+
from ccx.store.root import StorageIntegrityError
|
|
239
|
+
sidecar = value if value is not None else read_sidecar(engine, short)
|
|
240
|
+
if not sidecar or sidecar.get("mode") != "durable":
|
|
241
|
+
raise SessionUnavailable(f"{engine}:{short} has no shared variables; attach variables when creating it")
|
|
242
|
+
if not sidecar.get("stateId"):
|
|
243
|
+
raise StorageIntegrityError("Shared variable state is missing; inspect the worker")
|
|
244
|
+
return _valid_state_id(sidecar["stateId"])
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _valid_name(name: Any) -> str:
|
|
248
|
+
if (not isinstance(name, str) or not name.isidentifier()
|
|
249
|
+
or keyword.iskeyword(name) or name.startswith("__")):
|
|
250
|
+
raise InvalidRequest(
|
|
251
|
+
f"variable names must be valid non-keyword Python identifiers, got {name!r}"
|
|
252
|
+
)
|
|
253
|
+
return name
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _names(values: Mapping[str, Any]) -> list[str]:
|
|
257
|
+
return sorted(_valid_name(name) for name in values.keys())
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _json_clone(value: Any, label: str) -> Any:
|
|
261
|
+
try:
|
|
262
|
+
return json.loads(strict_json(value))
|
|
263
|
+
except (TypeError, ValueError) as error:
|
|
264
|
+
raise InvalidRequest(f"{label} must be JSON-compatible: {error}") from error
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def parse_cli_vars(items: list[str] | None) -> dict[str, Any]:
|
|
268
|
+
result: dict[str, Any] = {}
|
|
269
|
+
for raw in items or []:
|
|
270
|
+
if "=" not in raw:
|
|
271
|
+
raise InvalidRequest(
|
|
272
|
+
f"--var expects NAME=JSON, got {raw!r}"
|
|
273
|
+
)
|
|
274
|
+
name, encoded = raw.split("=", 1)
|
|
275
|
+
name = _valid_name(name)
|
|
276
|
+
if name in result:
|
|
277
|
+
raise InvalidRequest(f"duplicate --var binding {name!r}")
|
|
278
|
+
try:
|
|
279
|
+
value = json.loads(encoded)
|
|
280
|
+
except ValueError as error:
|
|
281
|
+
raise InvalidRequest(
|
|
282
|
+
f"--var {name}=... must contain valid JSON: {error}"
|
|
283
|
+
) from error
|
|
284
|
+
result[name] = _json_clone(value, f"variable {name!r}")
|
|
285
|
+
return result
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _active_controller_runtime():
|
|
289
|
+
try:
|
|
290
|
+
module = importlib.import_module('ccx.controllers.controller')
|
|
291
|
+
return module._active_runtime.get()
|
|
292
|
+
except Exception:
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass
|
|
297
|
+
class PreparedAttachment:
|
|
298
|
+
mode: str
|
|
299
|
+
names: list[str]
|
|
300
|
+
mapping: MutableMapping[str, Any] | None = None
|
|
301
|
+
values: dict[str, Any] | None = None
|
|
302
|
+
state_id: str | None = None
|
|
303
|
+
owner: "LiveOwner | None" = None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _check_initial_durable_size(names: list[str], values: dict[str, Any]) -> None:
|
|
307
|
+
# A fresh state has a fixed-length state ID; reject before worker creation.
|
|
308
|
+
document = {"schemaVersion": VARIABLE_STATE_SCHEMA_VERSION,
|
|
309
|
+
"stateId": "0" * 32, "names": names, "variables": values}
|
|
310
|
+
if len(strict_json(document).encode("utf-8")) > STATE_FILE_LIMIT:
|
|
311
|
+
raise InvalidRequest("worker variables exceed the 4 MiB state limit")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def prepare_attachment(values: Mapping[str, Any] | None) -> PreparedAttachment | None:
|
|
315
|
+
if values is None:
|
|
316
|
+
return None
|
|
317
|
+
if not isinstance(values, Mapping):
|
|
318
|
+
raise InvalidRequest("variables must be a mapping of Python names to values")
|
|
319
|
+
names = _names(values)
|
|
320
|
+
runtime = _active_controller_runtime()
|
|
321
|
+
if runtime is not None:
|
|
322
|
+
if not isinstance(values, MutableMapping):
|
|
323
|
+
raise InvalidRequest(
|
|
324
|
+
"controller-backed variables require a mutable mapping so worker rebinding can write back"
|
|
325
|
+
)
|
|
326
|
+
owner = runtime.live_vars_owner()
|
|
327
|
+
return PreparedAttachment(
|
|
328
|
+
mode="live",
|
|
329
|
+
names=names,
|
|
330
|
+
mapping=values,
|
|
331
|
+
owner=owner,
|
|
332
|
+
)
|
|
333
|
+
durable = _json_clone(dict(values), "variables")
|
|
334
|
+
_check_initial_durable_size(names, durable)
|
|
335
|
+
return PreparedAttachment(mode="durable", names=names, values=durable)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def prepare_durable_attachment(values: Mapping[str, Any] | None) -> PreparedAttachment | None:
|
|
339
|
+
if values is None:
|
|
340
|
+
return None
|
|
341
|
+
if not isinstance(values, Mapping):
|
|
342
|
+
raise InvalidRequest("variables must be a mapping of Python names to values")
|
|
343
|
+
names = _names(values)
|
|
344
|
+
durable = _json_clone(dict(values), "variables")
|
|
345
|
+
_check_initial_durable_size(names, durable)
|
|
346
|
+
return PreparedAttachment(mode="durable", names=names, values=durable)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def prepare_shared_durable_attachment(
|
|
350
|
+
engine: str,
|
|
351
|
+
short: str,
|
|
352
|
+
) -> PreparedAttachment:
|
|
353
|
+
store, owner = _env_worker(engine, short)
|
|
354
|
+
info = store.variables_info(owner.worker)
|
|
355
|
+
if info["mode"] != "durable":
|
|
356
|
+
raise InvalidRequest(f"{owner.worker} has no durable variables to share")
|
|
357
|
+
if not info["available"]:
|
|
358
|
+
raise SessionUnavailable(f"{owner.worker} durable variables are unavailable")
|
|
359
|
+
return PreparedAttachment(
|
|
360
|
+
mode="durable",
|
|
361
|
+
names=info["names"],
|
|
362
|
+
state_id=info["state_id"],
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _owner_gone(owner: Mapping[str, Any]) -> bool:
|
|
367
|
+
socket_path = owner.get("socket")
|
|
368
|
+
if not isinstance(socket_path, str) or not socket_path:
|
|
369
|
+
return False
|
|
370
|
+
try:
|
|
371
|
+
os.lstat(socket_path)
|
|
372
|
+
except FileNotFoundError:
|
|
373
|
+
return True
|
|
374
|
+
except OSError:
|
|
375
|
+
return False
|
|
376
|
+
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
377
|
+
try:
|
|
378
|
+
client.settimeout(0.25)
|
|
379
|
+
client.connect(socket_path)
|
|
380
|
+
return False
|
|
381
|
+
except (FileNotFoundError, ConnectionRefusedError):
|
|
382
|
+
return True
|
|
383
|
+
except OSError:
|
|
384
|
+
return False
|
|
385
|
+
finally:
|
|
386
|
+
client.close()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _unlink_sidecar(store, engine: str, worker_id: str) -> None:
|
|
390
|
+
with store.lease():
|
|
391
|
+
with store._gen("variables", engine) as directory:
|
|
392
|
+
os.unlink(worker_id + ".json", dir_fd=directory)
|
|
393
|
+
os.fsync(directory)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _clear_dead_binding(engine: str, worker_id: str, token: str) -> None:
|
|
397
|
+
store, owner = _env_worker(engine, worker_id)
|
|
398
|
+
with _worker_lock(engine, owner.worker_id):
|
|
399
|
+
value = read_sidecar(engine, owner.worker_id)
|
|
400
|
+
if (value and value.get("mode") == "live"
|
|
401
|
+
and (value.get("owner") or {}).get("token") == token):
|
|
402
|
+
# Recheck after acquiring the writer lock.
|
|
403
|
+
if _owner_gone(value["owner"]):
|
|
404
|
+
_unlink_sidecar(store, engine, owner.worker_id)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def publish_attachment(
|
|
409
|
+
attachment: PreparedAttachment | None,
|
|
410
|
+
engine: str,
|
|
411
|
+
short: str,
|
|
412
|
+
) -> None:
|
|
413
|
+
if attachment is None:
|
|
414
|
+
return
|
|
415
|
+
if not attachment.names and attachment.state_id is None:
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
store, owner = _env_worker(engine, short)
|
|
419
|
+
with _worker_lock(engine, owner.worker_id):
|
|
420
|
+
existing = read_sidecar(engine, owner.worker_id)
|
|
421
|
+
if attachment.mode == "live":
|
|
422
|
+
assert attachment.owner is not None
|
|
423
|
+
assert attachment.mapping is not None
|
|
424
|
+
if existing and existing["mode"] == "durable":
|
|
425
|
+
raise InvalidRequest(
|
|
426
|
+
f"{owner.worker} already has shared variables; choose one variable mode"
|
|
427
|
+
)
|
|
428
|
+
if existing:
|
|
429
|
+
old_owner = existing.get("owner")
|
|
430
|
+
if isinstance(old_owner, dict):
|
|
431
|
+
if old_owner.get("token") != attachment.owner.token:
|
|
432
|
+
if _owner_gone(old_owner):
|
|
433
|
+
_unlink_sidecar(store, engine, owner.worker_id)
|
|
434
|
+
existing = None
|
|
435
|
+
else:
|
|
436
|
+
raise InvalidRequest(
|
|
437
|
+
f"{owner.worker} already has a reachable live variable owner"
|
|
438
|
+
)
|
|
439
|
+
attachment.owner.register_worker(
|
|
440
|
+
engine, owner.worker_id, attachment.mapping, attachment.names
|
|
441
|
+
)
|
|
442
|
+
_atomic_json(sidecar_path(engine, owner.worker_id), {
|
|
443
|
+
"schemaVersion": VARIABLE_SCHEMA_VERSION,
|
|
444
|
+
"engine": engine,
|
|
445
|
+
"workerId": owner.worker_id,
|
|
446
|
+
"mode": "live",
|
|
447
|
+
"names": attachment.owner.worker_names(engine, owner.worker_id),
|
|
448
|
+
"owner": attachment.owner.public_identity(),
|
|
449
|
+
})
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
if attachment.mode != "durable":
|
|
453
|
+
raise InvalidRequest(f"unknown variable mode {attachment.mode!r}")
|
|
454
|
+
if existing and existing["mode"] == "live":
|
|
455
|
+
old_owner = existing.get("owner")
|
|
456
|
+
if isinstance(old_owner, dict) and _owner_gone(old_owner):
|
|
457
|
+
_unlink_sidecar(store, engine, owner.worker_id)
|
|
458
|
+
existing = None
|
|
459
|
+
else:
|
|
460
|
+
raise InvalidRequest(
|
|
461
|
+
f"{owner.worker} already owns live variables; cannot mix modes"
|
|
462
|
+
)
|
|
463
|
+
if attachment.state_id is not None:
|
|
464
|
+
state_id = _valid_state_id(attachment.state_id)
|
|
465
|
+
with _state_lock(state_id):
|
|
466
|
+
read_state(state_id)
|
|
467
|
+
_atomic_json(sidecar_path(engine, owner.worker_id), {
|
|
468
|
+
"schemaVersion": VARIABLE_SCHEMA_VERSION,
|
|
469
|
+
"engine": engine,
|
|
470
|
+
"workerId": owner.worker_id,
|
|
471
|
+
"mode": "durable",
|
|
472
|
+
"stateId": state_id,
|
|
473
|
+
})
|
|
474
|
+
return
|
|
475
|
+
|
|
476
|
+
assert attachment.values is not None
|
|
477
|
+
if existing:
|
|
478
|
+
state_id = durable_state_id(engine, owner.worker_id, existing)
|
|
479
|
+
else:
|
|
480
|
+
state_id = _new_state_id()
|
|
481
|
+
with _state_lock(state_id, create=existing is None):
|
|
482
|
+
if existing:
|
|
483
|
+
document = read_state(state_id)
|
|
484
|
+
values = dict(document["variables"])
|
|
485
|
+
names = set(document["names"])
|
|
486
|
+
else:
|
|
487
|
+
values, names = {}, set()
|
|
488
|
+
values.update(attachment.values)
|
|
489
|
+
names.update(attachment.names)
|
|
490
|
+
_atomic_json(state_path(state_id), _new_state_document(
|
|
491
|
+
state_id, names=sorted(names), variables=values,
|
|
492
|
+
))
|
|
493
|
+
_atomic_json(sidecar_path(engine, owner.worker_id), {
|
|
494
|
+
"schemaVersion": VARIABLE_SCHEMA_VERSION,
|
|
495
|
+
"engine": engine,
|
|
496
|
+
"workerId": owner.worker_id,
|
|
497
|
+
"mode": "durable",
|
|
498
|
+
"stateId": state_id,
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def variables_info(engine: str, short: str, name: str | None = None) -> dict[str, Any]:
|
|
503
|
+
"""Use the already-accepted single full-UUID variable read authority."""
|
|
504
|
+
store, owner = _env_worker(engine, short)
|
|
505
|
+
return store.variables_info(owner.worker, name=name)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _runtime_state(owner) -> str | None:
|
|
509
|
+
try:
|
|
510
|
+
if owner.engine == "claude":
|
|
511
|
+
from ccx.rpc.rpc import _rpc
|
|
512
|
+
reply = _rpc(owner.worker, {"op": "status"}, timeout=0.5)
|
|
513
|
+
return reply.get("state") if reply.get("ok") is True else None
|
|
514
|
+
from ccx.codex.runtime import observe_worker
|
|
515
|
+
observation = observe_worker(owner.worker_id, timeout=0.5)
|
|
516
|
+
if not observation["daemon_served"]:
|
|
517
|
+
return None
|
|
518
|
+
return observation["reply"].get("state")
|
|
519
|
+
except Exception: # an unreadable runtime never refuses; the send decides
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def should_publish_send_attachment(store, owner, delivery_id: str | None,
|
|
524
|
+
*, caps: dict | None = None) -> bool:
|
|
525
|
+
"""Preflight likely native refusal before touching attached variables.
|
|
526
|
+
|
|
527
|
+
Native admission may race after this check. A previously admitted delivery
|
|
528
|
+
token never reapplies its original variable values.
|
|
529
|
+
"""
|
|
530
|
+
if delivery_id:
|
|
531
|
+
if owner.engine == "claude":
|
|
532
|
+
from ccx.claude.delivery import read_receipt
|
|
533
|
+
else:
|
|
534
|
+
from ccx.codex.delivery import read_receipt
|
|
535
|
+
prior = read_receipt(owner.worker, delivery_id)
|
|
536
|
+
if prior and prior.get("state") in (
|
|
537
|
+
"owned", "submitted", "accepted", "rejected", "delivered", "abandoned",
|
|
538
|
+
):
|
|
539
|
+
return False
|
|
540
|
+
|
|
541
|
+
from ccx.errors import BudgetStopped, DeliveryRejected
|
|
542
|
+
record = store.record(owner.worker)
|
|
543
|
+
state = (record.get("lifecycle") or {}).get("state")
|
|
544
|
+
# A host or daemon that died mid-turn leaves a stale busy record that the
|
|
545
|
+
# send itself repairs, so only a live runtime's own state refuses here.
|
|
546
|
+
if state in ("busy", "waiting"):
|
|
547
|
+
state = _runtime_state(owner)
|
|
548
|
+
if state in ("busy", "waiting"):
|
|
549
|
+
raise DeliveryRejected(
|
|
550
|
+
f"worker is {state}; wait until idle before sending variables",
|
|
551
|
+
short=owner.short, state=state,
|
|
552
|
+
refusal="busy" if state == "busy" else "state_unavailable",
|
|
553
|
+
delivery_status="rejected",
|
|
554
|
+
)
|
|
555
|
+
if caps:
|
|
556
|
+
if owner.engine == "claude":
|
|
557
|
+
from ccx.claude.usage import cap_tripped, read_usage_cached
|
|
558
|
+
from ccx.constants import USAGE_STALE_LIMIT_S
|
|
559
|
+
data = read_usage_cached(stale_limit=USAGE_STALE_LIMIT_S)
|
|
560
|
+
trip = cap_tripped(data, caps)
|
|
561
|
+
else:
|
|
562
|
+
from ccx.codex.types import codex_cap_tripped
|
|
563
|
+
# A cap preflight may observe the existing daemon, never launch
|
|
564
|
+
# a second provider solely for a speculative send.
|
|
565
|
+
from ccx.codex.runtime import observation_daemon, daemon_call
|
|
566
|
+
deadline = time.monotonic() + 0.5
|
|
567
|
+
try:
|
|
568
|
+
data = (daemon_call({"op": "account-read", "tokens": False}, timeout=0.5)
|
|
569
|
+
if observation_daemon(deadline) else None)
|
|
570
|
+
if isinstance(data, dict) and "data" in data:
|
|
571
|
+
data = data["data"]
|
|
572
|
+
except (OSError, ValueError, SessionUnavailable):
|
|
573
|
+
data = None
|
|
574
|
+
trip = codex_cap_tripped(data, caps)
|
|
575
|
+
if trip:
|
|
576
|
+
key, used, limit = trip
|
|
577
|
+
raise BudgetStopped(
|
|
578
|
+
f"budget stop: usage cap {key} already exceeded ({used:g}% >= {limit:g}%)",
|
|
579
|
+
short=owner.short, reason=f"usage cap {key} exceeded", phase="preflight",
|
|
580
|
+
)
|
|
581
|
+
return True
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def with_variable_hint(text: str, names: list[str]) -> str:
|
|
585
|
+
if not names:
|
|
586
|
+
return text
|
|
587
|
+
joined = ", ".join(names)
|
|
588
|
+
return (
|
|
589
|
+
text.rstrip()
|
|
590
|
+
+ "\n\n[CCX variables available in direct python/python3: "
|
|
591
|
+
+ joined
|
|
592
|
+
+ ". Rebinding or deleting these names writes back to shared state. "
|
|
593
|
+
+ "command/env/explicit Python paths are ordinary native Python.]"
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _managed_shell_env_file(value: str | None) -> bool:
|
|
598
|
+
if not value:
|
|
599
|
+
return False
|
|
600
|
+
candidate = Path(value).expanduser()
|
|
601
|
+
# Identify a CCX bridge from this, a retired, or a foreign generation.
|
|
602
|
+
# No ACTIVE-pointer lookup or foreign shell-file read is needed.
|
|
603
|
+
if _generated_shell_file_shape(candidate):
|
|
604
|
+
return True
|
|
605
|
+
try:
|
|
606
|
+
candidate_resolved = candidate.resolve()
|
|
607
|
+
if _generated_shell_file_shape(candidate_resolved):
|
|
608
|
+
return True
|
|
609
|
+
from ccx.store.root import require_ccx_active
|
|
610
|
+
managed_root = (
|
|
611
|
+
require_ccx_active(constants.CCX_ROOT).pointer.generation_root
|
|
612
|
+
/ "variables" / "env"
|
|
613
|
+
).resolve()
|
|
614
|
+
return candidate_resolved.is_relative_to(managed_root)
|
|
615
|
+
except (OSError, RuntimeError):
|
|
616
|
+
return False
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _generated_shell_file_shape(path: Path) -> bool:
|
|
620
|
+
if (path.suffix != ".sh" or path.parent.name not in {"claude", "codex"}
|
|
621
|
+
or path.parent.parent.name != "env"
|
|
622
|
+
or path.parent.parent.parent.name != "variables"):
|
|
623
|
+
return False
|
|
624
|
+
from ccx.store.identity import canonical_worker_id, IdentityError
|
|
625
|
+
try:
|
|
626
|
+
canonical_worker_id(path.stem)
|
|
627
|
+
except IdentityError:
|
|
628
|
+
return False
|
|
629
|
+
return True
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _managed_zdotdir(value: str | None) -> bool:
|
|
633
|
+
"""Recognize this or a foreign generation's Codex zsh trampoline directory.
|
|
634
|
+
|
|
635
|
+
A user directory with the same shape is deliberately treated as managed:
|
|
636
|
+
never read or source a foreign worker's .zshenv.
|
|
637
|
+
"""
|
|
638
|
+
if not value:
|
|
639
|
+
return False
|
|
640
|
+
|
|
641
|
+
def matches(path: Path) -> bool:
|
|
642
|
+
return path.parts[-5:-1] == ("variables", "env", "codex", "zsh")
|
|
643
|
+
|
|
644
|
+
candidate = Path(value).expanduser()
|
|
645
|
+
if matches(candidate):
|
|
646
|
+
return True
|
|
647
|
+
try:
|
|
648
|
+
return matches(candidate.resolve())
|
|
649
|
+
except (OSError, RuntimeError):
|
|
650
|
+
return False
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
_BRIDGE_ORIGINALS = {
|
|
654
|
+
"BASH_ENV": "CCX_BRIDGE_ORIGINAL_BASH_ENV",
|
|
655
|
+
"ZDOTDIR": "CCX_BRIDGE_ORIGINAL_ZDOTDIR",
|
|
656
|
+
"CLAUDE_ENV_FILE": "CCX_BRIDGE_ORIGINAL_CLAUDE_ENV_FILE",
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def sanitize_inherited_shell_bridge_env(
|
|
661
|
+
env: dict[str, str] | None = None,
|
|
662
|
+
) -> dict[str, str]:
|
|
663
|
+
"""Restore native user startup paths, never a parent's CCX Python bridge.
|
|
664
|
+
|
|
665
|
+
Each original worker-owned shell shim exports one-use snapshots of the
|
|
666
|
+
user's startup paths. A nested worker consumes those snapshots only if
|
|
667
|
+
the inherited value is itself a managed CCX path. Explicit user overrides
|
|
668
|
+
and unset-versus-empty original values are preserved.
|
|
669
|
+
"""
|
|
670
|
+
target = os.environ if env is None else env
|
|
671
|
+
for key, marker in _BRIDGE_ORIGINALS.items():
|
|
672
|
+
snapshot_text = target.pop(marker, None)
|
|
673
|
+
value = target.get(key)
|
|
674
|
+
managed = (_managed_zdotdir(value) if key == "ZDOTDIR"
|
|
675
|
+
else _managed_shell_env_file(value))
|
|
676
|
+
if not managed:
|
|
677
|
+
continue
|
|
678
|
+
target.pop(key, None)
|
|
679
|
+
try:
|
|
680
|
+
original = json.loads(snapshot_text) if isinstance(snapshot_text, str) else None
|
|
681
|
+
except ValueError:
|
|
682
|
+
original = None
|
|
683
|
+
if (isinstance(original, dict) and original.get("set") is True
|
|
684
|
+
and isinstance(original.get("value"), str)):
|
|
685
|
+
restored = original["value"]
|
|
686
|
+
# Refuse a pointer to any other worker, even when forged as a
|
|
687
|
+
# user-original path. Do not source foreign CCX-owned scripts.
|
|
688
|
+
if not (_managed_zdotdir(restored) if key == "ZDOTDIR"
|
|
689
|
+
else _managed_shell_env_file(restored)):
|
|
690
|
+
target[key] = restored
|
|
691
|
+
return target
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _shell_original_exports(env: Mapping[str, str]) -> list[str]:
|
|
695
|
+
"""Emit exact user startup-path snapshots for a managed tool shell."""
|
|
696
|
+
return [
|
|
697
|
+
f"export {marker}={shlex.quote(json.dumps({'set': key in env, 'value': env.get(key)}))}"
|
|
698
|
+
for key, marker in _BRIDGE_ORIGINALS.items()
|
|
699
|
+
]
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _external_shell_env_file(value: str | None) -> str | None:
|
|
703
|
+
if not value:
|
|
704
|
+
return None
|
|
705
|
+
if _managed_shell_env_file(value):
|
|
706
|
+
return None
|
|
707
|
+
# A bridge from a different CCX_ROOT/generation must not override this
|
|
708
|
+
# worker's python/python3 functions. Do not source or read foreign scripts.
|
|
709
|
+
try:
|
|
710
|
+
path = Path(value).expanduser().resolve()
|
|
711
|
+
if _generated_shell_file_shape(path):
|
|
712
|
+
return None
|
|
713
|
+
except (OSError, RuntimeError):
|
|
714
|
+
return None
|
|
715
|
+
return str(Path(value).expanduser())
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _python_bridge_functions(engine: str, short: str, *, root: Path, zsh: bool) -> list[str]:
|
|
719
|
+
"""Render the same Python bridge command for both shell startup files."""
|
|
720
|
+
python = shlex.quote(sys.executable)
|
|
721
|
+
executable = shlex.quote(current_executable_path())
|
|
722
|
+
engine_q = shlex.quote(engine)
|
|
723
|
+
short_q = shlex.quote(short)
|
|
724
|
+
root_q = shlex.quote(str(root))
|
|
725
|
+
lines: list[str] = []
|
|
726
|
+
for name in ("python3", "python"):
|
|
727
|
+
name_q = shlex.quote(name)
|
|
728
|
+
command = (
|
|
729
|
+
f"{python} {executable} _live-vars-exec "
|
|
730
|
+
f"--root {root_q} --engine {engine_q} --short {short_q} "
|
|
731
|
+
f"--invoked-as {name_q} -- \"$@\""
|
|
732
|
+
)
|
|
733
|
+
# macOS may supply python3 without a native ``python`` executable.
|
|
734
|
+
# Do not publish a python function that can only fail with rc 127;
|
|
735
|
+
# do not silently remap python to python3, either.
|
|
736
|
+
native_python = (("whence -p python" if zsh else "type -P python")
|
|
737
|
+
+ " >/dev/null 2>&1 && ") if name == "python" else ""
|
|
738
|
+
lines.extend([
|
|
739
|
+
f"if {native_python}! typeset -f {name} >/dev/null 2>&1 && ! alias {name} >/dev/null 2>&1; then",
|
|
740
|
+
# Quoted zsh function names cannot expand a user-defined alias
|
|
741
|
+
# while the startup script is parsed.
|
|
742
|
+
(f" function '{name}' {{ {command}; }}" if zsh
|
|
743
|
+
else f" {name}() {{ {command}; }}"),
|
|
744
|
+
"fi",
|
|
745
|
+
])
|
|
746
|
+
return lines
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _publish_shell_script(
|
|
750
|
+
store, parts: tuple[str, ...], name: str, data: bytes, *,
|
|
751
|
+
exact_dir_mode: bool = False,
|
|
752
|
+
) -> None:
|
|
753
|
+
"""Reuse matching private script bytes; publish changed bytes atomically.
|
|
754
|
+
|
|
755
|
+
Every comparison occurs against a nofollow, user-owned regular file inside
|
|
756
|
+
the generation's pinned directory. A warm call needs no replace or fsync.
|
|
757
|
+
"""
|
|
758
|
+
from ccx.store.root import StorageIntegrityError
|
|
759
|
+
|
|
760
|
+
with store.lease():
|
|
761
|
+
with store._gen(*parts, create=True) as directory:
|
|
762
|
+
if exact_dir_mode:
|
|
763
|
+
directory_stat = os.fstat(directory)
|
|
764
|
+
if (not stat.S_ISDIR(directory_stat.st_mode)
|
|
765
|
+
or directory_stat.st_uid != os.getuid()
|
|
766
|
+
or stat.S_IMODE(directory_stat.st_mode) != 0o700):
|
|
767
|
+
raise StorageIntegrityError(
|
|
768
|
+
"Codex zsh directory must be owner-owned mode 0700"
|
|
769
|
+
)
|
|
770
|
+
try:
|
|
771
|
+
old = os.stat(name, dir_fd=directory, follow_symlinks=False)
|
|
772
|
+
except FileNotFoundError:
|
|
773
|
+
pass
|
|
774
|
+
else:
|
|
775
|
+
if (not stat.S_ISREG(old.st_mode) or old.st_uid != os.getuid()
|
|
776
|
+
or stat.S_IMODE(old.st_mode) != 0o600 or old.st_nlink != 1):
|
|
777
|
+
raise StorageIntegrityError(
|
|
778
|
+
"managed shell script must be a private regular file"
|
|
779
|
+
)
|
|
780
|
+
source_fd = os.open(
|
|
781
|
+
name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC |
|
|
782
|
+
os.O_NONBLOCK, dir_fd=directory,
|
|
783
|
+
)
|
|
784
|
+
with os.fdopen(source_fd, "rb") as source:
|
|
785
|
+
opened = os.fstat(source.fileno())
|
|
786
|
+
if (not stat.S_ISREG(opened.st_mode)
|
|
787
|
+
or opened.st_uid != os.getuid()
|
|
788
|
+
or stat.S_IMODE(opened.st_mode) != 0o600
|
|
789
|
+
or opened.st_nlink != 1):
|
|
790
|
+
raise StorageIntegrityError(
|
|
791
|
+
"managed shell script changed ownership or mode"
|
|
792
|
+
)
|
|
793
|
+
existing = source.read(len(data) + 1)
|
|
794
|
+
finished = os.fstat(source.fileno())
|
|
795
|
+
now = os.stat(name, dir_fd=directory, follow_symlinks=False)
|
|
796
|
+
if (old.st_dev == opened.st_dev == finished.st_dev == now.st_dev
|
|
797
|
+
and old.st_ino == opened.st_ino == finished.st_ino == now.st_ino
|
|
798
|
+
and old.st_mtime_ns == finished.st_mtime_ns == now.st_mtime_ns
|
|
799
|
+
and old.st_size == finished.st_size == now.st_size
|
|
800
|
+
and existing == data):
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
temp_name = "." + name + "." + uuid.uuid4().hex + ".tmp"
|
|
804
|
+
script_fd = os.open(
|
|
805
|
+
temp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL |
|
|
806
|
+
os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=directory,
|
|
807
|
+
)
|
|
808
|
+
try:
|
|
809
|
+
with os.fdopen(script_fd, "wb") as sink:
|
|
810
|
+
sink.write(data)
|
|
811
|
+
sink.flush()
|
|
812
|
+
os.fsync(sink.fileno())
|
|
813
|
+
os.replace(
|
|
814
|
+
temp_name, name, src_dir_fd=directory, dst_dir_fd=directory,
|
|
815
|
+
)
|
|
816
|
+
os.fsync(directory)
|
|
817
|
+
except BaseException:
|
|
818
|
+
try:
|
|
819
|
+
os.unlink(temp_name, dir_fd=directory)
|
|
820
|
+
except FileNotFoundError:
|
|
821
|
+
pass
|
|
822
|
+
raise
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def ensure_shell_env(
|
|
826
|
+
engine: str,
|
|
827
|
+
short: str,
|
|
828
|
+
*,
|
|
829
|
+
inherited_env_file: str | None = None,
|
|
830
|
+
) -> Path:
|
|
831
|
+
store, owner = _env_worker(engine, short)
|
|
832
|
+
return _ensure_shell_env_owned(
|
|
833
|
+
store, engine, owner.worker_id, inherited_env_file=inherited_env_file,
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _ensure_shell_env_owned(
|
|
838
|
+
store, engine: str, worker_id: str, *,
|
|
839
|
+
inherited_env_file: str | None,
|
|
840
|
+
native_env: Mapping[str, str] | None = None,
|
|
841
|
+
) -> Path:
|
|
842
|
+
"""Use an already-attested full UUID; the generation lease revalidates it."""
|
|
843
|
+
path = (store.pointer.generation_root / "variables" / "env" /
|
|
844
|
+
engine / f"{worker_id}.sh")
|
|
845
|
+
inherited_env_file = _external_shell_env_file(inherited_env_file)
|
|
846
|
+
|
|
847
|
+
lines = ["# generated by ccx; sourced for managed worker shell commands"]
|
|
848
|
+
if inherited_env_file:
|
|
849
|
+
inherited = Path(inherited_env_file).expanduser()
|
|
850
|
+
try:
|
|
851
|
+
same = inherited.resolve() == path.resolve()
|
|
852
|
+
except OSError:
|
|
853
|
+
same = False
|
|
854
|
+
if not same:
|
|
855
|
+
lines.extend(
|
|
856
|
+
[
|
|
857
|
+
f"if [ -f {shlex.quote(str(inherited))} ]; then",
|
|
858
|
+
f" . {shlex.quote(str(inherited))}",
|
|
859
|
+
"fi",
|
|
860
|
+
]
|
|
861
|
+
)
|
|
862
|
+
lines.extend(_shell_original_exports(os.environ if native_env is None else native_env))
|
|
863
|
+
lines.extend(_python_bridge_functions(engine, worker_id, root=store.root, zsh=False))
|
|
864
|
+
text = "\n".join(lines) + "\n"
|
|
865
|
+
# Only the sealed ACTIVE generation's uid-private owner directory may
|
|
866
|
+
# receive this shell script. Never resurrect CCX_ROOT/variables/<short>.
|
|
867
|
+
from ccx.store.root import _FILE_LIMIT
|
|
868
|
+
data = text.encode("utf-8")
|
|
869
|
+
if len(data) > _FILE_LIMIT:
|
|
870
|
+
raise SessionUnavailable("managed full-UUID shell environment script too large")
|
|
871
|
+
_publish_shell_script(store, ("variables", "env", engine), path.name, data)
|
|
872
|
+
return path
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def codex_zdotdir_path(worker_id: str) -> Path:
|
|
876
|
+
store, owner = _env_worker("codex", worker_id)
|
|
877
|
+
return (store.pointer.generation_root / "variables" / "env" / "codex" /
|
|
878
|
+
"zsh" / owner.worker_id)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def ensure_codex_zshenv(worker_id: str, *, original_zdotdir: str | None) -> Path:
|
|
882
|
+
"""Install the restore-first .zshenv in the sealed, full-UUID generation."""
|
|
883
|
+
store, owner = _env_worker("codex", worker_id)
|
|
884
|
+
return _ensure_codex_zshenv_owned(
|
|
885
|
+
store, owner.worker_id, original_zdotdir=original_zdotdir,
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _zsh_user_startup_lines(stage: str, managed: Path, *,
|
|
890
|
+
original_zdotdir: str | None = None) -> list[str]:
|
|
891
|
+
"""Replay native user startup stages while keeping CCX ZDOTDIR effective.
|
|
892
|
+
|
|
893
|
+
A nested zsh is a new shell of the *same worker*, so its own .zshenv must
|
|
894
|
+
see the managed directory. The original user's directory is remembered
|
|
895
|
+
only for startup-file sourcing; the previous user's .zshenv (or any later
|
|
896
|
+
startup stage) may update its subsequent native lookup directory.
|
|
897
|
+
"""
|
|
898
|
+
lines = [f"# generated by ccx; native user {stage} once, CCX bridge retained"]
|
|
899
|
+
if stage == ".zshenv":
|
|
900
|
+
lines.extend([
|
|
901
|
+
f"typeset -g __ccx_managed_zdotdir={shlex.quote(str(managed))}",
|
|
902
|
+
f"typeset -g __ccx_user_zdotdir_set={'0' if original_zdotdir is None else '1'}",
|
|
903
|
+
f"typeset -g __ccx_user_zdotdir_value={shlex.quote(original_zdotdir or '')}",
|
|
904
|
+
])
|
|
905
|
+
lines.extend([
|
|
906
|
+
'if [[ $__ccx_user_zdotdir_set == 0 ]]; then',
|
|
907
|
+
' unset ZDOTDIR',
|
|
908
|
+
f' __ccx_user_startup="$HOME/{stage}"',
|
|
909
|
+
'elif [[ -z $__ccx_user_zdotdir_value ]]; then',
|
|
910
|
+
" export ZDOTDIR=''",
|
|
911
|
+
' __ccx_user_startup=""',
|
|
912
|
+
'else',
|
|
913
|
+
' export ZDOTDIR="$__ccx_user_zdotdir_value"',
|
|
914
|
+
f' __ccx_user_startup="${{__ccx_user_zdotdir_value}}/{stage}"',
|
|
915
|
+
'fi',
|
|
916
|
+
'if [[ -n $__ccx_user_startup && -f $__ccx_user_startup ]]; then',
|
|
917
|
+
' case "${__ccx_user_startup:A}" in',
|
|
918
|
+
# A retired/foreign CCX-generated shim is not a user startup file.
|
|
919
|
+
f' {shlex.quote(str(managed))}/.*|*/variables/env/codex/zsh/*/.*) ;;',
|
|
920
|
+
' *) builtin source "$__ccx_user_startup" ;;',
|
|
921
|
+
' esac',
|
|
922
|
+
'fi',
|
|
923
|
+
'if [[ -z ${ZDOTDIR+x} ]]; then',
|
|
924
|
+
' __ccx_user_zdotdir_set=0',
|
|
925
|
+
' __ccx_user_zdotdir_value=""',
|
|
926
|
+
'else',
|
|
927
|
+
' __ccx_user_zdotdir_set=1',
|
|
928
|
+
' __ccx_user_zdotdir_value="$ZDOTDIR"',
|
|
929
|
+
'fi',
|
|
930
|
+
'export ZDOTDIR="$__ccx_managed_zdotdir"',
|
|
931
|
+
'unset __ccx_user_startup',
|
|
932
|
+
])
|
|
933
|
+
return lines
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _ensure_codex_zshenv_owned(
|
|
937
|
+
store, worker_id: str, *, original_zdotdir: str | None,
|
|
938
|
+
native_env: Mapping[str, str] | None = None,
|
|
939
|
+
) -> Path:
|
|
940
|
+
from ccx.store.root import _FILE_LIMIT
|
|
941
|
+
|
|
942
|
+
directory = (store.pointer.generation_root / "variables" / "env" /
|
|
943
|
+
"codex" / "zsh" / worker_id)
|
|
944
|
+
if _managed_zdotdir(original_zdotdir):
|
|
945
|
+
original_zdotdir = None
|
|
946
|
+
for stage in (".zshenv", ".zprofile", ".zshrc", ".zlogin", ".zlogout"):
|
|
947
|
+
lines = _zsh_user_startup_lines(
|
|
948
|
+
stage, directory, original_zdotdir=original_zdotdir,
|
|
949
|
+
)
|
|
950
|
+
if stage == ".zshenv":
|
|
951
|
+
lines.extend(_shell_original_exports(
|
|
952
|
+
os.environ if native_env is None else native_env,
|
|
953
|
+
))
|
|
954
|
+
lines.extend(_python_bridge_functions("codex", worker_id, root=store.root, zsh=True))
|
|
955
|
+
data = ("\n".join(lines) + "\n").encode("utf-8")
|
|
956
|
+
if len(data) > _FILE_LIMIT:
|
|
957
|
+
raise SessionUnavailable("managed full-UUID zsh startup script too large")
|
|
958
|
+
_publish_shell_script(
|
|
959
|
+
store, ("variables", "env", "codex", "zsh", worker_id),
|
|
960
|
+
stage, data, exact_dir_mode=True,
|
|
961
|
+
)
|
|
962
|
+
return directory
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def configure_claude_worker_env(
|
|
966
|
+
env: dict[str, str], short: str, *, host_pid: int,
|
|
967
|
+
) -> dict[str, str]:
|
|
968
|
+
sanitize_inherited_shell_bridge_env(env)
|
|
969
|
+
inherited = env.get("CLAUDE_ENV_FILE")
|
|
970
|
+
store, owner = _env_worker("claude", short)
|
|
971
|
+
path = _ensure_shell_env_owned(
|
|
972
|
+
store, "claude", owner.worker_id, inherited_env_file=inherited,
|
|
973
|
+
native_env=env,
|
|
974
|
+
)
|
|
975
|
+
env["CLAUDE_ENV_FILE"] = str(path)
|
|
976
|
+
mark_claude_bridge(short, host_pid)
|
|
977
|
+
return env
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def codex_thread_config(worker_id: str) -> dict[str, Any]:
|
|
981
|
+
"""Attested full-UUID Bash/zsh bridges for one original native Codex thread.
|
|
982
|
+
|
|
983
|
+
The daemon first validates the native claim/source/dispatch; this function
|
|
984
|
+
only prepares the shell config and cannot start/adopt a native thread.
|
|
985
|
+
Preserve Codex's user/default inheritance policy.
|
|
986
|
+
"""
|
|
987
|
+
store, owner = _env_worker("codex", worker_id)
|
|
988
|
+
# The per-thread policy may be constructed by a nested CCX caller as
|
|
989
|
+
# well as by the shared (already scrubbed) codexd. Never capture that
|
|
990
|
+
# caller's bridge as this worker's genuine user-shell baseline.
|
|
991
|
+
native_env = sanitize_inherited_shell_bridge_env(dict(os.environ))
|
|
992
|
+
bash_env = _ensure_shell_env_owned(
|
|
993
|
+
store, "codex", owner.worker_id,
|
|
994
|
+
inherited_env_file=native_env.get("BASH_ENV"), native_env=native_env,
|
|
995
|
+
)
|
|
996
|
+
zdotdir = _ensure_codex_zshenv_owned(
|
|
997
|
+
store, owner.worker_id, original_zdotdir=native_env.get("ZDOTDIR"),
|
|
998
|
+
native_env=native_env,
|
|
999
|
+
)
|
|
1000
|
+
return {"shell_environment_policy": {"set": {
|
|
1001
|
+
"CCX_SELF_WORKER_ID": owner.worker_id,
|
|
1002
|
+
"CCX_SELF_ENGINE": "codex",
|
|
1003
|
+
"CCX_ROOT": str(constants.CCX_ROOT),
|
|
1004
|
+
"BASH_ENV": str(bash_env),
|
|
1005
|
+
"ZDOTDIR": str(zdotdir),
|
|
1006
|
+
}}}
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def mark_claude_bridge(short: str, host_pid: int) -> None:
|
|
1010
|
+
"""Store exact full owner + current host pid as an atomic owned sidecar."""
|
|
1011
|
+
from ccx.store.root import _write_json
|
|
1012
|
+
store, owner = _env_worker("claude", short)
|
|
1013
|
+
with store.lease():
|
|
1014
|
+
with store._gen("variables", "env", "claude", create=True) as fd:
|
|
1015
|
+
_write_json(fd, owner.worker_id + ".bridge.json", {
|
|
1016
|
+
"workerId": owner.worker_id,
|
|
1017
|
+
"engine": "claude",
|
|
1018
|
+
"generation": store.generation,
|
|
1019
|
+
"hostPid": int(host_pid),
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def claude_bridge_ready(short: str, host_pid: int | None) -> bool:
|
|
1024
|
+
if not host_pid:
|
|
1025
|
+
return False
|
|
1026
|
+
from ccx.store.root import _read_json
|
|
1027
|
+
store, owner = _env_worker("claude", short)
|
|
1028
|
+
with store.lease():
|
|
1029
|
+
try:
|
|
1030
|
+
with store._gen("variables", "env", "claude") as fd:
|
|
1031
|
+
value = _read_json(fd, owner.worker_id + ".bridge.json")
|
|
1032
|
+
except FileNotFoundError:
|
|
1033
|
+
return False
|
|
1034
|
+
return (
|
|
1035
|
+
isinstance(value, dict)
|
|
1036
|
+
and value.get("workerId") == owner.worker_id
|
|
1037
|
+
and value.get("engine") == "claude"
|
|
1038
|
+
and value.get("generation") == store.generation
|
|
1039
|
+
and value.get("hostPid") == int(host_pid)
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def require_send_variable_permission(owner, record: dict) -> None:
|
|
1044
|
+
"""Refuse a later-SEND variable attachment the original worker cannot take."""
|
|
1045
|
+
create_args = record.get("createArgs") or {}
|
|
1046
|
+
if owner.engine == "claude":
|
|
1047
|
+
if not bool(create_args.get("skip_permissions", True)):
|
|
1048
|
+
raise CapabilityUnsupported(
|
|
1049
|
+
'Shared variables require full permissions (permissions="full")',
|
|
1050
|
+
short=owner.short, engine=owner.engine,
|
|
1051
|
+
capability="shared_python_variables",
|
|
1052
|
+
)
|
|
1053
|
+
lifecycle = record.get("lifecycle") or {}
|
|
1054
|
+
state = lifecycle.get("state")
|
|
1055
|
+
host_pid = lifecycle.get("hostPid")
|
|
1056
|
+
if (state not in {"dead", "crashed", "idle-killed", "budget-stopped"}
|
|
1057
|
+
and host_pid
|
|
1058
|
+
and not claude_bridge_ready(owner.worker_id, host_pid)):
|
|
1059
|
+
raise SessionUnavailable(
|
|
1060
|
+
"this live Claude host predates the CCX shared-Python bridge; "
|
|
1061
|
+
"kill/revive it or create a new worker before attaching variables",
|
|
1062
|
+
short=owner.short,
|
|
1063
|
+
)
|
|
1064
|
+
elif not (create_args.get("sandbox") == "danger-full-access"
|
|
1065
|
+
and create_args.get("approvalPolicy") == "never"):
|
|
1066
|
+
raise CapabilityUnsupported(
|
|
1067
|
+
'Shared variables require full permissions (permissions="full")',
|
|
1068
|
+
short=owner.short, engine=owner.engine,
|
|
1069
|
+
capability="shared_python_variables",
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
class _ThreadStreamProxy:
|
|
1074
|
+
def __init__(self, original):
|
|
1075
|
+
self.original = original
|
|
1076
|
+
self._routes: dict[int, Any] = {}
|
|
1077
|
+
self._lock = threading.Lock()
|
|
1078
|
+
|
|
1079
|
+
def _target(self):
|
|
1080
|
+
with self._lock:
|
|
1081
|
+
return self._routes.get(threading.get_ident(), self.original)
|
|
1082
|
+
|
|
1083
|
+
@contextmanager
|
|
1084
|
+
def route(self, target):
|
|
1085
|
+
ident = threading.get_ident()
|
|
1086
|
+
with self._lock:
|
|
1087
|
+
self._routes[ident] = target
|
|
1088
|
+
try:
|
|
1089
|
+
yield
|
|
1090
|
+
finally:
|
|
1091
|
+
with self._lock:
|
|
1092
|
+
self._routes.pop(ident, None)
|
|
1093
|
+
|
|
1094
|
+
def write(self, value):
|
|
1095
|
+
return self._target().write(value)
|
|
1096
|
+
|
|
1097
|
+
def read(self, *args):
|
|
1098
|
+
return self._target().read(*args)
|
|
1099
|
+
|
|
1100
|
+
def readline(self, *args):
|
|
1101
|
+
return self._target().readline(*args)
|
|
1102
|
+
|
|
1103
|
+
def flush(self):
|
|
1104
|
+
return self._target().flush()
|
|
1105
|
+
|
|
1106
|
+
def isatty(self):
|
|
1107
|
+
return self._target().isatty()
|
|
1108
|
+
|
|
1109
|
+
def fileno(self):
|
|
1110
|
+
return self._target().fileno()
|
|
1111
|
+
|
|
1112
|
+
@property
|
|
1113
|
+
def buffer(self):
|
|
1114
|
+
return self._target().buffer
|
|
1115
|
+
|
|
1116
|
+
@property
|
|
1117
|
+
def encoding(self):
|
|
1118
|
+
return getattr(self._target(), "encoding", None)
|
|
1119
|
+
|
|
1120
|
+
@property
|
|
1121
|
+
def errors(self):
|
|
1122
|
+
return getattr(self._target(), "errors", None)
|
|
1123
|
+
|
|
1124
|
+
def __iter__(self):
|
|
1125
|
+
return iter(self._target())
|
|
1126
|
+
|
|
1127
|
+
def __getattr__(self, name):
|
|
1128
|
+
return getattr(self._target(), name)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
@dataclass
|
|
1132
|
+
class _Binding:
|
|
1133
|
+
mapping: MutableMapping[str, Any]
|
|
1134
|
+
key: str
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
class LiveOwner:
|
|
1138
|
+
def __init__(self):
|
|
1139
|
+
self.pid = os.getpid()
|
|
1140
|
+
self.token = uuid.uuid4().hex
|
|
1141
|
+
basename = f"{self.pid}-{self.token}.sock"
|
|
1142
|
+
owner_dir, choice = _owner_runtime(basename)
|
|
1143
|
+
candidate = owner_dir / basename
|
|
1144
|
+
_require_unix_socket_budget(candidate) # before any parent writes
|
|
1145
|
+
self.socket_path = owners_dir(owner_dir, runtime=choice) / basename
|
|
1146
|
+
self._registry_lock = threading.RLock()
|
|
1147
|
+
self._exec_lock = threading.Lock()
|
|
1148
|
+
self._workers: dict[tuple[str, str], dict[str, _Binding]] = {}
|
|
1149
|
+
self._queue: queue.Queue[Any] = queue.Queue()
|
|
1150
|
+
self._closed = threading.Event()
|
|
1151
|
+
self._current: dict[str, Any] | None = None
|
|
1152
|
+
|
|
1153
|
+
self._old_stdin = sys.stdin
|
|
1154
|
+
self._old_stdout = sys.stdout
|
|
1155
|
+
self._old_stderr = sys.stderr
|
|
1156
|
+
self._stdin_proxy = _ThreadStreamProxy(sys.stdin)
|
|
1157
|
+
self._stdout_proxy = _ThreadStreamProxy(sys.stdout)
|
|
1158
|
+
self._stderr_proxy = _ThreadStreamProxy(sys.stderr)
|
|
1159
|
+
sys.stdin = self._stdin_proxy
|
|
1160
|
+
sys.stdout = self._stdout_proxy
|
|
1161
|
+
sys.stderr = self._stderr_proxy
|
|
1162
|
+
|
|
1163
|
+
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
1164
|
+
try:
|
|
1165
|
+
try:
|
|
1166
|
+
self.socket_path.unlink()
|
|
1167
|
+
except FileNotFoundError:
|
|
1168
|
+
pass
|
|
1169
|
+
bind_private_unix_socket(self._server, self.socket_path)
|
|
1170
|
+
self._server.listen()
|
|
1171
|
+
# Closing a listening fd from another thread does not reliably
|
|
1172
|
+
# interrupt a blocked accept() on Linux. Poll for shutdown.
|
|
1173
|
+
self._server.settimeout(0.1)
|
|
1174
|
+
except BaseException:
|
|
1175
|
+
try:
|
|
1176
|
+
self._server.close()
|
|
1177
|
+
except OSError:
|
|
1178
|
+
pass
|
|
1179
|
+
if sys.stdin is self._stdin_proxy:
|
|
1180
|
+
sys.stdin = self._old_stdin
|
|
1181
|
+
if sys.stdout is self._stdout_proxy:
|
|
1182
|
+
sys.stdout = self._old_stdout
|
|
1183
|
+
if sys.stderr is self._stderr_proxy:
|
|
1184
|
+
sys.stderr = self._old_stderr
|
|
1185
|
+
raise
|
|
1186
|
+
|
|
1187
|
+
self._executor_thread = threading.Thread(
|
|
1188
|
+
target=self._executor_loop,
|
|
1189
|
+
name=f"ccx-live-vars-{self.pid}",
|
|
1190
|
+
daemon=True,
|
|
1191
|
+
)
|
|
1192
|
+
self._accept_thread = threading.Thread(
|
|
1193
|
+
target=self._accept_loop,
|
|
1194
|
+
name=f"ccx-live-vars-accept-{self.pid}",
|
|
1195
|
+
daemon=True,
|
|
1196
|
+
)
|
|
1197
|
+
self._executor_thread.start()
|
|
1198
|
+
self._accept_thread.start()
|
|
1199
|
+
|
|
1200
|
+
def public_identity(self) -> dict[str, Any]:
|
|
1201
|
+
return {
|
|
1202
|
+
"pid": self.pid,
|
|
1203
|
+
"token": self.token,
|
|
1204
|
+
"socket": str(self.socket_path),
|
|
1205
|
+
"python": {
|
|
1206
|
+
"major": sys.version_info.major,
|
|
1207
|
+
"minor": sys.version_info.minor,
|
|
1208
|
+
"cacheTag": sys.implementation.cache_tag,
|
|
1209
|
+
"platform": sysconfig.get_platform(),
|
|
1210
|
+
},
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
def register_worker(
|
|
1214
|
+
self,
|
|
1215
|
+
engine: str,
|
|
1216
|
+
short: str,
|
|
1217
|
+
mapping: MutableMapping[str, Any],
|
|
1218
|
+
names: list[str],
|
|
1219
|
+
) -> None:
|
|
1220
|
+
self._exec_lock.acquire()
|
|
1221
|
+
try:
|
|
1222
|
+
with self._registry_lock:
|
|
1223
|
+
bindings = self._workers.setdefault((engine, short), {})
|
|
1224
|
+
for name in names:
|
|
1225
|
+
bindings[name] = _Binding(mapping=mapping, key=name)
|
|
1226
|
+
finally:
|
|
1227
|
+
self._exec_lock.release()
|
|
1228
|
+
|
|
1229
|
+
def worker_names(self, engine: str, short: str) -> list[str]:
|
|
1230
|
+
with self._registry_lock:
|
|
1231
|
+
return sorted(self._workers.get((engine, short), {}))
|
|
1232
|
+
|
|
1233
|
+
def _accept_loop(self) -> None:
|
|
1234
|
+
while not self._closed.is_set():
|
|
1235
|
+
try:
|
|
1236
|
+
conn = accept_same_user(self._server)
|
|
1237
|
+
except socket.timeout:
|
|
1238
|
+
continue
|
|
1239
|
+
except OSError:
|
|
1240
|
+
return
|
|
1241
|
+
if conn is None:
|
|
1242
|
+
continue
|
|
1243
|
+
threading.Thread(
|
|
1244
|
+
target=self._handle_connection,
|
|
1245
|
+
args=(conn,),
|
|
1246
|
+
daemon=True,
|
|
1247
|
+
name="ccx-live-vars-client",
|
|
1248
|
+
).start()
|
|
1249
|
+
|
|
1250
|
+
@staticmethod
|
|
1251
|
+
def _read_line(conn: socket.socket) -> bytes:
|
|
1252
|
+
data = bytearray()
|
|
1253
|
+
while b"\n" not in data:
|
|
1254
|
+
chunk = conn.recv(65536)
|
|
1255
|
+
if not chunk:
|
|
1256
|
+
break
|
|
1257
|
+
data.extend(chunk)
|
|
1258
|
+
return bytes(data).split(b"\n", 1)[0]
|
|
1259
|
+
|
|
1260
|
+
@staticmethod
|
|
1261
|
+
def _send(conn: socket.socket, value: dict[str, Any]) -> None:
|
|
1262
|
+
conn.sendall((_WIRE_DUMPS(value, separators=(",", ":")) + "\n").encode("utf-8"))
|
|
1263
|
+
|
|
1264
|
+
def _handle_connection(self, conn: socket.socket) -> None:
|
|
1265
|
+
try:
|
|
1266
|
+
if self._closed.is_set():
|
|
1267
|
+
self._send(conn, {"ok": False, "errorKind": "state_unavailable"})
|
|
1268
|
+
conn.close()
|
|
1269
|
+
return
|
|
1270
|
+
raw = self._read_line(conn)
|
|
1271
|
+
request = _WIRE_LOADS(raw) if raw else {}
|
|
1272
|
+
if request.get("token") != self.token:
|
|
1273
|
+
self._send(conn, {"ok": False, "errorKind": "state_unavailable"})
|
|
1274
|
+
conn.close()
|
|
1275
|
+
return
|
|
1276
|
+
if request.get("op") == "ping":
|
|
1277
|
+
self._send(conn, {"ok": True})
|
|
1278
|
+
conn.close()
|
|
1279
|
+
return
|
|
1280
|
+
if request.get("op") != "exec":
|
|
1281
|
+
self._send(conn, {"ok": False, "errorKind": "invalid_request"})
|
|
1282
|
+
conn.close()
|
|
1283
|
+
return
|
|
1284
|
+
self._exec_lock.acquire()
|
|
1285
|
+
if self._closed.is_set():
|
|
1286
|
+
self._exec_lock.release()
|
|
1287
|
+
self._send(conn, {"ok": False, "errorKind": "state_unavailable"})
|
|
1288
|
+
conn.close()
|
|
1289
|
+
return
|
|
1290
|
+
self._current = {
|
|
1291
|
+
"worker": f"{request.get('engine')}:{request.get('short')}",
|
|
1292
|
+
"startedAt": time.time(),
|
|
1293
|
+
}
|
|
1294
|
+
self._queue.put((conn, request))
|
|
1295
|
+
except Exception as error:
|
|
1296
|
+
try:
|
|
1297
|
+
self._send(
|
|
1298
|
+
conn,
|
|
1299
|
+
{
|
|
1300
|
+
"ok": False,
|
|
1301
|
+
"errorKind": "protocol_error",
|
|
1302
|
+
"message": f"{type(error).__name__}: {error}",
|
|
1303
|
+
},
|
|
1304
|
+
)
|
|
1305
|
+
except OSError:
|
|
1306
|
+
pass
|
|
1307
|
+
conn.close()
|
|
1308
|
+
|
|
1309
|
+
def _restore_streams(self) -> None:
|
|
1310
|
+
if sys.stdin is self._stdin_proxy:
|
|
1311
|
+
sys.stdin = self._old_stdin
|
|
1312
|
+
if sys.stdout is self._stdout_proxy:
|
|
1313
|
+
sys.stdout = self._old_stdout
|
|
1314
|
+
if sys.stderr is self._stderr_proxy:
|
|
1315
|
+
sys.stderr = self._old_stderr
|
|
1316
|
+
|
|
1317
|
+
def _executor_loop(self) -> None:
|
|
1318
|
+
while True:
|
|
1319
|
+
item = self._queue.get()
|
|
1320
|
+
if item is None:
|
|
1321
|
+
self._restore_streams()
|
|
1322
|
+
return
|
|
1323
|
+
conn, request = item
|
|
1324
|
+
try:
|
|
1325
|
+
response = self._execute(request)
|
|
1326
|
+
self._send(conn, response)
|
|
1327
|
+
except BaseException as error:
|
|
1328
|
+
try:
|
|
1329
|
+
self._send(
|
|
1330
|
+
conn,
|
|
1331
|
+
{
|
|
1332
|
+
"ok": False,
|
|
1333
|
+
"errorKind": "owner_error",
|
|
1334
|
+
"message": f"{type(error).__name__}: {error}",
|
|
1335
|
+
},
|
|
1336
|
+
)
|
|
1337
|
+
except BaseException:
|
|
1338
|
+
pass
|
|
1339
|
+
finally:
|
|
1340
|
+
try:
|
|
1341
|
+
conn.close()
|
|
1342
|
+
except OSError:
|
|
1343
|
+
pass
|
|
1344
|
+
self._current = None
|
|
1345
|
+
self._exec_lock.release()
|
|
1346
|
+
|
|
1347
|
+
def _execute(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
1348
|
+
engine = str(request.get("engine") or "")
|
|
1349
|
+
short = str(request.get("short") or "")
|
|
1350
|
+
with self._registry_lock:
|
|
1351
|
+
bindings = dict(self._workers.get((engine, short), {}))
|
|
1352
|
+
if not bindings:
|
|
1353
|
+
return {"ok": False, "errorKind": "state_unavailable"}
|
|
1354
|
+
|
|
1355
|
+
seed: dict[str, Any] = {}
|
|
1356
|
+
for name, binding in bindings.items():
|
|
1357
|
+
if name in binding.mapping:
|
|
1358
|
+
seed[name] = binding.mapping[name]
|
|
1359
|
+
|
|
1360
|
+
native = request.get("nativePython")
|
|
1361
|
+
if not isinstance(native, dict):
|
|
1362
|
+
native = {}
|
|
1363
|
+
stdin_bytes = base64.b64decode(request.get("stdinBase64") or "")
|
|
1364
|
+
stdin_buffer = io.BytesIO(stdin_bytes)
|
|
1365
|
+
stdout_buffer = io.BytesIO()
|
|
1366
|
+
stderr_buffer = io.BytesIO()
|
|
1367
|
+
stdin_text = io.TextIOWrapper(
|
|
1368
|
+
stdin_buffer,
|
|
1369
|
+
encoding=getattr(self._old_stdin, "encoding", None) or "utf-8",
|
|
1370
|
+
errors=getattr(self._old_stdin, "errors", None) or "strict",
|
|
1371
|
+
)
|
|
1372
|
+
stdout_text = io.TextIOWrapper(
|
|
1373
|
+
stdout_buffer,
|
|
1374
|
+
encoding=getattr(self._old_stdout, "encoding", None) or "utf-8",
|
|
1375
|
+
errors=getattr(self._old_stdout, "errors", None) or "replace",
|
|
1376
|
+
write_through=True,
|
|
1377
|
+
)
|
|
1378
|
+
stderr_text = io.TextIOWrapper(
|
|
1379
|
+
stderr_buffer,
|
|
1380
|
+
encoding=getattr(self._old_stderr, "encoding", None) or "utf-8",
|
|
1381
|
+
errors=getattr(self._old_stderr, "errors", None) or "replace",
|
|
1382
|
+
write_through=True,
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
old_cwd = os.getcwd()
|
|
1386
|
+
old_argv = sys.argv[:]
|
|
1387
|
+
old_path = sys.path[:]
|
|
1388
|
+
try:
|
|
1389
|
+
cwd = str(request.get("cwd") or old_cwd)
|
|
1390
|
+
os.chdir(cwd)
|
|
1391
|
+
native_path = native.get("sysPath")
|
|
1392
|
+
if isinstance(native_path, list) and all(
|
|
1393
|
+
isinstance(value, str) for value in native_path
|
|
1394
|
+
):
|
|
1395
|
+
sys.path[:] = native_path
|
|
1396
|
+
args = request.get("args")
|
|
1397
|
+
if not isinstance(args, list) or not all(isinstance(v, str) for v in args):
|
|
1398
|
+
return {"ok": False, "errorKind": "invalid_request"}
|
|
1399
|
+
with (
|
|
1400
|
+
self._stdin_proxy.route(stdin_text),
|
|
1401
|
+
self._stdout_proxy.route(stdout_text),
|
|
1402
|
+
self._stderr_proxy.route(stderr_text),
|
|
1403
|
+
):
|
|
1404
|
+
exit_code, final_ns = execute_target(args, seed)
|
|
1405
|
+
stdout_text.flush()
|
|
1406
|
+
stderr_text.flush()
|
|
1407
|
+
finally:
|
|
1408
|
+
os.chdir(old_cwd)
|
|
1409
|
+
sys.argv = old_argv
|
|
1410
|
+
sys.path[:] = old_path
|
|
1411
|
+
|
|
1412
|
+
deleted: list[str] = []
|
|
1413
|
+
with self._registry_lock:
|
|
1414
|
+
current = self._workers.get((engine, short), {})
|
|
1415
|
+
for name, binding in bindings.items():
|
|
1416
|
+
if name in final_ns:
|
|
1417
|
+
binding.mapping[name] = final_ns[name]
|
|
1418
|
+
else:
|
|
1419
|
+
binding.mapping.pop(name, None)
|
|
1420
|
+
if current.get(name) is binding:
|
|
1421
|
+
current.pop(name, None)
|
|
1422
|
+
deleted.append(name)
|
|
1423
|
+
names = sorted(current)
|
|
1424
|
+
|
|
1425
|
+
self._publish_live_names(engine, short, names)
|
|
1426
|
+
return {
|
|
1427
|
+
"ok": True,
|
|
1428
|
+
"exitCode": int(exit_code),
|
|
1429
|
+
"stdoutBase64": base64.b64encode(stdout_buffer.getvalue()).decode("ascii"),
|
|
1430
|
+
"stderrBase64": base64.b64encode(stderr_buffer.getvalue()).decode("ascii"),
|
|
1431
|
+
"deleted": deleted,
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
def _publish_live_names(self, engine: str, short: str, names: list[str]) -> None:
|
|
1435
|
+
with _worker_lock(engine, short):
|
|
1436
|
+
value = read_sidecar(engine, short)
|
|
1437
|
+
if (
|
|
1438
|
+
not value
|
|
1439
|
+
or value.get("mode") != "live"
|
|
1440
|
+
or (value.get("owner") or {}).get("token") != self.token
|
|
1441
|
+
):
|
|
1442
|
+
return
|
|
1443
|
+
value["names"] = names
|
|
1444
|
+
_atomic_json(sidecar_path(engine, short), value)
|
|
1445
|
+
|
|
1446
|
+
def close(self) -> None:
|
|
1447
|
+
if self._closed.is_set():
|
|
1448
|
+
return
|
|
1449
|
+
self._closed.set()
|
|
1450
|
+
try:
|
|
1451
|
+
self._server.close()
|
|
1452
|
+
except OSError:
|
|
1453
|
+
pass
|
|
1454
|
+
try:
|
|
1455
|
+
self.socket_path.unlink()
|
|
1456
|
+
except FileNotFoundError:
|
|
1457
|
+
pass
|
|
1458
|
+
self._queue.put(None)
|
|
1459
|
+
# Wait briefly for both daemon loops to finish. In particular, the
|
|
1460
|
+
# executor restores sys.stdin/stdout/stderr; without this join a new
|
|
1461
|
+
# LiveOwner can wrap a proxy from a closed owner and strand it forever.
|
|
1462
|
+
for worker in (self._accept_thread, self._executor_thread):
|
|
1463
|
+
if threading.current_thread() is not worker:
|
|
1464
|
+
worker.join(timeout=1.0)
|
|
1465
|
+
# Never remove a sidecar that a newer controller or durable send owns.
|
|
1466
|
+
for engine, worker_id in list(self._workers):
|
|
1467
|
+
try:
|
|
1468
|
+
store, owned = _env_worker(engine, worker_id)
|
|
1469
|
+
with _worker_lock(engine, owned.worker_id):
|
|
1470
|
+
value = read_sidecar(engine, owned.worker_id)
|
|
1471
|
+
if (value and value.get("mode") == "live"
|
|
1472
|
+
and (value.get("owner") or {}).get("token") == self.token):
|
|
1473
|
+
_unlink_sidecar(store, engine, owned.worker_id)
|
|
1474
|
+
except (OSError, SessionUnavailable):
|
|
1475
|
+
pass
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def _native_python_metadata(executable: str) -> dict[str, Any] | None:
|
|
1479
|
+
code = (
|
|
1480
|
+
"import json,sys,sysconfig;"
|
|
1481
|
+
"print(json.dumps({'version':list(sys.version_info[:3]),"
|
|
1482
|
+
"'cacheTag':sys.implementation.cache_tag,"
|
|
1483
|
+
"'platform':sysconfig.get_platform(),"
|
|
1484
|
+
"'executable':sys.executable,'prefix':sys.prefix,"
|
|
1485
|
+
"'execPrefix':sys.exec_prefix,'sysPath':sys.path}))"
|
|
1486
|
+
)
|
|
1487
|
+
try:
|
|
1488
|
+
output = subprocess.check_output(
|
|
1489
|
+
[executable, "-c", code],
|
|
1490
|
+
text=True,
|
|
1491
|
+
stderr=subprocess.DEVNULL,
|
|
1492
|
+
timeout=5,
|
|
1493
|
+
)
|
|
1494
|
+
value = json.loads(output)
|
|
1495
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
1496
|
+
return None
|
|
1497
|
+
return value if isinstance(value, dict) else None
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _compatible(owner: Mapping[str, Any], native: Mapping[str, Any]) -> bool:
|
|
1501
|
+
version = native.get("version")
|
|
1502
|
+
return bool(
|
|
1503
|
+
isinstance(version, list)
|
|
1504
|
+
and len(version) >= 2
|
|
1505
|
+
and version[0] == owner.get("major")
|
|
1506
|
+
and version[1] == owner.get("minor")
|
|
1507
|
+
and native.get("platform") == owner.get("platform")
|
|
1508
|
+
and native.get("cacheTag") == owner.get("cacheTag")
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def _durable_runner_argv(
|
|
1513
|
+
executable: str,
|
|
1514
|
+
*,
|
|
1515
|
+
state: Path,
|
|
1516
|
+
lock: Path,
|
|
1517
|
+
cwd: str,
|
|
1518
|
+
args: list[str],
|
|
1519
|
+
) -> list[str]:
|
|
1520
|
+
runner = Path(__file__).with_name("live_vars_runner.py")
|
|
1521
|
+
bootstrap = (
|
|
1522
|
+
"import runpy; "
|
|
1523
|
+
f"runpy.run_path({str(runner)!r}, run_name='__main__')"
|
|
1524
|
+
)
|
|
1525
|
+
return [
|
|
1526
|
+
executable,
|
|
1527
|
+
"-c",
|
|
1528
|
+
bootstrap,
|
|
1529
|
+
"--state",
|
|
1530
|
+
str(state),
|
|
1531
|
+
"--lock",
|
|
1532
|
+
str(lock),
|
|
1533
|
+
"--cwd",
|
|
1534
|
+
cwd,
|
|
1535
|
+
"--real-python",
|
|
1536
|
+
executable,
|
|
1537
|
+
"--",
|
|
1538
|
+
*args,
|
|
1539
|
+
]
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def _exec_native(executable: str, args: list[str], *, env: dict[str, str] | None = None) -> int:
|
|
1543
|
+
os.execvpe(executable, [executable, *args], os.environ.copy() if env is None else env)
|
|
1544
|
+
return 127
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def worker_exec(engine: str, short: str, invoked_as: str, args: list[str], *,
|
|
1548
|
+
native_env: dict[str, str] | None = None) -> int:
|
|
1549
|
+
from ccx.store.root import GenerationError
|
|
1550
|
+
from ccx.store.identity import IdentityError
|
|
1551
|
+
|
|
1552
|
+
real_python = shutil.which(invoked_as)
|
|
1553
|
+
if not real_python:
|
|
1554
|
+
print(f"ccx: cannot resolve native {invoked_as}", file=sys.stderr)
|
|
1555
|
+
return 127
|
|
1556
|
+
|
|
1557
|
+
# Stage0's exact ACTIVE owner/read gate stays before any native execution.
|
|
1558
|
+
try:
|
|
1559
|
+
store, owned = _env_worker(engine, short)
|
|
1560
|
+
binding = read_sidecar(engine, owned.worker_id)
|
|
1561
|
+
except (IdentityError, GenerationError, OSError) as error:
|
|
1562
|
+
print(f"CcxStateUnavailable: managed worker variables unavailable: {error}",
|
|
1563
|
+
file=sys.stderr)
|
|
1564
|
+
return 69
|
|
1565
|
+
if binding is None:
|
|
1566
|
+
return _exec_native(real_python, args, env=native_env)
|
|
1567
|
+
|
|
1568
|
+
if len(args) >= 2 and args[0] == "-m" and args[1] in _NATIVE_FALLTHROUGH_MODULES:
|
|
1569
|
+
return _exec_native(real_python, args, env=native_env)
|
|
1570
|
+
# Shared variables cover `-c`, `-m`, stdin and scripts; any other leading
|
|
1571
|
+
# interpreter option (--version, -V, -u, -I, ...) runs native Python.
|
|
1572
|
+
if args and args[0].startswith("-") and args[0] not in ("-", "-c", "-m"):
|
|
1573
|
+
return _exec_native(real_python, args, env=native_env)
|
|
1574
|
+
|
|
1575
|
+
short = owned.worker_id
|
|
1576
|
+
if binding["mode"] == "durable":
|
|
1577
|
+
try:
|
|
1578
|
+
state_id = _valid_state_id(binding.get("stateId"))
|
|
1579
|
+
with store.lease():
|
|
1580
|
+
state = state_path(state_id)
|
|
1581
|
+
lock = state_lock_path(state_id)
|
|
1582
|
+
except (GenerationError, SessionUnavailable, OSError) as error:
|
|
1583
|
+
print(f"CcxStateUnavailable: durable variable state unavailable: {error}",
|
|
1584
|
+
file=sys.stderr)
|
|
1585
|
+
return 69
|
|
1586
|
+
os.execvpe(
|
|
1587
|
+
real_python,
|
|
1588
|
+
_durable_runner_argv(
|
|
1589
|
+
real_python, state=state, lock=lock, cwd=os.getcwd(), args=args,
|
|
1590
|
+
),
|
|
1591
|
+
os.environ.copy() if native_env is None else native_env,
|
|
1592
|
+
)
|
|
1593
|
+
return 127
|
|
1594
|
+
|
|
1595
|
+
if binding["mode"] != "live":
|
|
1596
|
+
print("CcxStateUnavailable: invalid worker variable mode", file=sys.stderr)
|
|
1597
|
+
return 69
|
|
1598
|
+
try:
|
|
1599
|
+
value = read_sidecar(engine, short)
|
|
1600
|
+
except (IdentityError, GenerationError, OSError) as error:
|
|
1601
|
+
print(f"CcxStateUnavailable: live variable binding unavailable: {error}",
|
|
1602
|
+
file=sys.stderr)
|
|
1603
|
+
return 69
|
|
1604
|
+
if not value or value.get("mode") != "live":
|
|
1605
|
+
print("CcxStateUnavailable: live variable binding changed", file=sys.stderr)
|
|
1606
|
+
return 69
|
|
1607
|
+
|
|
1608
|
+
owner = value.get("owner")
|
|
1609
|
+
if not isinstance(owner, dict):
|
|
1610
|
+
print("CcxStateUnavailable: live variable owner metadata is missing", file=sys.stderr)
|
|
1611
|
+
return 69
|
|
1612
|
+
if _owner_gone(owner):
|
|
1613
|
+
_clear_dead_binding(engine, short, str(owner.get("token") or ""))
|
|
1614
|
+
return _exec_native(real_python, args, env=native_env)
|
|
1615
|
+
owner_python = owner.get("python")
|
|
1616
|
+
native = _native_python_metadata(real_python)
|
|
1617
|
+
if not isinstance(owner_python, dict) or native is None:
|
|
1618
|
+
print(
|
|
1619
|
+
"ccx: cannot verify Python compatibility for shared variables; running native Python",
|
|
1620
|
+
file=sys.stderr,
|
|
1621
|
+
)
|
|
1622
|
+
return _exec_native(real_python, args, env=native_env)
|
|
1623
|
+
if not _compatible(owner_python, native):
|
|
1624
|
+
print(
|
|
1625
|
+
"ccx: selected Python is incompatible with the live variable owner; running native Python",
|
|
1626
|
+
file=sys.stderr,
|
|
1627
|
+
)
|
|
1628
|
+
return _exec_native(real_python, args, env=native_env)
|
|
1629
|
+
|
|
1630
|
+
request = {
|
|
1631
|
+
"schemaVersion": VARIABLE_SCHEMA_VERSION,
|
|
1632
|
+
"op": "exec",
|
|
1633
|
+
"token": owner.get("token"),
|
|
1634
|
+
"engine": engine,
|
|
1635
|
+
"short": short,
|
|
1636
|
+
"args": args,
|
|
1637
|
+
"cwd": os.getcwd(),
|
|
1638
|
+
"stdinBase64": base64.b64encode(
|
|
1639
|
+
sys.stdin.buffer.read() if not args or args[0] == "-" else b""
|
|
1640
|
+
).decode("ascii"),
|
|
1641
|
+
"nativePython": native,
|
|
1642
|
+
}
|
|
1643
|
+
sock = owner.get("socket")
|
|
1644
|
+
if not isinstance(sock, str):
|
|
1645
|
+
print("CcxStateUnavailable: live variable owner socket is missing", file=sys.stderr)
|
|
1646
|
+
return 69
|
|
1647
|
+
|
|
1648
|
+
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
1649
|
+
try:
|
|
1650
|
+
client.connect(sock)
|
|
1651
|
+
client.sendall((json.dumps(request, separators=(",", ":")) + "\n").encode("utf-8"))
|
|
1652
|
+
raw = LiveOwner._read_line(client)
|
|
1653
|
+
except OSError as error:
|
|
1654
|
+
print(
|
|
1655
|
+
f"CcxStateUnavailable: shared variable owner is unavailable: {error}",
|
|
1656
|
+
file=sys.stderr,
|
|
1657
|
+
)
|
|
1658
|
+
return 69
|
|
1659
|
+
finally:
|
|
1660
|
+
client.close()
|
|
1661
|
+
|
|
1662
|
+
try:
|
|
1663
|
+
response = json.loads(raw)
|
|
1664
|
+
except ValueError:
|
|
1665
|
+
print("CcxStateUnavailable: invalid response from shared variable owner", file=sys.stderr)
|
|
1666
|
+
return 69
|
|
1667
|
+
|
|
1668
|
+
if not response.get("ok"):
|
|
1669
|
+
kind = response.get("errorKind")
|
|
1670
|
+
if kind == "owner_busy":
|
|
1671
|
+
print(
|
|
1672
|
+
f"CcxStateBusy: shared Python owner is busy: {response.get('current')}",
|
|
1673
|
+
file=sys.stderr,
|
|
1674
|
+
)
|
|
1675
|
+
return 75
|
|
1676
|
+
print(
|
|
1677
|
+
"CcxStateUnavailable: shared variable execution is unavailable"
|
|
1678
|
+
+ (f": {response.get('message')}" if response.get("message") else ""),
|
|
1679
|
+
file=sys.stderr,
|
|
1680
|
+
)
|
|
1681
|
+
return 69
|
|
1682
|
+
|
|
1683
|
+
stdout = base64.b64decode(response.get("stdoutBase64") or "")
|
|
1684
|
+
stderr = base64.b64decode(response.get("stderrBase64") or "")
|
|
1685
|
+
if stdout:
|
|
1686
|
+
sys.stdout.buffer.write(stdout)
|
|
1687
|
+
sys.stdout.buffer.flush()
|
|
1688
|
+
if stderr:
|
|
1689
|
+
sys.stderr.buffer.write(stderr)
|
|
1690
|
+
sys.stderr.buffer.flush()
|
|
1691
|
+
return int(response.get("exitCode") or 0)
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def cmd_worker_exec(args) -> None:
|
|
1695
|
+
target_args = list(getattr(args, "target_args", []) or [])
|
|
1696
|
+
if target_args and target_args[0] == "--":
|
|
1697
|
+
target_args = target_args[1:]
|
|
1698
|
+
raise SystemExit(
|
|
1699
|
+
worker_exec(
|
|
1700
|
+
str(args.engine),
|
|
1701
|
+
str(args.short),
|
|
1702
|
+
str(args.invoked_as),
|
|
1703
|
+
target_args,
|
|
1704
|
+
native_env=getattr(args, "_bridge_native_env", None),
|
|
1705
|
+
)
|
|
1706
|
+
)
|