@drakon-systems/multi-clawd 1.7.2 → 1.7.4
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/README.md +141 -4
- package/dist/account-env.js +8 -2
- package/dist/chain-audit.js +2 -2
- package/dist/explain-core.js +1 -0
- package/dist/health.js +31 -2
- package/dist/hermes-core.js +258 -0
- package/dist/index.js +173 -47
- package/dist/login-health.js +2 -0
- package/dist/shim-core.js +84 -0
- package/dist/shim.js +24 -1
- package/dist/sticky.js +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -2
- package/scripts/cli.mjs +18 -1
- package/scripts/doctor.mjs +14 -0
- package/scripts/hermes.mjs +469 -0
- package/scripts/hermes_bridge.py +745 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Secret-safe JSON/stdin bridge for multi-clawd's Hermes adapter.
|
|
3
|
+
|
|
4
|
+
The bridge deliberately has no command-line token interface. A caller selects
|
|
5
|
+
one Hermes profile/home with HERMES_HOME, sends exactly one JSON document on
|
|
6
|
+
stdin, and receives exactly one JSON document on stdout.
|
|
7
|
+
|
|
8
|
+
Only stable ``claude setup-token`` values are accepted. A rotating Claude
|
|
9
|
+
grant (a native or config-dir ``.credentials.json``) is single-use on refresh,
|
|
10
|
+
so duplicating one into a second store guarantees that one of the copies dies.
|
|
11
|
+
For a *native* login that copy is unnecessary anyway: Hermes' own
|
|
12
|
+
``claude_code`` credential source already reads that exact file directly. A
|
|
13
|
+
*config-dir* login has no such fallback — as of Hermes Agent 0.19.1,
|
|
14
|
+
``claude_code`` only reads the native path, never an arbitrary config dir — so
|
|
15
|
+
it can only reach this bridge via its own setup token, never a duplicated
|
|
16
|
+
grant. Requests carrying refresh tokens or expiries are refused outright.
|
|
17
|
+
|
|
18
|
+
Planning reads the PROFILE-LOCAL auth store directly. ``read_credential_pool``
|
|
19
|
+
falls back to the global-root ``auth.json`` when a profile has no entries for a
|
|
20
|
+
provider, so planning against it would copy unrelated global credentials into
|
|
21
|
+
the profile on the first sync. It is used only to report the effective view
|
|
22
|
+
that Hermes itself would see, and is labelled as such.
|
|
23
|
+
|
|
24
|
+
Writes are the two Hermes files the adapter owns a slice of. Each individual
|
|
25
|
+
write is atomic inside Hermes (``write_credential_pool`` merges the on-disk pool
|
|
26
|
+
under a lock; ``save_config`` writes YAML through a temp file + rename), but the
|
|
27
|
+
pair is NOT atomic. The pool is written first and the config second, both are
|
|
28
|
+
idempotent, and both are verified by re-reading afterwards, so an interruption
|
|
29
|
+
between them leaves a state that a re-run repairs rather than one that needs a
|
|
30
|
+
rollback. There is deliberately no rollback path: rewriting a user's whole
|
|
31
|
+
config.yaml to undo a strategy key is more dangerous than leaving a stale
|
|
32
|
+
strategy behind.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import copy
|
|
38
|
+
import hashlib
|
|
39
|
+
import io
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import stat
|
|
44
|
+
import sys
|
|
45
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Any
|
|
49
|
+
|
|
50
|
+
PROVIDER = "anthropic"
|
|
51
|
+
MANAGED_SOURCE = "manual:multi-clawd"
|
|
52
|
+
MANAGED_ID_PREFIX = "multi-clawd-"
|
|
53
|
+
STRATEGIES = ("fill_first", "round_robin", "random", "least_used")
|
|
54
|
+
DEFAULT_STRATEGY = "fill_first"
|
|
55
|
+
MAX_REQUEST_BYTES = 2 * 1024 * 1024
|
|
56
|
+
MAX_AUTH_STORE_BYTES = 8 * 1024 * 1024
|
|
57
|
+
MAX_ACCOUNT_ID_LENGTH = 64
|
|
58
|
+
MAX_CREDENTIALS = 64
|
|
59
|
+
ACCOUNT_ID_ALPHABET = frozenset("abcdefghijklmnopqrstuvwxyz0123456789_-")
|
|
60
|
+
# Cleared whenever a managed row is written: a setup token never expires on a
|
|
61
|
+
# schedule, so a stale expiry copied from an older row would quarantine it.
|
|
62
|
+
STALE_EXPIRY_FIELDS = ("expires_at", "expires_at_ms", "last_refresh")
|
|
63
|
+
# Mirrors src/hermes-core.ts's parseClaudeSetupToken: ASCII-only, and shaped
|
|
64
|
+
# like the current `sk-ant-oat01-...` setup-token family. The version digits
|
|
65
|
+
# are intentionally unconstrained beyond "two or more" so a future
|
|
66
|
+
# `sk-ant-oat02-...` does not need both sides of the bridge updated in lockstep.
|
|
67
|
+
SETUP_TOKEN_RE = re.compile(r"^sk-ant-oat\d{2,}-[\x21-\x7e]+$")
|
|
68
|
+
API_KEY_PREFIX = "sk-ant-api"
|
|
69
|
+
# Hermes' own agent.credential_pool.STATUS_* values. Anything else on disk is
|
|
70
|
+
# either stale, from another tool, or attacker-controlled, so it is dropped
|
|
71
|
+
# rather than echoed — see safe_row().
|
|
72
|
+
KNOWN_ROW_STATUSES = frozenset({"ok", "exhausted", "dead"})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BridgeError(Exception):
|
|
76
|
+
def __init__(self, code: str, message: str) -> None:
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
self.code = code
|
|
79
|
+
self.safe_message = message
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class DesiredCredential:
|
|
84
|
+
account_id: str
|
|
85
|
+
id: str
|
|
86
|
+
label: str
|
|
87
|
+
access_token: str
|
|
88
|
+
priority: int
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def fail(code: str, message: str) -> None:
|
|
92
|
+
raise BridgeError(code, message)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def record(value: Any, code: str = "malformed_request") -> dict[str, Any]:
|
|
96
|
+
if not isinstance(value, dict):
|
|
97
|
+
fail(code, "request data is malformed")
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def nonempty_string(value: Any, code: str, message: str) -> str:
|
|
102
|
+
if not isinstance(value, str) or not value.strip() or "\x00" in value:
|
|
103
|
+
fail(code, message)
|
|
104
|
+
return value.strip()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def stable_id(account_id: str) -> str:
|
|
108
|
+
digest = hashlib.sha256(f"multi-clawd/hermes/{account_id}".encode()).hexdigest()
|
|
109
|
+
return f"{MANAGED_ID_PREFIX}{digest[:16]}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def selected_home(request: dict[str, Any]) -> Path:
|
|
113
|
+
raw_home = os.environ.get("HERMES_HOME", "")
|
|
114
|
+
if not raw_home.strip():
|
|
115
|
+
fail("hermes_home_required", "HERMES_HOME must select the target Hermes home")
|
|
116
|
+
env_home = Path(raw_home).expanduser()
|
|
117
|
+
if not env_home.is_absolute():
|
|
118
|
+
fail("invalid_hermes_home", "HERMES_HOME must be an absolute path")
|
|
119
|
+
env_home = env_home.resolve(strict=False)
|
|
120
|
+
|
|
121
|
+
supplied = [request.get(key) for key in ("targetHome", "hermesHome") if key in request]
|
|
122
|
+
if len(supplied) > 1 and supplied[0] != supplied[1]:
|
|
123
|
+
fail("target_home_mismatch", "payload target does not match HERMES_HOME")
|
|
124
|
+
if supplied:
|
|
125
|
+
raw_target = nonempty_string(
|
|
126
|
+
supplied[0], "invalid_target_home", "payload target home is malformed"
|
|
127
|
+
)
|
|
128
|
+
target = Path(raw_target).expanduser()
|
|
129
|
+
if not target.is_absolute() or target.resolve(strict=False) != env_home:
|
|
130
|
+
fail("target_home_mismatch", "payload target does not match HERMES_HOME")
|
|
131
|
+
return env_home
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def assert_profile_exists(home: Path) -> None:
|
|
135
|
+
"""Never fabricate a named profile.
|
|
136
|
+
|
|
137
|
+
Hermes' ``ensure_hermes_home()`` refuses to mkdir ``<root>/profiles/<name>``
|
|
138
|
+
on purpose, so a deleted profile is not resurrected as an empty skeleton.
|
|
139
|
+
The adapter honours that invariant instead of working around it.
|
|
140
|
+
"""
|
|
141
|
+
if home.parent.name == "profiles" and not home.is_dir():
|
|
142
|
+
fail(
|
|
143
|
+
"hermes_profile_missing",
|
|
144
|
+
"the named Hermes profile does not exist; create it with "
|
|
145
|
+
"`hermes profile create <name>` before syncing",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def request_operation(request: dict[str, Any]) -> str:
|
|
150
|
+
operation = request.get("operation")
|
|
151
|
+
alias = request.get("op")
|
|
152
|
+
if operation is not None and alias is not None and operation != alias:
|
|
153
|
+
fail("malformed_request", "request operation is ambiguous")
|
|
154
|
+
operation = operation if operation is not None else alias
|
|
155
|
+
if operation not in {"probe", "doctor", "apply"}:
|
|
156
|
+
fail("unsupported_operation", "operation must be probe, doctor, or apply")
|
|
157
|
+
return operation
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_desired(value: Any) -> list[DesiredCredential]:
|
|
161
|
+
if not isinstance(value, list) or not value:
|
|
162
|
+
fail("malformed_credentials", "credentials must be a non-empty array")
|
|
163
|
+
if len(value) > MAX_CREDENTIALS:
|
|
164
|
+
fail("malformed_credentials", "too many credentials were submitted")
|
|
165
|
+
|
|
166
|
+
desired: list[DesiredCredential] = []
|
|
167
|
+
seen_ids: set[str] = set()
|
|
168
|
+
seen_priorities: set[int] = set()
|
|
169
|
+
allowed = {"accountId", "id", "label", "source", "authType", "accessToken", "priority"}
|
|
170
|
+
rotating = {"refreshToken", "expiresAtMs", "expiresAt", "refresh_token", "expires_at"}
|
|
171
|
+
for value_row in value:
|
|
172
|
+
row = record(value_row, "malformed_credentials")
|
|
173
|
+
if rotating & set(row):
|
|
174
|
+
fail(
|
|
175
|
+
"rotating_grant_not_supported",
|
|
176
|
+
"only stable Claude setup tokens can be imported; rotating grants are single-use — "
|
|
177
|
+
"a native ~/.claude login is already read directly by Hermes' own claude_code "
|
|
178
|
+
"credential source, and a configDir login needs its own setup token instead "
|
|
179
|
+
"(claude_code cannot be pointed at a configDir)",
|
|
180
|
+
)
|
|
181
|
+
if set(row) - allowed:
|
|
182
|
+
fail("malformed_credentials", "credential data contains unsupported fields")
|
|
183
|
+
account_id = nonempty_string(
|
|
184
|
+
row.get("accountId"), "malformed_credentials", "credential data is malformed"
|
|
185
|
+
)
|
|
186
|
+
if (
|
|
187
|
+
len(account_id) > MAX_ACCOUNT_ID_LENGTH
|
|
188
|
+
or not account_id[0].isalnum()
|
|
189
|
+
or any(ch not in ACCOUNT_ID_ALPHABET for ch in account_id)
|
|
190
|
+
):
|
|
191
|
+
fail("malformed_credentials", "credential account id is malformed")
|
|
192
|
+
credential_id = nonempty_string(
|
|
193
|
+
row.get("id"), "malformed_credentials", "credential data is malformed"
|
|
194
|
+
)
|
|
195
|
+
if credential_id != stable_id(account_id):
|
|
196
|
+
fail("invalid_managed_id", "managed credential id is not deterministic for its account")
|
|
197
|
+
if credential_id in seen_ids:
|
|
198
|
+
fail("duplicate_managed_ids", "desired managed credential ids must be unique")
|
|
199
|
+
seen_ids.add(credential_id)
|
|
200
|
+
|
|
201
|
+
expected_label = f"multi-clawd:{account_id}"
|
|
202
|
+
if row.get("label") != expected_label:
|
|
203
|
+
fail("malformed_credentials", "managed credential label is malformed")
|
|
204
|
+
if row.get("source") != MANAGED_SOURCE or row.get("authType") != "oauth":
|
|
205
|
+
fail("malformed_credentials", "managed credential metadata is malformed")
|
|
206
|
+
access_token = nonempty_string(
|
|
207
|
+
row.get("accessToken"), "malformed_credentials", "credential data is malformed"
|
|
208
|
+
)
|
|
209
|
+
if access_token.startswith(API_KEY_PREFIX):
|
|
210
|
+
fail(
|
|
211
|
+
"malformed_credentials",
|
|
212
|
+
"that looks like a Claude API key, not a setup token — setup tokens start with "
|
|
213
|
+
"sk-ant-oat",
|
|
214
|
+
)
|
|
215
|
+
if not access_token.isascii() or not SETUP_TOKEN_RE.match(access_token):
|
|
216
|
+
fail("malformed_credentials", "the submitted setup token is malformed")
|
|
217
|
+
priority = row.get("priority")
|
|
218
|
+
if (
|
|
219
|
+
isinstance(priority, bool)
|
|
220
|
+
or not isinstance(priority, int)
|
|
221
|
+
or priority < 0
|
|
222
|
+
or priority >= MAX_CREDENTIALS
|
|
223
|
+
):
|
|
224
|
+
fail("malformed_credentials", "credential priority is malformed")
|
|
225
|
+
if priority in seen_priorities:
|
|
226
|
+
fail("malformed_credentials", "credential priorities must be unique")
|
|
227
|
+
seen_priorities.add(priority)
|
|
228
|
+
|
|
229
|
+
desired.append(
|
|
230
|
+
DesiredCredential(
|
|
231
|
+
account_id=account_id,
|
|
232
|
+
id=credential_id,
|
|
233
|
+
label=expected_label,
|
|
234
|
+
access_token=access_token,
|
|
235
|
+
priority=priority,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
return desired
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def read_local_pool(home: Path) -> list[Any]:
|
|
242
|
+
"""Read this home's OWN anthropic pool rows — no global-root fallback.
|
|
243
|
+
|
|
244
|
+
Hermes exposes no public profile-local reader, so the store is read here
|
|
245
|
+
under a strict size/shape bound. Unlike Hermes' internal loader this never
|
|
246
|
+
degrades a corrupt store to an empty one: planning against ``[]`` when the
|
|
247
|
+
file is really unreadable would look like "nothing is configured" and add
|
|
248
|
+
duplicate rows.
|
|
249
|
+
"""
|
|
250
|
+
path = home / "auth.json"
|
|
251
|
+
try:
|
|
252
|
+
info = path.stat()
|
|
253
|
+
except FileNotFoundError:
|
|
254
|
+
return []
|
|
255
|
+
except OSError:
|
|
256
|
+
fail("auth_store_unreadable", "the Hermes auth store could not be read")
|
|
257
|
+
if not stat.S_ISREG(info.st_mode):
|
|
258
|
+
fail("auth_store_unreadable", "the Hermes auth store is not a regular file")
|
|
259
|
+
if info.st_size > MAX_AUTH_STORE_BYTES:
|
|
260
|
+
fail("auth_store_unreadable", "the Hermes auth store is too large to plan against safely")
|
|
261
|
+
try:
|
|
262
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
263
|
+
except Exception:
|
|
264
|
+
fail(
|
|
265
|
+
"auth_store_unreadable",
|
|
266
|
+
"the Hermes auth store is unreadable or unparseable; repair it with Hermes first",
|
|
267
|
+
)
|
|
268
|
+
if not isinstance(data, dict):
|
|
269
|
+
fail("auth_store_unreadable", "the Hermes auth store has an unexpected shape")
|
|
270
|
+
pool = data.get("credential_pool")
|
|
271
|
+
if pool is None:
|
|
272
|
+
return []
|
|
273
|
+
if not isinstance(pool, dict):
|
|
274
|
+
fail("malformed_pool_rows", "the Hermes credential pool has an unexpected shape")
|
|
275
|
+
rows = pool.get(PROVIDER)
|
|
276
|
+
if rows is None:
|
|
277
|
+
return []
|
|
278
|
+
if not isinstance(rows, list):
|
|
279
|
+
fail("malformed_pool_rows", "the Hermes anthropic credential pool is malformed")
|
|
280
|
+
return rows
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def is_managed_row(row_id: Any, source: Any) -> bool:
|
|
284
|
+
return (isinstance(row_id, str) and row_id.startswith(MANAGED_ID_PREFIX)) or source == MANAGED_SOURCE
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def pool_findings(rows: list[Any]) -> dict[str, dict[str, list[str]]]:
|
|
288
|
+
"""Split pool observations into what blocks a sync and what merely informs.
|
|
289
|
+
|
|
290
|
+
Only multi-clawd's OWN rows can block: duplicate managed ids, and managed
|
|
291
|
+
rows that are half-managed or unusable. Everything else — several
|
|
292
|
+
``claude_code`` rows, a malformed row belonging to another tool — is a
|
|
293
|
+
legitimate user state that is none of this adapter's business, so it is
|
|
294
|
+
reported as a warning and never fails doctor or sync.
|
|
295
|
+
|
|
296
|
+
Unrelated rows are identified by position only; their ids and labels can
|
|
297
|
+
carry account identifiers and are never echoed.
|
|
298
|
+
"""
|
|
299
|
+
duplicate_managed: dict[str, int] = {}
|
|
300
|
+
malformed_managed: list[str] = []
|
|
301
|
+
claude_code: list[str] = []
|
|
302
|
+
malformed_unrelated: list[str] = []
|
|
303
|
+
|
|
304
|
+
for index, row in enumerate(rows):
|
|
305
|
+
marker = f"row:{index}"
|
|
306
|
+
if not isinstance(row, dict):
|
|
307
|
+
malformed_unrelated.append(marker)
|
|
308
|
+
continue
|
|
309
|
+
row_id = row.get("id")
|
|
310
|
+
source = row.get("source")
|
|
311
|
+
id_ok = isinstance(row_id, str) and bool(row_id.strip())
|
|
312
|
+
source_ok = source is None or isinstance(source, str)
|
|
313
|
+
if is_managed_row(row_id if id_ok else None, source if source_ok else None):
|
|
314
|
+
label = row_id if id_ok else marker
|
|
315
|
+
duplicate_managed[label] = duplicate_managed.get(label, 0) + 1
|
|
316
|
+
has_managed_id = id_ok and row_id.startswith(MANAGED_ID_PREFIX)
|
|
317
|
+
has_managed_source = source == MANAGED_SOURCE
|
|
318
|
+
if (
|
|
319
|
+
not id_ok
|
|
320
|
+
or has_managed_id != has_managed_source
|
|
321
|
+
or row.get("auth_type") != "oauth"
|
|
322
|
+
or not isinstance(row.get("label"), str)
|
|
323
|
+
or not isinstance(row.get("access_token"), str)
|
|
324
|
+
or not row.get("access_token")
|
|
325
|
+
):
|
|
326
|
+
malformed_managed.append(label)
|
|
327
|
+
elif not id_ok or not source_ok:
|
|
328
|
+
malformed_unrelated.append(marker)
|
|
329
|
+
if source == "claude_code":
|
|
330
|
+
claude_code.append(marker)
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
"errors": {
|
|
334
|
+
"duplicateManagedIds": sorted(
|
|
335
|
+
row_id for row_id, count in duplicate_managed.items() if count > 1
|
|
336
|
+
),
|
|
337
|
+
"malformedManagedRows": sorted(set(malformed_managed)),
|
|
338
|
+
},
|
|
339
|
+
"warnings": {
|
|
340
|
+
"multipleClaudeCodeRows": sorted(claude_code) if len(claude_code) > 1 else [],
|
|
341
|
+
"malformedUnrelatedRows": sorted(set(malformed_unrelated)),
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def finding_counts(findings: dict[str, dict[str, list[str]]]) -> tuple[int, int]:
|
|
347
|
+
errors = sum(len(rows) for rows in findings["errors"].values())
|
|
348
|
+
warnings = sum(len(rows) for rows in findings["warnings"].values())
|
|
349
|
+
return errors, warnings
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def assert_pool_safe(findings: dict[str, dict[str, list[str]]]) -> None:
|
|
353
|
+
errors = findings["errors"]
|
|
354
|
+
if errors["duplicateManagedIds"]:
|
|
355
|
+
fail("duplicate_managed_ids", "Hermes contains duplicate multi-clawd managed credential ids")
|
|
356
|
+
if errors["malformedManagedRows"]:
|
|
357
|
+
fail("malformed_managed_rows", "Hermes contains malformed multi-clawd managed rows")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def unrelated_row_malformed(row: dict[str, Any]) -> bool:
|
|
361
|
+
row_id = row.get("id")
|
|
362
|
+
source = row.get("source")
|
|
363
|
+
id_ok = isinstance(row_id, str) and bool(row_id.strip())
|
|
364
|
+
source_ok = source is None or isinstance(source, str)
|
|
365
|
+
return not id_ok or not source_ok
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def safe_row(row: Any, index: int) -> dict[str, Any]:
|
|
369
|
+
"""Render one pool row with no secret material and no unrelated identifiers.
|
|
370
|
+
|
|
371
|
+
An unrelated row belongs to another tool, or to a hand-edited auth.json —
|
|
372
|
+
every one of its fields (not just id/label) can carry an arbitrary
|
|
373
|
+
attacker-chosen string, so nothing beyond its position is ever echoed for
|
|
374
|
+
it. A managed row only reports the fixed values this adapter itself
|
|
375
|
+
defines (its deterministic id/label, the constant source/authType, and a
|
|
376
|
+
validated integer priority), plus lastStatus only when it is one of
|
|
377
|
+
Hermes' own known status strings — never whatever happens to be on disk.
|
|
378
|
+
"""
|
|
379
|
+
if not isinstance(row, dict):
|
|
380
|
+
return {"index": index, "managed": False, "malformed": True}
|
|
381
|
+
if not is_managed_row(row.get("id"), row.get("source")):
|
|
382
|
+
result: dict[str, Any] = {"index": index, "managed": False}
|
|
383
|
+
if unrelated_row_malformed(row):
|
|
384
|
+
result["malformed"] = True
|
|
385
|
+
return result
|
|
386
|
+
priority = row.get("priority")
|
|
387
|
+
last_status = row.get("last_status")
|
|
388
|
+
result = {
|
|
389
|
+
"index": index,
|
|
390
|
+
"managed": True,
|
|
391
|
+
"id": row.get("id"),
|
|
392
|
+
"label": row.get("label"),
|
|
393
|
+
"source": MANAGED_SOURCE,
|
|
394
|
+
"authType": "oauth",
|
|
395
|
+
"priority": priority if isinstance(priority, int) and not isinstance(priority, bool) else None,
|
|
396
|
+
"lastStatus": last_status if last_status in KNOWN_ROW_STATUSES else None,
|
|
397
|
+
}
|
|
398
|
+
return {key: value for key, value in result.items() if value is not None}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def current_strategy(config: dict[str, Any]) -> str | None:
|
|
402
|
+
strategies = config.get("credential_pool_strategies")
|
|
403
|
+
if strategies is None:
|
|
404
|
+
return None
|
|
405
|
+
if not isinstance(strategies, dict):
|
|
406
|
+
fail("malformed_config", "credential_pool_strategies must be a mapping")
|
|
407
|
+
value = strategies.get(PROVIDER)
|
|
408
|
+
if value is not None and not isinstance(value, str):
|
|
409
|
+
fail("malformed_config", "anthropic credential pool strategy must be a string")
|
|
410
|
+
return value
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def effective_strategy(requested: str | None, current: str | None) -> str:
|
|
414
|
+
"""An omitted --strategy preserves whatever Hermes already has.
|
|
415
|
+
|
|
416
|
+
A value this adapter does not recognise is still preserved rather than
|
|
417
|
+
policed: it belongs to the user's Hermes install, not to multi-clawd.
|
|
418
|
+
"""
|
|
419
|
+
if requested is not None:
|
|
420
|
+
return requested
|
|
421
|
+
if current is not None:
|
|
422
|
+
return current
|
|
423
|
+
return DEFAULT_STRATEGY
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def build_new_row(credential: DesiredCredential) -> dict[str, Any]:
|
|
427
|
+
from agent.credential_pool import PooledCredential
|
|
428
|
+
|
|
429
|
+
return PooledCredential(
|
|
430
|
+
provider=PROVIDER,
|
|
431
|
+
id=credential.id,
|
|
432
|
+
label=credential.label,
|
|
433
|
+
auth_type="oauth",
|
|
434
|
+
priority=credential.priority,
|
|
435
|
+
source=MANAGED_SOURCE,
|
|
436
|
+
access_token=credential.access_token,
|
|
437
|
+
).to_dict()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def merge_credential(existing: dict[str, Any], credential: DesiredCredential) -> dict[str, Any]:
|
|
441
|
+
"""Replace the managed fields, keep Hermes' runtime bookkeeping.
|
|
442
|
+
|
|
443
|
+
Expiry fields are cleared: a setup token carries no expiry, and a value left
|
|
444
|
+
over from an older row would make Hermes treat a perfectly good credential
|
|
445
|
+
as expired.
|
|
446
|
+
"""
|
|
447
|
+
updated = copy.deepcopy(existing)
|
|
448
|
+
updated.update(
|
|
449
|
+
{
|
|
450
|
+
"id": credential.id,
|
|
451
|
+
"label": credential.label,
|
|
452
|
+
"source": MANAGED_SOURCE,
|
|
453
|
+
"auth_type": "oauth",
|
|
454
|
+
"priority": credential.priority,
|
|
455
|
+
"access_token": credential.access_token,
|
|
456
|
+
}
|
|
457
|
+
)
|
|
458
|
+
updated.pop("refresh_token", None)
|
|
459
|
+
for field in STALE_EXPIRY_FIELDS:
|
|
460
|
+
updated.pop(field, None)
|
|
461
|
+
return updated
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def equivalent(existing: dict[str, Any], desired: DesiredCredential) -> bool:
|
|
465
|
+
return (
|
|
466
|
+
existing.get("id") == desired.id
|
|
467
|
+
and existing.get("label") == desired.label
|
|
468
|
+
and existing.get("source") == MANAGED_SOURCE
|
|
469
|
+
and existing.get("auth_type") == "oauth"
|
|
470
|
+
and existing.get("priority") == desired.priority
|
|
471
|
+
and existing.get("access_token") == desired.access_token
|
|
472
|
+
and existing.get("refresh_token") is None
|
|
473
|
+
and all(existing.get(field) is None for field in STALE_EXPIRY_FIELDS)
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def plan_rows(
|
|
478
|
+
local_rows: list[Any], desired: list[DesiredCredential]
|
|
479
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
480
|
+
"""Build the managed rows to send, from the profile-local rows only.
|
|
481
|
+
|
|
482
|
+
Only these rows are handed to ``write_credential_pool``; it re-reads the
|
|
483
|
+
on-disk pool under its lock and keeps every entry it does not receive, so
|
|
484
|
+
unrelated credentials survive without ever being read, copied, or rewritten
|
|
485
|
+
by this adapter.
|
|
486
|
+
"""
|
|
487
|
+
by_id: dict[str, dict[str, Any]] = {}
|
|
488
|
+
for row in local_rows:
|
|
489
|
+
if isinstance(row, dict) and isinstance(row.get("id"), str) and row["id"] not in by_id:
|
|
490
|
+
by_id[row["id"]] = row
|
|
491
|
+
|
|
492
|
+
managed_rows: list[dict[str, Any]] = []
|
|
493
|
+
actions: list[dict[str, Any]] = []
|
|
494
|
+
for credential in desired:
|
|
495
|
+
existing = by_id.get(credential.id)
|
|
496
|
+
if existing is None:
|
|
497
|
+
managed_rows.append(build_new_row(credential))
|
|
498
|
+
action = "add"
|
|
499
|
+
elif existing.get("source") != MANAGED_SOURCE:
|
|
500
|
+
fail("managed_id_collision", "a managed credential id is owned by another source")
|
|
501
|
+
elif equivalent(existing, credential):
|
|
502
|
+
managed_rows.append(copy.deepcopy(existing))
|
|
503
|
+
action = "noop"
|
|
504
|
+
else:
|
|
505
|
+
managed_rows.append(merge_credential(existing, credential))
|
|
506
|
+
action = "update"
|
|
507
|
+
actions.append(
|
|
508
|
+
{
|
|
509
|
+
"accountId": credential.account_id,
|
|
510
|
+
"id": credential.id,
|
|
511
|
+
"action": action,
|
|
512
|
+
"priority": credential.priority,
|
|
513
|
+
}
|
|
514
|
+
)
|
|
515
|
+
return managed_rows, actions
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def config_is_safe_to_rewrite(home: Path, config: dict[str, Any]) -> bool:
|
|
519
|
+
"""Refuse to rewrite a config.yaml Hermes could not parse.
|
|
520
|
+
|
|
521
|
+
``read_raw_config()`` fails open to ``{}`` on a parse error. Saving that
|
|
522
|
+
back would erase the user's whole configuration to persist one strategy
|
|
523
|
+
key, so a file with real content that read as empty is treated as a hard
|
|
524
|
+
stop rather than as an empty config.
|
|
525
|
+
"""
|
|
526
|
+
if config:
|
|
527
|
+
return True
|
|
528
|
+
path = home / "config.yaml"
|
|
529
|
+
try:
|
|
530
|
+
if path.stat().st_size > MAX_AUTH_STORE_BYTES:
|
|
531
|
+
return False
|
|
532
|
+
text = path.read_text(encoding="utf-8")
|
|
533
|
+
except FileNotFoundError:
|
|
534
|
+
return True
|
|
535
|
+
except Exception:
|
|
536
|
+
return False
|
|
537
|
+
return not any(
|
|
538
|
+
line.strip() and not line.lstrip().startswith("#") for line in text.splitlines()
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def verify_pool_write(home: Path, desired: list[DesiredCredential]) -> None:
|
|
543
|
+
written = {
|
|
544
|
+
row["id"]: row
|
|
545
|
+
for row in read_local_pool(home)
|
|
546
|
+
if isinstance(row, dict) and isinstance(row.get("id"), str)
|
|
547
|
+
}
|
|
548
|
+
for credential in desired:
|
|
549
|
+
row = written.get(credential.id)
|
|
550
|
+
if row is None or not equivalent(row, credential):
|
|
551
|
+
fail(
|
|
552
|
+
"pool_write_unverified",
|
|
553
|
+
"Hermes accepted the credential write but the managed rows are not on disk; "
|
|
554
|
+
"check the Hermes install and re-run",
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def verify_strategy_write(strategy: str) -> None:
|
|
559
|
+
from hermes_cli.config import read_raw_config
|
|
560
|
+
|
|
561
|
+
if current_strategy(read_raw_config()) != strategy:
|
|
562
|
+
fail(
|
|
563
|
+
"strategy_write_unverified",
|
|
564
|
+
"the managed credentials were written but Hermes did not persist the pool "
|
|
565
|
+
"strategy (a package-manager-managed install refuses config writes); set it "
|
|
566
|
+
"with `hermes config set credential_pool_strategies.anthropic <strategy>`",
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def observed_state(home: Path, operation: str) -> dict[str, Any]:
|
|
571
|
+
from hermes_cli.auth import read_credential_pool
|
|
572
|
+
from hermes_cli.config import read_raw_config
|
|
573
|
+
|
|
574
|
+
local_rows = read_local_pool(home)
|
|
575
|
+
effective_rows = read_credential_pool(PROVIDER)
|
|
576
|
+
if not isinstance(effective_rows, list):
|
|
577
|
+
effective_rows = []
|
|
578
|
+
config = read_raw_config()
|
|
579
|
+
findings = pool_findings(local_rows)
|
|
580
|
+
errors, warnings = finding_counts(findings)
|
|
581
|
+
strategy = current_strategy(config)
|
|
582
|
+
response: dict[str, Any] = {
|
|
583
|
+
"ok": True,
|
|
584
|
+
"operation": operation,
|
|
585
|
+
"home": str(home),
|
|
586
|
+
"provider": PROVIDER,
|
|
587
|
+
"strategy": strategy,
|
|
588
|
+
"effectiveStrategy": effective_strategy(None, strategy),
|
|
589
|
+
# Rows this home actually owns — the only rows a sync ever plans against.
|
|
590
|
+
"localRowCount": len(local_rows),
|
|
591
|
+
"localRows": [safe_row(row, index) for index, row in enumerate(local_rows)],
|
|
592
|
+
# What Hermes itself would resolve here, which for a profile with no
|
|
593
|
+
# anthropic entries of its own is the global root's pool, read-only.
|
|
594
|
+
"effectiveRowCount": len(effective_rows),
|
|
595
|
+
"effectiveIncludesGlobalFallback": not local_rows and bool(effective_rows),
|
|
596
|
+
"findings": findings,
|
|
597
|
+
"errorCount": errors,
|
|
598
|
+
"warningCount": warnings,
|
|
599
|
+
}
|
|
600
|
+
if operation == "doctor":
|
|
601
|
+
response["healthy"] = errors == 0
|
|
602
|
+
return response
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def apply_response(home: Path, request: dict[str, Any]) -> dict[str, Any]:
|
|
606
|
+
from hermes_cli.auth import write_credential_pool
|
|
607
|
+
from hermes_cli.config import read_raw_config, save_config
|
|
608
|
+
|
|
609
|
+
raw_strategy = request.get("strategy")
|
|
610
|
+
if raw_strategy is not None and (
|
|
611
|
+
not isinstance(raw_strategy, str) or raw_strategy not in STRATEGIES
|
|
612
|
+
):
|
|
613
|
+
fail("invalid_strategy", "strategy is not supported by Hermes")
|
|
614
|
+
dry_run = request.get("dryRun", False)
|
|
615
|
+
if not isinstance(dry_run, bool):
|
|
616
|
+
fail("malformed_request", "dryRun must be a boolean")
|
|
617
|
+
desired = parse_desired(request.get("credentials"))
|
|
618
|
+
|
|
619
|
+
# Complete every validation and build the whole plan before writing.
|
|
620
|
+
local_rows = read_local_pool(home)
|
|
621
|
+
config = read_raw_config()
|
|
622
|
+
findings = pool_findings(local_rows)
|
|
623
|
+
errors, warnings = finding_counts(findings)
|
|
624
|
+
assert_pool_safe(findings)
|
|
625
|
+
current = current_strategy(config)
|
|
626
|
+
strategy = effective_strategy(raw_strategy, current)
|
|
627
|
+
managed_rows, actions = plan_rows(local_rows, desired)
|
|
628
|
+
strategy_changed = current != strategy
|
|
629
|
+
pool_changed = any(action["action"] != "noop" for action in actions)
|
|
630
|
+
would_write = strategy_changed or pool_changed
|
|
631
|
+
|
|
632
|
+
if not dry_run and would_write:
|
|
633
|
+
if strategy_changed and not config_is_safe_to_rewrite(home, config):
|
|
634
|
+
fail(
|
|
635
|
+
"config_unreadable",
|
|
636
|
+
"Hermes config.yaml could not be parsed; refusing to overwrite it. "
|
|
637
|
+
"Repair it, or re-run without --strategy",
|
|
638
|
+
)
|
|
639
|
+
# Pool first, config second: the pool write merges under Hermes' lock
|
|
640
|
+
# and is idempotent, so an interruption before the config write leaves a
|
|
641
|
+
# state a re-run repairs. See the module docstring.
|
|
642
|
+
if pool_changed:
|
|
643
|
+
write_credential_pool(PROVIDER, managed_rows)
|
|
644
|
+
verify_pool_write(home, desired)
|
|
645
|
+
if strategy_changed:
|
|
646
|
+
new_config = copy.deepcopy(config)
|
|
647
|
+
strategies = new_config.setdefault("credential_pool_strategies", {})
|
|
648
|
+
if not isinstance(strategies, dict):
|
|
649
|
+
fail("malformed_config", "credential_pool_strategies must be a mapping")
|
|
650
|
+
strategies[PROVIDER] = strategy
|
|
651
|
+
save_config(
|
|
652
|
+
new_config,
|
|
653
|
+
strip_defaults=False,
|
|
654
|
+
preserve_keys={("credential_pool_strategies", PROVIDER)},
|
|
655
|
+
)
|
|
656
|
+
verify_strategy_write(strategy)
|
|
657
|
+
|
|
658
|
+
resulting = read_local_pool(home) if (would_write and not dry_run) else local_rows
|
|
659
|
+
return {
|
|
660
|
+
"ok": True,
|
|
661
|
+
"operation": "apply",
|
|
662
|
+
"home": str(home),
|
|
663
|
+
"provider": PROVIDER,
|
|
664
|
+
"dryRun": dry_run,
|
|
665
|
+
"wouldWrite": would_write,
|
|
666
|
+
"wrote": bool(would_write and not dry_run),
|
|
667
|
+
"strategy": strategy,
|
|
668
|
+
"requestedStrategy": raw_strategy,
|
|
669
|
+
"currentStrategy": current,
|
|
670
|
+
"strategyChanged": strategy_changed,
|
|
671
|
+
"actions": actions,
|
|
672
|
+
"localRowCount": len(local_rows),
|
|
673
|
+
"preservedRowCount": sum(
|
|
674
|
+
1
|
|
675
|
+
for row in local_rows
|
|
676
|
+
if not isinstance(row, dict) or row.get("id") not in {item.id for item in desired}
|
|
677
|
+
),
|
|
678
|
+
# Managed rows for accounts that are no longer configured. They are
|
|
679
|
+
# preserved, never silently deleted — removing a credential is the
|
|
680
|
+
# user's call, via Hermes.
|
|
681
|
+
"orphanManagedRowCount": sum(
|
|
682
|
+
1
|
|
683
|
+
for row in local_rows
|
|
684
|
+
if isinstance(row, dict)
|
|
685
|
+
and is_managed_row(row.get("id"), row.get("source"))
|
|
686
|
+
and row.get("id") not in {item.id for item in desired}
|
|
687
|
+
),
|
|
688
|
+
"resultingRows": [safe_row(row, index) for index, row in enumerate(resulting)],
|
|
689
|
+
"findings": findings,
|
|
690
|
+
"errorCount": errors,
|
|
691
|
+
"warningCount": warnings,
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def dispatch(request: Any) -> dict[str, Any]:
|
|
696
|
+
request = record(request)
|
|
697
|
+
home = selected_home(request)
|
|
698
|
+
assert_profile_exists(home)
|
|
699
|
+
operation = request_operation(request)
|
|
700
|
+
if operation in {"probe", "doctor"}:
|
|
701
|
+
return observed_state(home, operation)
|
|
702
|
+
return apply_response(home, request)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def read_request() -> Any:
|
|
706
|
+
data = sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1)
|
|
707
|
+
if not data or len(data) > MAX_REQUEST_BYTES:
|
|
708
|
+
fail("malformed_request", "request JSON is missing or too large")
|
|
709
|
+
try:
|
|
710
|
+
return json.loads(data)
|
|
711
|
+
except Exception:
|
|
712
|
+
fail("malformed_json", "request JSON is malformed")
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def main() -> int:
|
|
716
|
+
if len(sys.argv) != 1:
|
|
717
|
+
response = {
|
|
718
|
+
"ok": False,
|
|
719
|
+
"error": {"code": "argv_not_supported", "message": "bridge accepts requests only on stdin"},
|
|
720
|
+
}
|
|
721
|
+
print(json.dumps(response, separators=(",", ":")))
|
|
722
|
+
return 2
|
|
723
|
+
|
|
724
|
+
try:
|
|
725
|
+
request = read_request()
|
|
726
|
+
# Hermes APIs occasionally print warnings. Discard them so neither a
|
|
727
|
+
# provider error nor a malformed local file can echo secret material.
|
|
728
|
+
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
|
|
729
|
+
response = dispatch(request)
|
|
730
|
+
status = 0
|
|
731
|
+
except BridgeError as error:
|
|
732
|
+
response = {"ok": False, "error": {"code": error.code, "message": error.safe_message}}
|
|
733
|
+
status = 1
|
|
734
|
+
except Exception:
|
|
735
|
+
response = {
|
|
736
|
+
"ok": False,
|
|
737
|
+
"error": {"code": "internal_error", "message": "Hermes bridge operation failed safely"},
|
|
738
|
+
}
|
|
739
|
+
status = 1
|
|
740
|
+
print(json.dumps(response, separators=(",", ":"), sort_keys=True))
|
|
741
|
+
return status
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
if __name__ == "__main__":
|
|
745
|
+
raise SystemExit(main())
|