witbitz-code 1.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.
@@ -0,0 +1,159 @@
1
+ """Bring this computer into a Spaces account by QR, without a secret in the QR — spaces/public/deviceLink.js, plus the
2
+ pieces of spaces/public/recovery.js the link needs.
3
+
4
+ THIS computer (empty) draws a QR: an ephemeral P-256 PUBLIC key + a random ref. Nothing secret.
5
+ A signed-in device scans it, confirms, seals the account to that public key, posts it to the ref.
6
+ THIS computer polls the ref and opens it with the private half, which never leaves this process.
7
+
8
+ The relay in between sees two ECDH public keys and AES-GCM output; it cannot combine them.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from cryptography.hazmat.primitives import hashes
19
+ from cryptography.hazmat.primitives.asymmetric import ec
20
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
21
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
22
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
23
+
24
+ from . import _js
25
+ from ._js import b64u, truthy, unb64u_loose
26
+
27
+ LINK_PREFIX = "wbzlink1:"
28
+ _LINK_INFO = b"witbitz-device-link-v1"
29
+ _BACKUP_SALT = b"witbitz-spaces-backup-v1"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class LinkChallenge:
34
+ ref: str
35
+ pub: str
36
+ text: str
37
+ priv: ec.EllipticCurvePrivateKey = field(repr=False)
38
+
39
+
40
+ def _export_pub(key: ec.EllipticCurvePublicKey) -> str:
41
+ """Compressed point (33 bytes), so the QR stays sparse enough to scan in poor light."""
42
+ return b64u(key.public_bytes(Encoding.X962, PublicFormat.CompressedPoint))
43
+
44
+
45
+ def _import_pub(b64: Any) -> ec.EllipticCurvePublicKey:
46
+ raw = unb64u_loose(b64)
47
+ if len(raw) != 33 or raw[0] not in (2, 3):
48
+ raise ValueError("bad_point")
49
+ try: # rejects an x that is ≥ p or not on the curve, as the JS decompression does
50
+ return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), raw)
51
+ except ValueError as e:
52
+ raise ValueError("bad_point") from e
53
+
54
+
55
+ def _shared_key(priv: ec.EllipticCurvePrivateKey, pub: ec.EllipticCurvePublicKey, ref: Any) -> AESGCM:
56
+ """Both sides derive the same AES-GCM key from the shared point, bound to the ref so a blob can't be replayed at another."""
57
+ bits = priv.exchange(ec.ECDH(), pub)
58
+ salt = _js.js_string(ref).encode("utf-8")
59
+ return AESGCM(HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=_LINK_INFO).derive(bits))
60
+
61
+
62
+ def new_link_challenge() -> LinkChallenge:
63
+ """Mint the challenge to draw as a QR. `priv` never leaves this process."""
64
+ priv = ec.generate_private_key(ec.SECP256R1())
65
+ ref = os.urandom(16).hex()
66
+ pub = _export_pub(priv.public_key())
67
+ return LinkChallenge(ref=ref, pub=pub, text=LINK_PREFIX + ref + "." + pub, priv=priv)
68
+
69
+
70
+ def parse_challenge(text: Any) -> dict | None:
71
+ """What a camera saw → {ref, pub}, or None for anything else."""
72
+ s = "" if text is None or text is _js.UNDEFINED else _js.js_string(text)
73
+ if not s.startswith(LINK_PREFIX):
74
+ return None
75
+ pieces = s[len(LINK_PREFIX):].split(".")
76
+ ref = pieces[0]
77
+ pub = pieces[1] if len(pieces) > 1 else ""
78
+ if len(pieces) > 2 or not re.fullmatch(r"[0-9a-f]{32}", ref) or not re.fullmatch(r"[A-Za-z0-9_-]{44}", pub):
79
+ return None
80
+ return {"ref": ref, "pub": pub}
81
+
82
+
83
+ def seal_to_challenge(challenge: dict, payload: Any) -> dict:
84
+ """The GIVING side (a signed-in device): seal a payload to a scanned challenge → what it POSTs to /api/link."""
85
+ theirs = _import_pub(challenge["pub"])
86
+ mine = ec.generate_private_key(ec.SECP256R1())
87
+ key = _shared_key(mine, theirs, challenge["ref"])
88
+ iv = os.urandom(12)
89
+ ct = key.encrypt(iv, _js.stringify(payload).encode("utf-8"), None)
90
+ return {"ref": challenge["ref"], "epk": _export_pub(mine.public_key()), "blob": b64u(iv + ct)}
91
+
92
+
93
+ def open_link_reply(priv: ec.EllipticCurvePrivateKey, reply: dict) -> Any:
94
+ """Open what the ref handed back. Raises on anything that isn't ours and intact."""
95
+ key = _shared_key(priv, _import_pub(reply.get("epk")), reply.get("ref"))
96
+ raw = unb64u_loose(reply.get("blob"))
97
+ pt = key.decrypt(raw[:12], raw[12:], None)
98
+ return _js.parse(pt.decode("utf-8-sig", "replace"))
99
+
100
+
101
+ def _ptr(x: Any) -> dict | None:
102
+ if isinstance(x, dict) and isinstance(x.get("room"), str) and isinstance(x.get("mk"), str) and x["room"] and x["mk"]:
103
+ return {"room": x["room"], "mk": x["mk"]}
104
+ return None
105
+
106
+
107
+ def read_link_payload(p: Any) -> dict | None:
108
+ """What the receiving device applies, or None if there is no usable key in it."""
109
+ if not isinstance(p, dict) or not isinstance(p.get("master"), str) or not p["master"]:
110
+ return None
111
+ a = p.get("anchor")
112
+ anchor = None
113
+ if isinstance(a, dict) and a.get("method") in ("google", "email") and isinstance(a.get("email"), str) and a["email"]:
114
+ token = a.get("token")
115
+ if a["method"] != "email" or (isinstance(token, str) and token):
116
+ anchor = {"method": a["method"], "email": a["email"]}
117
+ if isinstance(token, str) and token:
118
+ anchor["token"] = token
119
+ if truthy(a.get("exp")):
120
+ anchor["exp"] = a["exp"]
121
+ rel = p.get("rel")
122
+ return {
123
+ "master": p["master"],
124
+ "code": p["code"] if isinstance(p.get("code"), str) else "",
125
+ "email": p["email"] if isinstance(p.get("email"), str) else "",
126
+ # Half a release is no release: a token-less rel must not look like one and skip the gated read.
127
+ "rel": {"gid": _js.js_string(rel.get("gid") if truthy(rel.get("gid")) else ""), "token": rel["token"]}
128
+ if isinstance(rel, dict) and isinstance(rel.get("token"), str) and rel["token"] else None,
129
+ "idx": _ptr(p.get("idx")),
130
+ "pipes": _ptr(p.get("pipes")),
131
+ "anchor": anchor,
132
+ }
133
+
134
+
135
+ # ── recovery.js: where the account backup lives and how it opens ─────────────────────────────────────────────────────
136
+ def _backup_hkdf(secret: bytes, info: str, n: int) -> bytes:
137
+ return HKDF(algorithm=hashes.SHA256(), length=n, salt=_BACKUP_SALT, info=info.encode()).derive(secret)
138
+
139
+
140
+ def derive_keys_from_secret(secret: bytes) -> dict:
141
+ """{id (hex storage key), key (AES-GCM), wt (hex write token)} — separate HKDF labels, so the id can't reveal the key."""
142
+ if not secret or len(secret) < 16:
143
+ raise ValueError("bad_secret")
144
+ return {
145
+ "id": _backup_hkdf(secret, "id", 16).hex(),
146
+ "key": AESGCM(_backup_hkdf(secret, "enc", 32)),
147
+ "wt": _backup_hkdf(secret, "bak-write", 32).hex(),
148
+ }
149
+
150
+
151
+ def seal(key: AESGCM, obj: Any) -> str:
152
+ iv = os.urandom(12)
153
+ return b64u(iv + key.encrypt(iv, _js.stringify(obj).encode("utf-8"), None))
154
+
155
+
156
+ def unseal(key: AESGCM, blob: Any) -> Any:
157
+ """base64url(iv‖ciphertext) → object. Raises on a wrong key or any tamper."""
158
+ buf = unb64u_loose(blob)
159
+ return _js.parse(key.decrypt(buf[:12], buf[12:], None).decode("utf-8-sig", "replace"))
@@ -0,0 +1,250 @@
1
+ """"Allow this folder": ONE card for a folder outside the project, for the whole conversation — spaces/public/codeFolders.js.
2
+
3
+ OpenCode asks `external_directory` for the folder of EACH file it reaches and for each folder a command touches, so every
4
+ new subfolder asked again. The card names the folder the ask belongs to (its git root, or the top folder under home), and
5
+ allowing it adds `external_directory <folder>/* allow` to the session (connector.py), which covers everything inside.
6
+ Held to the JS answers by tools/code-folders.vectors.json.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ FOLDER_ROUTE = "/witbitz/folder"
16
+
17
+ _SYSTEM = ("/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/libx32", "/proc", "/root", "/run", "/sbin", "/sys",
18
+ "/usr", "/var")
19
+ _SECRET_PARTS = {".ssh", ".aws", ".gnupg", ".kube", ".docker", ".git"}
20
+ _GLOB = re.compile(r"[*?\[\]{}]")
21
+
22
+
23
+ def norm_abs(p: Any) -> str:
24
+ """posix.normalize for absolute paths; '' when not absolute."""
25
+ if not isinstance(p, str) or not p.startswith("/") or "\0" in p:
26
+ return ""
27
+ out: list[str] = []
28
+ for part in p.split("/"):
29
+ if not part or part == ".":
30
+ continue
31
+ if part == "..":
32
+ if out:
33
+ out.pop()
34
+ else:
35
+ out.append(part)
36
+ return "/" + "/".join(out)
37
+
38
+
39
+ def _under(p: str, root: str) -> bool:
40
+ return p.startswith("/") if root == "/" else (p == root or p.startswith(root + "/"))
41
+
42
+
43
+ def _parent(p: str) -> str:
44
+ return p[: p.rfind("/")] or "/"
45
+
46
+
47
+ def folder_root(directory: Any, *, home: str = "", has_git: Callable[[str], bool] = lambda p: False) -> str | None:
48
+ """The folder an outside path belongs to; None when it must not be widened (home itself, "/", a system folder, a
49
+ secret store, a hidden folder right under home)."""
50
+ d = norm_abs(directory)
51
+ h = "" if norm_abs(home) == "/" else norm_abs(home) # a home of "/" is no home to go by
52
+ if not d or d == "/" or (h and d == h) or _GLOB.search(d):
53
+ return None
54
+ if any(part in _SECRET_PARTS for part in d.split("/")):
55
+ return None
56
+ if any(_under(d, s) for s in _SYSTEM):
57
+ return None
58
+ if h and _under(h, d):
59
+ return None
60
+ in_home = bool(h) and _under(d, h)
61
+ if in_home and d[len(h) + 1:].startswith("."):
62
+ return None
63
+ p = d
64
+ while p != "/" and not (h and p == h) and p != "/tmp": # never home or /tmp themselves, or above them
65
+ if in_home and not _under(p, h):
66
+ break
67
+ try:
68
+ git = bool(has_git(p))
69
+ except Exception:
70
+ git = False
71
+ if git:
72
+ return p
73
+ p = _parent(p)
74
+ if in_home:
75
+ return h + "/" + d[len(h) + 1:].split("/")[0]
76
+ if _under(d, "/tmp"):
77
+ return "/tmp" if d == "/tmp" else "/tmp/" + d[5:].split("/")[0]
78
+ return d
79
+
80
+
81
+ def ask_dirs(ask: Any) -> list[str] | None:
82
+ pats = ask.get("patterns") if isinstance(ask, dict) and isinstance(ask.get("patterns"), list) else []
83
+ if not pats:
84
+ return None
85
+ dirs = []
86
+ for p in pats:
87
+ if not isinstance(p, str) or not p.endswith("/*"):
88
+ return None
89
+ d = norm_abs(p[:-2])
90
+ if not d or _GLOB.search(p[:-2]):
91
+ return None
92
+ dirs.append(d)
93
+ return dirs
94
+
95
+
96
+ def folders_for_ask(ask: Any, *, home: str = "", has_git: Callable[[str], bool] = lambda p: False) -> list[str] | None:
97
+ if not isinstance(ask, dict) or ask.get("permission") != "external_directory":
98
+ return None
99
+ dirs = ask_dirs(ask)
100
+ if dirs is None:
101
+ return None
102
+ roots: list[str] = []
103
+ for d in dirs:
104
+ r = folder_root(d, home=home, has_git=has_git)
105
+ if not r:
106
+ return None
107
+ if r not in roots:
108
+ roots.append(r)
109
+ return roots
110
+
111
+
112
+ def folder_rule(root: str) -> dict:
113
+ return {"permission": "external_directory", "pattern": root + "/*", "action": "allow"}
114
+
115
+
116
+ def allowed_folders(rules: Any) -> list[str]:
117
+ last: dict[str, Any] = {}
118
+ for r in rules if isinstance(rules, list) else []:
119
+ if not isinstance(r, dict) or r.get("permission") != "external_directory":
120
+ continue
121
+ pattern = r.get("pattern")
122
+ if not isinstance(pattern, str) or not pattern.endswith("/*"):
123
+ continue
124
+ last[pattern] = r.get("action")
125
+ out: list[str] = []
126
+ for pattern, action in last.items():
127
+ d = norm_abs(pattern[:-2])
128
+ if action == "allow" and d and d != "/" and not _GLOB.search(pattern[:-2]) and d not in out:
129
+ out.append(d)
130
+ return out
131
+
132
+
133
+ def inside_folders(path: Any, folders: Any) -> bool:
134
+ a = norm_abs(path)
135
+ return bool(a) and any(bool(r) and r != "/" and _under(a, r) for r in (norm_abs(f) for f in (folders if isinstance(folders, list) else [])))
136
+
137
+
138
+ def folders_cover(ask: Any, folders: Any) -> bool:
139
+ dirs = ask_dirs(ask)
140
+ return dirs is not None and all(inside_folders(d, folders) for d in dirs)
141
+
142
+
143
+ def display_path(p: str, home: str) -> str:
144
+ h = norm_abs(home)
145
+ return "~" + p[len(h):] if h and h != "/" and _under(p, h) else p
146
+
147
+
148
+ # ── the route (tools/code-folders.mjs) ────────────────────────────────────────────────────────────────────────────────
149
+ _MAX_DEPTH = 4 # sessions above the asking one
150
+ _ASK_ID = re.compile(r"per[A-Za-z0-9_-]{1,80}")
151
+
152
+
153
+ def _uri_component(s: str) -> str:
154
+ from urllib.parse import quote
155
+ return quote(s, safe="-_.!~*'()") # encodeURIComponent
156
+
157
+
158
+ def has_git_on_disk(p: str) -> bool:
159
+ import os
160
+ return os.path.exists(p + "/.git") # a directory, or a file (a worktree or submodule)
161
+
162
+
163
+ async def folder_route(*, method: Any, query: str, body: Any, pending: Callable[[str], Any], call: Callable[..., Any],
164
+ home: str | None = None, has_git: Callable[[str], bool] = has_git_on_disk,
165
+ log: Callable[[str], Any] = lambda m: None) -> tuple[int, str]:
166
+ """GET /witbitz/folder?directory=D&ask=ID → the folder(s) the card offers; POST {ask} → allow them for the conversation.
167
+ `pending(directory)` → waiting asks or None; `call(method, path, directory, body=None)` → (ok, status, json)."""
168
+ import os
169
+ from urllib.parse import parse_qs
170
+
171
+ from . import _js
172
+
173
+ home = home if home is not None else os.path.expanduser("~")
174
+
175
+ def out(st: int, o: Any) -> tuple[int, str]:
176
+ return st, _js.stringify(o)
177
+
178
+ def offer(folders: list[str]) -> list[dict]:
179
+ return [{"path": p, "display": display_path(p, home)} for p in folders]
180
+
181
+ q = parse_qs(query or "", keep_blank_values=True)
182
+ directory = (q.get("directory") or [""])[0]
183
+ if not directory.startswith("/"):
184
+ return out(400, {"error": "no project folder"})
185
+ ask_id = (q.get("ask") or [""])[0]
186
+ if method == "POST":
187
+ try:
188
+ parsed = _js.parse(body) if isinstance(body, str) and body else None
189
+ except ValueError:
190
+ parsed = None
191
+ ask_id = parsed.get("ask") if isinstance(parsed, dict) and isinstance(parsed.get("ask"), str) else ""
192
+ elif method != "GET":
193
+ return out(405, {"error": "GET or POST"})
194
+ if not _ASK_ID.fullmatch(ask_id):
195
+ return out(400, {"error": "not an approval id"})
196
+ lst = await pending(directory)
197
+ if lst is None:
198
+ return out(502, {"error": "OpenCode cannot say what is waiting right now"})
199
+ ask = next((a for a in lst if isinstance(a, dict) and a.get("id") == ask_id), None)
200
+ if ask is None:
201
+ return out(404, {"error": "that approval is no longer waiting"})
202
+ folders = folders_for_ask(ask, home=home, has_git=has_git)
203
+ if method == "GET":
204
+ return out(200, {"folders": offer(folders or [])})
205
+ if not folders:
206
+ return out(409, {"error": "this is not a folder to allow whole — allow it once instead"})
207
+
208
+ chain: list[str] = []
209
+ sessions: dict[str, Any] = {}
210
+ sid = ask.get("sessionID")
211
+ while isinstance(sid, str) and sid and sid not in chain and len(chain) <= _MAX_DEPTH:
212
+ try:
213
+ ok, _, data = await call("GET", f"/session/{_uri_component(sid)}", directory)
214
+ except Exception:
215
+ ok, data = False, None
216
+ if not ok or not data:
217
+ if not chain:
218
+ return out(502, {"error": "OpenCode did not return the session"})
219
+ break
220
+ chain.append(sid)
221
+ sessions[sid] = data
222
+ sid = data.get("parentID") if isinstance(data, dict) and isinstance(data.get("parentID"), str) else ""
223
+ for i, s_id in enumerate(chain):
224
+ s = sessions.get(s_id)
225
+ have = allowed_folders(s.get("permission") if isinstance(s, dict) else None)
226
+ missing = [folder_rule(f) for f in folders if f not in have]
227
+ if not missing:
228
+ continue
229
+ try:
230
+ ok, status, _ = await call("PATCH", f"/session/{_uri_component(s_id)}", directory, {"permission": missing})
231
+ except Exception:
232
+ ok, status = False, None
233
+ if not ok:
234
+ if i == 0:
235
+ return out(502, {"error": "OpenCode did not take the folder rule"})
236
+ log(f"code-folders: could not allow {', '.join(folders)} on {s_id} ({status if status is not None else 'no answer'})")
237
+ answered: list[str] = []
238
+ others = [x for x in lst if isinstance(x, dict) and x is not ask and x.get("permission") == "external_directory"
239
+ and x.get("sessionID") in chain and folders_cover(x, folders)]
240
+ for a in [ask, *others]:
241
+ try:
242
+ ok, _, _ = await call("POST", f"/permission/{_uri_component(a['id'])}/reply", directory, {"reply": "once"})
243
+ except Exception:
244
+ ok = False
245
+ if ok:
246
+ answered.append(a["id"])
247
+ if ask["id"] not in answered:
248
+ return out(502, {"error": "the folder is allowed, but OpenCode did not take the answer — allow it again"})
249
+ log(f"code-folders: allowed {', '.join(folders)} for {chain[0]}")
250
+ return out(200, {"folders": offer(folders), "answered": answered})
witbitz_code/link.py ADDED
@@ -0,0 +1,205 @@
1
+ """Make this computer an ADDED DEVICE of a Spaces account by device-link — tools/rc-link.mjs.
2
+
3
+ It shows a QR carrying an ephemeral PUBLIC key (no secret); a signed-in phone or desktop scans it (Settings → Back up &
4
+ recovery → Add a device) and seals the account to that key; this process opens the reply. The reply is relayed
5
+ content-blind through /api/link between two public keys the server cannot combine.
6
+
7
+ Unlike rc-link.mjs this never writes the account to disk: the pairing flow needs it only long enough to publish the
8
+ computer, so it stays in memory.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import math
15
+ import os
16
+ import re
17
+ import sys
18
+ import time
19
+ from collections.abc import Callable
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from . import _js, devicelink, net, qr
24
+ from ._js import truthy
25
+
26
+ DEFAULT_ORIGINS = "https://spaces.witbitz.chat,https://witbitz-spaces.pages.dev"
27
+ POLL_S = 2.5
28
+ LIFE_MS = 300_000 # the ref stays valid until a seal is posted, so a slow (Google-failed → email-code) verification fits
29
+
30
+
31
+ def parse_origins(env: Any) -> list[str]:
32
+ """Poll EVERY origin whose /api/link relay is live — the node connects whichever one the phone sealed the reply to."""
33
+ s = _js.js_string(env if truthy(env) else DEFAULT_ORIGINS)
34
+ return [o for o in (re.sub(r"/\Z", "", _js.trim(x)) for x in s.split(",")) if o]
35
+
36
+
37
+ def _now_ms() -> int:
38
+ return int(time.time() * 1000)
39
+
40
+
41
+ def account_from_payload(pay: Any) -> dict | None:
42
+ """The account this computer now belongs to, from an opened link payload: the master + the index-room pointer."""
43
+ if not isinstance(pay, dict) or not isinstance(pay.get("master"), str) or not pay["master"]:
44
+ return None
45
+ anchor = pay.get("anchor")
46
+ email = pay.get("email") if truthy(pay.get("email")) else (anchor.get("email") if isinstance(anchor, dict) else None)
47
+
48
+ def orelse(v: Any, dflt: Any) -> Any:
49
+ return v if truthy(v) else dflt
50
+
51
+ return {
52
+ "v": 1,
53
+ "linkedAt": _now_ms(),
54
+ "master": pay["master"],
55
+ "code": orelse(pay.get("code"), ""),
56
+ "email": orelse(email, ""),
57
+ "idx": orelse(pay.get("idx"), None),
58
+ "rel": orelse(pay.get("rel"), None),
59
+ "pipes": orelse(pay.get("pipes"), None),
60
+ "anchor": orelse(anchor, None),
61
+ }
62
+
63
+
64
+ def _node_base64(s: str) -> bytes:
65
+ """Buffer.from(s, 'base64'): lenient — stops at '=', skips anything outside the alphabet."""
66
+ body = re.sub(r"[^A-Za-z0-9+/]", "", s.split("=", 1)[0])
67
+ body = body[: len(body) - (len(body) % 4 == 1)]
68
+ return _js.atob(body)
69
+
70
+
71
+ async def pull_index_room(account: Any, origins: list[str], post_json: net.PostJson) -> dict | None:
72
+ """GAP-CLOSER: the link carried no index-room pointer → derive the backup location from the master, read the gated
73
+ backup with the release token, and take its indexRoom {room, mk}. Tries each origin; the pointer or None."""
74
+ if not isinstance(account, dict) or not truthy(account.get("master")):
75
+ return None
76
+ try:
77
+ master = _node_base64(_js.js_string(account["master"]).replace("-", "+").replace("_", "/"))
78
+ except ValueError:
79
+ return None
80
+ if len(master) != 32:
81
+ return None
82
+ loc = devicelink.derive_keys_from_secret(master)
83
+ rel = account.get("rel")
84
+ token = rel.get("token") if isinstance(rel, dict) else None
85
+ for origin in origins:
86
+ blob = None
87
+ if truthy(token):
88
+ try:
89
+ r = await post_json(f"{origin}/api/backup/release", {"token": token, "id": loc["id"]})
90
+ if isinstance(r, dict) and truthy(r.get("ok")) and truthy(r.get("blob")):
91
+ blob = r["blob"]
92
+ except Exception:
93
+ pass # try the next origin
94
+ if not blob:
95
+ continue
96
+ try:
97
+ payload = devicelink.unseal(loc["key"], blob)
98
+ ir = payload.get("indexRoom") if isinstance(payload, dict) else None
99
+ if isinstance(ir, dict) and truthy(ir.get("room")) and truthy(ir.get("mk")):
100
+ return ir
101
+ except Exception:
102
+ pass # wrong key / tamper
103
+ return None
104
+
105
+
106
+ def _write_private(path: Path, text: str) -> None:
107
+ path.parent.mkdir(parents=True, exist_ok=True)
108
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
109
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
110
+ f.write(text)
111
+ os.chmod(path, 0o600)
112
+
113
+
114
+ def _out(*a: Any, **kw: Any) -> None:
115
+ print(*a, flush=True, **kw)
116
+
117
+
118
+ def _err(*a: Any) -> None:
119
+ print(*a, file=sys.stderr, flush=True)
120
+
121
+
122
+ async def link_node(*, life_ms: float | None = None, poll_s: float = POLL_S, origins: list[str] | None = None,
123
+ get_json: net.GetJson | None = None, post_json: net.PostJson | None = None,
124
+ challenge: devicelink.LinkChallenge | None = None, svg_path: Path | None = None,
125
+ out: Callable = _out, err: Callable = _err) -> dict | None:
126
+ """Show the device-link QR and wait for a signed-in device to seal the account to this computer. Returns the account,
127
+ or None when it did not complete (expired, or a reply that would not open) — never exits the process."""
128
+ if origins is None:
129
+ origins = parse_origins(os.environ.get("RC_LINK_ORIGIN"))
130
+ if life_ms is None:
131
+ raw = os.environ.get("RC_LINK_WAIT_MS")
132
+ life_ms = _js.to_number(raw) if raw else LIFE_MS
133
+ client = None
134
+ if get_json is None or post_json is None:
135
+ client = net.new_client()
136
+ get_json = get_json or net.make_get_json(client)
137
+ post_json = post_json or net.make_post_json(client)
138
+ try:
139
+ return await _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_path, out, err)
140
+ finally:
141
+ if client is not None:
142
+ await client.aclose()
143
+
144
+
145
+ async def _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_path, out, err) -> dict | None:
146
+ ch = challenge or devicelink.new_link_challenge()
147
+ out(f"witbitz-code: pairing this computer with your account · polling {', '.join(origins)}\n")
148
+ out(qr.qr_ansi(ch.text) + "\n")
149
+ svg = svg_path or Path.home() / ".witbitz-rc.link.svg"
150
+ try:
151
+ _write_private(svg, qr.qr_svg(ch.text) + "\n")
152
+ out(f" (QR also saved to {svg} · raw: {ch.text})")
153
+ except OSError:
154
+ out(f" (raw: {ch.text})")
155
+ wait_s = life_ms / 1000 if math.isfinite(life_ms) else 0
156
+ out(f" waiting up to {round(wait_s)}s for your device to seal the account to this computer…\n")
157
+ until = time.monotonic() + wait_s
158
+ warned = False
159
+ while time.monotonic() < until:
160
+ await asyncio.sleep(poll_s)
161
+ r = None
162
+ for origin in origins:
163
+ try:
164
+ rr = await get_json(f"{origin}/api/link?ref={ch.ref}")
165
+ except Exception:
166
+ continue
167
+ if isinstance(rr, dict) and rr.get("reason") == "unavailable" and not warned:
168
+ err(f"witbitz-code: note — {origin} has no device-link relay; ignoring it.")
169
+ warned = True
170
+ if isinstance(rr, dict) and truthy(rr.get("ok")):
171
+ r = rr
172
+ break
173
+ if r is None:
174
+ continue # 404 on all = not sealed yet
175
+ pay = None
176
+ try:
177
+ pay = devicelink.read_link_payload(devicelink.open_link_reply(ch.priv, {"ref": ch.ref, "epk": r.get("epk"), "blob": r.get("blob")}))
178
+ except Exception:
179
+ pass # not ours / tampered
180
+ if not pay:
181
+ err("witbitz-code: a reply arrived but did not open — start again on both devices.")
182
+ return None
183
+ account = account_from_payload(pay)
184
+ if not account:
185
+ err("witbitz-code: the reply carried no account key.")
186
+ return None
187
+ via = "link" if account["idx"] else ""
188
+ if not account["idx"]:
189
+ out("witbitz-code: no index pointer in the link — recovering it from your account backup… ", end="")
190
+ idx = None
191
+ try:
192
+ idx = await pull_index_room(account, origins, post_json)
193
+ except Exception:
194
+ pass
195
+ if idx:
196
+ account["idx"], via = idx, "backup"
197
+ out("✓")
198
+ else:
199
+ out("not found (the release token may have expired — pair again)")
200
+ out(f"witbitz-code: ✓ linked as a device of {account['email'] or '(your account)'}")
201
+ room = account["idx"].get("room") if isinstance(account["idx"], dict) else None
202
+ out(f" index room → {room} (via {via})" if account["idx"] else " index room → (none)")
203
+ return account
204
+ err("witbitz-code: the code expired before your device sealed to it. Run it again for a fresh QR.")
205
+ return None
witbitz_code/net.py ADDED
@@ -0,0 +1,67 @@
1
+ """The few HTTP calls the pairing flow makes, behind small injectable functions (tests replace them; nothing here is
2
+ retried or cached)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from . import _js
14
+
15
+ API = "https://api.witbitz.chat/v1/space"
16
+ ORIGIN = "https://witbitz-spaces.pages.dev"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ApiReply:
21
+ status: int
22
+ j: Any # the parsed JSON body, or None when it was not JSON
23
+
24
+
25
+ ApiCall = Callable[[dict], Awaitable[ApiReply]]
26
+ GetJson = Callable[[str], Awaitable[Any]]
27
+ PostJson = Callable[[str, dict], Awaitable[Any]]
28
+
29
+
30
+ def _json_of(r: httpx.Response) -> Any:
31
+ return _js.parse(r.content.decode("utf-8-sig", "replace")) # fetch's r.json(): BOM dropped, then JSON.parse
32
+
33
+
34
+ def make_api_call(client: httpx.AsyncClient) -> ApiCall:
35
+ """POST {op:'state', …} to the account API, as the Spaces app would (same Origin)."""
36
+ api = os.environ.get("RC_BASE") or API
37
+ origin = os.environ.get("RC_ORIGIN") or ORIGIN
38
+
39
+ async def call(body: dict) -> ApiReply:
40
+ r = await client.post(api, content=_js.stringify(body).encode("utf-8"),
41
+ headers={"content-type": "application/json", "origin": origin})
42
+ try:
43
+ j = _json_of(r)
44
+ except ValueError:
45
+ j = None
46
+ return ApiReply(r.status_code, j)
47
+
48
+ return call
49
+
50
+
51
+ def make_get_json(client: httpx.AsyncClient) -> GetJson:
52
+ async def get_json(url: str) -> Any:
53
+ return _json_of(await client.get(url, headers={"cache-control": "no-store"}))
54
+
55
+ return get_json
56
+
57
+
58
+ def make_post_json(client: httpx.AsyncClient) -> PostJson:
59
+ async def post_json(url: str, body: dict) -> Any:
60
+ r = await client.post(url, content=_js.stringify(body).encode("utf-8"), headers={"content-type": "application/json"})
61
+ return _json_of(r)
62
+
63
+ return post_json
64
+
65
+
66
+ def new_client() -> httpx.AsyncClient:
67
+ return httpx.AsyncClient(timeout=httpx.Timeout(30.0), follow_redirects=True)