chartremotely 0.3.2__tar.gz → 0.3.3__tar.gz

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.
Files changed (66) hide show
  1. {chartremotely-0.3.2 → chartremotely-0.3.3}/PKG-INFO +2 -2
  2. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/displays.py +5 -5
  3. chartremotely-0.3.3/chartremotely/holder.py +123 -0
  4. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/mcpclient.py +13 -6
  5. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/patron.py +20 -24
  6. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/setup.py +38 -38
  7. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely.egg-info/PKG-INFO +2 -2
  8. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely.egg-info/SOURCES.txt +1 -0
  9. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely.egg-info/requires.txt +1 -1
  10. {chartremotely-0.3.2 → chartremotely-0.3.3}/pyproject.toml +2 -2
  11. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_hearing.py +9 -8
  12. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_patron.py +21 -19
  13. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_setup.py +24 -16
  14. {chartremotely-0.3.2 → chartremotely-0.3.3}/LICENSE +0 -0
  15. {chartremotely-0.3.2 → chartremotely-0.3.3}/README.md +0 -0
  16. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/__init__.py +0 -0
  17. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/answer.py +0 -0
  18. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/assets/shortcut.plist +0 -0
  19. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/ax.py +0 -0
  20. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/cli.py +0 -0
  21. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/config.py +0 -0
  22. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/doctor.py +0 -0
  23. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/guilock.py +0 -0
  24. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/hearing.py +0 -0
  25. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/keystore.py +0 -0
  26. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/layout.py +0 -0
  27. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/popups.py +0 -0
  28. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/push.py +0 -0
  29. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/recent.py +0 -0
  30. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/registry.py +0 -0
  31. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/relay.py +0 -0
  32. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/requestlog.py +0 -0
  33. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/resolve.py +0 -0
  34. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/scales.py +0 -0
  35. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/screen.py +0 -0
  36. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/server.py +0 -0
  37. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/services.py +0 -0
  38. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/shortcut.py +0 -0
  39. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/snapshot.py +0 -0
  40. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/studies.py +0 -0
  41. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/symbol.py +0 -0
  42. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/tailnet.py +0 -0
  43. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/terminal.py +0 -0
  44. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/timeframe.py +0 -0
  45. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/understand.py +0 -0
  46. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/vocab.py +0 -0
  47. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/voice.py +0 -0
  48. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/window.py +0 -0
  49. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely.egg-info/dependency_links.txt +0 -0
  50. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely.egg-info/entry_points.txt +0 -0
  51. {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely.egg-info/top_level.txt +0 -0
  52. {chartremotely-0.3.2 → chartremotely-0.3.3}/setup.cfg +0 -0
  53. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_changelog.py +0 -0
  54. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_popups.py +0 -0
  55. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_push.py +0 -0
  56. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_recent.py +0 -0
  57. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_relay.py +0 -0
  58. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_requestlog.py +0 -0
  59. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_resolve.py +0 -0
  60. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_scales.py +0 -0
  61. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_screen.py +0 -0
  62. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_snapshot.py +0 -0
  63. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_stamp_changelog.py +0 -0
  64. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_timeframe.py +0 -0
  65. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_understand.py +0 -0
  66. {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_voice.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -219,7 +219,7 @@ Requires-Dist: pyobjc-framework-Quartz>=10; extra == "macos"
219
219
  Requires-Dist: pyobjc-framework-Security>=10; extra == "macos"
220
220
  Requires-Dist: mlx-whisper>=0.4.3; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "macos"
221
221
  Provides-Extra: setup
222
- Requires-Dist: tollbooth-dpyc[nostr]==0.90.0; extra == "setup"
222
+ Requires-Dist: tollbooth-dpyc[nostr]==0.102.1; extra == "setup"
223
223
  Provides-Extra: dev
224
224
  Requires-Dist: pytest>=8; extra == "dev"
225
225
  Requires-Dist: ruff>=0.6; extra == "dev"
@@ -21,7 +21,7 @@ import threading
21
21
  import time
22
22
  from collections.abc import Callable
23
23
 
24
- from . import config, keystore, mcpclient, requestlog
24
+ from . import config, holder, mcpclient, requestlog
25
25
  from .config import DATA_DIR
26
26
 
27
27
  PATH = DATA_DIR / "displays.json"
@@ -81,11 +81,11 @@ def refresh(cfg: dict | None = None, client_for=None, path=None) -> list[str] |
81
81
  npub = cfg.get("owner_npub")
82
82
  if not (base and npub and cfg.get("agent_id")):
83
83
  return None
84
+ if not holder.connected(npub):
85
+ return None # not signed in: a background refresh never opens a connection
84
86
  try:
85
- token = keystore.load_token(npub)
86
- if not token:
87
- return None
88
- payload = (client_for or mcpclient.Client)(base, timeout=30.0).call(TOOL, {"npub": npub, "dpop_token": token})
87
+ bearer = holder.bearer(base, npub)
88
+ payload = (client_for or mcpclient.Client)(base, timeout=30.0, bearer=bearer).call(TOOL, {"npub": npub})
89
89
  except Exception as exc: # noqa: BLE001 - a missing list must never cost a reply
90
90
  requestlog.outcome("serve", TOOL, "", f"display list failed: {type(exc).__name__}")
91
91
  return None
@@ -0,0 +1,123 @@
1
+ """This Mac as a *holder*: a connection the operator knows, into whose wallet
2
+ the owner's npub is proven by Nostr DM.
3
+
4
+ The agent holds no key and keeps no grant. It keeps only this connection's
5
+ OAuth tokens, in the login Keychain (as the owner's voice sign-in), and names
6
+ the npub on each priced call; the operator checks the grant it holds.
7
+
8
+ Becoming a holder is the OAuth Authorization Code flow, run headless: the
9
+ operator's ``/authorize`` approves at once (a holder holds nothing until a DM
10
+ proves an npub into it), so the agent reads the code from the redirect
11
+ instead of opening a browser. A refresh token is single-use, so each refresh
12
+ stores the pair it returns.
13
+
14
+ Stdlib only, like the rest of the listener.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import json
22
+ import secrets
23
+ import time
24
+ import urllib.error
25
+ import urllib.parse
26
+ import urllib.request
27
+
28
+ from . import keystore
29
+
30
+ REDIRECT_URI = "http://127.0.0.1/holder"
31
+ #: Refresh this long before the access token lapses.
32
+ REFRESH_EARLY_SECONDS = 60
33
+
34
+
35
+ class HolderError(RuntimeError):
36
+ """The operator would not connect this Mac."""
37
+
38
+
39
+ def _b64url(raw: bytes) -> str:
40
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
41
+
42
+
43
+ def _post(url: str, body: bytes, content_type: str) -> dict:
44
+ request = urllib.request.Request(url, data=body, method="POST", headers={"Content-Type": content_type})
45
+ try:
46
+ with urllib.request.urlopen(request, timeout=30) as response:
47
+ return json.loads(response.read().decode())
48
+ except urllib.error.HTTPError as exc:
49
+ raise HolderError(f"the operator refused ({exc.code})") from None
50
+ except OSError as exc:
51
+ raise HolderError(f"could not reach the operator: {exc}") from None
52
+
53
+
54
+ class _KeepRedirect(urllib.request.HTTPRedirectHandler):
55
+ def redirect_request(self, *args, **kwargs):
56
+ return None
57
+
58
+
59
+ def _token(base: str, form: dict, client_id: str) -> dict:
60
+ t = _post(f"{base}/token",
61
+ urllib.parse.urlencode({**form, "client_id": client_id, "resource": f"{base}/mcp"}).encode(),
62
+ "application/x-www-form-urlencoded")
63
+ return {"client_id": client_id, "access_token": t["access_token"], "refresh_token": t["refresh_token"],
64
+ "expires_at": time.time() + float(t.get("expires_in") or 3600)}
65
+
66
+
67
+ def _login(base: str) -> dict:
68
+ client_id = _post(f"{base}/register", json.dumps({
69
+ "client_name": "ChartRemotely agent",
70
+ "redirect_uris": [REDIRECT_URI],
71
+ "grant_types": ["authorization_code", "refresh_token"],
72
+ "response_types": ["code"],
73
+ "token_endpoint_auth_method": "none",
74
+ }).encode(), "application/json")["client_id"]
75
+ verifier = _b64url(secrets.token_bytes(48))
76
+ state = _b64url(secrets.token_bytes(16))
77
+ query = urllib.parse.urlencode({
78
+ "response_type": "code", "client_id": client_id, "redirect_uri": REDIRECT_URI, "state": state,
79
+ "code_challenge": _b64url(hashlib.sha256(verifier.encode()).digest()),
80
+ "code_challenge_method": "S256", "resource": f"{base}/mcp",
81
+ })
82
+ try:
83
+ urllib.request.build_opener(_KeepRedirect).open(f"{base}/authorize?{query}", timeout=30)
84
+ raise HolderError("the operator did not redirect back")
85
+ except urllib.error.HTTPError as exc:
86
+ location = exc.headers.get("Location", "")
87
+ back = urllib.parse.parse_qs(urllib.parse.urlparse(location).query)
88
+ if back.get("state") != [state] or not back.get("code"):
89
+ raise HolderError("the operator's reply did not match this connection")
90
+ return _token(base, {"grant_type": "authorization_code", "code": back["code"][0],
91
+ "redirect_uri": REDIRECT_URI, "code_verifier": verifier}, client_id)
92
+
93
+
94
+ def _stored(npub: str) -> dict | None:
95
+ raw = keystore.load_token(npub)
96
+ try:
97
+ t = json.loads(raw) if raw else None
98
+ except ValueError:
99
+ return None # a sign-in saved before this Mac connected as a holder
100
+ return t if isinstance(t, dict) and t.get("refresh_token") else None
101
+
102
+
103
+ def bearer(base: str, npub: str) -> str:
104
+ """The bearer for the next call: refreshed when it is about to lapse, or a
105
+ fresh connection when there is none. Raises HolderError or KeystoreError."""
106
+ t = _stored(npub)
107
+ if t and t["expires_at"] - REFRESH_EARLY_SECONDS <= time.time():
108
+ try:
109
+ t = _token(base, {"grant_type": "refresh_token", "refresh_token": t["refresh_token"]}, t["client_id"])
110
+ except HolderError:
111
+ t = None
112
+ if not t:
113
+ t = _login(base)
114
+ keystore.save_token(npub, json.dumps(t))
115
+ return t["access_token"]
116
+
117
+
118
+ def connected(npub: str) -> bool:
119
+ """True when this Mac has a connection to the operator for ``npub``."""
120
+ try:
121
+ return _stored(npub) is not None
122
+ except keystore.KeystoreError:
123
+ return False
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
  import itertools
16
16
  import json
17
17
  import urllib.request
18
+ from collections.abc import Callable
18
19
 
19
20
  PROTOCOL_VERSION = "2025-06-18"
20
21
 
@@ -53,22 +54,28 @@ def tool_payload(result: dict) -> dict:
53
54
  class Client:
54
55
  """A session with one operator's ``/mcp`` endpoint."""
55
56
 
56
- def __init__(self, base_url: str, timeout: float = 60.0) -> None:
57
+ def __init__(self, base_url: str, timeout: float = 60.0, *, bearer: str = "",
58
+ signer: Callable[[str, str, bytes], str] | None = None) -> None:
59
+ """``bearer`` is this Mac's holder token; ``signer`` signs each request
60
+ with a key (NIP-98) and wins over it. With neither, the call is anonymous."""
57
61
  self.url = base_url.rstrip("/") + "/mcp"
58
62
  self.timeout = timeout
63
+ self.bearer = bearer
64
+ self.signer = signer
59
65
  self.session: str | None = None
60
66
  self._ids = itertools.count(1)
61
67
 
62
68
  def _send(self, message: dict) -> dict:
63
- # The operator refuses a request with no bearer. The agent proves
64
- # itself in its arguments, so at the connection it is anonymous.
69
+ # Identity rides on the connection: a signed request, this Mac's holder
70
+ # token, or (for free tools) an anonymous bearer.
71
+ body = json.dumps(message).encode()
65
72
  headers = {"Content-Type": "application/json",
66
73
  "Accept": "application/json, text/event-stream",
67
- "Authorization": "Bearer anonymous"}
74
+ "Authorization": (self.signer("POST", self.url, body) if self.signer
75
+ else f"Bearer {self.bearer or 'anonymous'}")}
68
76
  if self.session:
69
77
  headers["mcp-session-id"] = self.session
70
- request = urllib.request.Request(
71
- self.url, data=json.dumps(message).encode(), headers=headers, method="POST")
78
+ request = urllib.request.Request(self.url, data=body, headers=headers, method="POST")
72
79
  try:
73
80
  with urllib.request.urlopen(request, timeout=self.timeout) as response:
74
81
  self.session = response.headers.get("mcp-session-id") or self.session
@@ -2,9 +2,9 @@
2
2
 
3
3
  The Mac plays by the operator's rules like any other MCP client. A chart
4
4
  change heard by voice is a call to the operator's own ``chart_show_chart``
5
- (and "read" to ``chart_read_chart``), made with the display owner's ``npub``
6
- and the ``dpop_token`` they earned by answering a Nostr DM - the same
7
- envelope every patron sends. The operator prices it, relays it to the named
5
+ (and "read" to ``chart_read_chart``), made as the display owner's ``npub`` over
6
+ this Mac's holder connection, into which the owner proved that npub by
7
+ answering a Nostr DM. The operator prices it, relays it to the named
8
8
  display (blank "Where?": this Mac, by its agent_id), and that display's relay
9
9
  changes the chart and takes its picture. Nothing here drives the chart.
10
10
 
@@ -17,7 +17,7 @@ while the call finishes on its own and its outcome is logged.
17
17
  When the sign-in has expired the owner hears so, and one renewal runs in the
18
18
  background: a fresh ``chart_request_npub_proof`` DM, then
19
19
  ``chart_receive_npub_proof`` on a bounded, slowing schedule until they answer,
20
- and the new token goes back into the Keychain. The nsec is never asked for.
20
+ which proves the npub into this Mac's connection again. The nsec is never asked for.
21
21
 
22
22
  Stdlib only (the Keychain behind a lazy import), like the relay.
23
23
  """
@@ -28,7 +28,7 @@ import threading
28
28
  import time
29
29
  from collections.abc import Callable
30
30
 
31
- from . import config, keystore, mcpclient, recent, requestlog
31
+ from . import config, holder, keystore, mcpclient, recent, requestlog
32
32
 
33
33
  ERR = "ERR "
34
34
  #: How long a spoken reply waits for an early refusal before handing off.
@@ -39,7 +39,7 @@ READ_SECONDS = 25.0
39
39
  CALL_TIMEOUT = 60.0
40
40
  AS_IS = {"", "as is", "asis"}
41
41
 
42
- #: What the SDK answers when a paid call's dpop_token is missing, unknown or expired.
42
+ #: What the SDK answers when the connection does not (or no longer) prove the npub.
43
43
  PROOF_CODES = frozenset({"proof_missing", "proof_required", "proof_refresh_needed", "proof_invalid"})
44
44
  EXPIRED = ERR + "Your ChartRemotely sign-in expired. Answer the DM on your phone to renew."
45
45
  REASON = "Your Mac's ChartRemotely voice control needs you to sign in again."
@@ -88,10 +88,15 @@ def refusal(payload: dict) -> str | None:
88
88
 
89
89
  # -- the call -----------------------------------------------------------------------
90
90
 
91
- def _call(base: str, tool: str, args: dict) -> dict:
92
- """One tool call. Never raises: an unreachable operator is an ``error`` payload."""
91
+ def _call(base: str, npub: str, tool: str, args: dict) -> dict:
92
+ """One tool call over this Mac's connection for ``npub``. Never raises: an
93
+ unreachable operator is an ``error`` payload."""
93
94
  try:
94
- return mcpclient.Client(base, timeout=CALL_TIMEOUT).call(tool, args)
95
+ return mcpclient.Client(base, timeout=CALL_TIMEOUT, bearer=holder.bearer(base, npub)).call(tool, args)
96
+ except holder.HolderError as exc:
97
+ return {"success": False, "error": str(exc)}
98
+ except keystore.KeystoreError:
99
+ return {"success": False, "error": "The Keychain would not give me your ChartRemotely sign-in."}
95
100
  except mcpclient.McpError as exc:
96
101
  return {"success": False, "error": str(exc)}
97
102
  except Exception as exc: # noqa: BLE001 - a background call must never kill the listener
@@ -117,21 +122,12 @@ def send(command: str, where: str, cfg: dict | None = None) -> str:
117
122
  if isinstance(mapped, str):
118
123
  return mapped
119
124
  tool, args = mapped
120
- try:
121
- token = keystore.load_token(npub)
122
- except keystore.KeystoreError as exc:
123
- requestlog.outcome("serve", tool, wanted, str(exc))
124
- return ERR + "The Keychain would not give me your ChartRemotely sign-in."
125
- if not token:
126
- renew(base, npub)
127
- return EXPIRED
128
-
129
- envelope = {**args, "display": wanted or agent_id, "npub": npub, "dpop_token": token}
125
+ envelope = {**args, "display": wanted or agent_id, "npub": npub}
130
126
  box: dict = {}
131
127
  done = threading.Event()
132
128
 
133
129
  def work() -> None:
134
- payload = _call(base, tool, envelope)
130
+ payload = _call(base, npub, tool, envelope)
135
131
  box["payload"] = payload
136
132
  settle(base, npub, tool, wanted, payload)
137
133
  if tool == "chart_show_chart" and refusal(payload) is None \
@@ -154,7 +150,7 @@ def send(command: str, where: str, cfg: dict | None = None) -> str:
154
150
 
155
151
 
156
152
  def settle(base: str, npub: str, tool: str, where: str, payload: dict) -> None:
157
- """Log how a call ended (no token, no arguments) and renew an expired sign-in."""
153
+ """Log how a call ended (no arguments) and renew an expired sign-in."""
158
154
  if payload.get("error_code") in PROOF_CODES:
159
155
  renew(base, npub)
160
156
  requestlog.outcome("serve", tool, where, refusal(payload))
@@ -175,16 +171,16 @@ def renew(base: str, npub: str, sleep: Callable[[float], None] = time.sleep) ->
175
171
 
176
172
  def _renew(base: str, npub: str, sleep: Callable[[float], None]) -> None:
177
173
  try:
178
- sent = _call(base, "chart_request_npub_proof", {"patron_npub": npub, "reason": REASON})
174
+ sent = _call(base, npub, "chart_request_npub_proof", {"patron_npub": npub, "reason": REASON})
179
175
  phrase = sent.get("dpop_token")
180
176
  if not phrase:
181
177
  requestlog.outcome("serve", "renew", "", refusal(sent) or "no proof request was sent")
182
178
  return
183
179
  for delay in RENEW_DELAYS:
184
180
  sleep(delay)
185
- got = _call(base, "chart_receive_npub_proof", {"patron_npub": npub, "dpop_token": phrase})
181
+ got = _call(base, npub, "chart_receive_npub_proof", {"patron_npub": npub, "dpop_token": phrase})
186
182
  if got.get("success") is not False and not got.get("error"):
187
- keystore.save_token(npub, str(got.get("dpop_token") or phrase))
183
+ # Proven into this Mac's connection; nothing to keep here.
188
184
  requestlog.outcome("serve", "renew", "", None)
189
185
  return
190
186
  if got.get("error_code") not in (NOT_YET, None):
@@ -34,7 +34,7 @@ import tempfile
34
34
  from collections.abc import Callable
35
35
  from pathlib import Path
36
36
 
37
- from . import config, mcpclient, relay
37
+ from . import config, holder, mcpclient, relay
38
38
  from .terminal import Terminal, wants_color
39
39
 
40
40
  OPERATOR_URL = "https://chartremotely-mcp.fastmcp.app"
@@ -51,10 +51,12 @@ class SetupError(RuntimeError):
51
51
 
52
52
  # -- identity -----------------------------------------------------------------
53
53
 
54
- def prove_by_dm(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> str:
55
- """Prove an existing npub by the Secure Courier DM. Returns the dpop_token.
54
+ def prove_by_dm(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> None:
55
+ """Prove an existing npub by the Secure Courier DM, into this Mac's connection.
56
56
 
57
- The human answers from their own Nostr client, so no key passes through here.
57
+ ``client`` carries this Mac's holder token, so the operator keeps the grant
58
+ for this connection. The human answers from their own Nostr client, so no
59
+ key passes through here.
58
60
  """
59
61
  sent = client.call("chart_request_npub_proof", {
60
62
  "patron_npub": npub, "verify_at": SITE,
@@ -71,7 +73,6 @@ def prove_by_dm(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> str:
71
73
  got = client.call("chart_receive_npub_proof", {"patron_npub": npub, "dpop_token": phrase})
72
74
  if got.get("error"):
73
75
  raise SetupError(got["error"])
74
- return got.get("dpop_token") or phrase
75
76
 
76
77
 
77
78
  def new_key() -> tuple[str, str]:
@@ -82,11 +83,11 @@ def new_key() -> tuple[str, str]:
82
83
  return key.public_key.bech32(), key.bech32()
83
84
 
84
85
 
85
- def sign_once(nsec: str, tool: str) -> str:
86
- """One kind-27235 proof for one tool call, signed by the SDK."""
87
- from tollbooth.identity_proof import create_proof
86
+ def signer_for(nsec: str) -> Callable[[str, str, bytes], str]:
87
+ """Signs each request with ``nsec`` (NIP-98), by the SDK."""
88
+ from tollbooth.identity_proof import sign_request
88
89
 
89
- return create_proof(nsec, tool)
90
+ return lambda method, url, body: sign_request(nsec, method=method, url=url, body=body)
90
91
 
91
92
 
92
93
  def keep_for_human(npub: str, nsec: str, ask: Ask, say: Say) -> None:
@@ -106,11 +107,11 @@ def keep_for_human(npub: str, nsec: str, ask: Ask, say: Say) -> None:
106
107
  keystore.clear_if_unchanged(nsec)
107
108
 
108
109
 
109
- def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bool]:
110
+ def identity(base: str, ask: Ask, say: Say) -> tuple[str, mcpclient.Client, bool]:
110
111
  """Who owns this display, proven.
111
112
 
112
- Returns (npub, proof for chart_pair_agent, whether that proof is a DM
113
- sign-in the listener can keep using).
113
+ Returns (npub, a client that acts as that npub for chart_pair_agent, and
114
+ whether this Mac's connection now holds the npub for voice).
114
115
  """
115
116
  say("Your Nostr identity owns this display:")
116
117
  say(" 1 I have an npub (I'll answer a Nostr DM to prove it)")
@@ -121,7 +122,9 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
121
122
  npub = ask("Your npub: ").strip()
122
123
  if not npub.startswith("npub1"):
123
124
  raise SetupError("that is not an npub")
124
- return npub, prove_by_dm(client, npub, ask, say), True
125
+ client = mcpclient.Client(base, bearer=holder.bearer(base, npub))
126
+ prove_by_dm(client, npub, ask, say)
127
+ return npub, client, True
125
128
  if choice == "2":
126
129
  from . import keystore
127
130
 
@@ -133,7 +136,7 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
133
136
  picked = saved[int(ask("Which key? ").strip() or "1") - 1]
134
137
  nsec = keystore.load(picked) # macOS asks the human first
135
138
  try:
136
- return picked, sign_once(nsec, "chart_pair_agent"), False
139
+ return picked, mcpclient.Client(base, signer=signer_for(nsec)), False
137
140
  finally:
138
141
  del nsec
139
142
  if choice == "3":
@@ -141,7 +144,7 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
141
144
  try:
142
145
  say(f"Your new npub is {npub}")
143
146
  keep_for_human(npub, nsec, ask, say)
144
- return npub, sign_once(nsec, "chart_pair_agent"), False
147
+ return npub, mcpclient.Client(base, signer=signer_for(nsec)), False
145
148
  finally:
146
149
  del nsec
147
150
  raise SetupError("choose 1, 2 or 3")
@@ -149,40 +152,38 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
149
152
 
150
153
  # -- the voice sign-in --------------------------------------------------------
151
154
 
152
- def keep_sign_in(npub: str, token: str) -> None:
153
- """The owner's npub into the config; their sign-in into the Keychain, only."""
154
- from . import keystore
155
-
156
- keystore.save_token(npub, token)
155
+ def keep_sign_in(npub: str) -> None:
156
+ """The owner's npub into the config. Their sign-in is this Mac's connection,
157
+ whose tokens the holder keeps in the Keychain."""
157
158
  config.update(owner_npub=npub)
158
159
 
159
160
 
160
161
  def has_sign_in(npub: str) -> bool:
161
- from . import keystore
162
+ return holder.connected(npub)
162
163
 
163
- try:
164
- return bool(keystore.load_token(npub))
165
- except keystore.KeystoreError:
166
- return False
167
164
 
168
-
169
- def voice_sign_in(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> None:
165
+ def voice_sign_in(base: str, npub: str, ask: Ask, say: Say) -> None:
170
166
  """Voice commands are priced tool calls made as the owner: prove the npub by DM for them."""
171
167
  config.update(owner_npub=npub)
172
168
  say(f"Voice commands are paid for by {npub[:12]}…, signed in by a Nostr DM.")
173
169
  if ask("Sign in for voice now? [Y/n] ").strip().lower().startswith("n"):
174
170
  say("Then your first voice command sends the DM.")
175
171
  return
176
- keep_sign_in(npub, prove_by_dm(client, npub, ask, say))
172
+ prove_by_dm(mcpclient.Client(base, bearer=holder.bearer(base, npub)), npub, ask, say)
173
+ keep_sign_in(npub)
177
174
  say("Voice is signed in.")
178
175
 
179
176
 
180
177
  # -- pairing ------------------------------------------------------------------
181
178
 
182
- def pair(client: mcpclient.Client, base: str, npub: str, proof: str, label: str) -> dict:
183
- """Adopt this machine's pairing code with the patron's proof, then collect."""
179
+ def pair(client: mcpclient.Client, base: str, npub: str, label: str) -> dict:
180
+ """Adopt this machine's pairing code as the patron, then collect.
181
+
182
+ ``client`` proves the npub on its connection: signed with their key, or
183
+ this Mac's connection that holds it.
184
+ """
184
185
  code, expires_in = relay.open_code(base)
185
- answer = client.call("chart_pair_agent", {"code": code, "label": label, "npub": npub, "dpop_token": proof})
186
+ answer = client.call("chart_pair_agent", {"code": code, "label": label, "npub": npub})
186
187
  if not answer.get("ok"):
187
188
  raise SetupError(answer.get("error") or "the operator refused the pairing")
188
189
  return relay.collect(base, code, expires_in)
@@ -250,7 +251,6 @@ def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) ->
250
251
 
251
252
  # 1. Identity and pairing, unless this Mac is already paired.
252
253
  out.step(1, total, "Identity")
253
- client = mcpclient.Client(base)
254
254
  if cfg.get("agent_id") and cfg.get("agent_secret"):
255
255
  out.ok(f"This Mac is already paired ({cfg['agent_id']}).")
256
256
  npub = cfg.get("owner_npub") or out.ask("Your npub (whose display this is): ").strip()
@@ -260,18 +260,18 @@ def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) ->
260
260
  config.update(owner_npub=npub)
261
261
  out.ok("Voice is already signed in.")
262
262
  else:
263
- voice_sign_in(client, npub, out.ask, out.say)
263
+ voice_sign_in(base, npub, out.ask, out.say)
264
264
  else:
265
- npub, proof, by_dm = identity(client, out.ask, out.say)
265
+ npub, client, by_dm = identity(base, out.ask, out.say)
266
266
  label = out.ask("Name this display (e.g. Desk, Office wall): ").strip() or "display"
267
- pair(client, base, npub, proof, label)
267
+ pair(client, base, npub, label)
268
268
  out.ok(f"Paired as “{label}”.")
269
269
  if by_dm:
270
- keep_sign_in(npub, proof)
270
+ keep_sign_in(npub)
271
271
  out.ok("Voice is signed in with the same DM.")
272
272
  else:
273
- voice_sign_in(client, npub, out.ask, out.say)
274
- del proof
273
+ voice_sign_in(base, npub, out.ask, out.say)
274
+ del client
275
275
 
276
276
  # 2. Tailscale.
277
277
  from . import tailnet
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -219,7 +219,7 @@ Requires-Dist: pyobjc-framework-Quartz>=10; extra == "macos"
219
219
  Requires-Dist: pyobjc-framework-Security>=10; extra == "macos"
220
220
  Requires-Dist: mlx-whisper>=0.4.3; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "macos"
221
221
  Provides-Extra: setup
222
- Requires-Dist: tollbooth-dpyc[nostr]==0.90.0; extra == "setup"
222
+ Requires-Dist: tollbooth-dpyc[nostr]==0.102.1; extra == "setup"
223
223
  Provides-Extra: dev
224
224
  Requires-Dist: pytest>=8; extra == "dev"
225
225
  Requires-Dist: ruff>=0.6; extra == "dev"
@@ -10,6 +10,7 @@ chartremotely/displays.py
10
10
  chartremotely/doctor.py
11
11
  chartremotely/guilock.py
12
12
  chartremotely/hearing.py
13
+ chartremotely/holder.py
13
14
  chartremotely/keystore.py
14
15
  chartremotely/layout.py
15
16
  chartremotely/mcpclient.py
@@ -13,4 +13,4 @@ pyobjc-framework-Security>=10
13
13
  mlx-whisper>=0.4.3
14
14
 
15
15
  [setup]
16
- tollbooth-dpyc[nostr]==0.90.0
16
+ tollbooth-dpyc[nostr]==0.102.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  description = "Remote control of desktop charting applications on macOS"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -25,7 +25,7 @@ macos = [
25
25
  ]
26
26
  # `chartremotely setup` makes and proves Nostr identities. The key and proof
27
27
  # code is the tollbooth-dpyc SDK's own, never rewritten here.
28
- setup = ["tollbooth-dpyc[nostr]==0.90.0"]
28
+ setup = ["tollbooth-dpyc[nostr]==0.102.1"]
29
29
  dev = ["pytest>=8", "ruff>=0.6"]
30
30
 
31
31
  [project.urls]
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
  import pytest
14
14
  from test_understand import ROWS
15
15
 
16
- from chartremotely import displays, hearing, keystore, shortcut
16
+ from chartremotely import displays, hearing, holder, keystore, shortcut
17
17
 
18
18
  #: A recording big enough to be one: anything under hearing.MIN_BYTES is a bare header.
19
19
  M4A = b"m4a-bytes" * 100
@@ -163,20 +163,21 @@ def test_the_list_is_fetched_as_the_owner_and_cached_without_a_token(monkeypatch
163
163
  calls = []
164
164
 
165
165
  class Client:
166
- def __init__(self, base, timeout):
167
- self.base = base
166
+ def __init__(self, base, timeout, bearer=""):
167
+ self.base, self.bearer = base, bearer
168
168
 
169
169
  def call(self, tool, args):
170
- calls.append((self.base, tool, dict(args)))
170
+ calls.append((self.base, self.bearer, tool, dict(args)))
171
171
  return STATUS
172
172
 
173
- monkeypatch.setattr(keystore, "load_token", lambda npub: "seven-amber-door")
173
+ monkeypatch.setattr(holder, "connected", lambda npub: True)
174
+ monkeypatch.setattr(holder, "bearer", lambda base, npub: "dpyc_ha_conn")
174
175
  cfg = {"operator_url": "https://op.example/", "agent_id": "a1", "owner_npub": "npub1x"}
175
176
  path = tmp_path / "displays.json"
176
177
  assert displays.refresh(cfg, client_for=Client, path=path) == ["Mac mini", "Studio"]
177
- assert calls == [("https://op.example", "chart_agent_status",
178
- {"npub": "npub1x", "dpop_token": "seven-amber-door"})]
179
- assert "seven-amber-door" not in path.read_text()
178
+ # The connection proves the npub; no proof rides in the arguments.
179
+ assert calls == [("https://op.example", "dpyc_ha_conn", "chart_agent_status", {"npub": "npub1x"})]
180
+ assert "dpyc_ha_conn" not in path.read_text()
180
181
  assert displays.load(path) == ["Mac mini", "Studio"]
181
182
  assert not displays.stale(path)
182
183
  assert displays.stale(path, now=lambda: json.loads(path.read_text())["fetched_at"] + displays.MAX_AGE + 1)
@@ -14,11 +14,11 @@ import urllib.request
14
14
 
15
15
  import pytest
16
16
 
17
- from chartremotely import config, keystore, mcpclient, patron
17
+ from chartremotely import config, holder, mcpclient, patron
18
18
  from chartremotely.answer import Answer
19
19
 
20
20
  NPUB = "npub1" + "p" * 58
21
- TOKEN = "seven-amber-door"
21
+ BEARER = "dpyc_ha_this-macs-connection"
22
22
 
23
23
 
24
24
  class FakeOperator:
@@ -33,14 +33,16 @@ class FakeOperator:
33
33
  self.gate = threading.Event()
34
34
  self.gate.set()
35
35
  self.lock = threading.Lock()
36
+ self.bearers = []
36
37
 
37
- def client(self, base, timeout=60.0):
38
+ def client(self, base, timeout=60.0, bearer="", signer=None):
38
39
  operator = self
39
40
 
40
41
  class Client:
41
42
  def call(self, tool, args):
42
43
  with operator.lock:
43
44
  operator.calls.append((tool, dict(args)))
45
+ operator.bearers.append(bearer)
44
46
  if tool not in ("chart_request_npub_proof", "chart_receive_npub_proof"):
45
47
  operator.gate.wait(10)
46
48
  answer = operator.answers[tool]
@@ -62,11 +64,8 @@ def operator(monkeypatch, tmp_path):
62
64
  config.update(operator_url="https://op.test", agent_id="a1", agent_secret="s1", owner_npub=NPUB)
63
65
  fake = FakeOperator()
64
66
  monkeypatch.setattr(mcpclient, "Client", fake.client)
65
- vault = {NPUB: TOKEN}
66
- monkeypatch.setattr(keystore, "load_token", lambda npub: vault.get(npub))
67
- monkeypatch.setattr(keystore, "save_token", lambda npub, token: vault.__setitem__(npub, token))
67
+ monkeypatch.setattr(holder, "bearer", lambda base, npub: BEARER)
68
68
  monkeypatch.setattr(patron, "RENEW_DELAYS", (0.0,) * 5)
69
- fake.vault = vault
70
69
  yield fake
71
70
  fake.gate.set()
72
71
  _wait_for_renewal()
@@ -84,7 +83,9 @@ def test_a_set_calls_chart_show_chart_as_the_owner_with_this_macs_id_when_where_
84
83
  said = patron.send("set PLTR | half", "")
85
84
  assert said == "Chart PLTR at half scale sent to this Mac. Good luck."
86
85
  assert operator.calls == [("chart_show_chart", {
87
- "security": "PLTR", "scale": "half", "display": "a1", "npub": NPUB, "dpop_token": TOKEN})]
86
+ "security": "PLTR", "scale": "half", "display": "a1", "npub": NPUB})]
87
+ # The connection proves the npub; no proof rides in the arguments.
88
+ assert operator.bearers == [BEARER]
88
89
 
89
90
 
90
91
  def test_a_named_display_goes_as_said_and_the_hand_off_names_it(operator):
@@ -175,25 +176,26 @@ def test_an_expired_sign_in_is_spoken_and_renewed_once(operator, monkeypatch):
175
176
  assert operator.tools().count("chart_receive_npub_proof") == 3
176
177
  request = next(a for t, a in operator.calls if t == "chart_request_npub_proof")
177
178
  assert request["patron_npub"] == NPUB and "nsec" not in json.dumps(request).lower()
178
- assert operator.vault[NPUB] == "fresh-code"
179
+ # The renewal proves the npub into the same connection.
180
+ assert set(operator.bearers) == {BEARER}
179
181
 
180
182
 
181
- def test_no_sign_in_yet_starts_the_dm_without_calling_the_paid_tool(operator):
182
- operator.vault.clear()
183
- assert patron.send("set PLTR | half", "") == patron.EXPIRED
184
- _wait_for_renewal()
185
- assert "chart_show_chart" not in operator.tools()
186
- assert operator.vault[NPUB] == "fresh-code"
183
+ def test_a_mac_the_operator_will_not_connect_says_why(operator, monkeypatch):
184
+ def refused(base, npub):
185
+ raise holder.HolderError("the operator refused (503)")
186
+
187
+ monkeypatch.setattr(holder, "bearer", refused)
188
+ assert patron.send("read", "") == patron.ERR + "the operator refused (503)"
189
+ assert operator.tools() == []
187
190
 
188
191
 
189
192
  def test_a_renewal_that_is_refused_stops_rather_than_polling_on(operator):
190
- operator.vault.clear()
193
+ operator.answers["chart_show_chart"] = {"success": False, "error_code": "proof_required", "error": "no"}
191
194
  operator.answers["chart_receive_npub_proof"] = [{"success": False, "error_code": "courier_expired",
192
195
  "error": "that request expired"}]
193
196
  patron.send("set PLTR | half", "")
194
197
  _wait_for_renewal()
195
198
  assert operator.tools().count("chart_receive_npub_proof") == 1
196
- assert NPUB not in operator.vault
197
199
 
198
200
 
199
201
  def test_the_sign_in_is_never_logged_or_written_to_the_config(operator, capsys, monkeypatch):
@@ -204,9 +206,9 @@ def test_the_sign_in_is_never_logged_or_written_to_the_config(operator, capsys,
204
206
  _wait_for_renewal()
205
207
  time.sleep(0.1)
206
208
  logged = capsys.readouterr().err
207
- assert TOKEN not in logged and "fresh-code" not in logged and "PLTR" not in logged
209
+ assert BEARER not in logged and "fresh-code" not in logged and "PLTR" not in logged
208
210
  stored = config.CONFIG_PATH.read_text()
209
- assert TOKEN not in stored and "fresh-code" not in stored
211
+ assert BEARER not in stored and "fresh-code" not in stored
210
212
  assert json.loads(stored)["owner_npub"] == NPUB
211
213
 
212
214
 
@@ -13,7 +13,7 @@ import types
13
13
 
14
14
  import pytest
15
15
 
16
- from chartremotely import config, keystore, mcpclient, relay, services, setup, shortcut
16
+ from chartremotely import config, holder, keystore, mcpclient, relay, services, setup, shortcut
17
17
 
18
18
  NSEC = "nsec1" + "q" * 58
19
19
  NPUB = "npub1" + "p" * 58
@@ -202,8 +202,8 @@ def test_an_existing_npub_is_proven_by_dm_and_no_key_is_asked_for():
202
202
  client = StubClient({"chart_request_npub_proof": {"dpop_token": "seven-amber-door"},
203
203
  "chart_receive_npub_proof": {"success": True, "dpop_token": "seven-amber-door"}})
204
204
  asked, said = [], []
205
- token = setup.prove_by_dm(client, NPUB, lambda q: asked.append(q) or "", said.append)
206
- assert token == "seven-amber-door"
205
+ # Proven into the connection `client` carries: nothing comes back to keep.
206
+ assert setup.prove_by_dm(client, NPUB, lambda q: asked.append(q) or "", said.append) is None
207
207
  assert [t for t, _ in client.calls] == ["chart_request_npub_proof", "chart_receive_npub_proof"]
208
208
  assert any("seven-amber-door" in s for s in said), "the human sees the code to compare"
209
209
  assert any("cache_duration" in s and "unlimited" in s for s in said), "a longer sign-in is offered"
@@ -224,9 +224,9 @@ def test_setup_adopts_its_own_pairing_code(monkeypatch):
224
224
  collected = []
225
225
  monkeypatch.setattr(relay, "collect", lambda base, code, exp: collected.append(code) or {"agent_id": "a1"})
226
226
  client = StubClient({"chart_pair_agent": {"ok": True, "display": "Desk", "agent_id": "a1"}})
227
- setup.pair(client, "https://op.test", NPUB, "PROOF", "Desk")
228
- assert client.calls == [("chart_pair_agent", {"code": "ABC234", "label": "Desk", "npub": NPUB,
229
- "dpop_token": "PROOF"})]
227
+ setup.pair(client, "https://op.test", NPUB, "Desk")
228
+ # The client proves the npub on its connection; no proof rides in the arguments.
229
+ assert client.calls == [("chart_pair_agent", {"code": "ABC234", "label": "Desk", "npub": NPUB})]
230
230
  assert collected == ["ABC234"]
231
231
 
232
232
 
@@ -234,7 +234,7 @@ def test_a_refused_pairing_says_why(monkeypatch):
234
234
  monkeypatch.setattr(relay, "open_code", lambda base: ("ABC234", 900.0))
235
235
  client = StubClient({"chart_pair_agent": {"success": False, "error": "prove it"}})
236
236
  with pytest.raises(setup.SetupError, match="prove it"):
237
- setup.pair(client, "https://op.test", NPUB, "PROOF", "Desk")
237
+ setup.pair(client, "https://op.test", NPUB, "Desk")
238
238
 
239
239
 
240
240
  # -- the whole run never keeps the key -------------------------------------------
@@ -243,13 +243,15 @@ def test_a_made_key_never_reaches_the_agents_config(monkeypatch, tmp_path):
243
243
  monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
244
244
  monkeypatch.setattr(config, "CONFIG_PATH", tmp_path / "config.json")
245
245
  monkeypatch.setattr(setup, "new_key", lambda: (NPUB, NSEC))
246
- monkeypatch.setattr(setup, "sign_once", lambda nsec, tool: f"proof-for-{tool}")
246
+ signers = []
247
+ monkeypatch.setattr(setup, "signer_for", lambda nsec: signers.append(nsec) or "signs-with-the-key")
247
248
  kept = []
248
249
  monkeypatch.setattr(setup, "keep_for_human", lambda npub, nsec, ask, say: kept.append(npub))
249
250
  monkeypatch.setattr(relay, "open_code", lambda base: ("ABC234", 900.0))
250
251
  monkeypatch.setattr(relay, "collect", lambda base, code, exp: config.update(
251
252
  operator_url=base, agent_id="a1", agent_secret="agent-secret"))
252
- monkeypatch.setattr(mcpclient, "Client", lambda base: StubClient(
253
+ clients = []
254
+ monkeypatch.setattr(mcpclient, "Client", lambda base, **kw: clients.append(kw) or StubClient(
253
255
  {"chart_pair_agent": {"ok": True, "display": "Desk", "agent_id": "a1"}}))
254
256
 
255
257
  from chartremotely import tailnet
@@ -269,7 +271,8 @@ def test_a_made_key_never_reaches_the_agents_config(monkeypatch, tmp_path):
269
271
  assert setup.run(ask=lambda q: next(answers), say=said.append) == 0
270
272
 
271
273
  stored = (tmp_path / "config.json").read_text()
272
- assert NSEC not in stored and "proof-for" not in stored
274
+ assert NSEC not in stored and "signs-with-the-key" not in stored
275
+ assert signers == [NSEC] and clients[0] == {"signer": "signs-with-the-key"}, "pairing is signed with the key"
273
276
  assert json.loads(stored)["agent_id"] == "a1"
274
277
  assert kept == [NPUB], "the made key is handed to the human's Keychain"
275
278
  assert built == ["https://mac.example.ts.net/chart"]
@@ -289,7 +292,7 @@ def _stand_in_the_mac(monkeypatch, tmp_path, answers):
289
292
  monkeypatch.setattr(relay, "open_code", lambda base: ("ABC234", 900.0))
290
293
  monkeypatch.setattr(relay, "collect", lambda base, code, exp: config.update(
291
294
  operator_url=base, agent_id="a1", agent_secret="agent-secret"))
292
- monkeypatch.setattr(mcpclient, "Client", lambda base: StubClient(answers))
295
+ monkeypatch.setattr(mcpclient, "Client", lambda base, **kw: StubClient(answers))
293
296
  from chartremotely import tailnet
294
297
  monkeypatch.setattr(tailnet, "status", lambda: {"Self": {"DNSName": "mac.example.ts.net."}})
295
298
  monkeypatch.setattr(tailnet, "serve", lambda port: None)
@@ -301,7 +304,12 @@ def _stand_in_the_mac(monkeypatch, tmp_path, answers):
301
304
  monkeypatch.setattr(setup.subprocess, "run", lambda *a, **k: None)
302
305
  _no_hearing(monkeypatch)
303
306
  vault = {}
304
- monkeypatch.setattr(keystore, "save_token", lambda npub, token: vault.__setitem__(npub, token))
307
+
308
+ def connect(base, npub): # this Mac's holder connection, kept in the Keychain
309
+ vault[npub] = '{"refresh_token": "r"}'
310
+ return "dpyc_ha_conn"
311
+
312
+ monkeypatch.setattr(holder, "bearer", connect)
305
313
  monkeypatch.setattr(keystore, "load_token", lambda npub: vault.get(npub))
306
314
  return vault
307
315
 
@@ -314,9 +322,9 @@ def test_a_dm_proven_owner_keeps_the_sign_in_in_the_keychain_not_the_config(monk
314
322
  answers = iter(["", "1", NPUB, "", "Desk"])
315
323
  said = []
316
324
  assert setup.run(ask=lambda q: next(answers), say=said.append) == 0
317
- assert vault == {NPUB: "seven-amber-door"}
325
+ assert set(vault) == {NPUB}, "the connection that holds the npub is kept in the Keychain"
318
326
  stored = (tmp_path / "config.json").read_text()
319
- assert "seven-amber-door" not in stored
327
+ assert "refresh_token" not in stored and "dpyc_ha_conn" not in stored
320
328
  assert json.loads(stored)["owner_npub"] == NPUB
321
329
 
322
330
 
@@ -327,8 +335,8 @@ def test_an_already_paired_mac_is_signed_in_for_voice_by_dm(monkeypatch, tmp_pat
327
335
  config.update(operator_url="https://op.test", agent_id="a1", agent_secret="agent-secret")
328
336
  answers = iter(["", NPUB, "y", ""])
329
337
  assert setup.run(ask=lambda q: next(answers), say=lambda s: None) == 0
330
- assert vault == {NPUB: "seven-amber-door"}
331
- assert "seven-amber-door" not in (tmp_path / "config.json").read_text()
338
+ assert set(vault) == {NPUB}
339
+ assert "refresh_token" not in (tmp_path / "config.json").read_text()
332
340
 
333
341
 
334
342
  def test_setup_removes_a_retired_ask_shortcut_and_names_any_it_could_not(monkeypatch, tmp_path):
File without changes
File without changes
File without changes