ccx-native 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +807 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +269 -0
  16. package/ccx/claude/host/session.py +5244 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,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)