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.
- {chartremotely-0.3.2 → chartremotely-0.3.3}/PKG-INFO +2 -2
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/displays.py +5 -5
- chartremotely-0.3.3/chartremotely/holder.py +123 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/mcpclient.py +13 -6
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/patron.py +20 -24
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/setup.py +38 -38
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely.egg-info/PKG-INFO +2 -2
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely.egg-info/SOURCES.txt +1 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely.egg-info/requires.txt +1 -1
- {chartremotely-0.3.2 → chartremotely-0.3.3}/pyproject.toml +2 -2
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_hearing.py +9 -8
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_patron.py +21 -19
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_setup.py +24 -16
- {chartremotely-0.3.2 → chartremotely-0.3.3}/LICENSE +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/README.md +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/__init__.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/answer.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/assets/shortcut.plist +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/ax.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/cli.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/config.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/doctor.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/guilock.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/hearing.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/keystore.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/layout.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/popups.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/push.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/recent.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/registry.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/relay.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/requestlog.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/resolve.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/scales.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/screen.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/server.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/services.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/studies.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/symbol.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/terminal.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/understand.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/vocab.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/voice.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely/window.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/setup.cfg +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_changelog.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_popups.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_push.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_recent.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_relay.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_requestlog.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_resolve.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_scales.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_screen.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_snapshot.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_stamp_changelog.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_timeframe.py +0 -0
- {chartremotely-0.3.2 → chartremotely-0.3.3}/tests/test_understand.py +0 -0
- {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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
#
|
|
64
|
-
#
|
|
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": "
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) ->
|
|
55
|
-
"""Prove an existing npub by the Secure Courier DM
|
|
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
|
-
|
|
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
|
|
86
|
-
"""
|
|
87
|
-
from tollbooth.identity_proof import
|
|
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
|
|
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(
|
|
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,
|
|
113
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
153
|
-
"""The owner's npub into the config
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
183
|
-
"""Adopt this machine's pairing code
|
|
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
|
|
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(
|
|
263
|
+
voice_sign_in(base, npub, out.ask, out.say)
|
|
264
264
|
else:
|
|
265
|
-
npub,
|
|
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,
|
|
267
|
+
pair(client, base, npub, label)
|
|
268
268
|
out.ok(f"Paired as “{label}”.")
|
|
269
269
|
if by_dm:
|
|
270
|
-
keep_sign_in(npub
|
|
270
|
+
keep_sign_in(npub)
|
|
271
271
|
out.ok("Voice is signed in with the same DM.")
|
|
272
272
|
else:
|
|
273
|
-
voice_sign_in(
|
|
274
|
-
del
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "chartremotely"
|
|
3
|
-
version = "0.3.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
assert "
|
|
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,
|
|
17
|
+
from chartremotely import config, holder, mcpclient, patron
|
|
18
18
|
from chartremotely.answer import Answer
|
|
19
19
|
|
|
20
20
|
NPUB = "npub1" + "p" * 58
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
179
|
+
# The renewal proves the npub into the same connection.
|
|
180
|
+
assert set(operator.bearers) == {BEARER}
|
|
179
181
|
|
|
180
182
|
|
|
181
|
-
def
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
assert
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
206
|
-
assert
|
|
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, "
|
|
228
|
-
|
|
229
|
-
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
|
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 "
|
|
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
|
|
331
|
-
assert "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|