chartremotely 0.3.2__tar.gz → 0.3.4__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.4}/PKG-INFO +2 -2
  2. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/config.py +1 -1
  3. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/displays.py +5 -5
  4. chartremotely-0.3.4/chartremotely/holder.py +123 -0
  5. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/keystore.py +2 -2
  6. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/mcpclient.py +13 -6
  7. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/patron.py +21 -25
  8. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/setup.py +44 -43
  9. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely.egg-info/PKG-INFO +2 -2
  10. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely.egg-info/SOURCES.txt +1 -0
  11. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely.egg-info/requires.txt +1 -1
  12. {chartremotely-0.3.2 → chartremotely-0.3.4}/pyproject.toml +2 -2
  13. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_hearing.py +9 -8
  14. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_patron.py +24 -22
  15. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_setup.py +33 -25
  16. {chartremotely-0.3.2 → chartremotely-0.3.4}/LICENSE +0 -0
  17. {chartremotely-0.3.2 → chartremotely-0.3.4}/README.md +0 -0
  18. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/__init__.py +0 -0
  19. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/answer.py +0 -0
  20. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/assets/shortcut.plist +0 -0
  21. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/ax.py +0 -0
  22. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/cli.py +0 -0
  23. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/doctor.py +0 -0
  24. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/guilock.py +0 -0
  25. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/hearing.py +0 -0
  26. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/layout.py +0 -0
  27. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/popups.py +0 -0
  28. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/push.py +0 -0
  29. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/recent.py +0 -0
  30. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/registry.py +0 -0
  31. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/relay.py +0 -0
  32. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/requestlog.py +0 -0
  33. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/resolve.py +0 -0
  34. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/scales.py +0 -0
  35. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/screen.py +0 -0
  36. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/server.py +0 -0
  37. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/services.py +0 -0
  38. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/shortcut.py +0 -0
  39. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/snapshot.py +0 -0
  40. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/studies.py +0 -0
  41. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/symbol.py +0 -0
  42. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/tailnet.py +0 -0
  43. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/terminal.py +0 -0
  44. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/timeframe.py +0 -0
  45. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/understand.py +0 -0
  46. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/vocab.py +0 -0
  47. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/voice.py +0 -0
  48. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely/window.py +0 -0
  49. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely.egg-info/dependency_links.txt +0 -0
  50. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely.egg-info/entry_points.txt +0 -0
  51. {chartremotely-0.3.2 → chartremotely-0.3.4}/chartremotely.egg-info/top_level.txt +0 -0
  52. {chartremotely-0.3.2 → chartremotely-0.3.4}/setup.cfg +0 -0
  53. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_changelog.py +0 -0
  54. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_popups.py +0 -0
  55. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_push.py +0 -0
  56. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_recent.py +0 -0
  57. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_relay.py +0 -0
  58. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_requestlog.py +0 -0
  59. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_resolve.py +0 -0
  60. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_scales.py +0 -0
  61. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_screen.py +0 -0
  62. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_snapshot.py +0 -0
  63. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_stamp_changelog.py +0 -0
  64. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_timeframe.py +0 -0
  65. {chartremotely-0.3.2 → chartremotely-0.3.4}/tests/test_understand.py +0 -0
  66. {chartremotely-0.3.2 → chartremotely-0.3.4}/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.4
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.103.0; extra == "setup"
223
223
  Provides-Extra: dev
224
224
  Requires-Dist: pytest>=8; extra == "dev"
225
225
  Requires-Dist: ruff>=0.6; extra == "dev"
@@ -35,7 +35,7 @@ DEFAULTS: dict = {
35
35
  "agent_id": None,
36
36
  "agent_secret": None,
37
37
  # Whose display this is: the npub voice commands are billed to. Its
38
- # sign-in (the dpop_token) lives in the login Keychain, never here.
38
+ # sign-in (this Mac's connection tokens) lives in the login Keychain, never here.
39
39
  "owner_npub": None,
40
40
  "token": None,
41
41
  "port": 8899,
@@ -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
@@ -1,8 +1,8 @@
1
1
  """Keep a patron's secrets in their login Keychain, never in this agent's files.
2
2
 
3
3
  Two kinds, never mixed: the Nostr key ``setup`` may make for the human, and
4
- the voice sign-in - the ``dpop_token`` the operator returns once the owner
5
- has proven their npub by DM - which the listener passes on each priced call.
4
+ the voice sign-in - this Mac's OAuth tokens on the operator, whose connection
5
+ holds the owner's npub once they have proven it by DM.
6
6
 
7
7
  A key made by ``setup`` belongs to the human. It goes into their login
8
8
  Keychain as an internet password for the ChartRemotely site, and it is
@@ -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})
179
- phrase = sent.get("dpop_token")
174
+ sent = _call(base, npub, "chart_request_npub_proof", {"patron_npub": npub, "reason": REASON})
175
+ phrase = sent.get("nonce")
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, "nonce": 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):
@@ -10,9 +10,10 @@ what is already done, so running setup again is always safe:
10
10
  A made key is kept for the human - their Keychain, then Safari's saved
11
11
  passwords - and used once, in memory, to sign the pairing. It is never
12
12
  written to this agent's config. Then **pairing**, without a code to copy: setup adopts this machine's code itself.
13
- and **voice sign-in**: the ``dpop_token`` from the owner's DM proof, kept in
14
- the login Keychain (their npub goes in the config), so the listener can
15
- call the operator's priced tools as that patron.
13
+ and **voice sign-in**: this Mac's connection to the operator, into which the
14
+ owner's DM proof puts their npub; its OAuth tokens are kept in the login
15
+ Keychain (their npub goes in the config), so the listener can call the
16
+ operator's priced tools as that patron.
16
17
  2. **Tailscale**: the Mac's tailnet address, with ``/chart`` forwarded to the agent.
17
18
  3. **Services**: the listener and the relay, under launchd.
18
19
  4. **Permissions**: Accessibility and Screen Recording, asked for by the
@@ -34,7 +35,7 @@ import tempfile
34
35
  from collections.abc import Callable
35
36
  from pathlib import Path
36
37
 
37
- from . import config, mcpclient, relay
38
+ from . import config, holder, mcpclient, relay
38
39
  from .terminal import Terminal, wants_color
39
40
 
40
41
  OPERATOR_URL = "https://chartremotely-mcp.fastmcp.app"
@@ -51,15 +52,17 @@ class SetupError(RuntimeError):
51
52
 
52
53
  # -- identity -----------------------------------------------------------------
53
54
 
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.
55
+ def prove_by_dm(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> None:
56
+ """Prove an existing npub by the Secure Courier DM, into this Mac's connection.
56
57
 
57
- The human answers from their own Nostr client, so no key passes through here.
58
+ ``client`` carries this Mac's holder token, so the operator keeps the grant
59
+ for this connection. The human answers from their own Nostr client, so no
60
+ key passes through here.
58
61
  """
59
62
  sent = client.call("chart_request_npub_proof", {
60
63
  "patron_npub": npub, "verify_at": SITE,
61
64
  "reason": "You asked to pair a Mac with ChartRemotely."})
62
- phrase = sent.get("dpop_token")
65
+ phrase = sent.get("nonce")
63
66
  if not phrase:
64
67
  raise SetupError(sent.get("error") or "the operator did not send a proof request")
65
68
  say(f"A Nostr DM is on its way to {npub[:12]}…")
@@ -68,10 +71,9 @@ def prove_by_dm(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> str:
68
71
  say("may set cache_duration (30 days, or unlimited) so voice keeps working that")
69
72
  say("long; left alone, the sign-in lasts 2 hours.")
70
73
  ask("Press Return once you have replied… ")
71
- got = client.call("chart_receive_npub_proof", {"patron_npub": npub, "dpop_token": phrase})
74
+ got = client.call("chart_receive_npub_proof", {"patron_npub": npub, "nonce": phrase})
72
75
  if got.get("error"):
73
76
  raise SetupError(got["error"])
74
- return got.get("dpop_token") or phrase
75
77
 
76
78
 
77
79
  def new_key() -> tuple[str, str]:
@@ -82,11 +84,11 @@ def new_key() -> tuple[str, str]:
82
84
  return key.public_key.bech32(), key.bech32()
83
85
 
84
86
 
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
87
+ def signer_for(nsec: str) -> Callable[[str, str, bytes], str]:
88
+ """Signs each request with ``nsec`` (NIP-98), by the SDK."""
89
+ from tollbooth.identity_proof import sign_request
88
90
 
89
- return create_proof(nsec, tool)
91
+ return lambda method, url, body: sign_request(nsec, method=method, url=url, body=body)
90
92
 
91
93
 
92
94
  def keep_for_human(npub: str, nsec: str, ask: Ask, say: Say) -> None:
@@ -106,11 +108,11 @@ def keep_for_human(npub: str, nsec: str, ask: Ask, say: Say) -> None:
106
108
  keystore.clear_if_unchanged(nsec)
107
109
 
108
110
 
109
- def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bool]:
111
+ def identity(base: str, ask: Ask, say: Say) -> tuple[str, mcpclient.Client, bool]:
110
112
  """Who owns this display, proven.
111
113
 
112
- Returns (npub, proof for chart_pair_agent, whether that proof is a DM
113
- sign-in the listener can keep using).
114
+ Returns (npub, a client that acts as that npub for chart_pair_agent, and
115
+ whether this Mac's connection now holds the npub for voice).
114
116
  """
115
117
  say("Your Nostr identity owns this display:")
116
118
  say(" 1 I have an npub (I'll answer a Nostr DM to prove it)")
@@ -121,7 +123,9 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
121
123
  npub = ask("Your npub: ").strip()
122
124
  if not npub.startswith("npub1"):
123
125
  raise SetupError("that is not an npub")
124
- return npub, prove_by_dm(client, npub, ask, say), True
126
+ client = mcpclient.Client(base, bearer=holder.bearer(base, npub))
127
+ prove_by_dm(client, npub, ask, say)
128
+ return npub, client, True
125
129
  if choice == "2":
126
130
  from . import keystore
127
131
 
@@ -133,7 +137,7 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
133
137
  picked = saved[int(ask("Which key? ").strip() or "1") - 1]
134
138
  nsec = keystore.load(picked) # macOS asks the human first
135
139
  try:
136
- return picked, sign_once(nsec, "chart_pair_agent"), False
140
+ return picked, mcpclient.Client(base, signer=signer_for(nsec)), False
137
141
  finally:
138
142
  del nsec
139
143
  if choice == "3":
@@ -141,7 +145,7 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
141
145
  try:
142
146
  say(f"Your new npub is {npub}")
143
147
  keep_for_human(npub, nsec, ask, say)
144
- return npub, sign_once(nsec, "chart_pair_agent"), False
148
+ return npub, mcpclient.Client(base, signer=signer_for(nsec)), False
145
149
  finally:
146
150
  del nsec
147
151
  raise SetupError("choose 1, 2 or 3")
@@ -149,40 +153,38 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bo
149
153
 
150
154
  # -- the voice sign-in --------------------------------------------------------
151
155
 
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)
156
+ def keep_sign_in(npub: str) -> None:
157
+ """The owner's npub into the config. Their sign-in is this Mac's connection,
158
+ whose tokens the holder keeps in the Keychain."""
157
159
  config.update(owner_npub=npub)
158
160
 
159
161
 
160
162
  def has_sign_in(npub: str) -> bool:
161
- from . import keystore
163
+ return holder.connected(npub)
162
164
 
163
- try:
164
- return bool(keystore.load_token(npub))
165
- except keystore.KeystoreError:
166
- return False
167
165
 
168
-
169
- def voice_sign_in(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> None:
166
+ def voice_sign_in(base: str, npub: str, ask: Ask, say: Say) -> None:
170
167
  """Voice commands are priced tool calls made as the owner: prove the npub by DM for them."""
171
168
  config.update(owner_npub=npub)
172
169
  say(f"Voice commands are paid for by {npub[:12]}…, signed in by a Nostr DM.")
173
170
  if ask("Sign in for voice now? [Y/n] ").strip().lower().startswith("n"):
174
171
  say("Then your first voice command sends the DM.")
175
172
  return
176
- keep_sign_in(npub, prove_by_dm(client, npub, ask, say))
173
+ prove_by_dm(mcpclient.Client(base, bearer=holder.bearer(base, npub)), npub, ask, say)
174
+ keep_sign_in(npub)
177
175
  say("Voice is signed in.")
178
176
 
179
177
 
180
178
  # -- pairing ------------------------------------------------------------------
181
179
 
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."""
180
+ def pair(client: mcpclient.Client, base: str, npub: str, label: str) -> dict:
181
+ """Adopt this machine's pairing code as the patron, then collect.
182
+
183
+ ``client`` proves the npub on its connection: signed with their key, or
184
+ this Mac's connection that holds it.
185
+ """
184
186
  code, expires_in = relay.open_code(base)
185
- answer = client.call("chart_pair_agent", {"code": code, "label": label, "npub": npub, "dpop_token": proof})
187
+ answer = client.call("chart_pair_agent", {"code": code, "label": label, "npub": npub})
186
188
  if not answer.get("ok"):
187
189
  raise SetupError(answer.get("error") or "the operator refused the pairing")
188
190
  return relay.collect(base, code, expires_in)
@@ -250,7 +252,6 @@ def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) ->
250
252
 
251
253
  # 1. Identity and pairing, unless this Mac is already paired.
252
254
  out.step(1, total, "Identity")
253
- client = mcpclient.Client(base)
254
255
  if cfg.get("agent_id") and cfg.get("agent_secret"):
255
256
  out.ok(f"This Mac is already paired ({cfg['agent_id']}).")
256
257
  npub = cfg.get("owner_npub") or out.ask("Your npub (whose display this is): ").strip()
@@ -260,18 +261,18 @@ def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) ->
260
261
  config.update(owner_npub=npub)
261
262
  out.ok("Voice is already signed in.")
262
263
  else:
263
- voice_sign_in(client, npub, out.ask, out.say)
264
+ voice_sign_in(base, npub, out.ask, out.say)
264
265
  else:
265
- npub, proof, by_dm = identity(client, out.ask, out.say)
266
+ npub, client, by_dm = identity(base, out.ask, out.say)
266
267
  label = out.ask("Name this display (e.g. Desk, Office wall): ").strip() or "display"
267
- pair(client, base, npub, proof, label)
268
+ pair(client, base, npub, label)
268
269
  out.ok(f"Paired as “{label}”.")
269
270
  if by_dm:
270
- keep_sign_in(npub, proof)
271
+ keep_sign_in(npub)
271
272
  out.ok("Voice is signed in with the same DM.")
272
273
  else:
273
- voice_sign_in(client, npub, out.ask, out.say)
274
- del proof
274
+ voice_sign_in(base, npub, out.ask, out.say)
275
+ del client
275
276
 
276
277
  # 2. Tailscale.
277
278
  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.4
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.103.0; 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.103.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.3.2"
3
+ version = "0.3.4"
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.103.0"]
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:
@@ -28,19 +28,21 @@ class FakeOperator:
28
28
  self.calls = []
29
29
  self.answers = {"chart_show_chart": {"ok": True, "display": "Desk", "result": "Showing PLTR"},
30
30
  "chart_read_chart": {"ok": True, "display": "Desk", "result": "PLTR at daily"},
31
- "chart_request_npub_proof": {"success": True, "dpop_token": "fresh-code"},
32
- "chart_receive_npub_proof": [{"success": True, "dpop_token": "fresh-code"}]}
31
+ "chart_request_npub_proof": {"success": True, "nonce": "fresh-code"},
32
+ "chart_receive_npub_proof": [{"success": True, "nonce": "fresh-code"}]}
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):
@@ -167,7 +168,7 @@ def test_an_expired_sign_in_is_spoken_and_renewed_once(operator, monkeypatch):
167
168
  operator.answers["chart_receive_npub_proof"] = [
168
169
  {"success": False, "error_code": "courier_not_found", "error": "No reply found"},
169
170
  {"success": False, "error_code": "courier_not_found", "error": "No reply found"},
170
- {"success": True, "dpop_token": "fresh-code"}]
171
+ {"success": True, "nonce": "fresh-code"}]
171
172
  assert patron.send("set PLTR | half", "") == patron.EXPIRED
172
173
  assert patron.send("set PLTR | half", "") == patron.EXPIRED
173
174
  _wait_for_renewal()
@@ -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
@@ -199,11 +199,11 @@ class StubClient:
199
199
 
200
200
 
201
201
  def test_an_existing_npub_is_proven_by_dm_and_no_key_is_asked_for():
202
- client = StubClient({"chart_request_npub_proof": {"dpop_token": "seven-amber-door"},
203
- "chart_receive_npub_proof": {"success": True, "dpop_token": "seven-amber-door"}})
202
+ client = StubClient({"chart_request_npub_proof": {"nonce": "seven-amber-door"},
203
+ "chart_receive_npub_proof": {"success": True, "nonce": "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"
@@ -211,7 +211,7 @@ def test_an_existing_npub_is_proven_by_dm_and_no_key_is_asked_for():
211
211
 
212
212
 
213
213
  def test_a_dm_that_was_not_answered_stops_setup():
214
- client = StubClient({"chart_request_npub_proof": {"dpop_token": "x"},
214
+ client = StubClient({"chart_request_npub_proof": {"nonce": "x"},
215
215
  "chart_receive_npub_proof": {"error": "no reply yet"}})
216
216
  with pytest.raises(setup.SetupError, match="no reply yet"):
217
217
  setup.prove_by_dm(client, NPUB, lambda q: "", lambda s: None)
@@ -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,40 +304,45 @@ 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
 
308
316
 
309
317
  def test_a_dm_proven_owner_keeps_the_sign_in_in_the_keychain_not_the_config(monkeypatch, tmp_path):
310
318
  vault = _stand_in_the_mac(monkeypatch, tmp_path, {
311
- "chart_request_npub_proof": {"dpop_token": "seven-amber-door"},
312
- "chart_receive_npub_proof": {"success": True, "dpop_token": "seven-amber-door"},
319
+ "chart_request_npub_proof": {"nonce": "seven-amber-door"},
320
+ "chart_receive_npub_proof": {"success": True, "nonce": "seven-amber-door"},
313
321
  "chart_pair_agent": {"ok": True, "display": "Desk", "agent_id": "a1"}})
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
 
323
331
  def test_an_already_paired_mac_is_signed_in_for_voice_by_dm(monkeypatch, tmp_path):
324
332
  vault = _stand_in_the_mac(monkeypatch, tmp_path, {
325
- "chart_request_npub_proof": {"dpop_token": "seven-amber-door"},
326
- "chart_receive_npub_proof": {"success": True, "dpop_token": "seven-amber-door"}})
333
+ "chart_request_npub_proof": {"nonce": "seven-amber-door"},
334
+ "chart_receive_npub_proof": {"success": True, "nonce": "seven-amber-door"}})
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):
335
343
  _stand_in_the_mac(monkeypatch, tmp_path, {
336
- "chart_request_npub_proof": {"dpop_token": "seven-amber-door"},
337
- "chart_receive_npub_proof": {"success": True, "dpop_token": "seven-amber-door"}})
344
+ "chart_request_npub_proof": {"nonce": "seven-amber-door"},
345
+ "chart_receive_npub_proof": {"success": True, "nonce": "seven-amber-door"}})
338
346
  config.update(operator_url="https://op.test", agent_id="a1", agent_secret="agent-secret")
339
347
  monkeypatch.setattr(shortcut, "remove_retired", lambda: (["ChartRemotely Ask"], ["ChartRemotely Ask 2"]))
340
348
  answers = iter(["", NPUB, "y", ""])
File without changes
File without changes
File without changes