gabriel / muse public
agent.py python
594 lines 19.8 KB
Raw
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 20 hours 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 import argparse
87 import json
88 import logging
89 import sys
90 from typing import TypedDict
91
92 from muse.core.envelope import EnvelopeJson, make_envelope
93 from muse.core.errors import ExitCode
94 from muse.core.identity import hostname_from_url, load_identity
95 from muse.core.timing import start_timer
96 from muse.core.validation import sanitize_display
97
98 logger = logging.getLogger(__name__)
99
100 # ---------------------------------------------------------------------------
101 # TypedDicts
102 # ---------------------------------------------------------------------------
103
104 class _KeygenJson(EnvelopeJson):
105 """JSON schema for ``muse agent keygen --json``."""
106
107 status: str # "ok"
108 hub: str # hub URL used
109 account: int # SLIP-0010 account index
110 name: str | None # registered slot name (null if not registered)
111 msign_path: str # derivation path
112 public_key_b64: str # base64url-encoded 32-byte public key
113 fingerprint: str # SHA-256 hex of the public key
114 hd_seed_b64: str # base64url-encoded 64-byte sub-seed (MUSE_AGENT_HD_SEED value)
115
116 class _RegisterJson(EnvelopeJson):
117 """JSON schema for ``muse agent register --json``."""
118
119 status: str # "ok"
120 name: str
121 account: int
122 hub: str # hostname
123 msign_path: str
124
125 class _ListJson(EnvelopeJson):
126 """JSON envelope for ``muse agent list --json``."""
127
128 mode: str # always "agent-list"
129 slots: list[dict] # list of registered agent slot records
130
131 # ---------------------------------------------------------------------------
132 # Internal helpers
133 # ---------------------------------------------------------------------------
134
135 def _derive_agent_seed(mnemonic: str, account: int) -> bytes:
136 """Derive the 64-byte IDENTITY-domain agent sub-seed at *account*.
137
138 Uses :func:`muse.core.bip39.mnemonic_to_seed` and
139 :func:`muse.core.hdkeys.derive_agent_sub_seed` with
140 ``domain=DOMAIN_IDENTITY``.
141
142 Args:
143 mnemonic: BIP39 mnemonic phrase (space-separated words).
144 account: Agent account index (>= 0).
145
146 Returns:
147 64-byte agent sub-seed suitable for ``MUSE_AGENT_HD_SEED`` injection.
148
149 Raises:
150 SystemExit(1): If HD derivation libraries are not available.
151 """
152 try:
153 from muse.core.bip39 import mnemonic_to_seed
154 from muse.core.hdkeys import DOMAIN_IDENTITY, derive_agent_sub_seed
155 except ImportError as exc:
156 print(
157 f"muse agent: HD key derivation not available — {exc}",
158 file=sys.stderr,
159 )
160 raise SystemExit(ExitCode.USER_ERROR)
161
162 seed = mnemonic_to_seed(mnemonic)
163 return derive_agent_sub_seed(seed, domain=DOMAIN_IDENTITY, agent_id=account)
164
165 def _sub_seed_to_public(sub_seed: bytes) -> bytes:
166 """Derive the raw 32-byte Ed25519 public key from a 64-byte sub-seed.
167
168 Args:
169 sub_seed: 64-byte agent sub-seed from :func:`_derive_agent_seed`.
170
171 Returns:
172 32-byte raw Ed25519 public key.
173 """
174 from muse.core.hdkeys import derive_identity_key, dk_to_ed25519
175 dk = derive_identity_key(sub_seed)
176 private_key = dk_to_ed25519(dk)
177 dk.zero()
178 return private_key.public_key().public_bytes_raw()
179
180 def _emit_error(error: str, message: str, as_json: bool) -> None:
181 """Emit an error response and exit 1.
182
183 When *as_json* is ``True`` the error is printed as a JSON object on stdout
184 so agent consumers can parse it. Otherwise the message goes to stderr as
185 plain text.
186
187 Args:
188 error: Machine-readable error code string (e.g. ``"no_identity"``).
189 message: Human-readable description.
190 as_json: When ``True``, emit ``{"error": ..., "message": ...}`` on stdout.
191 """
192 if as_json:
193 print(json.dumps({"error": error, "message": message}))
194 else:
195 print(f"muse agent: {message}", file=sys.stderr)
196 raise SystemExit(ExitCode.USER_ERROR)
197
198 def _require_mnemonic(hub_url: str, *, as_json: bool = False) -> str:
199 """Load the BIP39 mnemonic for *hub_url* from ``~/.muse/identity.toml``.
200
201 Args:
202 hub_url: Hub URL or bare hostname.
203 as_json: Emit JSON error on stdout instead of plain-text stderr.
204
205 Returns:
206 Mnemonic phrase string.
207
208 Raises:
209 SystemExit(1): If no identity or no mnemonic is found.
210 """
211 hostname = hostname_from_url(hub_url)
212 entry = load_identity(hub_url)
213 if entry is None:
214 _emit_error(
215 "no_identity",
216 f"no identity found for {sanitize_display(hostname)} — "
217 "run `muse auth keygen --hub <url>` first.",
218 as_json,
219 )
220
221 mnemonic = entry.get("mnemonic", "").strip() # type: ignore[union-attr]
222 if not mnemonic:
223 _emit_error(
224 "no_mnemonic",
225 f"identity for {sanitize_display(hostname)} has no mnemonic — "
226 "HD key derivation requires a BIP39 mnemonic; "
227 "re-generate with `muse auth keygen --hub <url>`.",
228 as_json,
229 )
230
231 return mnemonic # type: ignore[return-value]
232
233 def _resolve_hub_url(args_hub: str | None, *, as_json: bool = False) -> str:
234 """Resolve the hub URL from CLI flag or repo config.
235
236 Args:
237 args_hub: Value of ``--hub`` flag, or ``None``.
238 as_json: Emit JSON error on stdout instead of plain-text stderr.
239
240 Returns:
241 Hub URL string.
242
243 Raises:
244 SystemExit(1): If no hub can be determined.
245 """
246 if args_hub:
247 return args_hub
248
249 from muse.cli.config import get_hub_url
250 url = get_hub_url()
251 if url:
252 return url
253
254 _emit_error(
255 "no_hub",
256 "no hub configured — pass --hub <url> or connect with "
257 "`muse hub connect <url>`.",
258 as_json,
259 )
260
261 # ---------------------------------------------------------------------------
262 # Command handlers
263 # ---------------------------------------------------------------------------
264
265 def run_keygen(args: argparse.Namespace) -> None:
266 """Derive and display the agent keypair at the requested account index.
267
268 Reads the BIP39 mnemonic from ``~/.muse/identity.toml``, derives the
269 IDENTITY-domain agent sub-seed at *account*, and prints the sub-seed
270 (as ``MUSE_AGENT_HD_SEED``), the public key fingerprint, and the
271 SLIP-0010 path.
272
273 Agent quickstart
274 ----------------
275 ::
276
277 muse agent keygen --account 1 --json
278 # inject into subprocess:
279 export MUSE_AGENT_HD_SEED=$(muse agent keygen --account 1 --json | python3 -c \
280 "import sys,json; print(json.load(sys.stdin)['hd_seed_b64'])")
281
282 JSON fields
283 -----------
284 schema Envelope schema integer (``1``).
285 duration_ms Wall-clock time from argument parsing to output.
286 exit_code Mirrors the process exit code (``0`` on success).
287 status ``"ok"`` on success.
288 hub Hub URL used.
289 account SLIP-0010 account index.
290 name Registered slot name or ``null``.
291 msign_path SLIP-0010 derivation path string.
292 public_key_b64 Base64url-encoded 32-byte Ed25519 public key.
293 fingerprint SHA-256 hex fingerprint of the public key.
294 hd_seed_b64 Base64url-encoded 64-byte sub-seed — set as ``MUSE_AGENT_HD_SEED``.
295
296 Exit codes
297 ----------
298 0 Keypair derived successfully.
299 1 No identity/mnemonic found, invalid account, or derivation error.
300 """
301 elapsed = start_timer()
302 json_out: bool = args.json_out
303 hub_url = _resolve_hub_url(getattr(args, "hub", None), as_json=json_out)
304 account: int = args.account
305 name: str | None = getattr(args, "name", None) or None
306
307 if account < 0:
308 _emit_error(
309 "invalid_account",
310 f"account must be >= 0; got {account}",
311 json_out,
312 )
313
314 mnemonic = _require_mnemonic(hub_url, as_json=json_out)
315
316 try:
317 sub_seed = _derive_agent_seed(mnemonic, account)
318 pub_bytes = _sub_seed_to_public(sub_seed)
319 except Exception as exc:
320 _emit_error("derivation_failed", f"key derivation failed — {exc}", json_out)
321
322 from muse.core.hdkeys import DOMAIN_IDENTITY, ENTITY_AGENT, MUSE_PURPOSE
323 msign_path = f"m/{MUSE_PURPOSE}'/{DOMAIN_IDENTITY}'/{ENTITY_AGENT}'/{account}'"
324
325 from muse.core.types import b64url_encode, public_key_fingerprint # noqa: PLC0415
326 hd_seed_b64 = b64url_encode(sub_seed)
327 pub_b64 = b64url_encode(pub_bytes)
328 fp = public_key_fingerprint(pub_bytes)
329
330 if json_out:
331 payload = _KeygenJson(
332 **make_envelope(elapsed),
333 status="ok",
334 hub=hub_url,
335 account=account,
336 name=name,
337 msign_path=msign_path,
338 public_key_b64=pub_b64,
339 fingerprint=fp,
340 hd_seed_b64=hd_seed_b64,
341 )
342 print(json.dumps(payload))
343 else:
344 hostname = hostname_from_url(hub_url)
345 print(f"Agent keypair — account {account} on {sanitize_display(hostname)}")
346 print(f" SLIP-0010 path : {msign_path}")
347 print(f" Fingerprint : {fp}")
348 print(f" Public key : {pub_b64}")
349 if name:
350 print(f" Name : {name}")
351 print()
352 print(f" MUSE_AGENT_HD_SEED={hd_seed_b64}")
353 print()
354 print(" Set this env var before starting the agent process.")
355
356 def run_list(args: argparse.Namespace) -> None:
357 """List all registered agent slots for the hub.
358
359 Reads from ``~/.muse/agent-slots.toml`` and prints one row per slot.
360
361 Agent quickstart
362 ----------------
363 ::
364
365 muse agent list --json
366 muse agent list --json | jq '.[].name'
367
368 JSON output
369 -----------
370 Emits a JSON envelope object with a standard header and a ``slots`` list.
371
372 schema Envelope schema integer (``1``).
373 duration_ms Wall-clock time from argument parsing to output.
374 exit_code Mirrors the process exit code (always ``0``).
375 mode Always ``"agent-list"``.
376 slots List of registered slot objects, each with:
377
378 name Slot name, or ``null`` if unnamed.
379 account HD account index.
380 hub Hostname of the hub (not the full URL).
381 msign_path HD derivation path for this slot.
382
383 Exit codes
384 ----------
385 0 Always (empty list is not an error).
386 """
387 elapsed = start_timer()
388 json_out: bool = args.json_out
389 hub_url = _resolve_hub_url(getattr(args, "hub", None), as_json=json_out)
390
391 from muse.core.agent_slots import list_slots
392 slots = list_slots(hub_url)
393
394 if json_out:
395 print(json.dumps(_ListJson(**make_envelope(elapsed), mode="agent-list", slots=slots)))
396 else:
397 hostname = hostname_from_url(hub_url)
398 if not slots:
399 print(
400 f"No registered agent slots for {sanitize_display(hostname)}. "
401 "Register one with `muse agent register --account N --name NAME`."
402 )
403 return
404
405 print(f"Agent slots for {sanitize_display(hostname)}:")
406 for slot in slots:
407 name_part = f" {slot['name']:<20} account={slot['account']:<4} {slot['msign_path']}"
408 print(name_part)
409
410 def run_register(args: argparse.Namespace) -> None:
411 """Register a named agent slot in the local slot registry.
412
413 Does not derive any keys — purely records the (name, account) binding in
414 ``~/.muse/agent-slots.toml`` so that ``muse agent list`` can display it.
415
416 Agent quickstart
417 ----------------
418 ::
419
420 muse agent register --account 1 --name worker --json
421 # → {"status": "ok", "name": "worker", "account": 1, ...}
422
423 JSON fields
424 -----------
425 schema Envelope schema integer (``1``).
426 duration_ms Wall-clock time from argument parsing to output.
427 exit_code Mirrors the process exit code (``0`` on success).
428 status ``"ok"`` on success.
429 name Registered slot name.
430 account SLIP-0010 account index.
431 hub Hub hostname.
432 msign_path SLIP-0010 derivation path string.
433
434 Exit codes
435 ----------
436 0 Slot registered.
437 1 Invalid arguments.
438 """
439 elapsed = start_timer()
440 json_out: bool = args.json_out
441 hub_url = _resolve_hub_url(getattr(args, "hub", None), as_json=json_out)
442 account: int = args.account
443 name: str = args.name
444
445 if account < 0:
446 _emit_error(
447 "invalid_account",
448 f"account must be >= 0; got {account}",
449 json_out,
450 )
451
452 from muse.core.agent_slots import register_slot
453 slot = register_slot(hub_url, name, account)
454
455 if json_out:
456 payload = _RegisterJson(
457 **make_envelope(elapsed),
458 status="ok",
459 name=slot["name"],
460 account=slot["account"],
461 hub=slot["hub"],
462 msign_path=slot["msign_path"],
463 )
464 print(json.dumps(payload))
465 else:
466 hostname = slot["hub"]
467 print(
468 f"Registered agent slot '{name}' → account {account} "
469 f"on {sanitize_display(hostname)}"
470 )
471 print(f" SLIP-0010 path: {slot['msign_path']}")
472
473 # ---------------------------------------------------------------------------
474 # Argument parser registration
475 # ---------------------------------------------------------------------------
476
477 def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
478 """Register the ``muse agent`` subcommand tree.
479
480 Subcommands: ``keygen``, ``list``, ``register``.
481
482 All subcommands accept ``--json`` / ``-j`` for machine-readable JSON output
483 with a standard envelope: ``schema_version``, ``exit_code``, ``duration_ms``.
484 """
485 agent_parser = subparsers.add_parser(
486 "agent",
487 help="Agent slot management and HD key derivation.",
488 description=(
489 "Manage agent slots and derive agent keypairs from the operator's "
490 "BIP39 mnemonic stored in ~/.muse/identity.toml."
491 ),
492 formatter_class=argparse.RawDescriptionHelpFormatter,
493 )
494 agent_subs = agent_parser.add_subparsers(
495 dest="agent_command", metavar="AGENT_COMMAND"
496 )
497 agent_subs.required = True
498
499 # ── keygen ──────────────────────────────────────────────────────────────
500 keygen_p = agent_subs.add_parser(
501 "keygen",
502 help="Derive agent keypair at account index N.",
503 description=(
504 "Read the BIP39 mnemonic from ~/.muse/identity.toml and derive "
505 "the IDENTITY-domain agent sub-seed at the given account index. "
506 "The hd_seed_b64 output value should be set as MUSE_AGENT_HD_SEED "
507 "in the agent's environment."
508 ),
509 )
510 keygen_p.add_argument(
511 "--account",
512 type=int,
513 required=True,
514 metavar="N",
515 help="SLIP-0010 account index for this agent (>= 0; 0 is service identity).",
516 )
517 keygen_p.add_argument(
518 "--hub",
519 metavar="URL",
520 default=None,
521 help="Hub URL to look up the mnemonic for (defaults to repo config).",
522 )
523 keygen_p.add_argument(
524 "--name",
525 metavar="NAME",
526 default=None,
527 help="Optional slot name to include in output (does not register the slot).",
528 )
529 keygen_p.add_argument(
530 "--json", "-j",
531 action="store_true",
532 dest="json_out",
533 help="Emit JSON on stdout.",
534 )
535 keygen_p.set_defaults(func=run_keygen)
536
537 # ── list ────────────────────────────────────────────────────────────────
538 list_p = agent_subs.add_parser(
539 "list",
540 help="List all registered agent slots.",
541 description=(
542 "Read ~/.muse/agent-slots.toml and display all named agent slots "
543 "registered for the given hub."
544 ),
545 )
546 list_p.add_argument(
547 "--hub",
548 metavar="URL",
549 default=None,
550 help="Hub URL to filter by (defaults to repo config).",
551 )
552 list_p.add_argument(
553 "--json", "-j",
554 action="store_true",
555 dest="json_out",
556 help="Emit JSON on stdout.",
557 )
558 list_p.set_defaults(func=run_list)
559
560 # ── register ─────────────────────────────────────────────────────────────
561 register_p = agent_subs.add_parser(
562 "register",
563 help="Register a named agent slot in the local registry.",
564 description=(
565 "Record a (name, account) binding in ~/.muse/agent-slots.toml "
566 "so that `muse agent list` can display it. Does not derive keys."
567 ),
568 )
569 register_p.add_argument(
570 "--account",
571 type=int,
572 required=True,
573 metavar="N",
574 help="SLIP-0010 account index for this agent (>= 0).",
575 )
576 register_p.add_argument(
577 "--name",
578 required=True,
579 metavar="NAME",
580 help="Human-readable slot label, e.g. 'orchestra'.",
581 )
582 register_p.add_argument(
583 "--hub",
584 metavar="URL",
585 default=None,
586 help="Hub URL (defaults to repo config).",
587 )
588 register_p.add_argument(
589 "--json", "-j",
590 action="store_true",
591 dest="json_out",
592 help="Emit JSON on stdout.",
593 )
594 register_p.set_defaults(func=run_register)
File History 1 commit
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 20 hours ago