gabriel / muse public
_core.py python
1,114 lines 45.3 KB
Raw
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 38 days ago
1 """muse hub — MuseHub fabric connection management.
2
3 The hub is not just a remote. It is the shared fabric where versioned
4 multidimensional state flows across agents, humans, and repositories.
5 Connecting a repo to a hub anchors it to the synchronisation layer that
6 enables push/pull, plugin discovery, and multi-agent coordination.
7
8 Separation of concerns
9 -----------------------
10 - ``muse remote`` manages generic push/pull endpoints (any Muse server).
11 - ``muse hub`` manages the *primary identity fabric* — the one hub this
12 repo belongs to for authentication, discovery, and coordination.
13
14 A repo has at most **one** hub. It may have many remotes.
15
16 Security model
17 --------------
18 - ``_normalise_url`` enforces HTTPS for non-loopback hosts at connect time.
19 - ``_hub_api`` validates the URL scheme before every network request,
20 preventing SSRF from a tampered ``config.toml``.
21 - ``_hub_api`` caps the response body at ``_MAX_API_RESPONSE_BYTES`` to
22 prevent OOM from a hostile hub.
23 - All user-controlled values (proposal titles, branch names, hub URLs) are passed
24 through ``sanitize_display()`` before being printed.
25 - All diagnostic messages go to **stderr**; **stdout** is reserved for JSON
26 and machine-readable data.
27
28 Subcommands
29 -----------
30 ::
31
32 muse hub connect <url> Attach this repo to a MuseHub instance.
33 muse hub status [--json] Show connection and identity information.
34 muse hub disconnect [--json] Remove the hub association from this repo.
35 muse hub ping [--json] Test HTTP connectivity to the hub.
36
37 muse hub proposal list [--json] List proposals on MuseHub.
38 muse hub proposal read <proposal-id> [--json] Show a single proposal.
39 muse hub proposal create [--json] Open a new proposal.
40 muse hub proposal merge <proposal-id> [--json] Merge a proposal.
41
42 JSON schemas
43 ------------
44 ``muse hub connect --json``::
45
46 {"status": "ok", "hub_url": "<url>", "hostname": "<host>",
47 "authenticated": true|false, "identity_name": "<name>",
48 "identity_type": "<type>"}
49
50 ``muse hub status --json``::
51
52 {"hub_url": "<url>", "hostname": "<host>",
53 "authenticated": true|false, "identity_type": "<type>",
54 "identity_name": "<name>", "identity_id": "<id>"}
55
56 ``muse hub disconnect --json``::
57
58 {"status": "ok"|"nothing_to_do", "hostname": "<host>"}
59
60 ``muse hub ping --json``::
61
62 {"status": "ok"|"error", "hub_url": "<url>",
63 "hostname": "<host>", "reachable": true|false, "message": "<msg>"}
64
65 Agent workflow examples
66 -----------------------
67 ::
68
69 # Verify hub auth before starting work
70 muse hub status --json | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d['authenticated'] else 1)"
71
72 # Create a proposal and capture the ID
73 muse hub proposal create --title "feat: x" --json | python3 -c "import json,sys; print(json.load(sys.stdin)['proposalId'])"
74
75 # List open proposals (machine-readable)
76 muse hub proposal list --json
77
78 # Merge with squash strategy
79 muse hub proposal merge af54753d --strategy squash --json
80 """
81
82 import argparse
83 import http.client
84 import json
85 import logging
86 import re
87 import sys
88 import textwrap
89 import urllib.error
90 import urllib.parse
91 import urllib.request
92 from collections.abc import Mapping
93 from typing import IO, TypedDict
94
95 from muse.cli.config import (
96 clear_hub_url,
97 get_hub_url,
98 get_remote,
99 list_remotes,
100 set_hub_url,
101 )
102 from muse.core.envelope import EnvelopeJson, make_envelope
103 from muse.core.errors import ExitCode
104 from muse.core.identity import IdentityEntry, load_identity
105 from muse.core.repo import find_repo_root
106 from muse.core.refs import read_current_branch
107 from muse.core.timing import start_timer
108 from muse.core.types import Metadata, short_id, split_id
109 from muse.core.validation import clamp_int, sanitize_display
110
111 logger = logging.getLogger(__name__)
112
113 type _HubPayload = dict[str, str | bool | list[str]]
114 type _ProposalPayload = dict[str, str | list[str]]
115
116 _CONNECT_TIMEOUT = 8 # seconds for ping/status health check
117 _MAX_API_RESPONSE_BYTES = 4 * 1024 * 1024 # 4 MiB cap on API responses
118
119 # Only allow http and https — no file://, ftp://, data://, etc.
120 _ALLOWED_API_SCHEMES = frozenset({"http", "https"})
121
122 # Maximum proposals fetched when resolving an 8-char prefix — repos with more proposals
123 # than this will not find older ones by prefix. Intentionally conservative;
124 # raise if real-world repos hit the limit.
125 _PROPOSAL_PREFIX_RESOLVE_LIMIT = 100
126 _MAX_PROPOSAL_BODY_LINES = 20 # body lines shown in text mode before truncation hint
127 _MAX_PROPOSAL_TITLE_LEN = 512 # client-side guard: titles longer than this are rejected
128 _MAX_ISSUE_TITLE_LEN = 512 # client-side guard: issue titles longer than this are rejected
129 _MAX_REPO_NAME_LEN = 255 # mirrors CreateRepoRequest.name max_length on the server
130 _MAX_REPO_DESC_LEN = 1024 # reasonable cap on description before sending to server
131 _MAX_ISSUE_LABEL_LEN = 255 # mirrors label name max_length on the server
132 _MAX_ISSUE_COMMENT_LEN = 50_000 # mirrors IssueCommentCreate.body max_length on the server
133 _MAX_LABEL_NAME_LEN = 50 # mirrors LabelCreate.name max_length on the server
134 _MAX_LABEL_DESC_LEN = 200 # mirrors LabelCreate.description max_length on the server
135 _MAX_HANDLE_LEN = 64 # mirrors hub identity handle max_length on the server
136
137 # Handle format: starts with letter/digit; continues with letters, digits,
138 # hyphens, underscores. No leading/trailing whitespace; no shell metacharacters;
139 # no Unicode confusables. Enforced before any network I/O.
140 _HANDLE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
141
142 # ── TypedDicts ────────────────────────────────────────────────────────────────
143
144 class _ConnectJson(EnvelopeJson):
145 """JSON output for ``muse hub connect --json``.
146
147 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
148
149 Fields
150 ------
151 status Always ``"ok"`` on success — all error paths raise SystemExit
152 before any JSON is emitted.
153 hub_url Normalised hub URL with no trailing slash (e.g. ``https://musehub.ai``).
154 hostname Host and optional port in display form (e.g. ``musehub.ai``).
155 authenticated ``True`` when an Ed25519 identity is stored for this hub in
156 ``~/.muse/identity.toml``.
157 identity_name Handle of the stored identity, or ``""`` when not authenticated.
158 identity_type ``"human"`` | ``"agent"`` | ``""`` when not authenticated.
159 """
160
161 status: str
162 hub_url: str
163 hostname: str
164 authenticated: bool
165 identity_name: str
166 identity_type: str
167
168 class _StatusJson(EnvelopeJson):
169 """JSON output for ``muse hub status --json``.
170
171 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
172
173 All domain keys are always present so agents can access them without guard checks.
174
175 Fields
176 ------
177 hub_url URL stored in ``.muse/config.toml``.
178 hostname Host and optional port in display form.
179 authenticated ``True`` when an Ed25519 identity is stored for this hub.
180 identity_type ``"human"`` | ``"agent"`` | ``""`` when not authenticated.
181 identity_name Handle of the stored identity, or ``""`` when not authenticated.
182 identity_id Fingerprint of the stored key, or ``""`` when not authenticated.
183 capabilities List of capability strings (e.g. ``["read:*"]``); ``[]`` for
184 humans or when not authenticated.
185 """
186
187 hub_url: str
188 hostname: str
189 authenticated: bool
190 identity_type: str
191 identity_name: str
192 identity_id: str
193 capabilities: list[str]
194
195 class _DisconnectJson(EnvelopeJson):
196 """JSON output for ``muse hub disconnect --json``.
197
198 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
199
200 Fields
201 ------
202 status ``"ok"`` when a hub was disconnected; ``"nothing_to_do"`` when
203 no hub was configured (idempotent — always exits 0).
204 hub_url Full normalised URL that was removed, or ``""`` on nothing_to_do.
205 hostname Host[:port] display form, or ``""`` on nothing_to_do.
206 """
207
208 status: str
209 hub_url: str
210 hostname: str
211
212 class _PingJson(EnvelopeJson):
213 """JSON output for ``muse hub ping --json``.
214
215 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
216
217 Fields
218 ------
219 status ``"ok"`` when the hub returned HTTP 2xx; ``"error"`` otherwise.
220 hub_url URL that was pinged.
221 hostname Host[:port] display form.
222 reachable ``True`` when the hub returned a 2xx response.
223 message Human-readable result: ``"HTTP 200 OK"`` on success, or an error
224 reason (timeout string, DNS error, HTTP status code, etc.).
225 """
226
227 status: str
228 hub_url: str
229 hostname: str
230 reachable: bool
231 message: str
232
233 class _RepoEntry(TypedDict, total=False):
234 """A single repository entry in MuseHub list and search API responses.
235
236 All fields are optional (``total=False``) because different endpoints
237 return different subsets. Callers must guard with ``.get()`` or check
238 key presence before accessing.
239
240 Fields
241 ------
242 owner Handle of the repository owner (e.g. ``"gabriel"``).
243 slug URL-safe repository slug (e.g. ``"muse"``).
244 repoId ID of the repository (camelCase — matches the raw API response).
245 """
246
247 owner: str
248 slug: str
249 repoId: str
250
251 class _ProposalEntry(TypedDict, total=False):
252 """A single proposal entry in MuseHub proposal list API responses.
253
254 All fields are optional (``total=False``) because list responses may
255 omit fields that require an extra database lookup. The ``read`` endpoint
256 populates the full set; the ``list`` endpoint populates a subset.
257
258 Fields
259 ------
260 proposalId ID of the proposal (camelCase — matches raw API response).
261 title Human-readable proposal title.
262 state Lifecycle state: ``"open"`` | ``"merged"`` | ``"closed"``.
263 fromBranch Source branch name (camelCase — matches raw API response).
264 toBranch Target branch name (camelCase — matches raw API response).
265 author Handle of the proposal author.
266 createdAt ISO-8601 UTC creation timestamp (camelCase — matches raw API).
267 """
268
269 proposalId: str
270 title: str
271 state: str
272 fromBranch: str
273 toBranch: str
274 author: str
275 createdAt: str
276
277 class _RepoJson(EnvelopeJson):
278 """JSON output for ``muse hub repo create --json``.
279
280 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
281
282 Fields
283 ------
284 repo_id ID of the newly created repository.
285 name Repository name as provided by the caller.
286 owner Handle of the owner (authenticated user or ``--owner`` override).
287 slug URL-safe slug derived from the name.
288 visibility ``"public"`` | ``"private"``.
289 description Free-text description (may be empty string).
290 clone_url Full push/pull URL for ``muse remote add`` / ``muse clone``.
291 tags List of topic tags applied at creation time.
292 created_at ISO-8601 UTC timestamp of creation (e.g. ``"2025-01-15T12:00:00Z"``).
293 """
294
295 repo_id: str
296 name: str
297 owner: str
298 slug: str
299 visibility: str
300 description: str
301 clone_url: str
302 tags: list[str]
303 created_at: str
304
305 class _HubApiResponse(TypedDict, total=False):
306 """Generic MuseHub API response envelope used internally by ``_hub_api``.
307
308 All fields are optional (``total=False``) because different endpoints
309 populate different subsets. This TypedDict is an internal contract — it
310 is never emitted directly to callers. Callers extract the specific fields
311 they need and construct domain-typed output dicts.
312
313 Fields
314 ------
315 repo_id ID of a repo, present on create/read repo endpoints.
316 repos List of :class:`_RepoEntry` dicts from list/search endpoints.
317 proposals List of :class:`_ProposalEntry` dicts from proposal endpoints.
318 """
319
320 repo_id: str
321 repos: list[_RepoEntry]
322 proposals: list[_ProposalEntry]
323
324 class _LabelEntry(TypedDict, total=False):
325 """A single label row returned by the MuseHub labels API.
326
327 All fields are optional (``total=False``) because create, read, and list
328 endpoints return different subsets. The ``list`` endpoint populates all
329 fields; the ``create`` response may omit ``repo_id``.
330
331 Fields
332 ------
333 label_id ID of the label.
334 repo_id ID of the repository this label belongs to.
335 name Display name of the label (e.g. ``"bug"``, ``"enhancement"``).
336 color Hex color code without ``#`` prefix (e.g. ``"d73a4a"``).
337 description Free-text description shown on the label chip.
338 """
339
340 label_id: str
341 repo_id: str
342 name: str
343 color: str
344 description: str
345
346 # ── Security — redirect refusal for ping ──────────────────────────────────────
347
348 class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
349 """Refuse all HTTP redirects for the ping health check.
350
351 No credentials travel on the ping request, but silently following
352 redirects (potentially cross-scheme or cross-host) is misleading about
353 what was actually reached and normalises insecure redirect behaviour.
354 """
355
356 def redirect_request(
357 self,
358 req: urllib.request.Request,
359 fp: IO[bytes],
360 code: int,
361 msg: str,
362 headers: http.client.HTTPMessage,
363 newurl: str,
364 ) -> urllib.request.Request | None:
365 raise urllib.error.HTTPError(
366 req.full_url,
367 code,
368 f"Redirect refused ({code}): hub redirected to {newurl!r}. Update the hub URL.",
369 headers,
370 fp,
371 )
372
373 _PING_OPENER = urllib.request.build_opener(_NoRedirectHandler())
374
375 # ── URL / hostname helpers ────────────────────────────────────────────────────
376
377 def _normalise_url(url: str) -> str:
378 """Normalise *url* to an https:// URL (or http:// for loopback addresses).
379
380 Adds ``https://`` when no scheme is present. Raises ``ValueError`` when
381 an explicit ``http://`` scheme is given for a non-loopback host — sending
382 sending credentials over cleartext HTTP to a remote server is never acceptable.
383
384 Exception: ``http://localhost``, ``http://127.0.0.1``, and
385 ``http://[::1]`` (with or without a port) are allowed because loopback
386 traffic never leaves the machine and cannot be intercepted in transit.
387
388 Args:
389 url: Raw user-supplied URL.
390
391 Returns:
392 Normalised URL without a trailing slash.
393
394 Raises:
395 ValueError: If the URL uses ``http://`` for a non-loopback host, or
396 uses a disallowed scheme (``file://``, ``ftp://``, etc.).
397 """
398 stripped = url.strip().rstrip("/")
399
400 # Check for an explicit URL scheme BEFORE adding https://, catching
401 # file:///etc/passwd, ftp://, data:text/plain, javascript:, etc.
402 #
403 # We can't unconditionally rely on urlparse's scheme detection because
404 # bare host:port inputs like "musehub.ai:8443" are misidentified:
405 # urlparse("musehub.ai:8443").scheme == "musehub.ai".
406 #
407 # Heuristic: if the URL contains "://" OR if the part after the first ":"
408 # is NOT a pure port number (digits), treat the prefix as a scheme.
409 # Examples:
410 # "musehub.ai:8443" → after ":" is "8443" (digits) → host:port, skip
411 # "localhost:8080" → after ":" is "8080" (digits) → host:port, skip
412 # "data:text/plain,x" → after ":" is "text/plain,x" → scheme, check
413 # "javascript:alert()" → after ":" is "alert()" → scheme, check
414 # "https://musehub.ai" → "://" present → scheme, check
415 colon_idx = stripped.find(":")
416 if colon_idx > 0:
417 after_colon = stripped[colon_idx + 1:].lstrip("/")
418 is_port = after_colon.isdigit() # bare port: "8443", "10003", etc.
419 if not is_port:
420 pre_scheme = urllib.parse.urlparse(stripped).scheme.lower()
421 if pre_scheme and pre_scheme not in _ALLOWED_API_SCHEMES:
422 raise ValueError(
423 f"URL scheme '{pre_scheme}' is not allowed. "
424 "Use https:// (or http:// for localhost)."
425 )
426
427 if not stripped.startswith(("http://", "https://")):
428 stripped = f"https://{stripped}"
429
430 if stripped.startswith("http://"):
431 # Use urlparse to correctly extract the hostname — handles IPv6
432 # bracket notation (e.g. http://[::1]:8080) where manual split(":")[0]
433 # would yield "[" instead of "::1".
434 _loopback = {"localhost", "127.0.0.1", "::1"}
435 parsed_host = urllib.parse.urlparse(stripped).hostname or ""
436 if parsed_host not in _loopback:
437 host = stripped[len("http://"):]
438 raise ValueError(
439 f"Insecure URL rejected: {stripped!r}\n"
440 f"MuseHub requires HTTPS for non-loopback hosts. Did you mean: https://{host}"
441 )
442 return stripped
443
444 def _hub_hostname(url: str) -> str:
445 """Extract the display hostname (host:port) from a hub URL."""
446 stripped = url.strip().rstrip("/")
447 if "://" in stripped:
448 stripped = stripped.split("://", 1)[1]
449 return stripped.split("/")[0]
450
451 def _ping_hub(url: str) -> tuple[bool, str]:
452 """Attempt an HTTP GET to ``<url>/health``.
453
454 Returns a ``(reachable, message)`` tuple. Never raises — all errors are
455 captured and surfaced as human-readable strings.
456
457 Security note
458 -------------
459 Only ``http`` and ``https`` schemes are attempted. Any other scheme
460 (``file://``, ``ftp://``, etc.) returns ``(False, "scheme not allowed")``
461 without opening a socket — prevents callers from accidentally probing the
462 local filesystem via a ``--hub`` override.
463
464 Exception coverage
465 ------------------
466 - ``urllib.error.HTTPError`` — non-2xx from a reachable server
467 - ``urllib.error.URLError`` — DNS failure, connection refused
468 - ``TimeoutError`` — connect/read timeout
469 - ``OSError`` — network-layer errors (SSL, connection reset, etc.)
470 - ``http.client.HTTPException`` — malformed HTTP response
471 (``BadStatusLine``, ``InvalidURL``) from a misbehaving server;
472 NOT a subclass of OSError so must be caught separately
473 """
474 # Scheme guard — never probe non-HTTP(S) targets.
475 scheme = urllib.parse.urlparse(url).scheme.lower()
476 if scheme not in _ALLOWED_API_SCHEMES:
477 return False, f"URL scheme '{scheme}' is not allowed (use http or https)"
478
479 # For localhost HTTPS, build a one-shot opener that trusts the mkcert CA.
480 # _PING_OPENER uses the default CA bundle (no mkcert), so it fails against
481 # the local dev server's self-signed cert.
482 ssl_ctx = _hub_api_ssl_context(url)
483 if ssl_ctx is not None:
484 opener = urllib.request.build_opener(
485 _NoRedirectHandler(),
486 urllib.request.HTTPSHandler(context=ssl_ctx),
487 )
488 else:
489 opener = _PING_OPENER
490
491 health_url = f"{url.rstrip('/')}/health"
492 try:
493 req = urllib.request.Request(health_url, method="GET")
494 with opener.open(req, timeout=_CONNECT_TIMEOUT) as resp:
495 status = resp.status
496 if 200 <= status < 300:
497 return True, f"HTTP {status} OK"
498 return False, f"HTTP {status}"
499 except urllib.error.HTTPError as exc:
500 return False, f"HTTP {exc.code} {exc.reason}"
501 except urllib.error.URLError as exc:
502 return False, str(exc.reason)
503 except TimeoutError:
504 return False, "timed out"
505 except OSError as exc:
506 return False, str(exc)
507 except http.client.HTTPException as exc:
508 # Malformed HTTP response (BadStatusLine, InvalidURL, etc.).
509 # Not an OSError subclass — must be caught explicitly.
510 return False, f"malformed response: {type(exc).__name__}"
511
512 # ── MuseHub HTTP helper ───────────────────────────────────────────────────────
513
514 def _hub_api_ssl_context(url: str) -> "ssl.SSLContext | None":
515 """Return an SSL context appropriate for *url*.
516
517 - ``https://localhost`` / ``https://127.0.0.1``: loads the local-dev
518 self-signed CA cert bundled with musehub so that muse CLI tools work
519 against the local hub without installing the cert system-wide.
520 - All other HTTPS URLs: return ``None``, letting ``urllib`` use the OS CA
521 store (standard verification).
522 - HTTP URLs: return ``None`` (no TLS at all).
523
524 The CA cert path mirrors ``muse.core.transport._httpx_verify`` so both the
525 push (httpx) and hub-API (urllib) paths trust the same certificate.
526
527 Path layout (6 levels up from this file reaches the ecosystem root):
528 ``hub/_core.py → hub/ → commands/ → cli/ → muse/ → muse/ → ecosystem/``
529 """
530 import ssl
531 from muse.core.transport import _mkcert_ca
532
533 if url.startswith("https://localhost") or url.startswith("https://127.0.0.1"):
534 ca = _mkcert_ca()
535 if ca is not None:
536 ctx = ssl.create_default_context(cafile=str(ca))
537 return ctx
538 return None
539
540 # Read-only HTTP methods that are permitted without an Authorization header so
541 # that unauthenticated clients can access public resources (e.g. reading issues
542 # on a public repo). Mutating methods (POST/PUT/DELETE/PATCH) always require
543 # a signing identity.
544 _READ_ONLY_METHODS: frozenset[str] = frozenset({"GET", "HEAD", "OPTIONS"})
545
546 def _hub_api(
547 hub_url: str,
548 identity: IdentityEntry,
549 method: str,
550 path: str,
551 body: Mapping[str, str | bool | list[str] | None] | None = None,
552 timeout: float = 10.0,
553 ) -> _HubApiResponse:
554 """Make a JSON request to the MuseHub API, signing it when credentials exist.
555
556 For read-only methods (GET/HEAD/OPTIONS) on public resources this function
557 proceeds without an ``Authorization`` header when no signing identity is
558 configured — allowing unauthenticated access to public repos (e.g. reading
559 issues, listing releases).
560
561 For mutating methods (POST/PUT/DELETE/PATCH) a signing identity is always
562 required and the function exits non-zero when none is found.
563
564 Security:
565 - Validates the hub URL scheme (``http``/``https`` only) before opening
566 any socket — prevents SSRF if ``config.toml`` is tampered with.
567 - Caps the response body at ``_MAX_API_RESPONSE_BYTES`` to prevent OOM
568 from a hostile or misbehaving hub.
569 - Sanitizes error detail from HTTP responses before printing.
570 - For ``https://localhost`` uses the local-dev CA cert (not CERT_NONE) so
571 self-signed certs are verified against a known CA rather than bypassed.
572
573 Args:
574 hub_url: Repository-level hub URL (e.g. ``https://localhost:1337/gabriel/muse``).
575 identity: Identity entry from ``~/.muse/identity.toml``.
576 method: HTTP method (``GET``, ``POST``, ``DELETE``, …).
577 path: API path appended to the server root (e.g. ``/gabriel/muse/issues/5``).
578 body: Optional JSON body for POST/PUT requests.
579 timeout: Connect+read timeout in seconds.
580
581 Returns:
582 Parsed JSON response body as a dict.
583
584 Raises:
585 SystemExit: On invalid URL scheme, missing token for mutating requests,
586 network errors, or non-2xx responses.
587 """
588 parsed = urllib.parse.urlparse(hub_url)
589 scheme = parsed.scheme.lower()
590 if scheme not in _ALLOWED_API_SCHEMES:
591 print(
592 f"❌ Hub URL scheme '{sanitize_display(scheme)}' is not allowed. "
593 "Use http or https.",
594 file=sys.stderr,
595 )
596 raise SystemExit(ExitCode.USER_ERROR)
597
598 server_root = f"{parsed.scheme}://{parsed.netloc}"
599 url = f"{server_root}{path}"
600
601 from muse.cli.config import get_signing_identity
602 signing = get_signing_identity(remote_url=hub_url)
603
604 normalized_method = method.upper()
605 if not signing and normalized_method not in _READ_ONLY_METHODS:
606 print(
607 "❌ No signing identity for this hub. Run:\n"
608 " muse auth keygen --hub <url>\n"
609 " muse auth register --hub <url> --handle <your-handle>",
610 file=sys.stderr,
611 )
612 raise SystemExit(ExitCode.USER_ERROR)
613
614 data: bytes | None = None
615 body_bytes: bytes | None = None
616 if body is not None:
617 body_bytes = json.dumps(body).encode()
618 data = body_bytes
619
620 headers: Metadata = {"Accept": "application/json"}
621 if signing:
622 from muse.core.msign import build_msign_header
623 headers["Authorization"] = build_msign_header(signing, normalized_method, url, body_bytes)
624 if body_bytes is not None:
625 headers["Content-Type"] = "application/json"
626
627 ssl_ctx = _hub_api_ssl_context(url)
628 req = urllib.request.Request(url, data=data, headers=headers, method=normalized_method)
629 try:
630 with urllib.request.urlopen(req, timeout=timeout, context=ssl_ctx) as resp: # noqa: S310
631 raw = resp.read(_MAX_API_RESPONSE_BYTES + 1)
632 if len(raw) > _MAX_API_RESPONSE_BYTES:
633 print(
634 f"❌ Response from {sanitize_display(url)} exceeds size limit.",
635 file=sys.stderr,
636 )
637 raise SystemExit(ExitCode.INTERNAL_ERROR)
638 text = raw.decode("utf-8", errors="replace")
639 parsed = json.loads(text) if text.strip() else {}
640 return parsed if isinstance(parsed, dict) else {}
641 except urllib.error.HTTPError as exc:
642 raw_body = exc.read().decode(errors="replace")
643 try:
644 detail = json.loads(raw_body).get("detail", raw_body)
645 except (json.JSONDecodeError, AttributeError):
646 detail = raw_body[:200]
647 print(
648 f"❌ MuseHub API error {exc.code}: {sanitize_display(str(detail))}",
649 file=sys.stderr,
650 )
651 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
652 except urllib.error.URLError as exc:
653 print(
654 f"❌ Cannot reach MuseHub: {sanitize_display(str(exc.reason))}",
655 file=sys.stderr,
656 )
657 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
658
659 # ── Repo / identity resolution helpers ───────────────────────────────────────
660
661 def _enrich_hub_url_from_remote(hub_base_url: str) -> str:
662 """Append owner/slug to a bare hub URL by cross-referencing configured remotes.
663
664 When the hub URL stored by ``muse hub connect`` is a bare base URL
665 (e.g. ``https://localhost:1337``) with no owner/slug path component,
666 this function searches the configured remotes for one whose netloc matches
667 the hub netloc and extracts the owner/slug from its path.
668
669 Returns the enriched URL on a match; returns *hub_base_url* unchanged when
670 no matching remote is found or when the caller is not inside a repo.
671 """
672 base_parsed = urllib.parse.urlparse(hub_base_url)
673 if len(base_parsed.path.strip("/").split("/")) >= 2 and base_parsed.path.strip("/"):
674 # Already has owner/slug — nothing to do.
675 return hub_base_url
676 root = find_repo_root()
677 if root is None:
678 return hub_base_url
679 for remote in list_remotes(root):
680 r = urllib.parse.urlparse(remote["url"])
681 if r.netloc == base_parsed.netloc:
682 parts = r.path.strip("/").split("/")
683 if len(parts) >= 2 and parts[0]:
684 return f"{base_parsed.scheme}://{base_parsed.netloc}/{parts[0]}/{parts[1]}"
685 return hub_base_url
686
687 def _resolve_repo_id(hub_url: str, identity: IdentityEntry) -> str:
688 """Look up the MuseHub repo ID from the hub URL's owner/slug path.
689
690 The hub URL is ``http://host/owner/slug``. This calls
691 ``GET /{owner}/{slug}/refs`` to obtain the ID, falling back to the
692 search endpoint if the refs response does not include ``repo_id``.
693
694 When the hub URL is a bare base URL (no owner/slug), the function first
695 attempts to enrich it from the configured remotes via
696 :func:`_enrich_hub_url_from_remote`.
697
698 Raises:
699 SystemExit: If the URL has no owner/slug or the repo ID cannot be resolved.
700 """
701 hub_url = _enrich_hub_url_from_remote(hub_url)
702 parsed = urllib.parse.urlparse(hub_url)
703 parts = parsed.path.strip("/").split("/")
704 if len(parts) < 2:
705 print(
706 f"❌ Hub URL '{sanitize_display(hub_url)}' does not contain "
707 "owner/slug — cannot resolve repo ID.",
708 file=sys.stderr,
709 )
710 raise SystemExit(ExitCode.USER_ERROR)
711 owner, slug = parts[0], parts[1]
712
713 data = _hub_api(hub_url, identity, "GET", f"/{owner}/{slug}/refs")
714 repo_id: str = str(data.get("repo_id", ""))
715 if not repo_id:
716 # Fallback: search endpoint
717 search = _hub_api(
718 hub_url, identity, "GET",
719 f"/api/search?q={urllib.parse.quote(slug)}&limit=5",
720 )
721 repos_val = search.get("repos", [])
722 repos_list: list[_RepoEntry] = (
723 [r for r in repos_val if isinstance(r, dict)]
724 if isinstance(repos_val, list) else []
725 )
726 for repo in repos_list:
727 if repo.get("owner") == owner and repo.get("slug") == slug:
728 repo_id = str(repo.get("repoId", ""))
729 break
730 if not repo_id:
731 print(
732 f"❌ Could not resolve repo ID for {sanitize_display(owner)}/{sanitize_display(slug)}.",
733 file=sys.stderr,
734 )
735 raise SystemExit(ExitCode.USER_ERROR)
736 return repo_id
737
738 def _resolve_repo_id_from_owner_repo(
739 hub_url: str,
740 identity: IdentityEntry,
741 owner_repo: str,
742 ) -> str:
743 """Resolve a repo ID from an ``owner/repo`` string.
744
745 Calls ``GET /{owner}/{slug}/refs`` to obtain the ID.
746
747 Args:
748 hub_url: Base hub URL (e.g. ``https://localhost:1337``).
749 identity: Caller identity for auth.
750 owner_repo: String in ``owner/slug`` format (e.g. ``gabriel/muse``).
751
752 Raises:
753 SystemExit: If owner_repo is malformed or the repo cannot be resolved.
754 """
755 parts = owner_repo.strip("/").split("/")
756 if len(parts) != 2:
757 print(f"❌ --repo must be in owner/repo format, got {owner_repo!r}.", file=sys.stderr)
758 raise SystemExit(ExitCode.USER_ERROR)
759 owner, slug = parts[0], parts[1]
760
761 # Build a repo-scoped URL to piggyback on _resolve_repo_id
762 base = hub_url.split("/api")[0].split("/#")[0].rstrip("/")
763 repo_hub_url = f"{base}/{owner}/{slug}"
764 return _resolve_repo_id(repo_hub_url, identity)
765
766 def _resolve_body(args: argparse.Namespace) -> str:
767 """Return the effective body string from ``--body`` or ``--body-file``.
768
769 Resolution order:
770 1. ``--body-file PATH`` — read the entire file at PATH as UTF-8.
771 Pass ``-`` to read from stdin instead.
772 2. ``--body TEXT`` — use the literal string.
773 3. (neither) — return ``""`` (empty string).
774
775 If both are provided ``--body-file`` wins and ``--body`` is ignored.
776
777 This helper exists because large bodies (markdown, code blocks, ASCII art)
778 cannot survive shell quoting when passed inline via ``--body``. Writing
779 the body to a temp file and using ``--body-file`` is the ergonomic path
780 for agents and humans alike.
781
782 Args:
783 args: Parsed :class:`argparse.Namespace`. Must have ``body`` and
784 ``body_file`` attributes (both optional/defaulting to ``None``).
785
786 Returns:
787 Body string (may be empty).
788
789 Raises:
790 SystemExit: If ``--body-file PATH`` cannot be read (file not found,
791 permission denied, etc.).
792 """
793 import os
794
795 body_file: str | None = getattr(args, "body_file", None)
796 body: str = getattr(args, "body", "") or ""
797
798 if not body_file:
799 return body
800
801 if body_file == "-":
802 return sys.stdin.read()
803
804 try:
805 with open(body_file, encoding="utf-8") as fh:
806 return fh.read()
807 except OSError as exc:
808 print(f"❌ Cannot read --body-file {body_file!r}: {exc}", file=sys.stderr)
809 raise SystemExit(ExitCode.USER_ERROR) from exc
810
811 def _validate_assignee(handle: str, *, allow_empty: bool = False) -> None:
812 """Validate a MuseHub handle before sending it to the API.
813
814 Called on every user-supplied handle — at create time (``--assignee``),
815 update time (``--assign``), and the dedicated ``issue assign`` command.
816
817 Threat model
818 ------------
819 Handles are echoed back to the terminal and embedded in API requests.
820 Malformed values could cause:
821
822 * **Terminal injection** — ANSI escape sequences hidden inside handle
823 strings can rewrite terminal output after the fact.
824 * **Null-byte truncation** — ``\\x00`` in a string truncates C-string
825 paths, and some JSON parsers mis-handle it.
826 * **Newline injection** — ``\\n`` in a value echoed to stdout/stderr can
827 make error messages look like success messages.
828 * **Control-character abuse** — backspace, carriage-return, etc. can
829 visually overwrite earlier output.
830
831 All of the above are rejected before any network I/O or terminal output.
832
833 Args:
834 handle: The handle string to validate. Trailing/leading
835 whitespace has already been stripped by argparse.
836 allow_empty: When ``True`` (used by ``issue assign``), an empty
837 string is accepted and means "unassign". When ``False``
838 (used by ``issue create``), an empty string is an error.
839
840 Raises:
841 SystemExit(ExitCode.USER_ERROR): On any validation failure.
842 """
843 if not handle:
844 if allow_empty:
845 return
846 print("❌ --assignee must not be empty.", file=sys.stderr)
847 raise SystemExit(ExitCode.USER_ERROR)
848
849 # Reject null bytes and every ASCII control character (0x00–0x1F, 0x7F).
850 # These can cause terminal injection, string truncation, or newline spoofing.
851 if any(ord(c) < 0x20 or ord(c) == 0x7F for c in handle):
852 print(
853 "❌ --assignee contains a control character (null byte, newline, "
854 "etc.) which is not allowed in handles.",
855 file=sys.stderr,
856 )
857 raise SystemExit(ExitCode.USER_ERROR)
858
859 # Reject non-ASCII entirely — Unicode confusables and RTL override
860 # characters can impersonate other handles visually.
861 if not handle.isascii():
862 print(
863 f"❌ --assignee {sanitize_display(handle)!r} contains non-ASCII "
864 "characters; handles must be plain ASCII.",
865 file=sys.stderr,
866 )
867 raise SystemExit(ExitCode.USER_ERROR)
868
869 if len(handle) > _MAX_HANDLE_LEN:
870 print(
871 f"❌ --assignee is too long ({len(handle)} chars); "
872 f"maximum is {_MAX_HANDLE_LEN}.",
873 file=sys.stderr,
874 )
875 raise SystemExit(ExitCode.USER_ERROR)
876
877 if not _HANDLE_RE.match(handle):
878 print(
879 f"❌ --assignee {sanitize_display(handle)!r} is not a valid handle. "
880 "Handles must start with a letter or digit and contain only "
881 "letters, digits, hyphens (-), and underscores (_).",
882 file=sys.stderr,
883 )
884 raise SystemExit(ExitCode.USER_ERROR)
885
886 def _get_hub_and_identity(
887 remote: str | None = None,
888 hub_url_override: str | None = None,
889 ) -> tuple[str, IdentityEntry]:
890 """Load hub URL and identity for the current repo, or exit with a helpful error.
891
892 Falls back to a named remote's URL when no hub is configured — useful for
893 local development where the remote IS the hub.
894
895 When *hub_url_override* is provided it takes precedence over the hub URL
896 stored in ``.muse/config.toml``. This is the value supplied via the
897 ``--hub`` CLI flag and lets callers (e.g. containerised agents) target a
898 hub at a different address than the one recorded in the repo config without
899 having to modify ``config.toml``.
900
901 Raises:
902 SystemExit: If not inside a repo, no hub/remote is configured, or not authenticated.
903 """
904 root = find_repo_root()
905 if root is None:
906 print("❌ Not inside a Muse repository.", file=sys.stderr)
907 raise SystemExit(ExitCode.REPO_NOT_FOUND)
908
909 hub_url = hub_url_override or get_hub_url(root)
910
911 if hub_url is None:
912 # No hub configured — try the specified remote, then 'local', then any remote.
913 candidate_name = remote or "local"
914 candidate_url = get_remote(candidate_name, root)
915 if candidate_url is None and remote is None:
916 remotes = list_remotes(root)
917 candidate_url = remotes[0]["url"] if remotes else None
918 if candidate_url:
919 candidate_name = remotes[0]["name"]
920 if candidate_url:
921 hub_url = candidate_url
922 logger.debug(
923 "No hub configured — using remote '%s' as hub URL: %s",
924 candidate_name, hub_url,
925 )
926 else:
927 print("❌ No hub connected and no remotes configured.", file=sys.stderr)
928 print(
929 " Run `muse hub connect <url>` or `muse remote add local <url>`.",
930 file=sys.stderr,
931 )
932 raise SystemExit(ExitCode.USER_ERROR)
933
934 identity = load_identity(hub_url)
935 if identity is None:
936 print(
937 f"❌ Not authenticated for {sanitize_display(hub_url)}.",
938 file=sys.stderr,
939 )
940 print(f" Run: muse auth register --hub {hub_url}", file=sys.stderr)
941 raise SystemExit(ExitCode.USER_ERROR)
942
943 return hub_url, identity
944
945 def _get_hub_and_optional_identity(
946 remote: str | None = None,
947 hub_url_override: str | None = None,
948 ) -> tuple[str, IdentityEntry]:
949 """Like ``_get_hub_and_identity`` but returns an empty identity on missing auth.
950
951 Use this for read-only commands (``hub issue read``, ``hub issue list``, …)
952 that should work against public repos even when no signing identity is
953 configured. The empty identity dict is passed to ``_hub_api``, which will
954 omit the Authorization header for GET requests — allowing unauthenticated
955 access to public resources.
956
957 When an identity IS configured it is returned normally, so authenticated
958 callers get signed requests and can access private repos.
959
960 Mutating operations must use ``_get_hub_and_identity`` (strict) to fail
961 fast with a helpful error before making any network request.
962
963 Returns:
964 ``(hub_url, identity)`` where *identity* may be ``{"type": "human"}``
965 when no signing identity is configured.
966 """
967 import io as _io
968
969 from muse.core.repo import find_repo_root
970 from muse.cli.config import get_hub_url, get_remote, list_remotes
971
972 # Resolve the hub URL first (independent of identity).
973 root = find_repo_root()
974 hub_url: str | None = hub_url_override or (get_hub_url(root) if root else None)
975 if hub_url is None and root:
976 candidate_name = remote or "local"
977 candidate_url = get_remote(candidate_name, root)
978 if candidate_url is None and remote is None:
979 remotes = list_remotes(root)
980 candidate_url = remotes[0]["url"] if remotes else None
981 hub_url = candidate_url
982
983 if not hub_url:
984 print("❌ No hub connected and no remotes configured.", file=sys.stderr)
985 print(
986 " Run `muse hub connect <url>` or `muse remote add local <url>`.",
987 file=sys.stderr,
988 )
989 raise SystemExit(ExitCode.USER_ERROR)
990
991 # Try loading the identity; silently fall back to empty dict on failure so
992 # callers can proceed with unauthenticated GET requests on public resources.
993 identity = load_identity(hub_url)
994 if identity is None:
995 empty_identity: IdentityEntry = {"type": "human"}
996 return hub_url, empty_identity
997
998 return hub_url, identity
999
1000 def _resolve_proposal_id(
1001 hub_url: str,
1002 identity: IdentityEntry,
1003 repo_id: str,
1004 proposal_id_or_prefix: str,
1005 ) -> str:
1006 """Resolve a proposal ID prefix to a full ID.
1007
1008 If *proposal_id_or_prefix* looks like a full ID (≥32 chars with hyphens), it
1009 is returned as-is. Otherwise the most recent ``_PROPOSAL_PREFIX_RESOLVE_LIMIT``
1010 proposals are fetched and the first match whose ``proposalId`` starts with the prefix
1011 is returned.
1012
1013 All user-supplied and API-sourced strings are sanitized before printing.
1014
1015 Note
1016 ----
1017 Prefix resolution is capped at :data:`_PROPOSAL_PREFIX_RESOLVE_LIMIT` proposals.
1018 On repositories with many more open proposals, very old proposals may not be findable
1019 by prefix — pass the full ID in that case.
1020
1021 Raises:
1022 SystemExit: When no match is found or multiple proposals match the prefix.
1023 """
1024 # Recognise a full content-addressed ID (sha256:<64-hex>, blake3:<64-hex>)
1025 # or a legacy hyphenated UUID — return as-is without fetching the list.
1026 try:
1027 split_id(proposal_id_or_prefix)
1028 return proposal_id_or_prefix
1029 except ValueError:
1030 pass
1031 if "-" in proposal_id_or_prefix and len(proposal_id_or_prefix) >= 32:
1032 return proposal_id_or_prefix # legacy UUID passthrough
1033
1034 data = _hub_api(
1035 hub_url, identity, "GET",
1036 f"/api/repos/{repo_id}/proposals?limit={_PROPOSAL_PREFIX_RESOLVE_LIMIT}",
1037 )
1038 proposals_val = data.get("proposals", [])
1039 proposals: list[_ProposalEntry] = (
1040 [r for r in proposals_val if isinstance(r, dict)]
1041 if isinstance(proposals_val, list) else []
1042 )
1043 matches = [p for p in proposals if str(p.get("proposalId", "")).startswith(proposal_id_or_prefix)]
1044
1045 if not matches:
1046 print(
1047 f"❌ No proposal found with ID prefix '{sanitize_display(proposal_id_or_prefix)}'.",
1048 file=sys.stderr,
1049 )
1050 raise SystemExit(ExitCode.USER_ERROR)
1051 if len(matches) > 1:
1052 print(
1053 f"❌ Ambiguous prefix '{sanitize_display(proposal_id_or_prefix)}' "
1054 f"— matches {len(matches)} proposals:",
1055 file=sys.stderr,
1056 )
1057 for m in matches:
1058 print(
1059 f" {sanitize_display(str(m.get('proposalId', '')))} "
1060 f"{sanitize_display(str(m.get('title', '')))}",
1061 file=sys.stderr,
1062 )
1063 raise SystemExit(ExitCode.USER_ERROR)
1064 return str(matches[0].get("proposalId", proposal_id_or_prefix))
1065
1066 def _format_proposal(entry: _ProposalEntry, *, verbose: bool = False) -> str:
1067 """Format a single proposal for human-readable display.
1068
1069 All fields from the API response are sanitized via :func:`sanitize_display`
1070 before inclusion in the returned string — including ``author`` and
1071 ``createdAt`` in verbose mode.
1072
1073 Args:
1074 entry: Raw proposal dict from the MuseHub API.
1075 verbose: When ``True``, append author name and creation date (YYYY-MM-DD).
1076 """
1077 proposal_id = sanitize_display(str(entry.get("proposalId", "")))
1078 title = sanitize_display(str(entry.get("title", "(no title)")))
1079 state = str(entry.get("state", "?"))
1080 from_b = sanitize_display(str(entry.get("fromBranch", "?")))
1081 to_b = sanitize_display(str(entry.get("toBranch", "?")))
1082 state_icon = {"open": "🟢", "merged": "🟣", "closed": "⛔"}.get(state, "❓")
1083 line = f" {state_icon} {proposal_id} {from_b} → {to_b} {title}"
1084 if verbose:
1085 author = sanitize_display(str(entry.get("author", "")))
1086 created = sanitize_display(str(entry.get("createdAt", ""))[:10])
1087 line += f"\n by {author or '?'} {created}"
1088 return line
1089
1090 def _resolve_hub_override(args: argparse.Namespace) -> str | None:
1091 """Return the effective hub URL override, resolving --repo as an alias for --hub.
1092
1093 ``--repo owner/repo`` is the idiomatic git/gh CLI style. When provided,
1094 the hub base URL is read from the repo config and the owner/repo path is
1095 appended, producing a full ``--hub``-compatible URL.
1096 """
1097 if args.hub:
1098 return args.hub
1099 repo_arg: str | None = getattr(args, "repo", None)
1100 if not repo_arg:
1101 return None
1102 root = find_repo_root()
1103 if root is not None:
1104 configured = get_hub_url(root)
1105 if configured:
1106 # Strip any trailing owner/repo path — keep only scheme://host[:port].
1107 parsed = urllib.parse.urlparse(configured)
1108 base = f"{parsed.scheme}://{parsed.netloc}"
1109 return f"{base}/{repo_arg.strip('/')}"
1110 # Fallback: no config — caller will get a helpful error from _get_hub_and_identity.
1111 return None
1112
1113 __all__ = ['logger', '_CONNECT_TIMEOUT', '_MAX_API_RESPONSE_BYTES', '_ALLOWED_API_SCHEMES', '_PROPOSAL_PREFIX_RESOLVE_LIMIT', '_MAX_PROPOSAL_BODY_LINES', '_MAX_PROPOSAL_TITLE_LEN', '_MAX_ISSUE_TITLE_LEN', '_MAX_REPO_NAME_LEN', '_MAX_REPO_DESC_LEN', '_MAX_ISSUE_LABEL_LEN', '_MAX_ISSUE_COMMENT_LEN', '_MAX_LABEL_NAME_LEN', '_MAX_LABEL_DESC_LEN', '_MAX_HANDLE_LEN', '_HANDLE_RE', '_ConnectJson', '_StatusJson', '_DisconnectJson', '_PingJson', '_RepoEntry', '_ProposalEntry', '_RepoJson', '_HubApiResponse', '_LabelEntry', '_NoRedirectHandler', '_PING_OPENER', '_normalise_url', '_hub_hostname', '_ping_hub', '_hub_api', '_enrich_hub_url_from_remote', '_resolve_repo_id', '_resolve_repo_id_from_owner_repo', '_resolve_body', '_validate_assignee', '_get_hub_and_identity', '_get_hub_and_optional_identity', '_resolve_proposal_id', '_format_proposal', '_resolve_hub_override', 'argparse', 'http', 'json', 'logging', 're', 'sys', 'textwrap', 'urllib', 'Mapping', 'IO', 'TypedDict', 'clear_hub_url', 'get_hub_url', 'get_remote', 'list_remotes', 'set_hub_url', 'ExitCode', 'IdentityEntry', 'load_identity', 'find_repo_root', 'read_current_branch', 'Metadata', 'short_id', 'clamp_int', 'sanitize_display',
1114 'EnvelopeJson', 'make_envelope', 'start_timer', '_HubPayload', '_ProposalPayload']
File History 3 commits
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 38 days ago
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f chore: merge main — carry all urllib/typing/test fixes from dev Sonnet 4.6 minor 50 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 50 days ago