@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.
@@ -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())