chartremotely 0.3.1__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.1 → chartremotely-0.3.3}/PKG-INFO +25 -16
- {chartremotely-0.3.1 → chartremotely-0.3.3}/README.md +23 -14
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/displays.py +5 -5
- chartremotely-0.3.3/chartremotely/holder.py +123 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/mcpclient.py +14 -4
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/patron.py +20 -24
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/setup.py +38 -38
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely.egg-info/PKG-INFO +25 -16
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely.egg-info/SOURCES.txt +2 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely.egg-info/requires.txt +1 -1
- {chartremotely-0.3.1 → chartremotely-0.3.3}/pyproject.toml +2 -2
- chartremotely-0.3.3/tests/test_changelog.py +85 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_hearing.py +9 -8
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_patron.py +21 -19
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_setup.py +24 -16
- {chartremotely-0.3.1 → chartremotely-0.3.3}/LICENSE +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/__init__.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/answer.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/assets/shortcut.plist +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/ax.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/cli.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/config.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/doctor.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/guilock.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/hearing.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/keystore.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/layout.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/popups.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/push.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/recent.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/registry.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/relay.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/requestlog.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/resolve.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/scales.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/screen.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/server.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/services.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/studies.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/symbol.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/terminal.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/understand.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/vocab.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/voice.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely/window.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/setup.cfg +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_popups.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_push.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_recent.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_relay.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_requestlog.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_resolve.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_scales.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_screen.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_snapshot.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_stamp_changelog.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_timeframe.py +0 -0
- {chartremotely-0.3.1 → chartremotely-0.3.3}/tests/test_understand.py +0 -0
- {chartremotely-0.3.1 → 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"
|
|
@@ -305,6 +305,7 @@ chartremotely serve run the local listener
|
|
|
305
305
|
chartremotely pair adopt this display to an operator
|
|
306
306
|
chartremotely relay hold a connection open for the operator
|
|
307
307
|
chartremotely doctor check everything the agent needs
|
|
308
|
+
chartremotely token print the local listener's access token
|
|
308
309
|
```
|
|
309
310
|
|
|
310
311
|
`serve` and `relay` are two different transports and most setups want both.
|
|
@@ -421,19 +422,23 @@ curl -fsSL https://chartremotely.tollbooth-dpyc.com/install.sh | sh
|
|
|
421
422
|
|
|
422
423
|
That installs [uv](https://docs.astral.sh/uv/) if it is missing, installs this
|
|
423
424
|
agent from PyPI (`uv tool install --python 3.12 'chartremotely[macos,setup]'`),
|
|
424
|
-
and runs `chartremotely setup
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
425
|
+
and runs `chartremotely setup`. Setup first says what is installing — version,
|
|
426
|
+
where it came from, the Python it runs on — lists its six steps, and asks
|
|
427
|
+
before changing anything:
|
|
428
|
+
|
|
429
|
+
1. **Identity** — proves who owns the display (your existing npub by answering
|
|
430
|
+
a Nostr DM, a key already saved on this Mac, or a new key it makes and saves
|
|
431
|
+
for you in the Keychain and, through Safari, in iCloud Passwords), then
|
|
432
|
+
pairs this Mac with that identity, with no code to copy;
|
|
433
|
+
2. **Tailscale** — gives the Mac its tailnet address with `tailscale serve`;
|
|
434
|
+
3. **Services** — installs the listener and the relay as launchd agents;
|
|
435
|
+
4. **Permissions** — asks macOS for Accessibility and Screen Recording for the
|
|
436
|
+
Python that runs them;
|
|
437
|
+
5. **Hearing** — fetches the Whisper speech model (about 1.6 GB, once) and
|
|
438
|
+
your display names;
|
|
439
|
+
6. **Shortcut** — makes this Mac's Shortcut, "ChartRemotely" (talk, or type
|
|
440
|
+
when it asks), opens it for import, and removes a "ChartRemotely Ask" an
|
|
441
|
+
older setup left in your library.
|
|
437
442
|
|
|
438
443
|
Run it again at any time: finished steps are skipped. `chartremotely doctor`
|
|
439
444
|
reports anything still missing.
|
|
@@ -473,7 +478,11 @@ switch to a grid - discovery follows, and takes about a second.
|
|
|
473
478
|
|
|
474
479
|
## Status
|
|
475
480
|
|
|
476
|
-
|
|
481
|
+
In service. Releases are published to PyPI and installed with the one-line
|
|
482
|
+
installer above; a paired Mac takes commands by voice, from the web app at
|
|
483
|
+
<https://chartremotely.tollbooth-dpyc.com>, and from any MCP client through the
|
|
484
|
+
operator ([ChartRemotely-mcp](https://github.com/lonniev/ChartRemotely-mcp)).
|
|
485
|
+
thinkorswim is the one charting application it drives today.
|
|
477
486
|
|
|
478
487
|
## License
|
|
479
488
|
|
|
@@ -78,6 +78,7 @@ chartremotely serve run the local listener
|
|
|
78
78
|
chartremotely pair adopt this display to an operator
|
|
79
79
|
chartremotely relay hold a connection open for the operator
|
|
80
80
|
chartremotely doctor check everything the agent needs
|
|
81
|
+
chartremotely token print the local listener's access token
|
|
81
82
|
```
|
|
82
83
|
|
|
83
84
|
`serve` and `relay` are two different transports and most setups want both.
|
|
@@ -194,19 +195,23 @@ curl -fsSL https://chartremotely.tollbooth-dpyc.com/install.sh | sh
|
|
|
194
195
|
|
|
195
196
|
That installs [uv](https://docs.astral.sh/uv/) if it is missing, installs this
|
|
196
197
|
agent from PyPI (`uv tool install --python 3.12 'chartremotely[macos,setup]'`),
|
|
197
|
-
and runs `chartremotely setup
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
198
|
+
and runs `chartremotely setup`. Setup first says what is installing — version,
|
|
199
|
+
where it came from, the Python it runs on — lists its six steps, and asks
|
|
200
|
+
before changing anything:
|
|
201
|
+
|
|
202
|
+
1. **Identity** — proves who owns the display (your existing npub by answering
|
|
203
|
+
a Nostr DM, a key already saved on this Mac, or a new key it makes and saves
|
|
204
|
+
for you in the Keychain and, through Safari, in iCloud Passwords), then
|
|
205
|
+
pairs this Mac with that identity, with no code to copy;
|
|
206
|
+
2. **Tailscale** — gives the Mac its tailnet address with `tailscale serve`;
|
|
207
|
+
3. **Services** — installs the listener and the relay as launchd agents;
|
|
208
|
+
4. **Permissions** — asks macOS for Accessibility and Screen Recording for the
|
|
209
|
+
Python that runs them;
|
|
210
|
+
5. **Hearing** — fetches the Whisper speech model (about 1.6 GB, once) and
|
|
211
|
+
your display names;
|
|
212
|
+
6. **Shortcut** — makes this Mac's Shortcut, "ChartRemotely" (talk, or type
|
|
213
|
+
when it asks), opens it for import, and removes a "ChartRemotely Ask" an
|
|
214
|
+
older setup left in your library.
|
|
210
215
|
|
|
211
216
|
Run it again at any time: finished steps are skipped. `chartremotely doctor`
|
|
212
217
|
reports anything still missing.
|
|
@@ -246,7 +251,11 @@ switch to a grid - discovery follows, and takes about a second.
|
|
|
246
251
|
|
|
247
252
|
## Status
|
|
248
253
|
|
|
249
|
-
|
|
254
|
+
In service. Releases are published to PyPI and installed with the one-line
|
|
255
|
+
installer above; a paired Mac takes commands by voice, from the web app at
|
|
256
|
+
<https://chartremotely.tollbooth-dpyc.com>, and from any MCP client through the
|
|
257
|
+
operator ([ChartRemotely-mcp](https://github.com/lonniev/ChartRemotely-mcp)).
|
|
258
|
+
thinkorswim is the one charting application it drives today.
|
|
250
259
|
|
|
251
260
|
## License
|
|
252
261
|
|
|
@@ -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,19 +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:
|
|
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()
|
|
63
72
|
headers = {"Content-Type": "application/json",
|
|
64
|
-
"Accept": "application/json, text/event-stream"
|
|
73
|
+
"Accept": "application/json, text/event-stream",
|
|
74
|
+
"Authorization": (self.signer("POST", self.url, body) if self.signer
|
|
75
|
+
else f"Bearer {self.bearer or 'anonymous'}")}
|
|
65
76
|
if self.session:
|
|
66
77
|
headers["mcp-session-id"] = self.session
|
|
67
|
-
request = urllib.request.Request(
|
|
68
|
-
self.url, data=json.dumps(message).encode(), headers=headers, method="POST")
|
|
78
|
+
request = urllib.request.Request(self.url, data=body, headers=headers, method="POST")
|
|
69
79
|
try:
|
|
70
80
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
71
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
|