chartremotely 0.2.0__py3-none-any.whl
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/__init__.py +0 -0
- chartremotely/assets/shortcut-template.plist +1226 -0
- chartremotely/ax.py +278 -0
- chartremotely/cli.py +136 -0
- chartremotely/config.py +68 -0
- chartremotely/doctor.py +118 -0
- chartremotely/keystore.py +101 -0
- chartremotely/layout.py +106 -0
- chartremotely/mcpclient.py +98 -0
- chartremotely/push.py +58 -0
- chartremotely/registry.py +53 -0
- chartremotely/relay.py +145 -0
- chartremotely/resolve.py +207 -0
- chartremotely/scales.py +77 -0
- chartremotely/server.py +69 -0
- chartremotely/services.py +116 -0
- chartremotely/setup.py +202 -0
- chartremotely/shortcut.py +56 -0
- chartremotely/snapshot.py +88 -0
- chartremotely/studies.py +265 -0
- chartremotely/symbol.py +160 -0
- chartremotely/tailnet.py +51 -0
- chartremotely/timeframe.py +210 -0
- chartremotely/vocab.py +140 -0
- chartremotely/window.py +134 -0
- chartremotely-0.2.0.dist-info/METADATA +381 -0
- chartremotely-0.2.0.dist-info/RECORD +31 -0
- chartremotely-0.2.0.dist-info/WHEEL +5 -0
- chartremotely-0.2.0.dist-info/entry_points.txt +2 -0
- chartremotely-0.2.0.dist-info/licenses/LICENSE +202 -0
- chartremotely-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Keep a patron's Nostr key for the patron, never for this agent.
|
|
2
|
+
|
|
3
|
+
A key made by ``setup`` belongs to the human. It goes into their login
|
|
4
|
+
Keychain as an internet password for the ChartRemotely site, and it is
|
|
5
|
+
handed to Safari once, through the clipboard, so Safari can save it to
|
|
6
|
+
iCloud Passwords where their other devices and apps find it. A command-line
|
|
7
|
+
tool cannot write a synchronizable Keychain item itself (that needs an
|
|
8
|
+
entitled app: ``errSecMissingEntitlement``), which is why Safari does the
|
|
9
|
+
syncing.
|
|
10
|
+
|
|
11
|
+
Nothing here writes the key to the agent's config, a log, a URL or a
|
|
12
|
+
command line. It travels as ``kSecValueData`` or on a pipe, and nowhere else.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import subprocess
|
|
18
|
+
import threading
|
|
19
|
+
|
|
20
|
+
SERVER = "chartremotely.tollbooth-dpyc.com"
|
|
21
|
+
LABEL = "ChartRemotely — Nostr key"
|
|
22
|
+
CLIPBOARD_SECONDS = 60
|
|
23
|
+
|
|
24
|
+
ERR_DUPLICATE = -25299
|
|
25
|
+
ERR_NOT_FOUND = -25300
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class KeystoreError(RuntimeError):
|
|
29
|
+
"""The Keychain refused, and says why by its status code."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _security():
|
|
33
|
+
import Security # lazy: macOS only
|
|
34
|
+
|
|
35
|
+
return Security
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def item_query(npub: str) -> dict:
|
|
39
|
+
"""The attributes that name one saved key. The secret is never among them."""
|
|
40
|
+
S = _security()
|
|
41
|
+
return {S.kSecClass: S.kSecClassInternetPassword,
|
|
42
|
+
S.kSecAttrServer: SERVER,
|
|
43
|
+
S.kSecAttrAccount: npub}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def save(npub: str, nsec: str) -> None:
|
|
47
|
+
"""Store (or replace) the patron's key in their login Keychain."""
|
|
48
|
+
S = _security()
|
|
49
|
+
secret = nsec.encode()
|
|
50
|
+
status = S.SecItemAdd({**item_query(npub), S.kSecAttrLabel: LABEL, S.kSecValueData: secret}, None)[0]
|
|
51
|
+
if status == ERR_DUPLICATE:
|
|
52
|
+
status = S.SecItemUpdate(item_query(npub), {S.kSecValueData: secret})
|
|
53
|
+
if status != 0:
|
|
54
|
+
raise KeystoreError(f"the Keychain refused to save the key (status {status})")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def saved_npubs() -> list[str]:
|
|
58
|
+
"""npubs with a key saved for this site. Reads names only, never secrets."""
|
|
59
|
+
S = _security()
|
|
60
|
+
query = {S.kSecClass: S.kSecClassInternetPassword, S.kSecAttrServer: SERVER,
|
|
61
|
+
S.kSecReturnAttributes: True, S.kSecMatchLimit: S.kSecMatchLimitAll}
|
|
62
|
+
status, found = S.SecItemCopyMatching(query, None)
|
|
63
|
+
if status == ERR_NOT_FOUND or not found:
|
|
64
|
+
return []
|
|
65
|
+
if status != 0:
|
|
66
|
+
raise KeystoreError(f"the Keychain could not be read (status {status})")
|
|
67
|
+
return sorted(str(item.get(S.kSecAttrAccount) or "") for item in found if item.get(S.kSecAttrAccount))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load(npub: str) -> str:
|
|
71
|
+
"""The saved key for one npub. macOS asks the human before it answers."""
|
|
72
|
+
S = _security()
|
|
73
|
+
status, data = S.SecItemCopyMatching({**item_query(npub), S.kSecReturnData: True}, None)
|
|
74
|
+
if status != 0 or data is None:
|
|
75
|
+
raise KeystoreError(f"no key saved for {npub} (status {status})")
|
|
76
|
+
return bytes(data).decode()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# -- the clipboard, for Safari's Save password prompt --------------------------
|
|
80
|
+
|
|
81
|
+
def copy(secret: str) -> None:
|
|
82
|
+
"""Put a secret on the clipboard over a pipe, never on a command line."""
|
|
83
|
+
subprocess.run(["pbcopy"], input=secret.encode(), check=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def clear_if_unchanged(secret: str) -> bool:
|
|
87
|
+
"""Empty the clipboard, but only if it still holds this secret."""
|
|
88
|
+
current = subprocess.run(["pbpaste"], capture_output=True, check=False).stdout.decode()
|
|
89
|
+
if current != secret:
|
|
90
|
+
return False
|
|
91
|
+
subprocess.run(["pbcopy"], input=b"", check=True)
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def copy_briefly(secret: str, seconds: float = CLIPBOARD_SECONDS) -> threading.Timer:
|
|
96
|
+
"""Copy a secret and clear it again after ``seconds``, unless something replaced it."""
|
|
97
|
+
copy(secret)
|
|
98
|
+
timer = threading.Timer(seconds, clear_if_unchanged, args=(secret,))
|
|
99
|
+
timer.daemon = True
|
|
100
|
+
timer.start()
|
|
101
|
+
return timer
|
chartremotely/layout.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Find controls without assuming where on screen anything lives.
|
|
2
|
+
|
|
3
|
+
thinkorswim layouts differ per user, and one user's layout differs through
|
|
4
|
+
the day - charts get rearranged, panes resized, grids switched. Anything
|
|
5
|
+
that sweeps a fixed screen region works on the machine it was written on
|
|
6
|
+
and nowhere else.
|
|
7
|
+
|
|
8
|
+
The accessibility tree is no help on its own: the chart's controls are not
|
|
9
|
+
reachable through ``AXChildren``. The combo box holding the symbol reports
|
|
10
|
+
the window as its parent, yet the window does not list it as a child. The
|
|
11
|
+
link exists upward but not downward, so hit-testing is the only way in.
|
|
12
|
+
|
|
13
|
+
What the tree *does* expose reliably is the container layout - the split
|
|
14
|
+
groups and tab groups that tile the window. So: ask the tree where the
|
|
15
|
+
panes are, then hit-test relative to each pane's own bounds. Move the chart
|
|
16
|
+
anywhere and the search follows it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from . import ax
|
|
22
|
+
|
|
23
|
+
# Panes smaller than this cannot be a chart.
|
|
24
|
+
MIN_PANE_W, MIN_PANE_H = 400, 300
|
|
25
|
+
|
|
26
|
+
# Charting apps put the symbol entry and the toolbar in a band at the top of
|
|
27
|
+
# the pane. Searching that band first is an optimisation, not an assumption:
|
|
28
|
+
# discovery falls back to the whole pane.
|
|
29
|
+
BAND_HEIGHT = 140
|
|
30
|
+
STEP_X, STEP_Y = 12, 6
|
|
31
|
+
|
|
32
|
+
CONTAINER_ROLES = ("AXTabGroup", "AXSplitGroup", "AXScrollArea", "AXGroup")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def panes(ax_app, window_prefix: str) -> list[tuple[int, int, int, int]]:
|
|
36
|
+
"""Candidate chart panes as (x, y, w, h), largest first.
|
|
37
|
+
|
|
38
|
+
Derived entirely from what the tree reports, so it holds wherever the
|
|
39
|
+
user has dragged things.
|
|
40
|
+
"""
|
|
41
|
+
window = ax.window(ax_app, window_prefix)
|
|
42
|
+
if window is None:
|
|
43
|
+
return []
|
|
44
|
+
found: set[tuple[int, int, int, int]] = set()
|
|
45
|
+
|
|
46
|
+
def visit(element):
|
|
47
|
+
if ax.attr(element, "AXRole") not in CONTAINER_ROLES:
|
|
48
|
+
return
|
|
49
|
+
p, s = ax.position(element), ax.size(element)
|
|
50
|
+
if p and s and s.width >= MIN_PANE_W and s.height >= MIN_PANE_H:
|
|
51
|
+
found.add((int(p.x), int(p.y), int(s.width), int(s.height)))
|
|
52
|
+
|
|
53
|
+
ax.walk(window, visit, budget=[400000])
|
|
54
|
+
# Whole-window fallback: a layout may expose no qualifying container.
|
|
55
|
+
p, s = ax.position(window), ax.size(window)
|
|
56
|
+
if p and s:
|
|
57
|
+
found.add((int(p.x), int(p.y), int(s.width), int(s.height)))
|
|
58
|
+
return sorted(found, key=lambda r: -(r[2] * r[3]))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def scan(ax_app, rect, matches, *, band: int | None = BAND_HEIGHT,
|
|
62
|
+
step_x: int = STEP_X, step_y: int = STEP_Y) -> tuple[int, int] | None:
|
|
63
|
+
"""Hit-test inside a rect until ``matches(element)`` is true.
|
|
64
|
+
|
|
65
|
+
Coordinates are derived from the rect, never hardcoded. ``band`` limits
|
|
66
|
+
the search to the top of the pane; pass None to sweep all of it.
|
|
67
|
+
"""
|
|
68
|
+
x0, y0, w, h = rect
|
|
69
|
+
height = min(band, h) if band else h
|
|
70
|
+
for y in range(y0 + 2, y0 + height, step_y):
|
|
71
|
+
for x in range(x0 + 2, x0 + w, step_x):
|
|
72
|
+
if matches(ax.element_at(ax_app, x, y)):
|
|
73
|
+
return x, y
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def scan_all(ax_app, rect, matches, *, band: int | None = BAND_HEIGHT,
|
|
78
|
+
step_x: int = STEP_X, step_y: int = STEP_Y) -> list[tuple[int, int]]:
|
|
79
|
+
"""Every distinct match in a rect, de-duplicated by element bounds.
|
|
80
|
+
|
|
81
|
+
Used where a window may hold several charts: each gets its own hit.
|
|
82
|
+
"""
|
|
83
|
+
x0, y0, w, h = rect
|
|
84
|
+
height = min(band, h) if band else h
|
|
85
|
+
hits: list[tuple[int, int]] = []
|
|
86
|
+
seen: set[tuple[int, int]] = set()
|
|
87
|
+
for y in range(y0 + 2, y0 + height, step_y):
|
|
88
|
+
for x in range(x0 + 2, x0 + w, step_x):
|
|
89
|
+
element = ax.element_at(ax_app, x, y)
|
|
90
|
+
if not matches(element):
|
|
91
|
+
continue
|
|
92
|
+
p = ax.position(element)
|
|
93
|
+
key = (int(p.x), int(p.y)) if p else (x, y)
|
|
94
|
+
if key not in seen:
|
|
95
|
+
seen.add(key)
|
|
96
|
+
hits.append((x, y))
|
|
97
|
+
return hits
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def find_in_panes(ax_app, window_prefix: str, matches, *, band: int | None = BAND_HEIGHT):
|
|
101
|
+
"""Search each pane in turn, largest first. Returns (x, y) or None."""
|
|
102
|
+
for rect in panes(ax_app, window_prefix):
|
|
103
|
+
hit = scan(ax_app, rect, matches, band=band)
|
|
104
|
+
if hit:
|
|
105
|
+
return hit
|
|
106
|
+
return None
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Just enough of an MCP client to call three operator tools.
|
|
2
|
+
|
|
3
|
+
``setup`` proves who the patron is and pairs this machine, which means
|
|
4
|
+
calling ``chart_request_npub_proof``, ``chart_receive_npub_proof`` and
|
|
5
|
+
``chart_pair_agent``. A full MCP SDK would be the only reason this agent
|
|
6
|
+
grew a dependency tree, so this speaks the streamable-HTTP transport
|
|
7
|
+
directly: initialize, the initialized notification, then ``tools/call``,
|
|
8
|
+
reading JSON or server-sent-event answers and carrying the session header.
|
|
9
|
+
|
|
10
|
+
Stdlib only, importable anywhere.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import itertools
|
|
16
|
+
import json
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class McpError(RuntimeError):
|
|
23
|
+
"""The operator could not be reached, or answered with an error."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_body(raw: str) -> dict:
|
|
27
|
+
"""One JSON-RPC message, from a JSON body or the last SSE ``data:`` line."""
|
|
28
|
+
raw = raw.strip()
|
|
29
|
+
if not raw:
|
|
30
|
+
return {}
|
|
31
|
+
if raw.startswith("{"):
|
|
32
|
+
return json.loads(raw)
|
|
33
|
+
data = [line[5:].strip() for line in raw.splitlines() if line.startswith("data:")]
|
|
34
|
+
if not data:
|
|
35
|
+
raise McpError("the operator sent an answer this client cannot read")
|
|
36
|
+
return json.loads(data[-1])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def tool_payload(result: dict) -> dict:
|
|
40
|
+
"""What a tool answered: its structured content, else its first text block as JSON."""
|
|
41
|
+
if isinstance(result.get("structuredContent"), dict):
|
|
42
|
+
return result["structuredContent"]
|
|
43
|
+
for block in result.get("content") or []:
|
|
44
|
+
if block.get("type") == "text":
|
|
45
|
+
try:
|
|
46
|
+
parsed = json.loads(block.get("text") or "")
|
|
47
|
+
except ValueError:
|
|
48
|
+
return {"text": block.get("text")}
|
|
49
|
+
return parsed if isinstance(parsed, dict) else {"value": parsed}
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Client:
|
|
54
|
+
"""A session with one operator's ``/mcp`` endpoint."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, base_url: str, timeout: float = 60.0) -> None:
|
|
57
|
+
self.url = base_url.rstrip("/") + "/mcp"
|
|
58
|
+
self.timeout = timeout
|
|
59
|
+
self.session: str | None = None
|
|
60
|
+
self._ids = itertools.count(1)
|
|
61
|
+
|
|
62
|
+
def _send(self, message: dict) -> dict:
|
|
63
|
+
headers = {"Content-Type": "application/json",
|
|
64
|
+
"Accept": "application/json, text/event-stream"}
|
|
65
|
+
if self.session:
|
|
66
|
+
headers["mcp-session-id"] = self.session
|
|
67
|
+
request = urllib.request.Request(
|
|
68
|
+
self.url, data=json.dumps(message).encode(), headers=headers, method="POST")
|
|
69
|
+
try:
|
|
70
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
71
|
+
self.session = response.headers.get("mcp-session-id") or self.session
|
|
72
|
+
return parse_body(response.read().decode("utf-8", "replace"))
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
raise McpError(f"could not reach the operator: {exc}") from None
|
|
75
|
+
|
|
76
|
+
def _start(self) -> None:
|
|
77
|
+
if self.session:
|
|
78
|
+
return
|
|
79
|
+
answer = self._send({
|
|
80
|
+
"jsonrpc": "2.0", "id": next(self._ids), "method": "initialize",
|
|
81
|
+
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
|
|
82
|
+
"clientInfo": {"name": "chartremotely-setup", "version": "1"}}})
|
|
83
|
+
if "error" in answer:
|
|
84
|
+
raise McpError(str(answer["error"].get("message", answer["error"])))
|
|
85
|
+
self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
86
|
+
|
|
87
|
+
def call(self, tool: str, arguments: dict) -> dict:
|
|
88
|
+
"""Call a tool and return what it answered. Raises McpError on a protocol error."""
|
|
89
|
+
self._start()
|
|
90
|
+
answer = self._send({"jsonrpc": "2.0", "id": next(self._ids), "method": "tools/call",
|
|
91
|
+
"params": {"name": tool, "arguments": arguments}})
|
|
92
|
+
if "error" in answer:
|
|
93
|
+
raise McpError(str(answer["error"].get("message", answer["error"])))
|
|
94
|
+
result = answer.get("result") or {}
|
|
95
|
+
payload = tool_payload(result)
|
|
96
|
+
if result.get("isError"):
|
|
97
|
+
raise McpError(payload.get("text") or json.dumps(payload) or "the tool failed")
|
|
98
|
+
return payload
|
chartremotely/push.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Tell the operator what the chart looks like after it changes.
|
|
2
|
+
|
|
3
|
+
A chart changed by voice goes straight to this machine over the tailnet, so
|
|
4
|
+
the operator never hears of it. Right after a change succeeds, one picture of
|
|
5
|
+
the chart pane is pushed up so the patron's browser can offer it. The operator
|
|
6
|
+
keeps only the newest, encrypted, for an hour.
|
|
7
|
+
|
|
8
|
+
Off the voice path on purpose: the push runs in the background after the
|
|
9
|
+
answer is already on its way, and any failure is swallowed. The chart has
|
|
10
|
+
already changed; a missing picture must never turn into a spoken error.
|
|
11
|
+
|
|
12
|
+
Stdlib only at module scope, like the relay: the capture is imported lazily.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
from . import config, relay
|
|
21
|
+
|
|
22
|
+
#: Time for the chart to redraw before its picture is taken.
|
|
23
|
+
SETTLE_SECONDS = 1.0
|
|
24
|
+
|
|
25
|
+
_lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def payload(cfg: dict, image: str) -> tuple[str, dict] | None:
|
|
29
|
+
"""Where to send a picture and what to send, or None when not paired."""
|
|
30
|
+
base = (cfg.get("operator_url") or "").rstrip("/")
|
|
31
|
+
agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
|
|
32
|
+
if not (base and agent_id and secret):
|
|
33
|
+
return None
|
|
34
|
+
return f"{base}/agent/snapshot", {"agent_id": agent_id, "secret": secret, "image": image}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def after_change(reply: str) -> str:
|
|
38
|
+
"""Start a picture push if the change worked. Returns the reply unchanged."""
|
|
39
|
+
if not reply.startswith("ERR"):
|
|
40
|
+
threading.Thread(target=push_now, name="push-latest", daemon=True).start()
|
|
41
|
+
return reply
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def push_now() -> None:
|
|
45
|
+
"""Take the chart's picture and send it. Never raises."""
|
|
46
|
+
try:
|
|
47
|
+
cfg = config.load()
|
|
48
|
+
if payload(cfg, "") is None:
|
|
49
|
+
return
|
|
50
|
+
# One at a time: two quick changes should not capture over each other.
|
|
51
|
+
with _lock:
|
|
52
|
+
time.sleep(SETTLE_SECONDS)
|
|
53
|
+
from . import snapshot, window
|
|
54
|
+
image = snapshot.as_reply(snapshot.shrink(window.capture()))
|
|
55
|
+
url, body = payload(cfg, image)
|
|
56
|
+
relay._post(url, body, timeout=30)
|
|
57
|
+
except Exception: # noqa: BLE001, S110 - deliberate: see the module docstring
|
|
58
|
+
pass
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The SEC company registry, cached locally.
|
|
2
|
+
|
|
3
|
+
Roughly 10,500 US issuers including ETFs. Ordered by prominence in the
|
|
4
|
+
source file, which makes the index a usable tie-breaker: NVDA, AAPL and
|
|
5
|
+
GOOGL are the first three entries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
from .config import DATA_DIR
|
|
15
|
+
|
|
16
|
+
SEC_URL = "https://www.sec.gov/files/company_tickers.json"
|
|
17
|
+
CACHE = DATA_DIR / "tickers.json"
|
|
18
|
+
MAX_AGE = 7 * 24 * 3600
|
|
19
|
+
# The SEC requires a User-Agent carrying a contact address and answers 403
|
|
20
|
+
# without one. A repository URL alone is not enough.
|
|
21
|
+
DEFAULT_CONTACT = "chartremotely@example.com"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _user_agent() -> str:
|
|
25
|
+
from .config import load
|
|
26
|
+
contact = load().get("contact") or DEFAULT_CONTACT
|
|
27
|
+
# SEC's documented format is plain "Name email". Anything fancier is
|
|
28
|
+
# rejected: a User-Agent carrying a URL answers 403, as does one with no
|
|
29
|
+
# contact address at all.
|
|
30
|
+
return f"ChartRemotely {contact}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fresh() -> bool:
|
|
34
|
+
return CACHE.exists() and time.time() - CACHE.stat().st_mtime < MAX_AGE
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def refresh() -> list[dict]:
|
|
38
|
+
request = urllib.request.Request(SEC_URL, headers={"User-Agent": _user_agent()})
|
|
39
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
40
|
+
raw = json.load(response)
|
|
41
|
+
rows = [{"t": v["ticker"], "n": v["title"], "r": rank}
|
|
42
|
+
for rank, v in enumerate(raw.values())]
|
|
43
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
tmp = CACHE.with_suffix(".tmp")
|
|
45
|
+
tmp.write_text(json.dumps(rows))
|
|
46
|
+
tmp.replace(CACHE)
|
|
47
|
+
return rows
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load(force: bool = False) -> list[dict]:
|
|
51
|
+
if force or not _fresh():
|
|
52
|
+
return refresh()
|
|
53
|
+
return json.loads(CACHE.read_text())
|
chartremotely/relay.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Talk to the operator, so a display can be driven from anywhere.
|
|
2
|
+
|
|
3
|
+
The agent dials OUT and holds a poll open. Nothing here ever listens on a
|
|
4
|
+
public port and the operator never connects to this machine: no Funnel, no
|
|
5
|
+
port forwarding, no firewall rules, and nothing on the patron's network
|
|
6
|
+
becomes reachable. That single choice is why pairing is safe to offer to
|
|
7
|
+
someone who is not a network engineer.
|
|
8
|
+
|
|
9
|
+
Stdlib only, on purpose. This runs on a machine whose owner granted
|
|
10
|
+
Accessibility permission to software that types into a trading
|
|
11
|
+
application - the dependency list is part of what they are auditing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
|
|
21
|
+
from . import config
|
|
22
|
+
|
|
23
|
+
#: Poll window. The operator holds a request open for slightly less than
|
|
24
|
+
#: this, so a timeout here means the network dropped it, not that the
|
|
25
|
+
#: operator is idle.
|
|
26
|
+
POLL_TIMEOUT = 35.0
|
|
27
|
+
CLAIM_INTERVAL = 3.0
|
|
28
|
+
BACKOFF_MAX = 60.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PairingError(RuntimeError):
|
|
32
|
+
"""Pairing could not be completed."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def execute(command: str) -> str:
|
|
36
|
+
"""Hand a relayed command to the agent's own vocabulary.
|
|
37
|
+
|
|
38
|
+
Imported lazily and wrapped here for one reason: the wire logic in this
|
|
39
|
+
module is pure, and importing vocab at module scope would drag the macOS
|
|
40
|
+
drivers in with it - making the whole file unimportable anywhere the
|
|
41
|
+
drivers are not installed, CI included.
|
|
42
|
+
"""
|
|
43
|
+
from .vocab import dispatch
|
|
44
|
+
return dispatch(command)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _post(url: str, payload: dict, timeout: float) -> dict:
|
|
48
|
+
body = json.dumps(payload).encode()
|
|
49
|
+
request = urllib.request.Request(
|
|
50
|
+
url, data=body, method="POST",
|
|
51
|
+
headers={"Content-Type": "application/json"})
|
|
52
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
53
|
+
raw = response.read().decode("utf-8", "replace")
|
|
54
|
+
return json.loads(raw) if raw.strip() else {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def open_code(base: str) -> tuple[str, float]:
|
|
58
|
+
"""Ask the operator for a pairing code. Returns the code and its lifetime in seconds."""
|
|
59
|
+
opened = _post(f"{base}/agent/open", {}, timeout=20)
|
|
60
|
+
code = opened.get("code")
|
|
61
|
+
if not code:
|
|
62
|
+
raise PairingError("the operator did not issue a pairing code")
|
|
63
|
+
return code, float(opened.get("expires_in") or 900)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def collect(base: str, code: str, expires_in: float) -> dict:
|
|
67
|
+
"""Wait until someone adopts the code, then keep the identity it earns."""
|
|
68
|
+
deadline = time.time() + expires_in
|
|
69
|
+
while time.time() < deadline:
|
|
70
|
+
time.sleep(CLAIM_INTERVAL)
|
|
71
|
+
try:
|
|
72
|
+
claimed = _post(f"{base}/agent/collect", {"code": code}, timeout=45)
|
|
73
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
74
|
+
# A read timeout is TimeoutError, not URLError - catching only
|
|
75
|
+
# the latter turned a slow first response into a hard failure.
|
|
76
|
+
# The code stays valid, and collect is idempotent, so retrying
|
|
77
|
+
# is always safe.
|
|
78
|
+
continue
|
|
79
|
+
if claimed.get("paired"):
|
|
80
|
+
return config.update(operator_url=base,
|
|
81
|
+
agent_id=claimed["agent_id"],
|
|
82
|
+
agent_secret=claimed["secret"])
|
|
83
|
+
raise PairingError("the pairing code expired before anyone claimed it")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def operator_base(operator_url: str | None) -> str:
|
|
87
|
+
base = (operator_url or config.load().get("operator_url") or "").rstrip("/")
|
|
88
|
+
if not base:
|
|
89
|
+
raise PairingError("no operator URL; pass one or set operator_url in config")
|
|
90
|
+
return base
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def pair(operator_url: str | None = None, on_code=print) -> dict:
|
|
94
|
+
"""Ask the operator for a code, show it, and wait to be adopted.
|
|
95
|
+
|
|
96
|
+
The patron reads the code to their MCP client, which calls
|
|
97
|
+
``pair_agent``. ``setup`` takes the same path and adopts the code itself.
|
|
98
|
+
This machine hands out nothing: it proves it is theirs and receives an
|
|
99
|
+
identity in return.
|
|
100
|
+
"""
|
|
101
|
+
base = operator_base(operator_url)
|
|
102
|
+
code, expires_in = open_code(base)
|
|
103
|
+
on_code(code)
|
|
104
|
+
return collect(base, code, expires_in)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run(operator_url: str | None = None, once: bool = False) -> None:
|
|
108
|
+
"""Hold a poll open, execute what arrives, report the result.
|
|
109
|
+
|
|
110
|
+
Commands are handed straight to the agent's own vocabulary. The
|
|
111
|
+
operator relays; this machine decides what is permitted, which is what
|
|
112
|
+
keeps a compromised operator from widening the surface.
|
|
113
|
+
"""
|
|
114
|
+
cfg = config.load()
|
|
115
|
+
base = (operator_url or cfg.get("operator_url") or "").rstrip("/")
|
|
116
|
+
agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
|
|
117
|
+
if not (base and agent_id and secret):
|
|
118
|
+
raise PairingError("this agent is not paired; run: chartremotely pair")
|
|
119
|
+
|
|
120
|
+
credentials = {"agent_id": agent_id, "secret": secret}
|
|
121
|
+
backoff = 1.0
|
|
122
|
+
while True:
|
|
123
|
+
try:
|
|
124
|
+
envelope = _post(f"{base}/agent/poll", credentials, timeout=POLL_TIMEOUT)
|
|
125
|
+
backoff = 1.0
|
|
126
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
127
|
+
# The operator being unreachable is not fatal: this machine
|
|
128
|
+
# still works locally, and the display should reconnect by
|
|
129
|
+
# itself when the operator returns.
|
|
130
|
+
time.sleep(backoff)
|
|
131
|
+
backoff = min(backoff * 2, BACKOFF_MAX)
|
|
132
|
+
if once:
|
|
133
|
+
return
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if envelope.get("command"):
|
|
137
|
+
reply = execute(str(envelope["command"]))
|
|
138
|
+
try:
|
|
139
|
+
_post(f"{base}/agent/result",
|
|
140
|
+
{**credentials, "id": envelope.get("id"), "reply": reply},
|
|
141
|
+
timeout=20)
|
|
142
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
143
|
+
pass # the caller has already timed out and refunded
|
|
144
|
+
if once:
|
|
145
|
+
return
|