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.
- witbitz_code/__init__.py +7 -0
- witbitz_code/__main__.py +3 -0
- witbitz_code/_js.py +312 -0
- witbitz_code/account.py +127 -0
- witbitz_code/asks.py +119 -0
- witbitz_code/attachments.py +266 -0
- witbitz_code/auto.py +561 -0
- witbitz_code/auto_runner.py +481 -0
- witbitz_code/cli.py +223 -0
- witbitz_code/codetools.py +128 -0
- witbitz_code/compress.py +25 -0
- witbitz_code/computers.py +189 -0
- witbitz_code/connector.py +818 -0
- witbitz_code/devicelink.py +159 -0
- witbitz_code/folders.py +250 -0
- witbitz_code/link.py +205 -0
- witbitz_code/net.py +67 -0
- witbitz_code/opencode_plugins/notes-init.md +8 -0
- witbitz_code/opencode_plugins/witbitz-media-server.mjs +791 -0
- witbitz_code/opencode_plugins/witbitz-notes.js +356 -0
- witbitz_code/opencode_plugins/witbitz-progress.js +120 -0
- witbitz_code/outputs.py +115 -0
- witbitz_code/pair.py +222 -0
- witbitz_code/pairings.py +214 -0
- witbitz_code/plugins.py +140 -0
- witbitz_code/policy.py +48 -0
- witbitz_code/qr.py +59 -0
- witbitz_code/relay.py +783 -0
- witbitz_code/seen.py +49 -0
- witbitz_code/tools_probe.py +67 -0
- witbitz_code-1.2.0.dist-info/METADATA +145 -0
- witbitz_code-1.2.0.dist-info/RECORD +36 -0
- witbitz_code-1.2.0.dist-info/WHEEL +5 -0
- witbitz_code-1.2.0.dist-info/entry_points.txt +2 -0
- witbitz_code-1.2.0.dist-info/licenses/LICENSE +21 -0
- witbitz_code-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -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"))
|
witbitz_code/folders.py
ADDED
|
@@ -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)
|