gabriel / muse public
agent.py python
539 lines 17.2 KB
Raw
sha256:2fa778aba8ab0ec15295b8624c6480a573482ffc9c206a6d9546f1c41d2c2b7b feat: supercharge muse blame + remove --porcelain everywhere Human patch 164 days ago
1 """muse agent — agent slot management and HD key derivation.
2
3 Manages the agent slot registry (``~/.muse/agent-slots.toml``) and derives
4 agent keypairs from the operator's BIP39 mnemonic. This is the command-line
5 face of the Phase 2 HD agent identity system.
6
7 Why a separate ``muse agent`` namespace?
8 -----------------------------------------
9 ``muse auth`` owns the *human* identity lifecycle (keygen, register, whoami,
10 logout). Agent slots are a distinct concept — they are derived sub-identities
11 scoped to a specific SLIP-0010 account index within the operator's key tree.
12 Keeping them separate prevents confusion between the human's key and the keys
13 that agents use.
14
15 Sub-seed injection
16 ------------------
17 The output of ``muse agent keygen`` is a base64url-encoded 64-byte sub-seed
18 suitable for injecting into an agent subprocess via the ``MUSE_AGENT_HD_SEED``
19 environment variable. The agent calls :func:`muse.core.hdkeys.derive_identity_key`
20 on the sub-seed to reconstruct its signing key — no mnemonic is ever passed to
21 the agent.
22
23 Subcommands
24 -----------
25 ::
26
27 muse agent keygen --account N [--hub HUB] [--name NAME] [--json]
28 muse agent list [--hub HUB] [--json]
29 muse agent register --account N --name NAME [--hub HUB] [--json]
30
31 JSON schemas
32 ------------
33 ``muse agent keygen --json``::
34
35 {
36 "status": "ok",
37 "hub": "<hub url>",
38 "account": <N>,
39 "name": "<name | null>",
40 "msign_path": "m/1075233755'/0'/1'/<N>'", # purpose'/domain_identity'/entity_agent'/account'
41 "public_key_b64": "<base64url 32 bytes>",
42 "fingerprint": "<sha256hex>",
43 "hd_seed_b64": "<base64url 64 bytes — set as MUSE_AGENT_HD_SEED>"
44 }
45
46 ``muse agent list --json``::
47
48 [
49 {
50 "name": "<name>",
51 "account": <N>,
52 "hub": "<hostname>",
53 "msign_path": "m/1075233755'/0'/1'/<N>'"
54 },
55 ...
56 ]
57
58 ``muse agent register --json``::
59
60 {
61 "status": "ok",
62 "name": "<name>",
63 "account": <N>,
64 "hub": "<hostname>",
65 "msign_path": "m/1075233755'/0'/1'/<N>'"
66 }
67
68 Agent workflow examples
69 -----------------------
70 ::
71
72 # Derive and inspect agent slot 1
73 muse agent keygen --account 1 --json
74
75 # Inject into a subprocess
76 export MUSE_AGENT_HD_SEED=$(muse agent keygen --account 1 --json | python3 -c \
77 "import sys,json; print(json.load(sys.stdin)['hd_seed_b64'])")
78
79 # Register the slot so muse agent list shows it
80 muse agent register --account 1 --name "my-agent"
81
82 # List all registered slots for the current hub
83 muse agent list --json
84 """
85
86 from __future__ import annotations
87
88 import argparse
89 import base64
90 import hashlib
91 import json
92 import logging
93 import sys
94 from typing import TypedDict
95
96 from muse.core.errors import ExitCode
97 from muse.core.identity import hostname_from_url, load_identity
98 from muse.core.validation import sanitize_display
99
100 logger = logging.getLogger(__name__)
101
102
103 # ---------------------------------------------------------------------------
104 # TypedDicts
105 # ---------------------------------------------------------------------------
106
107
108 class _KeygenJson(TypedDict):
109 """JSON schema for ``muse agent keygen --json``."""
110
111 status: str # "ok"
112 hub: str # hub URL used
113 account: int # SLIP-0010 account index
114 name: str | None # registered slot name (null if not registered)
115 msign_path: str # derivation path
116 public_key_b64: str # base64url-encoded 32-byte public key
117 fingerprint: str # SHA-256 hex of the public key
118 hd_seed_b64: str # base64url-encoded 64-byte sub-seed (MUSE_AGENT_HD_SEED value)
119
120
121 class _RegisterJson(TypedDict):
122 """JSON schema for ``muse agent register --json``."""
123
124 status: str # "ok"
125 name: str
126 account: int
127 hub: str # hostname
128 msign_path: str
129
130
131 # ---------------------------------------------------------------------------
132 # Internal helpers
133 # ---------------------------------------------------------------------------
134
135
136 def _derive_agent_seed(mnemonic: str, account: int) -> bytes:
137 """Derive the 64-byte IDENTITY-domain agent sub-seed at *account*.
138
139 Uses :func:`muse.core.bip39.mnemonic_to_seed` and
140 :func:`muse.core.hdkeys.derive_agent_sub_seed` with
141 ``domain=DOMAIN_IDENTITY``.
142
143 Args:
144 mnemonic: BIP39 mnemonic phrase (space-separated words).
145 account: Agent account index (>= 0).
146
147 Returns:
148 64-byte agent sub-seed suitable for ``MUSE_AGENT_HD_SEED`` injection.
149
150 Raises:
151 SystemExit(1): If HD derivation libraries are not available.
152 """
153 try:
154 from muse.core.bip39 import mnemonic_to_seed
155 from muse.core.hdkeys import DOMAIN_IDENTITY, derive_agent_sub_seed
156 except ImportError as exc:
157 print(
158 f"muse agent: HD key derivation not available — {exc}",
159 file=sys.stderr,
160 )
161 raise SystemExit(ExitCode.USER_ERROR)
162
163 seed = mnemonic_to_seed(mnemonic)
164 return derive_agent_sub_seed(seed, domain=DOMAIN_IDENTITY, agent_id=account)
165
166
167 def _sub_seed_to_public(sub_seed: bytes) -> bytes:
168 """Derive the raw 32-byte Ed25519 public key from a 64-byte sub-seed.
169
170 Args:
171 sub_seed: 64-byte agent sub-seed from :func:`_derive_agent_seed`.
172
173 Returns:
174 32-byte raw Ed25519 public key.
175 """
176 from muse.core.hdkeys import derive_identity_key, dk_to_ed25519
177 dk = derive_identity_key(sub_seed)
178 return dk_to_ed25519(dk).public_key().public_bytes_raw()
179
180
181 def _fingerprint(pub_bytes: bytes) -> str:
182 """Return the SHA-256 hex fingerprint of a raw public key."""
183 return hashlib.sha256(pub_bytes).hexdigest()
184
185
186 def _emit_error(error: str, message: str, as_json: bool) -> None:
187 """Emit an error response and exit 1.
188
189 When *as_json* is ``True`` the error is printed as a JSON object on stdout
190 so agent consumers can parse it. Otherwise the message goes to stderr as
191 plain text.
192
193 Args:
194 error: Machine-readable error code string (e.g. ``"no_identity"``).
195 message: Human-readable description.
196 as_json: When ``True``, emit ``{"error": ..., "message": ...}`` on stdout.
197 """
198 if as_json:
199 print(json.dumps({"error": error, "message": message}))
200 else:
201 print(f"muse agent: {message}", file=sys.stderr)
202 raise SystemExit(ExitCode.USER_ERROR)
203
204
205 def _require_mnemonic(hub_url: str, *, as_json: bool = False) -> str:
206 """Load the BIP39 mnemonic for *hub_url* from ``~/.muse/identity.toml``.
207
208 Args:
209 hub_url: Hub URL or bare hostname.
210 as_json: Emit JSON error on stdout instead of plain-text stderr.
211
212 Returns:
213 Mnemonic phrase string.
214
215 Raises:
216 SystemExit(1): If no identity or no mnemonic is found.
217 """
218 hostname = hostname_from_url(hub_url)
219 entry = load_identity(hub_url)
220 if entry is None:
221 _emit_error(
222 "no_identity",
223 f"no identity found for {sanitize_display(hostname)} — "
224 "run `muse auth keygen --hub <url>` first.",
225 as_json,
226 )
227
228 mnemonic = entry.get("mnemonic", "").strip() # type: ignore[union-attr]
229 if not mnemonic:
230 _emit_error(
231 "no_mnemonic",
232 f"identity for {sanitize_display(hostname)} has no mnemonic — "
233 "HD key derivation requires a BIP39 mnemonic; "
234 "re-generate with `muse auth keygen --hub <url>`.",
235 as_json,
236 )
237
238 return mnemonic # type: ignore[return-value]
239
240
241 def _resolve_hub_url(args_hub: str | None, *, as_json: bool = False) -> str:
242 """Resolve the hub URL from CLI flag or repo config.
243
244 Args:
245 args_hub: Value of ``--hub`` flag, or ``None``.
246 as_json: Emit JSON error on stdout instead of plain-text stderr.
247
248 Returns:
249 Hub URL string.
250
251 Raises:
252 SystemExit(1): If no hub can be determined.
253 """
254 if args_hub:
255 return args_hub
256
257 from muse.cli.config import get_hub_url
258 url = get_hub_url()
259 if url:
260 return url
261
262 _emit_error(
263 "no_hub",
264 "no hub configured — pass --hub <url> or connect with "
265 "`muse hub connect <url>`.",
266 as_json,
267 )
268
269
270 # ---------------------------------------------------------------------------
271 # Command handlers
272 # ---------------------------------------------------------------------------
273
274
275 def run_keygen(args: argparse.Namespace) -> None:
276 """Derive and display the agent keypair at the requested account index.
277
278 Reads the BIP39 mnemonic from ``~/.muse/identity.toml``, derives the
279 IDENTITY-domain agent sub-seed at *account*, and prints the sub-seed
280 (as ``MUSE_AGENT_HD_SEED``), the public key fingerprint, and the
281 SLIP-0010 path.
282
283 Args:
284 args: Parsed arguments — ``hub``, ``account``, ``name``, ``json``.
285
286 Exit codes:
287 0 Keypair derived successfully.
288 1 No identity/mnemonic found, invalid account, or derivation error.
289 """
290 as_json: bool = getattr(args, "json", False)
291 hub_url = _resolve_hub_url(getattr(args, "hub", None), as_json=as_json)
292 account: int = args.account
293 name: str | None = getattr(args, "name", None) or None
294
295 if account < 0:
296 _emit_error(
297 "invalid_account",
298 f"account must be >= 0; got {account}",
299 as_json,
300 )
301
302 mnemonic = _require_mnemonic(hub_url, as_json=as_json)
303
304 try:
305 sub_seed = _derive_agent_seed(mnemonic, account)
306 pub_bytes = _sub_seed_to_public(sub_seed)
307 except Exception as exc:
308 _emit_error("derivation_failed", f"key derivation failed — {exc}", as_json)
309
310 from muse.core.hdkeys import DOMAIN_IDENTITY, ENTITY_AGENT, MUSE_PURPOSE
311 msign_path = f"m/{MUSE_PURPOSE}'/{DOMAIN_IDENTITY}'/{ENTITY_AGENT}'/{account}'"
312
313 hd_seed_b64 = base64.urlsafe_b64encode(sub_seed).rstrip(b"=").decode()
314 pub_b64 = base64.urlsafe_b64encode(pub_bytes).rstrip(b"=").decode()
315 fp = _fingerprint(pub_bytes)
316
317 if as_json:
318 payload: _KeygenJson = {
319 "status": "ok",
320 "hub": hub_url,
321 "account": account,
322 "name": name,
323 "msign_path": msign_path,
324 "public_key_b64": pub_b64,
325 "fingerprint": fp,
326 "hd_seed_b64": hd_seed_b64,
327 }
328 print(json.dumps(payload))
329 else:
330 hostname = hostname_from_url(hub_url)
331 print(f"Agent keypair — account {account} on {sanitize_display(hostname)}")
332 print(f" SLIP-0010 path : {msign_path}")
333 print(f" Fingerprint : {fp}")
334 print(f" Public key : {pub_b64}")
335 if name:
336 print(f" Name : {name}")
337 print()
338 print(f" MUSE_AGENT_HD_SEED={hd_seed_b64}")
339 print()
340 print(" Set this env var before starting the agent process.")
341
342
343 def run_list(args: argparse.Namespace) -> None:
344 """List all registered agent slots for the hub.
345
346 Reads from ``~/.muse/agent-slots.toml`` and prints one row per slot.
347
348 Args:
349 args: Parsed arguments — ``hub``, ``json``.
350
351 Exit codes:
352 0 Always (empty list is not an error).
353 """
354 as_json: bool = getattr(args, "json", False)
355 hub_url = _resolve_hub_url(getattr(args, "hub", None), as_json=as_json)
356
357 from muse.core.agent_slots import list_slots
358 slots = list_slots(hub_url)
359
360 if as_json:
361 print(json.dumps(slots))
362 else:
363 hostname = hostname_from_url(hub_url)
364 if not slots:
365 print(
366 f"No registered agent slots for {sanitize_display(hostname)}. "
367 "Register one with `muse agent register --account N --name NAME`."
368 )
369 return
370
371 print(f"Agent slots for {sanitize_display(hostname)}:")
372 for slot in slots:
373 name_part = f" {slot['name']:<20} account={slot['account']:<4} {slot['msign_path']}"
374 print(name_part)
375
376
377 def run_register(args: argparse.Namespace) -> None:
378 """Register a named agent slot in the local slot registry.
379
380 Does not derive any keys — purely records the (name, account) binding in
381 ``~/.muse/agent-slots.toml`` so that ``muse agent list`` can display it.
382
383 Args:
384 args: Parsed arguments — ``hub``, ``account``, ``name``, ``json``.
385
386 Exit codes:
387 0 Slot registered.
388 1 Invalid arguments.
389 """
390 as_json: bool = getattr(args, "json", False)
391 hub_url = _resolve_hub_url(getattr(args, "hub", None), as_json=as_json)
392 account: int = args.account
393 name: str = args.name
394
395 if account < 0:
396 _emit_error(
397 "invalid_account",
398 f"account must be >= 0; got {account}",
399 as_json,
400 )
401
402 from muse.core.agent_slots import register_slot
403 slot = register_slot(hub_url, name, account)
404
405 if as_json:
406 payload: _RegisterJson = {
407 "status": "ok",
408 "name": slot["name"],
409 "account": slot["account"],
410 "hub": slot["hub"],
411 "msign_path": slot["msign_path"],
412 }
413 print(json.dumps(payload))
414 else:
415 hostname = slot["hub"]
416 print(
417 f"Registered agent slot '{name}' → account {account} "
418 f"on {sanitize_display(hostname)}"
419 )
420 print(f" SLIP-0010 path: {slot['msign_path']}")
421
422
423 # ---------------------------------------------------------------------------
424 # Argument parser registration
425 # ---------------------------------------------------------------------------
426
427
428 def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
429 """Register the ``muse agent`` subcommand tree."""
430 agent_parser = subparsers.add_parser(
431 "agent",
432 help="Agent slot management and HD key derivation.",
433 description=(
434 "Manage agent slots and derive agent keypairs from the operator's "
435 "BIP39 mnemonic stored in ~/.muse/identity.toml."
436 ),
437 formatter_class=argparse.RawDescriptionHelpFormatter,
438 )
439 agent_subs = agent_parser.add_subparsers(
440 dest="agent_command", metavar="AGENT_COMMAND"
441 )
442 agent_subs.required = True
443
444 # ── keygen ──────────────────────────────────────────────────────────────
445 keygen_p = agent_subs.add_parser(
446 "keygen",
447 help="Derive agent keypair at account index N.",
448 description=(
449 "Read the BIP39 mnemonic from ~/.muse/identity.toml and derive "
450 "the IDENTITY-domain agent sub-seed at the given account index. "
451 "The hd_seed_b64 output value should be set as MUSE_AGENT_HD_SEED "
452 "in the agent's environment."
453 ),
454 )
455 keygen_p.add_argument(
456 "--account",
457 type=int,
458 required=True,
459 metavar="N",
460 help="SLIP-0010 account index for this agent (>= 0; 0 is service identity).",
461 )
462 keygen_p.add_argument(
463 "--hub",
464 metavar="URL",
465 default=None,
466 help="Hub URL to look up the mnemonic for (defaults to repo config).",
467 )
468 keygen_p.add_argument(
469 "--name",
470 metavar="NAME",
471 default=None,
472 help="Optional slot name to include in output (does not register the slot).",
473 )
474 keygen_p.add_argument(
475 "--json",
476 action="store_true",
477 dest="json",
478 help="Emit JSON on stdout.",
479 )
480 keygen_p.set_defaults(func=run_keygen)
481
482 # ── list ────────────────────────────────────────────────────────────────
483 list_p = agent_subs.add_parser(
484 "list",
485 help="List all registered agent slots.",
486 description=(
487 "Read ~/.muse/agent-slots.toml and display all named agent slots "
488 "registered for the given hub."
489 ),
490 )
491 list_p.add_argument(
492 "--hub",
493 metavar="URL",
494 default=None,
495 help="Hub URL to filter by (defaults to repo config).",
496 )
497 list_p.add_argument(
498 "--json",
499 action="store_true",
500 dest="json",
501 help="Emit JSON array on stdout.",
502 )
503 list_p.set_defaults(func=run_list)
504
505 # ── register ─────────────────────────────────────────────────────────────
506 register_p = agent_subs.add_parser(
507 "register",
508 help="Register a named agent slot in the local registry.",
509 description=(
510 "Record a (name, account) binding in ~/.muse/agent-slots.toml "
511 "so that `muse agent list` can display it. Does not derive keys."
512 ),
513 )
514 register_p.add_argument(
515 "--account",
516 type=int,
517 required=True,
518 metavar="N",
519 help="SLIP-0010 account index for this agent (>= 0).",
520 )
521 register_p.add_argument(
522 "--name",
523 required=True,
524 metavar="NAME",
525 help="Human-readable slot label, e.g. 'orchestra'.",
526 )
527 register_p.add_argument(
528 "--hub",
529 metavar="URL",
530 default=None,
531 help="Hub URL (defaults to repo config).",
532 )
533 register_p.add_argument(
534 "--json",
535 action="store_true",
536 dest="json",
537 help="Emit JSON on stdout.",
538 )
539 register_p.set_defaults(func=run_register)
File History 1 commit
sha256:2fa778aba8ab0ec15295b8624c6480a573482ffc9c206a6d9546f1c41d2c2b7b feat: supercharge muse blame + remove --porcelain everywhere Human patch 164 days ago