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,176 @@
|
|
|
1
|
+
"""Claude Code OAuth identity shared by first-party account API calls.
|
|
2
|
+
|
|
3
|
+
These calls intentionally identify as the locally installed Claude Code build.
|
|
4
|
+
Anthropic's private account endpoints use that surface/version signal for feature
|
|
5
|
+
eligibility, so a baked-in version silently turns a valid account into a stale
|
|
6
|
+
client as Claude Code updates.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ccx.constants import CLAUDE_BIN, CLAUDE_DOTFILE, CLAUDE_HOME, USAGE_BETA
|
|
20
|
+
|
|
21
|
+
_VERSION_RE = re.compile(r"(?<![0-9])([0-9]+\.[0-9]+\.[0-9]+)(?![0-9])")
|
|
22
|
+
_NATIVE_REFRESH_TIMEOUT_S = 12.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_json(path: Path) -> dict | None:
|
|
26
|
+
try:
|
|
27
|
+
value = json.loads(path.read_text())
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|
|
30
|
+
return value if isinstance(value, dict) else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _oauth_credentials() -> dict | None:
|
|
34
|
+
data = _read_json(CLAUDE_HOME / ".credentials.json")
|
|
35
|
+
value = (data or {}).get("claudeAiOauth")
|
|
36
|
+
return value if isinstance(value, dict) else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _token_expired(credentials: dict, *, now_ms: int | None = None) -> bool:
|
|
40
|
+
"""Whether Claude's stored access token is conclusively expired.
|
|
41
|
+
|
|
42
|
+
Older/partially-written credential records can omit ``expiresAt``. In that
|
|
43
|
+
case the provider remains authoritative, so keep the token rather than
|
|
44
|
+
guessing that it is stale.
|
|
45
|
+
"""
|
|
46
|
+
expires_at = credentials.get("expiresAt")
|
|
47
|
+
if not isinstance(expires_at, (int, float)):
|
|
48
|
+
return False
|
|
49
|
+
if now_ms is None:
|
|
50
|
+
now_ms = int(time.time() * 1000)
|
|
51
|
+
return float(expires_at) <= now_ms
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _native_refresh(credentials: dict) -> bool:
|
|
55
|
+
"""Ask the installed Claude CLI to rotate an expired OAuth credential.
|
|
56
|
+
|
|
57
|
+
Claude Code owns the refresh-token protocol, rotation rules, and credential
|
|
58
|
+
persistence. Reusing its native refresh-token login surface keeps CCX out
|
|
59
|
+
of that private contract. This is best-effort: unsupported or failed
|
|
60
|
+
refreshes leave the existing credential untouched and callers fall back to
|
|
61
|
+
their normal no-signal/cache behavior.
|
|
62
|
+
"""
|
|
63
|
+
refresh_token = credentials.get("refreshToken")
|
|
64
|
+
scopes = credentials.get("scopes")
|
|
65
|
+
if not isinstance(refresh_token, str) or not refresh_token:
|
|
66
|
+
return False
|
|
67
|
+
if not isinstance(scopes, list) or not scopes or not all(
|
|
68
|
+
isinstance(scope, str) and scope for scope in scopes
|
|
69
|
+
):
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
refresh_expires_at = credentials.get("refreshTokenExpiresAt")
|
|
73
|
+
if isinstance(refresh_expires_at, (int, float)):
|
|
74
|
+
if float(refresh_expires_at) <= int(time.time() * 1000):
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
env = os.environ.copy()
|
|
78
|
+
env["CLAUDE_CODE_OAUTH_REFRESH_TOKEN"] = refresh_token
|
|
79
|
+
env["CLAUDE_CODE_OAUTH_SCOPES"] = " ".join(scopes)
|
|
80
|
+
try:
|
|
81
|
+
proc = subprocess.run(
|
|
82
|
+
[CLAUDE_BIN, "auth", "login", "--claudeai"],
|
|
83
|
+
check=False,
|
|
84
|
+
stdin=subprocess.DEVNULL,
|
|
85
|
+
stdout=subprocess.DEVNULL,
|
|
86
|
+
stderr=subprocess.DEVNULL,
|
|
87
|
+
env=env,
|
|
88
|
+
timeout=_NATIVE_REFRESH_TIMEOUT_S,
|
|
89
|
+
)
|
|
90
|
+
except (OSError, subprocess.SubprocessError):
|
|
91
|
+
return False
|
|
92
|
+
return proc.returncode == 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def oauth_token() -> str | None:
|
|
96
|
+
credentials = _oauth_credentials()
|
|
97
|
+
if not credentials:
|
|
98
|
+
return None
|
|
99
|
+
value = credentials.get("accessToken")
|
|
100
|
+
if not isinstance(value, str) or not value:
|
|
101
|
+
return None
|
|
102
|
+
if not _token_expired(credentials):
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
if _native_refresh(credentials):
|
|
106
|
+
refreshed = _oauth_credentials()
|
|
107
|
+
refreshed_value = (refreshed or {}).get("accessToken")
|
|
108
|
+
if (
|
|
109
|
+
isinstance(refreshed_value, str)
|
|
110
|
+
and refreshed_value
|
|
111
|
+
and not _token_expired(refreshed or {})
|
|
112
|
+
):
|
|
113
|
+
return refreshed_value
|
|
114
|
+
|
|
115
|
+
# Preserve the old behavior on refresh failure. The provider will reject
|
|
116
|
+
# a truly expired token and the usage caller can still fall back to cache.
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def oauth_organization_uuid() -> str | None:
|
|
121
|
+
data = _read_json(CLAUDE_DOTFILE)
|
|
122
|
+
value = ((data or {}).get("oauthAccount") or {}).get("organizationUuid")
|
|
123
|
+
return value if isinstance(value, str) and value else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@lru_cache(maxsize=1)
|
|
127
|
+
def claude_code_version() -> str | None:
|
|
128
|
+
"""Best-effort local Claude Code version without a persistent subprocess cost."""
|
|
129
|
+
try:
|
|
130
|
+
resolved = os.path.realpath(CLAUDE_BIN)
|
|
131
|
+
marker = "/claude/versions/"
|
|
132
|
+
if marker in resolved:
|
|
133
|
+
candidate = resolved.split(marker, 1)[1].split("/", 1)[0]
|
|
134
|
+
match = _VERSION_RE.fullmatch(candidate)
|
|
135
|
+
if match:
|
|
136
|
+
return match.group(1)
|
|
137
|
+
except (OSError, TypeError):
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
proc = subprocess.run(
|
|
142
|
+
[CLAUDE_BIN, "--version"],
|
|
143
|
+
check=False,
|
|
144
|
+
capture_output=True,
|
|
145
|
+
text=True,
|
|
146
|
+
timeout=2.0,
|
|
147
|
+
)
|
|
148
|
+
except (OSError, subprocess.SubprocessError):
|
|
149
|
+
return None
|
|
150
|
+
match = _VERSION_RE.search((proc.stdout or "") + "\n" + (proc.stderr or ""))
|
|
151
|
+
return match.group(1) if match else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def oauth_headers(token: str, *, content_type: bool = True) -> dict[str, str]:
|
|
155
|
+
"""Headers matching Claude Code's current first-party CLI surface.
|
|
156
|
+
|
|
157
|
+
If the installed version cannot be resolved we deliberately send an
|
|
158
|
+
unversioned identity instead of pretending to be an old known release. The
|
|
159
|
+
usage endpoint still degrades normally, while private feature eligibility
|
|
160
|
+
fails closed provider-side.
|
|
161
|
+
"""
|
|
162
|
+
version = claude_code_version()
|
|
163
|
+
user_agent = (
|
|
164
|
+
f"claude-cli/{version} (external, cli)"
|
|
165
|
+
if version
|
|
166
|
+
else "claude-cli (external, cli)"
|
|
167
|
+
)
|
|
168
|
+
headers = {
|
|
169
|
+
"Authorization": f"Bearer {token}",
|
|
170
|
+
"anthropic-beta": USAGE_BETA,
|
|
171
|
+
"User-Agent": user_agent,
|
|
172
|
+
"x-app": "cli",
|
|
173
|
+
}
|
|
174
|
+
if content_type:
|
|
175
|
+
headers["Content-Type"] = "application/json"
|
|
176
|
+
return headers
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import fcntl
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import shlex
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
import unicodedata
|
|
13
|
+
|
|
14
|
+
from ccx.system import oslayer
|
|
15
|
+
from ccx.constants import CLAUDE_DOTFILE, PERMISSION_HOOK_TIMEOUT_S
|
|
16
|
+
from ccx.system.executable import current_executable_path
|
|
17
|
+
from ccx.store.paths import private_mkdir, settings_path, sock_path, trust_ledger_path
|
|
18
|
+
|
|
19
|
+
_TRUST_KEY = "hasTrustDialogAccepted"
|
|
20
|
+
# Ledger "change" values: what a ccx trust write did to one projects entry.
|
|
21
|
+
TRUST_CREATED_ENTRY = "created-entry"
|
|
22
|
+
TRUST_ADDED_KEY = "added-key"
|
|
23
|
+
TRUST_REPLACED_VALUE = "replaced-value"
|
|
24
|
+
_CAS_ATTEMPTS = 5
|
|
25
|
+
|
|
26
|
+
def _write_claude_config(data) -> None:
|
|
27
|
+
"""Atomically write the selected native profile's global metadata.
|
|
28
|
+
|
|
29
|
+
The default stays HOME/.claude.json; an explicit CLAUDE_CONFIG_DIR uses
|
|
30
|
+
that native profile's .claude.json. Never write trust to the wrong profile.
|
|
31
|
+
"""
|
|
32
|
+
CLAUDE_DOTFILE.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
33
|
+
tmp = CLAUDE_DOTFILE.with_suffix(".json.ccx.tmp")
|
|
34
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
35
|
+
try:
|
|
36
|
+
os.chmod(tmp, 0o600)
|
|
37
|
+
except OSError:
|
|
38
|
+
pass
|
|
39
|
+
tmp.replace(CLAUDE_DOTFILE)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ensure_trusted(cwd: str) -> bool:
|
|
43
|
+
return ensure_trusted_paths([cwd])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ensure_trusted_paths(paths: list[str]) -> bool:
|
|
47
|
+
"""Trust the cwd and added directories in one native-profile rewrite.
|
|
48
|
+
|
|
49
|
+
Each directory needs its own exact-path entry: ancestor trust does not
|
|
50
|
+
override a child's explicit false value. Missing trust can block Claude
|
|
51
|
+
before SessionStart, leaving the host waiting for the boot watchdog.
|
|
52
|
+
Return whether a change was written; unreadable configs remain untouched.
|
|
53
|
+
"""
|
|
54
|
+
abs_paths = []
|
|
55
|
+
seen = set()
|
|
56
|
+
for raw in paths or []:
|
|
57
|
+
if not raw:
|
|
58
|
+
continue
|
|
59
|
+
# Claude's native trust lookup uses the same key as targeted untrust.
|
|
60
|
+
ap = _requested_trust_path(raw)
|
|
61
|
+
if ap not in seen:
|
|
62
|
+
seen.add(ap)
|
|
63
|
+
abs_paths.append(ap)
|
|
64
|
+
if not abs_paths:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def trust(data: dict) -> dict[str, dict] | None:
|
|
68
|
+
projects = data.setdefault("projects", {})
|
|
69
|
+
if not isinstance(projects, dict):
|
|
70
|
+
return None
|
|
71
|
+
changes: dict[str, dict] = {}
|
|
72
|
+
for ap in abs_paths:
|
|
73
|
+
# Exact path only. An ancestor's trust does not cover a child that
|
|
74
|
+
# carries its own `false`, and a missing entry is not trust either.
|
|
75
|
+
entry = projects.get(ap)
|
|
76
|
+
if isinstance(entry, dict) and entry.get(_TRUST_KEY) is True:
|
|
77
|
+
continue
|
|
78
|
+
if not isinstance(entry, dict):
|
|
79
|
+
changes[ap] = {"change": TRUST_CREATED_ENTRY}
|
|
80
|
+
projects[ap] = entry = {}
|
|
81
|
+
elif _TRUST_KEY not in entry:
|
|
82
|
+
changes[ap] = {"change": TRUST_ADDED_KEY}
|
|
83
|
+
else:
|
|
84
|
+
changes[ap] = {"change": TRUST_REPLACED_VALUE, "previous": entry[_TRUST_KEY]}
|
|
85
|
+
entry[_TRUST_KEY] = True
|
|
86
|
+
return changes or None
|
|
87
|
+
|
|
88
|
+
with _trust_lock():
|
|
89
|
+
changes = _update_claude_config(trust)
|
|
90
|
+
if not changes:
|
|
91
|
+
return False
|
|
92
|
+
# Record only what this write changed, so `ccx untrust` can undo
|
|
93
|
+
# exactly ccx's entries. The first record for a path wins: a later
|
|
94
|
+
# re-trust after someone else flipped it back is still ccx's doing.
|
|
95
|
+
ledger = _read_trust_ledger()
|
|
96
|
+
recorded = ledger["configs"].setdefault(str(CLAUDE_DOTFILE), {})
|
|
97
|
+
now_ms = int(time.time() * 1000)
|
|
98
|
+
for ap, change in changes.items():
|
|
99
|
+
recorded.setdefault(ap, {**change, "at_ms": now_ms})
|
|
100
|
+
_write_trust_ledger(ledger)
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@contextlib.contextmanager
|
|
105
|
+
def _trust_lock():
|
|
106
|
+
"""Serialize ccx's own trust writers (Claude's writers are handled by CAS)."""
|
|
107
|
+
path = trust_ledger_path().with_name("trust-ledger.lock")
|
|
108
|
+
private_mkdir(path.parent)
|
|
109
|
+
with os.fdopen(os.open(path, os.O_RDWR | os.O_CREAT, 0o600), "r+") as handle:
|
|
110
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
111
|
+
try:
|
|
112
|
+
yield
|
|
113
|
+
finally:
|
|
114
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _update_claude_config(mutate):
|
|
118
|
+
"""Read-modify-write the native .claude.json with compare-and-swap.
|
|
119
|
+
|
|
120
|
+
Claude rewrites this file itself. Re-reading it just before the replace and
|
|
121
|
+
starting over when its bytes moved keeps our write from erasing a
|
|
122
|
+
concurrent one; the remaining window is the rename itself. ``mutate``
|
|
123
|
+
edits the parsed document in place and returns a truthy result when it
|
|
124
|
+
changed something; that result is returned. Fails open (None) on an
|
|
125
|
+
unreadable or unparseable config, or if the file keeps changing.
|
|
126
|
+
"""
|
|
127
|
+
for _ in range(_CAS_ATTEMPTS):
|
|
128
|
+
before = _read_config_bytes()
|
|
129
|
+
if before is False:
|
|
130
|
+
return None
|
|
131
|
+
if before is None:
|
|
132
|
+
data: dict = {}
|
|
133
|
+
else:
|
|
134
|
+
try:
|
|
135
|
+
data = json.loads(before)
|
|
136
|
+
except ValueError:
|
|
137
|
+
return None
|
|
138
|
+
if not isinstance(data, dict):
|
|
139
|
+
return None
|
|
140
|
+
result = mutate(data)
|
|
141
|
+
if not result:
|
|
142
|
+
return None
|
|
143
|
+
if _read_config_bytes() != before:
|
|
144
|
+
continue
|
|
145
|
+
_write_claude_config(data)
|
|
146
|
+
return result
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _read_config_bytes() -> bytes | None | bool:
|
|
151
|
+
"""The config's bytes, None when it does not exist, False when unreadable."""
|
|
152
|
+
try:
|
|
153
|
+
return CLAUDE_DOTFILE.read_bytes()
|
|
154
|
+
except FileNotFoundError:
|
|
155
|
+
return None
|
|
156
|
+
except OSError:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _read_trust_ledger() -> dict:
|
|
161
|
+
try:
|
|
162
|
+
doc = json.loads(trust_ledger_path().read_text())
|
|
163
|
+
except (OSError, ValueError):
|
|
164
|
+
doc = {}
|
|
165
|
+
configs = doc.get("configs") if isinstance(doc, dict) else None
|
|
166
|
+
if not isinstance(configs, dict):
|
|
167
|
+
configs = {}
|
|
168
|
+
return {"schemaVersion": 1, "configs": configs}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _write_trust_ledger(ledger: dict) -> None:
|
|
172
|
+
path = trust_ledger_path()
|
|
173
|
+
private_mkdir(path.parent)
|
|
174
|
+
fd, tmp = tempfile.mkstemp(prefix=".trust-ledger.", dir=path.parent)
|
|
175
|
+
try:
|
|
176
|
+
with os.fdopen(fd, "w") as handle:
|
|
177
|
+
handle.write(json.dumps(ledger, indent=2, sort_keys=True))
|
|
178
|
+
os.replace(tmp, path)
|
|
179
|
+
except BaseException:
|
|
180
|
+
with contextlib.suppress(OSError):
|
|
181
|
+
os.unlink(tmp)
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def trusted_by_ccx() -> dict[str, dict]:
|
|
186
|
+
"""Ledger entries for the selected native profile: path -> what ccx changed."""
|
|
187
|
+
return dict(_read_trust_ledger()["configs"].get(str(CLAUDE_DOTFILE), {}))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _requested_trust_path(path: str) -> str:
|
|
191
|
+
"""Match Claude's native trust key, even after the final dir vanished.
|
|
192
|
+
|
|
193
|
+
First resolve the physical cwd and any existing symlinks; then apply the
|
|
194
|
+
same macOS NFC rule as Claude's project path, leaving Linux unchanged.
|
|
195
|
+
Only the resulting exact ledger key may be undone.
|
|
196
|
+
"""
|
|
197
|
+
from ccx.system.cwd_paths import canonical_cwd
|
|
198
|
+
key = canonical_cwd(path)
|
|
199
|
+
return unicodedata.normalize("NFC", key) if oslayer.claude_project_cwd_is_nfc() else key
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def untrust_paths(paths: list[str] | None = None, *, dry_run: bool = False) -> list[dict]:
|
|
203
|
+
"""Undo ccx's own trust writes in the selected native profile.
|
|
204
|
+
|
|
205
|
+
Only paths in the ledger are touched, and only while their entry still
|
|
206
|
+
holds the ``true`` ccx wrote: a created entry loses the key (and goes away
|
|
207
|
+
if nothing else was added to it), an added key is removed, and an
|
|
208
|
+
overridden value is restored. An entry someone changed since is left as
|
|
209
|
+
it is and dropped from the ledger. ``paths`` limits the undo (default:
|
|
210
|
+
every recorded path). Returns one ``{path, action}`` row per path, with
|
|
211
|
+
``action`` in ``removed-entry``, ``removed-key``, ``restored``,
|
|
212
|
+
``changed-since``, ``not-recorded`` or ``retry`` (the config kept
|
|
213
|
+
changing; the ledger row is kept).
|
|
214
|
+
"""
|
|
215
|
+
with _trust_lock():
|
|
216
|
+
ledger = _read_trust_ledger()
|
|
217
|
+
recorded = ledger["configs"].get(str(CLAUDE_DOTFILE), {})
|
|
218
|
+
if paths:
|
|
219
|
+
wanted = list(dict.fromkeys(_requested_trust_path(p) for p in paths if p))
|
|
220
|
+
else:
|
|
221
|
+
wanted = sorted(recorded)
|
|
222
|
+
rows: list[dict] = []
|
|
223
|
+
|
|
224
|
+
def undo(data: dict) -> list[dict] | None:
|
|
225
|
+
rows.clear()
|
|
226
|
+
projects = data.get("projects")
|
|
227
|
+
projects = projects if isinstance(projects, dict) else {}
|
|
228
|
+
changed = False
|
|
229
|
+
for ap in wanted:
|
|
230
|
+
change = recorded.get(ap)
|
|
231
|
+
if change is None:
|
|
232
|
+
rows.append({"path": ap, "action": "not-recorded"})
|
|
233
|
+
continue
|
|
234
|
+
entry = projects.get(ap)
|
|
235
|
+
if not isinstance(entry, dict) or entry.get(_TRUST_KEY) is not True:
|
|
236
|
+
rows.append({"path": ap, "action": "changed-since"})
|
|
237
|
+
continue
|
|
238
|
+
kind = change.get("change")
|
|
239
|
+
if kind == TRUST_REPLACED_VALUE:
|
|
240
|
+
entry[_TRUST_KEY] = change.get("previous")
|
|
241
|
+
action = "restored"
|
|
242
|
+
else:
|
|
243
|
+
del entry[_TRUST_KEY]
|
|
244
|
+
action = "removed-key"
|
|
245
|
+
if kind == TRUST_CREATED_ENTRY and not entry:
|
|
246
|
+
del projects[ap]
|
|
247
|
+
action = "removed-entry"
|
|
248
|
+
rows.append({"path": ap, "action": action})
|
|
249
|
+
changed = True
|
|
250
|
+
# A no-op result still has rows to report and ledger rows to drop.
|
|
251
|
+
return rows if changed and not dry_run else None
|
|
252
|
+
|
|
253
|
+
written = bool(wanted) and _update_claude_config(undo) is not None
|
|
254
|
+
if not dry_run and not written:
|
|
255
|
+
# The config kept changing under us (or became unreadable): keep
|
|
256
|
+
# those ledger rows so a retry can still undo them.
|
|
257
|
+
rows[:] = [row if row["action"] in ("changed-since", "not-recorded")
|
|
258
|
+
else {"path": row["path"], "action": "retry"} for row in rows]
|
|
259
|
+
if not rows and wanted:
|
|
260
|
+
# No callback ran: retain undo records until the config can be inspected.
|
|
261
|
+
rows = [{"path": ap, "action": "retry" if ap in recorded else "not-recorded"}
|
|
262
|
+
for ap in wanted]
|
|
263
|
+
if not dry_run and rows:
|
|
264
|
+
for row in rows:
|
|
265
|
+
if row["action"] != "retry":
|
|
266
|
+
recorded.pop(row["path"], None)
|
|
267
|
+
if not recorded:
|
|
268
|
+
ledger["configs"].pop(str(CLAUDE_DOTFILE), None)
|
|
269
|
+
_write_trust_ledger(ledger)
|
|
270
|
+
return [dict(row) for row in rows]
|
|
271
|
+
|
|
272
|
+
# --- settings (hooks) generator --------------------------------------------
|
|
273
|
+
|
|
274
|
+
HOOKS_WE_CARE_ABOUT = (
|
|
275
|
+
"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse",
|
|
276
|
+
"PostToolUseFailure",
|
|
277
|
+
"Stop", "SubagentStart", "SubagentStop", "Notification", "PermissionRequest",
|
|
278
|
+
"Elicitation",
|
|
279
|
+
)
|
|
280
|
+
# Hooks whose relay blocks until a CCX caller decides (approve/answer/respond).
|
|
281
|
+
# They get the long broker budget instead of Claude's default hook timeout.
|
|
282
|
+
BROKERED_HOOKS = ("PermissionRequest", "Elicitation")
|
|
283
|
+
|
|
284
|
+
def write_settings(short: str) -> pathlib.Path:
|
|
285
|
+
"""Generate the per-session settings JSON with hook commands pointing at our relay.
|
|
286
|
+
|
|
287
|
+
sys.argv[0] may be whatever the caller typed (e.g. `./ccx.py`), which can
|
|
288
|
+
be cwd-relative. Claude runs hooks from the session cwd, so resolve the
|
|
289
|
+
executable wrapper up front and pair it with the same Python that's running
|
|
290
|
+
us. shlex.quote covers paths with spaces.
|
|
291
|
+
"""
|
|
292
|
+
sock = sock_path(short)
|
|
293
|
+
py = shlex.quote(sys.executable)
|
|
294
|
+
script = shlex.quote(current_executable_path())
|
|
295
|
+
relay_cmd = f"{py} {script} hooks-relay {short}"
|
|
296
|
+
hooks = {}
|
|
297
|
+
for evt in HOOKS_WE_CARE_ABOUT:
|
|
298
|
+
hook = {"type": "command", "command": f"{relay_cmd} {evt}"}
|
|
299
|
+
if evt in BROKERED_HOOKS:
|
|
300
|
+
hook["timeout"] = int(PERMISSION_HOOK_TIMEOUT_S)
|
|
301
|
+
hooks[evt] = [{"hooks": [hook]}]
|
|
302
|
+
# Managed Claude workers use the classic terminal renderer: the user's
|
|
303
|
+
# native profile may select fullscreen, whose input surface does not
|
|
304
|
+
# acknowledge CCX's semantic PTY paste. This --settings sidecar overrides
|
|
305
|
+
# only the worker, never the user's own Claude settings.json.
|
|
306
|
+
cfg = {"hooks": hooks, "tui": "default"}
|
|
307
|
+
# Native Claude 2.1.293 otherwise stops at a one-time interactive
|
|
308
|
+
# "Bypass Permissions mode" consent dialog before SessionStart, even
|
|
309
|
+
# though CCX deliberately launched its own managed worker with
|
|
310
|
+
# --dangerously-skip-permissions. The caller's persisted createArgs
|
|
311
|
+
# is authoritative for this worker, including on host respawn.
|
|
312
|
+
#
|
|
313
|
+
# A per-session --settings acknowledgement suppresses only that
|
|
314
|
+
# *warning*. It does not set a default permission mode or override
|
|
315
|
+
# Claude's managed disableBypassPermissionsMode policy, which remains
|
|
316
|
+
# enforced by native Claude. Missing/malformed/legacy evidence is not
|
|
317
|
+
# an invitation to opt a restricted worker into bypass mode.
|
|
318
|
+
from ccx.store.records import read_record
|
|
319
|
+
rec = read_record(short) or {}
|
|
320
|
+
create_args = rec.get("createArgs") if isinstance(rec, dict) else None
|
|
321
|
+
if isinstance(create_args, dict) and create_args.get("skip_permissions") is True:
|
|
322
|
+
cfg["skipDangerousModePermissionPrompt"] = True
|
|
323
|
+
if os.environ.get("CCX_STATUSLINE_BRIDGE", "1").lower() not in ("0", "false", "no", "off"):
|
|
324
|
+
cfg["statusLine"] = {
|
|
325
|
+
"type": "command",
|
|
326
|
+
"command": f"{py} {script} statusline-relay {short}",
|
|
327
|
+
}
|
|
328
|
+
# Owner-private full-UUID settings are CCX sidecars, never provider-owned
|
|
329
|
+
# ~/.claude data. Publish fd-relative, nofollow and fsynced.
|
|
330
|
+
from ccx import constants
|
|
331
|
+
from ccx.store.root import require_ccx_active, _write_json
|
|
332
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
333
|
+
owner = store.resolve(short)
|
|
334
|
+
if owner.engine != "claude":
|
|
335
|
+
raise ValueError("Claude hook settings require Claude full UUID")
|
|
336
|
+
with store.lease():
|
|
337
|
+
with store._gen("workers", "claude", owner.worker_id) as fd:
|
|
338
|
+
_write_json(fd, "settings.json", cfg)
|
|
339
|
+
return settings_path(owner.worker)
|