witbitz-code 1.9.3__tar.gz → 1.9.5__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.
- {witbitz_code-1.9.3/src/witbitz_code.egg-info → witbitz_code-1.9.5}/PKG-INFO +1 -1
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/pyproject.toml +1 -1
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/cli.py +1 -1
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/link.py +6 -1
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/net.py +19 -3
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/pair.py +5 -3
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/pairings.py +7 -2
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/ui.py +2 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/ui_static/app.js +24 -7
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/ui_static/index.html +1 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_codekeys.py +2 -1
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_devicelink.py +22 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_pair_flows.py +69 -11
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_setup.py +4 -4
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_ui.py +64 -9
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_uninstall.py +2 -2
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_wslstart.py +2 -2
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/LICENSE +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/README.md +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/setup.cfg +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/browse.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/browsetree.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/icons/witbitz-code.ico +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/images.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/remind.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/seccheck.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/selfupdate.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/ui_static/app.css +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/uitools.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/winbg.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/wslstart.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_asks.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_attachments.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_auto.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_browse.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_browsetree.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_cli.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_codetools.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_confidential.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_dirs.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_folders.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_images.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_outputs.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_plugins.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_policy.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_ports.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_registry.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_remind.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_seccheck.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_selfupdate.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_uitools.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_winagent.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_winbg.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_windows_files.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "witbitz-code"
|
|
7
|
-
version = "1.9.
|
|
7
|
+
version = "1.9.5"
|
|
8
8
|
description = "Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -384,7 +384,7 @@ async def uninstall(args: list[str]) -> int:
|
|
|
384
384
|
return {"ok": False, "why": "could not give this computer's coding keys back"}
|
|
385
385
|
if entry:
|
|
386
386
|
codekeys.forget(p["computerId"])
|
|
387
|
-
r = await acct.unpublish_computer(call=call, idx=p["idx"], computer_id=p["computerId"])
|
|
387
|
+
r = await acct.unpublish_computer(call=net.call_for(call, p.get("origin")), idx=p["idx"], computer_id=p["computerId"])
|
|
388
388
|
if r.get("ok"):
|
|
389
389
|
pp.write_pairings(pp.remove_pairings(pp.read_pairings(), p.get("account") or "")["doc"])
|
|
390
390
|
return r
|
|
@@ -156,6 +156,7 @@ async def _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_pa
|
|
|
156
156
|
out(f" waiting up to {round(wait_s)}s for your device to seal the account to this computer…\n")
|
|
157
157
|
until = time.monotonic() + wait_s
|
|
158
158
|
warned = False
|
|
159
|
+
found = ""
|
|
159
160
|
while time.monotonic() < until:
|
|
160
161
|
await asyncio.sleep(poll_s)
|
|
161
162
|
r = None
|
|
@@ -168,7 +169,7 @@ async def _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_pa
|
|
|
168
169
|
err(f"witbitz-code: note — {origin} has no device-link relay; ignoring it.")
|
|
169
170
|
warned = True
|
|
170
171
|
if isinstance(rr, dict) and truthy(rr.get("ok")):
|
|
171
|
-
r = rr
|
|
172
|
+
r, found = rr, origin
|
|
172
173
|
break
|
|
173
174
|
if r is None:
|
|
174
175
|
continue # 404 on all = not sealed yet
|
|
@@ -184,6 +185,10 @@ async def _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_pa
|
|
|
184
185
|
if not account:
|
|
185
186
|
err("witbitz-code: the reply carried no account key.")
|
|
186
187
|
return None
|
|
188
|
+
# THE APP IT CAME FROM (the owner, 2026-10-22: a second account paired from the hosted app → "computers read refused
|
|
189
|
+
# (404: not_found)"): the relay that held the reply is that app's own address, and its rooms answer only their own app
|
|
190
|
+
# since 2026-10-18 — so this account's calls speak as it (pairings.json `origin`, net.call_for).
|
|
191
|
+
account["origin"] = found
|
|
187
192
|
via = "link" if account["idx"] else ""
|
|
188
193
|
if not account["idx"]:
|
|
189
194
|
out("witbitz-code: no index pointer in the link — recovering it from your account backup… ", end="")
|
|
@@ -4,6 +4,7 @@ retried or cached)."""
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
from collections.abc import Awaitable, Callable
|
|
8
9
|
from dataclasses import dataclass
|
|
9
10
|
from typing import Any
|
|
@@ -31,10 +32,24 @@ def _json_of(r: httpx.Response) -> Any:
|
|
|
31
32
|
return _js.parse(r.content.decode("utf-8-sig", "replace")) # fetch's r.json(): BOM dropped, then JSON.parse
|
|
32
33
|
|
|
33
34
|
|
|
34
|
-
def
|
|
35
|
-
"""
|
|
35
|
+
def app_origin(o: Any) -> str:
|
|
36
|
+
"""A pairing's app address, when it is one: https and nothing but scheme://host. Else '' (the default app)."""
|
|
37
|
+
return o if isinstance(o, str) and re.fullmatch(r"https://[A-Za-z0-9.-]+(:[0-9]+)?", o) else ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def call_for(call: Any, origin: Any) -> Any:
|
|
41
|
+
"""The same account call, speaking as the app `origin` pairs through (its rooms answer only their own app's addresses —
|
|
42
|
+
the hosted app's since 2026-10-18). A call without that ability (a test's fake) is returned unchanged."""
|
|
43
|
+
o = app_origin(origin)
|
|
44
|
+
f = getattr(call, "for_origin", None)
|
|
45
|
+
return f(o) if o and callable(f) else call
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def make_api_call(client: httpx.AsyncClient, origin: str | None = None) -> ApiCall:
|
|
49
|
+
"""POST {op:'state', …} to the account API, as the Spaces app would (same Origin). `origin`: the app this account was
|
|
50
|
+
paired from (a pairing's `origin`); none → RC_ORIGIN, else the regular app."""
|
|
36
51
|
api = os.environ.get("RC_BASE") or API
|
|
37
|
-
origin = os.environ.get("RC_ORIGIN") or ORIGIN
|
|
52
|
+
origin = app_origin(origin) or os.environ.get("RC_ORIGIN") or ORIGIN
|
|
38
53
|
|
|
39
54
|
async def call(body: dict) -> ApiReply:
|
|
40
55
|
r = await client.post(api, content=_js.stringify(body).encode("utf-8"),
|
|
@@ -45,6 +60,7 @@ def make_api_call(client: httpx.AsyncClient) -> ApiCall:
|
|
|
45
60
|
j = None
|
|
46
61
|
return ApiReply(r.status_code, j)
|
|
47
62
|
|
|
63
|
+
call.for_origin = lambda o: make_api_call(client, o) # type: ignore[attr-defined]
|
|
48
64
|
return call
|
|
49
65
|
|
|
50
66
|
|
|
@@ -94,6 +94,7 @@ def ensure_local_password(out: Callable[[str], Any] = _out) -> None:
|
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
async def _publish_entry(call: net.ApiCall, e: dict, out: Callable, err: Callable) -> bool:
|
|
97
|
+
call = net.call_for(call, e.get("origin")) # this account's app (the hosted app's rooms refuse the regular one)
|
|
97
98
|
r = await acct.publish_computer(call=call, idx=e["idx"], computer_id=e["computerId"], name=e["name"], relay=e["relay"],
|
|
98
99
|
secret=e["secret"])
|
|
99
100
|
who = e["account"] or "account"
|
|
@@ -220,7 +221,7 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
220
221
|
if entry:
|
|
221
222
|
codekeys.forget(p["computerId"])
|
|
222
223
|
_forget_payer(p["computerId"])
|
|
223
|
-
r = await acct.unpublish_computer(call=call, idx=p["idx"], computer_id=p["computerId"])
|
|
224
|
+
r = await acct.unpublish_computer(call=net.call_for(call, p.get("origin")), idx=p["idx"], computer_id=p["computerId"])
|
|
224
225
|
if not r["ok"]:
|
|
225
226
|
ok = False
|
|
226
227
|
err(f"witbitz-code: ✖ {p.get('account')}: {r['why']} — kept the local pairing so you can retry")
|
|
@@ -242,7 +243,8 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
242
243
|
cur, ok = doc, True
|
|
243
244
|
for p in to_rotate:
|
|
244
245
|
u = pp.upsert_pairing(cur, account=p.get("account") or "", idx=p["idx"], name=p.get("name") or "",
|
|
245
|
-
relay=p.get("relay", UNDEFINED), opencode_url=p.get("opencodeUrl", UNDEFINED),
|
|
246
|
+
relay=p.get("relay", UNDEFINED), opencode_url=p.get("opencodeUrl", UNDEFINED),
|
|
247
|
+
origin=p.get("origin", UNDEFINED), rotate=True)
|
|
246
248
|
if await _publish_entry(call, u["entry"], out, err):
|
|
247
249
|
cur = u["doc"]
|
|
248
250
|
pp.write_pairings(cur)
|
|
@@ -260,7 +262,7 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
260
262
|
err("witbitz-code: pairing did not complete — nothing changed.")
|
|
261
263
|
return 1
|
|
262
264
|
u = pp.upsert_pairing(doc, account=account.get("email") or "", idx=account["idx"], name=args.name,
|
|
263
|
-
opencode_url=args.opencode_url or UNDEFINED)
|
|
265
|
+
opencode_url=args.opencode_url or UNDEFINED, origin=account.get("origin") or UNDEFINED)
|
|
264
266
|
# THIS COMPUTER'S CODING KEYS from the account (codekeys.py; docs/code-account-keys.md) — the only way it gets model
|
|
265
267
|
# keys. Asked with the sign-in the scan carried, before anything is published: an account that cannot give keys (not
|
|
266
268
|
# invited, too many computers, …) leaves nothing behind.
|
|
@@ -114,8 +114,8 @@ def norm_pairings(doc: Any) -> dict:
|
|
|
114
114
|
|
|
115
115
|
|
|
116
116
|
def upsert_pairing(doc: Any, *, idx: dict, account: str = "", name: str = "", relay: Any = UNDEFINED,
|
|
117
|
-
opencode_url: Any = UNDEFINED,
|
|
118
|
-
mint_secret: Callable[[], str] = new_relay_secret) -> dict:
|
|
117
|
+
opencode_url: Any = UNDEFINED, origin: Any = UNDEFINED, rotate: bool = False,
|
|
118
|
+
mint_id: Callable[[], str] = new_computer_id, mint_secret: Callable[[], str] = new_relay_secret) -> dict:
|
|
119
119
|
"""Add this computer for an account, or refresh the existing entry (same id + secret unless `rotate`). Pure.
|
|
120
120
|
→ {doc, entry, isNew, sharedWith}: sharedWith names the other accounts that reach the same OpenCode."""
|
|
121
121
|
relay = RELAY_URL if relay is UNDEFINED else relay
|
|
@@ -138,6 +138,11 @@ def upsert_pairing(doc: Any, *, idx: dict, account: str = "", name: str = "", re
|
|
|
138
138
|
# An OpenCode asked for (`pair --port 4097`) wins; not asked → the entry keeps the one it had (tools/opencode-pair.mjs).
|
|
139
139
|
"opencodeUrl": pick(None if opencode_url is UNDEFINED else opencode_url, "opencodeUrl", DEFAULT_OPENCODE_URL),
|
|
140
140
|
}
|
|
141
|
+
# The app this account pairs through (its rooms answer only their own app — net.call_for). Absent = the regular app, so an
|
|
142
|
+
# entry written before 2026-10-22 (or by an older tool) reads as before, byte for byte.
|
|
143
|
+
app = pick(None if origin is UNDEFINED else origin, "origin", "")
|
|
144
|
+
if truthy(app):
|
|
145
|
+
entry["origin"] = app
|
|
141
146
|
pairings = list(cur["pairings"])
|
|
142
147
|
if i >= 0:
|
|
143
148
|
pairings[i] = entry
|
|
@@ -382,6 +382,8 @@ class Manager:
|
|
|
382
382
|
await self.start_serving()
|
|
383
383
|
return {"ok": False, "why": next((s for s in reversed(said) if "✖" in s or "pass --account" in s), "could not unpair").replace("witbitz-code: ", "", 1)}
|
|
384
384
|
self.pair_state = {"state": "idle", "n": self.pair_state.get("n", 0)}
|
|
385
|
+
if self.pairings(): # one account of several unpaired: the others stay reachable (the owner, 2026-10-22: several accounts)
|
|
386
|
+
await self.start_serving()
|
|
385
387
|
return {"ok": True}
|
|
386
388
|
|
|
387
389
|
async def set_login(self, on: bool) -> dict:
|
|
@@ -60,6 +60,13 @@ function render(s) {
|
|
|
60
60
|
const who = document.createElement('div'); who.className = 'who'; who.textContent = `${p.name} → ${p.account || 'your account'}`
|
|
61
61
|
const keys = document.createElement('div'); keys.className = 'keys'; keys.textContent = p.keys || ''
|
|
62
62
|
li.append(who, keys)
|
|
63
|
+
// SEVERAL ACCOUNTS (the owner, 2026-10-22: "can we make it so it can handle multiple accounts?"): each one leaves on its own
|
|
64
|
+
if (s.paired.length > 1 && p.account) {
|
|
65
|
+
const out = document.createElement('button'); out.type = 'button'; out.className = 'ghost danger small'; out.textContent = 'Unpair ' + p.account
|
|
66
|
+
out.disabled = !!s.busy
|
|
67
|
+
out.onclick = () => act('/api/unpair', { account: p.account }, `Unpair ${p.account}? Code in that account stops reaching this computer; the other accounts keep it.`)
|
|
68
|
+
li.append(out)
|
|
69
|
+
}
|
|
63
70
|
list.append(li)
|
|
64
71
|
}
|
|
65
72
|
|
|
@@ -70,8 +77,10 @@ function render(s) {
|
|
|
70
77
|
$('start').textContent = run === 'elsewhere' ? 'Run it here' : 'Start'
|
|
71
78
|
show($('stop'), paired && run !== 'stopped')
|
|
72
79
|
show($('pairAgain'), paired && !waiting)
|
|
80
|
+
show($('pairAdd'), paired && !waiting)
|
|
73
81
|
show($('unpair'), paired && !waiting)
|
|
74
|
-
|
|
82
|
+
$('unpair').textContent = s.paired.length > 1 ? 'Unpair all' : 'Unpair'
|
|
83
|
+
for (const id of ['pair', 'start', 'stop', 'pairAgain', 'pairAdd', 'unpair', 'pairCancel', 'pairRetry']) $(id).disabled = busy
|
|
75
84
|
|
|
76
85
|
show($('pairCard'), waiting || s.pair.state === 'failed')
|
|
77
86
|
if (waiting) {
|
|
@@ -121,7 +130,7 @@ async function refresh(fresh = false) {
|
|
|
121
130
|
|
|
122
131
|
async function act(path, body, ask) {
|
|
123
132
|
if (ask && !window.confirm(ask)) return false
|
|
124
|
-
for (const id of ['pair', 'start', 'stop', 'pairAgain', 'unpair']) $(id).disabled = true
|
|
133
|
+
for (const id of ['pair', 'start', 'stop', 'pairAgain', 'pairAdd', 'unpair']) $(id).disabled = true
|
|
125
134
|
let ok = false
|
|
126
135
|
try {
|
|
127
136
|
const r = await api(path, body)
|
|
@@ -257,7 +266,7 @@ async function quit() {
|
|
|
257
266
|
quitting = true
|
|
258
267
|
try { await api('/api/quit', {}) } catch { /* it is going away */ }
|
|
259
268
|
note('Witbitz Code stopped. Start menu → Witbitz Code starts it again.', 'bad')
|
|
260
|
-
for (const id of ['pair', 'start', 'stop', 'pairAgain', 'unpair', 'quit']) $(id).disabled = true
|
|
269
|
+
for (const id of ['pair', 'start', 'stop', 'pairAgain', 'pairAdd', 'unpair', 'quit']) $(id).disabled = true
|
|
261
270
|
}
|
|
262
271
|
|
|
263
272
|
document.addEventListener('DOMContentLoaded', () => {
|
|
@@ -268,17 +277,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
268
277
|
$('pair').onclick = () => act('/api/pair', {})
|
|
269
278
|
$('start').onclick = () => act('/api/serve', { on: true })
|
|
270
279
|
$('stop').onclick = () => act('/api/serve', { on: false }, 'Stop? Code in Spaces will not reach this computer until you start it again.')
|
|
271
|
-
$('unpair').onclick = () => {
|
|
272
|
-
const
|
|
273
|
-
if (account
|
|
274
|
-
|
|
280
|
+
$('unpair').onclick = async () => {
|
|
281
|
+
const accounts = ((state && state.paired) || []).map((p) => p.account).filter(Boolean)
|
|
282
|
+
if (accounts.length > 1) { // every account, one at a time (the command unpairs one account per call)
|
|
283
|
+
if (!window.confirm(`Unpair all ${accounts.length} accounts? Code in each of them stops reaching this computer, and its coding keys go back.`)) return
|
|
284
|
+
for (const account of accounts) if (!(await act('/api/unpair', { account }))) return
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
act('/api/unpair', { account: accounts[0] || '' }, 'Unpair this computer? It leaves your Spaces account, its coding keys go back, and Code on every device drops it.')
|
|
275
288
|
}
|
|
289
|
+
|
|
276
290
|
$('pairAgain').onclick = async () => {
|
|
277
291
|
const account = accountOf()
|
|
278
292
|
if (account === null) return
|
|
279
293
|
if (!window.confirm('Pair again? This computer is unpaired first, then shows a new code to scan.')) return
|
|
280
294
|
if (await act('/api/unpair', { account })) act('/api/pair', {})
|
|
281
295
|
}
|
|
296
|
+
// ANOTHER ACCOUNT ON THIS COMPUTER: a new code beside the pairings there are (pairings.json holds one per account, on the
|
|
297
|
+
// same OpenCode); nothing is unpaired. Code here pauses while the code is up and comes back when it is scanned or cancelled.
|
|
298
|
+
$('pairAdd').onclick = () => act('/api/pair', {}, 'Add another account? A new code appears — scan it from the other account: Code in Spaces → Computers → Add a computer. Code on this computer pauses until it is scanned or cancelled.')
|
|
282
299
|
$('pairCancel').onclick = () => act('/api/pair/cancel', {})
|
|
283
300
|
$('pairRetry').onclick = () => act('/api/pair', {})
|
|
284
301
|
$('login').onchange = (e) => act('/api/login', { on: e.target.checked })
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
<button id="start" type="button" hidden>Start</button>
|
|
26
26
|
<button id="stop" type="button" class="ghost" hidden>Stop</button>
|
|
27
27
|
<button id="pairAgain" type="button" class="ghost" hidden>Pair again</button>
|
|
28
|
+
<button id="pairAdd" type="button" class="ghost" hidden>Add another account</button>
|
|
28
29
|
<button id="unpair" type="button" class="ghost danger" hidden>Unpair</button>
|
|
29
30
|
</div>
|
|
30
31
|
<p id="note" class="note" hidden></p>
|
|
@@ -10,7 +10,7 @@ import json
|
|
|
10
10
|
|
|
11
11
|
import httpx
|
|
12
12
|
import pytest
|
|
13
|
-
from conftest import requires_node, run_node
|
|
13
|
+
from conftest import requires_server_keys, requires_node, run_node
|
|
14
14
|
|
|
15
15
|
from witbitz_code import codekeys, confidential as cf, models as mdl
|
|
16
16
|
from witbitz_code.payers import SessionPayers
|
|
@@ -47,6 +47,7 @@ def test_a_secret_the_server_seals_to_this_computer_opens_here_and_nowhere_else(
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
@requires_node
|
|
50
|
+
@requires_server_keys
|
|
50
51
|
def test_this_computers_signature_is_the_one_the_server_accepts_and_only_for_its_op():
|
|
51
52
|
ident = codekeys.new_identity()
|
|
52
53
|
entry = {**ident, "keyId": f"{U}.{K}"}
|
|
@@ -220,3 +220,25 @@ def test_qr_ansi_and_svg_output_are_byte_identical_to_rc_qr():
|
|
|
220
220
|
assert qr.render_ansi(out["rows"], compact=False) == out["large"], "and WITBITZ_QR=large"
|
|
221
221
|
assert qr.render_svg(out["rows"]) == out["svg"]
|
|
222
222
|
assert len(qr.qr_modules(text)) == len(out["rows"]), "and the same version: the smallest that fits at level M"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_link_node_names_the_app_whose_relay_answered(tmp_path):
|
|
226
|
+
"""Each origin's /api/link is polled; the account remembers which answered — the app that scanned (owner, 2026-10-22)."""
|
|
227
|
+
master = bytes([9] * 32)
|
|
228
|
+
ch = devicelink.new_link_challenge()
|
|
229
|
+
out = run_node("""
|
|
230
|
+
import { sealToChallenge, linkPayload } from '@PUBLIC@/deviceLink.js'
|
|
231
|
+
OUT({ reply: await sealToChallenge({ ref: INPUT.ref, pub: INPUT.pub },
|
|
232
|
+
linkPayload({ vault: { master: INPUT.masterB64 }, email: 'me@x', idx: { room: 'sp-idx-9', mk: 'idxmk9' } })) })
|
|
233
|
+
""", {"masterB64": _js.btoa(master), "ref": ch.ref, "pub": ch.pub})
|
|
234
|
+
|
|
235
|
+
async def get_json(url):
|
|
236
|
+
return {"ok": True, **out["reply"]} if url.startswith("https://b.test/") else {"ok": False}
|
|
237
|
+
|
|
238
|
+
async def nopost(url, body):
|
|
239
|
+
return {}
|
|
240
|
+
|
|
241
|
+
account = asyncio.run(link.link_node(life_ms=5000, poll_s=0.01, origins=["https://a.test", "https://b.test"],
|
|
242
|
+
get_json=get_json, post_json=nopost, challenge=ch, svg_path=tmp_path / "q.svg",
|
|
243
|
+
out=lambda *a, **k: None, err=lambda *a: None))
|
|
244
|
+
assert account and account["origin"] == "https://b.test"
|
|
@@ -277,18 +277,18 @@ def test_status_output_is_json_free_of_secrets_even_for_js_written_files():
|
|
|
277
277
|
|
|
278
278
|
|
|
279
279
|
def test_rename_relabels_the_computer_in_the_account_and_here_with_no_scan(monkeypatch):
|
|
280
|
-
# WSL's pairing and Windows' were two "
|
|
281
|
-
api = MockApi(docs={"computers": with_computer(None, "cmp_wsl_1", {"name": "
|
|
282
|
-
entry = {"account": "a@
|
|
280
|
+
# WSL's pairing and Windows' were two "my-laptop" in Code (the owner, 2026-10-09); setup renames WSL's "… (WSL)"
|
|
281
|
+
api = MockApi(docs={"computers": with_computer(None, "cmp_wsl_1", {"name": "my-laptop", "relay": RELAY, "secret": SECRET}, 500)})
|
|
282
|
+
entry = {"account": "a@example.com", "idx": IDX, "computerId": "cmp_wsl_1", "secret": SECRET, "name": "my-laptop", "relay": RELAY,
|
|
283
283
|
"opencodeUrl": "http://127.0.0.1:4096"}
|
|
284
284
|
pp.write_pairings({"v": 1, "pairings": [entry]})
|
|
285
|
-
assert run(pair.rename(entry, "
|
|
285
|
+
assert run(pair.rename(entry, "my-laptop (WSL)", call=api.call, out=lambda m: None, err=lambda m: None)) is True
|
|
286
286
|
reg = norm_registry(api.plain("computers"))
|
|
287
|
-
assert reg["computers"]["cmp_wsl_1"]["name"] == "
|
|
288
|
-
assert [p["name"] for p in pp.read_pairings()["pairings"]] == ["
|
|
287
|
+
assert reg["computers"]["cmp_wsl_1"]["name"] == "my-laptop (WSL)" and reg["computers"]["cmp_wsl_1"]["secret"] == SECRET
|
|
288
|
+
assert [p["name"] for p in pp.read_pairings()["pairings"]] == ["my-laptop (WSL)"]
|
|
289
289
|
refused = MockApi(read_status=500)
|
|
290
|
-
assert run(pair.rename({**entry, "name": "
|
|
291
|
-
assert [p["name"] for p in pp.read_pairings()["pairings"]] == ["
|
|
290
|
+
assert run(pair.rename({**entry, "name": "my-laptop (WSL)"}, "x", call=refused.call, out=lambda m: None, err=lambda m: None)) is False
|
|
291
|
+
assert [p["name"] for p in pp.read_pairings()["pairings"]] == ["my-laptop (WSL)"], "nothing renamed here when the account refused"
|
|
292
292
|
|
|
293
293
|
|
|
294
294
|
def test_a_new_pairing_on_wsl_is_named_for_wsl(monkeypatch):
|
|
@@ -296,8 +296,66 @@ def test_a_new_pairing_on_wsl_is_named_for_wsl(monkeypatch):
|
|
|
296
296
|
|
|
297
297
|
from witbitz_code import ports
|
|
298
298
|
|
|
299
|
-
monkeypatch.setattr(socket, "gethostname", lambda: "
|
|
299
|
+
monkeypatch.setattr(socket, "gethostname", lambda: "my-laptop")
|
|
300
300
|
monkeypatch.setattr(ports, "on_wsl", lambda: True)
|
|
301
|
-
assert pp.hostname() == "
|
|
301
|
+
assert pp.hostname() == "my-laptop (WSL)"
|
|
302
302
|
monkeypatch.setattr(ports, "on_wsl", lambda: False)
|
|
303
|
-
assert pp.hostname() == "
|
|
303
|
+
assert pp.hostname() == "my-laptop"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_a_pairing_from_the_hosted_app_writes_that_app_s_account_and_remembers_it():
|
|
307
|
+
"""The owner, 2026-10-22: a second account added from the vaulted app failed "computers read refused (404)" — its
|
|
308
|
+
rooms answer only to the hosted app's origin. The relay that answered the scan names the app; each pairing keeps it."""
|
|
309
|
+
regular, hosted = MockApi(), MockApi()
|
|
310
|
+
asked = []
|
|
311
|
+
|
|
312
|
+
def for_origin(o):
|
|
313
|
+
asked.append(o)
|
|
314
|
+
return hosted.call
|
|
315
|
+
|
|
316
|
+
async def call(body):
|
|
317
|
+
return await regular.call(body)
|
|
318
|
+
call.for_origin = for_origin
|
|
319
|
+
keys = FakeKeys()
|
|
320
|
+
|
|
321
|
+
async def link_fn():
|
|
322
|
+
return {"email": "me@x", "idx": IDX, "master": "M" * 43, "anchor": ANCHOR, "origin": "https://spaces.witbitz.chat"}
|
|
323
|
+
|
|
324
|
+
cap = Capture()
|
|
325
|
+
assert run(pair.main(["--name", "desk"], call=call, link_fn=link_fn, keys=keys, **cap.kw())) == 0, cap.text()
|
|
326
|
+
assert asked and set(asked) == {"https://spaces.witbitz.chat"}
|
|
327
|
+
assert regular.writes == [] and hosted.writes, "the account was written through the hosted app's origin only"
|
|
328
|
+
p = pp.read_pairings()["pairings"][0]
|
|
329
|
+
assert p["origin"] == "https://spaces.witbitz.chat"
|
|
330
|
+
|
|
331
|
+
async def link_fn2():
|
|
332
|
+
return {"email": "work@y", "idx": IDX2, "master": "M" * 43, "anchor": {**ANCHOR, "email": "work@y"},
|
|
333
|
+
"origin": "https://witbitz-spaces.pages.dev"}
|
|
334
|
+
|
|
335
|
+
asked.clear()
|
|
336
|
+
assert run(pair.main([], call=call, link_fn=link_fn2, keys=keys, **Capture().kw())) == 0
|
|
337
|
+
assert set(asked) == {"https://witbitz-spaces.pages.dev"}, "a regular-app pairing writes through its own origin"
|
|
338
|
+
q = next(x for x in pp.read_pairings()["pairings"] if x["account"] == "work@y")
|
|
339
|
+
assert q["origin"] == "https://witbitz-spaces.pages.dev"
|
|
340
|
+
|
|
341
|
+
asked.clear()
|
|
342
|
+
assert run(pair.main(["--rotate", "--account", "me@x"], call=call, **Capture().kw())) == 0
|
|
343
|
+
assert set(asked) == {"https://spaces.witbitz.chat"}, "a rotate re-publishes to the app it was paired from"
|
|
344
|
+
assert next(x for x in pp.read_pairings()["pairings"] if x["account"] == "me@x")["origin"] == "https://spaces.witbitz.chat"
|
|
345
|
+
|
|
346
|
+
n = len(hosted.writes)
|
|
347
|
+
assert run(pair.main(["--unpair", "--account", "me@x"], call=call, keys=keys, **Capture().kw())) == 0
|
|
348
|
+
assert len(hosted.writes) > n, "the unpair tombstone goes to the hosted app's account too"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_call_for_ignores_anything_that_is_not_an_https_origin():
|
|
352
|
+
from witbitz_code import net
|
|
353
|
+
|
|
354
|
+
async def call(body):
|
|
355
|
+
return None
|
|
356
|
+
call.for_origin = lambda o: ("other", o)
|
|
357
|
+
assert net.call_for(call, None) is call
|
|
358
|
+
assert net.call_for(call, "http://spaces.witbitz.chat") is call
|
|
359
|
+
assert net.call_for(call, "https://evil.test/path") is call
|
|
360
|
+
assert net.call_for(call, "https://spaces.witbitz.chat") == ("other", "https://spaces.witbitz.chat")
|
|
361
|
+
assert net.call_for(lambda b: None, "https://spaces.witbitz.chat") is not None
|
|
@@ -108,7 +108,7 @@ class Fake:
|
|
|
108
108
|
async def pair(port):
|
|
109
109
|
self.paired.append(port)
|
|
110
110
|
if not any(p.get("computerId") == "c" for p in self._pairings):
|
|
111
|
-
self._pairings.append({"name": "desk", "account": "a@
|
|
111
|
+
self._pairings.append({"name": "desk", "account": "a@example.com", "opencodeUrl": f"http://127.0.0.1:{port}", "idx": {"room": "r"}, "computerId": "c"})
|
|
112
112
|
if self._gives:
|
|
113
113
|
self._keyed.add("c")
|
|
114
114
|
async def listening(port):
|
|
@@ -213,14 +213,14 @@ def test_s_stops_setup_and_leaves_the_running_one_alone():
|
|
|
213
213
|
def test_a_computer_removed_from_the_account_is_paired_again_and_shows_the_qr():
|
|
214
214
|
# the owner, 2026-10-08: reinstalled after the computer was removed and "it does not present the QR code" — its pairing
|
|
215
215
|
# and keys were still on disk, so setup said "✓ Paired" over keys the account had already taken back
|
|
216
|
-
f = Fake(pairings=[{"name": "desk", "account": "a@
|
|
216
|
+
f = Fake(pairings=[{"name": "desk", "account": "a@example.com", "opencodeUrl": "http://127.0.0.1:4096", "idx": {"room": "r"}, "computerId": "c"}], keyed={"c"})
|
|
217
217
|
asked = []
|
|
218
218
|
async def keys_state(p):
|
|
219
219
|
asked.append(p["computerId"])
|
|
220
220
|
return "live" if f.paired else "revoked"
|
|
221
221
|
r = run(wizard.run_setup(f.deps(keys_state=keys_state, forget_keys=lambda p: f._keyed.discard(p["computerId"]))))
|
|
222
222
|
assert f.paired == [4096] and r["paired"] and r["keys"] and f.served == [4096]
|
|
223
|
-
assert any('"desk" → a@
|
|
223
|
+
assert any('"desk" → a@example.com was removed from your Witbitz account' in line for line in f.said)
|
|
224
224
|
assert any("scan the code below" in line for line in f.said)
|
|
225
225
|
assert not any(line.startswith(" ✓ Paired") for line in f.said[:f.said.index(next(x for x in f.said if "was removed" in x))])
|
|
226
226
|
|
|
@@ -248,7 +248,7 @@ def test_windows_and_wsl_each_run_their_own_the_windows_one_moves_off_the_port_w
|
|
|
248
248
|
|
|
249
249
|
|
|
250
250
|
def test_on_wsl_a_pairing_named_like_the_windows_computer_is_renamed_once():
|
|
251
|
-
# WSL calls itself by the Windows computer's name: Windows' and WSL's pairings were two "
|
|
251
|
+
# WSL calls itself by the Windows computer's name: Windows' and WSL's pairings were two "my-laptop" in Code
|
|
252
252
|
f = Fake(pairings=[DESK], keyed={"c"})
|
|
253
253
|
renamed = []
|
|
254
254
|
|
|
@@ -11,7 +11,7 @@ import httpx
|
|
|
11
11
|
|
|
12
12
|
from witbitz_code import ui
|
|
13
13
|
|
|
14
|
-
DESK = {"name": "
|
|
14
|
+
DESK = {"name": "my-laptop", "account": "a@example.com", "opencodeUrl": "http://127.0.0.1:4097", "idx": {"room": "r"}, "computerId": "c"}
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def run(coro):
|
|
@@ -39,11 +39,11 @@ class World:
|
|
|
39
39
|
self.pair_calls.append(argv)
|
|
40
40
|
if "--unpair" in argv:
|
|
41
41
|
self.pairings = []
|
|
42
|
-
out("witbitz-code: ✓ removed \"
|
|
42
|
+
out("witbitz-code: ✓ removed \"my-laptop\" from a@example.com — every device drops it on its next sync")
|
|
43
43
|
return 0
|
|
44
44
|
await self.scan.wait()
|
|
45
45
|
self.pairings = [{**DESK, "opencodeUrl": f"http://127.0.0.1:{argv[argv.index('--port') + 1]}"}]
|
|
46
|
-
out("witbitz-code: ✓ paired \"
|
|
46
|
+
out("witbitz-code: ✓ paired \"my-laptop\" with a@example.com (pairings.json)")
|
|
47
47
|
return 0
|
|
48
48
|
|
|
49
49
|
def manager(self, listening=()):
|
|
@@ -58,7 +58,7 @@ class World:
|
|
|
58
58
|
keys_line=keys_line, login_spec=spec, login_state=lambda s: "on" if self.login["on"] else "off",
|
|
59
59
|
login_enable=lambda s: (self.login.__setitem__("on", True), (True, ""))[1],
|
|
60
60
|
login_disable=lambda s: self.login.__setitem__("on", False) or True,
|
|
61
|
-
label={"label": "
|
|
61
|
+
label={"label": "my-laptop", "kind": "Windows"}, log=deque(maxlen=60))
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
async def with_server(m):
|
|
@@ -113,8 +113,8 @@ def test_status_start_and_stop_serving_on_the_paired_port():
|
|
|
113
113
|
try:
|
|
114
114
|
async with httpx.AsyncClient() as c:
|
|
115
115
|
st = (await c.get(f"{base}/api/status", headers=authed(srv))).json()
|
|
116
|
-
assert st["computer"]["label"] == "
|
|
117
|
-
assert st["paired"] == [{"name": "
|
|
116
|
+
assert st["computer"]["label"] == "my-laptop" and st["serving"]["state"] == "stopped"
|
|
117
|
+
assert st["paired"] == [{"name": "my-laptop", "account": "a@example.com", "port": 4097, "keys": "coding keys: live · models this month $1.2 of $50 · tools up to $5"}]
|
|
118
118
|
assert (await c.post(f"{base}/api/serve", headers=authed(srv), json={"on": True})).json() == {"ok": True}
|
|
119
119
|
assert await until(lambda: _state(c, base, srv, "online"))
|
|
120
120
|
assert w.serves == [["--port", "4097"]], "the port this computer is paired for"
|
|
@@ -187,11 +187,11 @@ def test_a_cancelled_pairing_goes_back_to_serving_and_the_login_switch_writes_th
|
|
|
187
187
|
|
|
188
188
|
|
|
189
189
|
def test_the_page_names_which_computer_it_controls(monkeypatch):
|
|
190
|
-
monkeypatch.setattr(ui.socket, "gethostname", lambda: "
|
|
190
|
+
monkeypatch.setattr(ui.socket, "gethostname", lambda: "my-laptop")
|
|
191
191
|
monkeypatch.setattr(ui.ports, "on_wsl", lambda: True)
|
|
192
192
|
monkeypatch.setattr(ui.sys, "platform", "linux")
|
|
193
193
|
monkeypatch.setenv("WSL_DISTRO_NAME", "UbuntuD")
|
|
194
|
-
assert ui.computer_label() == {"label": "UbuntuD (WSL) on
|
|
194
|
+
assert ui.computer_label() == {"label": "UbuntuD (WSL) on my-laptop", "kind": "WSL UbuntuD"}
|
|
195
195
|
|
|
196
196
|
|
|
197
197
|
def _other_window_manager(w, held):
|
|
@@ -208,7 +208,7 @@ def _other_window_manager(w, held):
|
|
|
208
208
|
|
|
209
209
|
m = ui.Manager(serve=w.serve, is_listening=is_listening, pair=w.pair, load_pairings=lambda: list(w.pairings),
|
|
210
210
|
port_owners=lambda q: [{"pid": 19824, "cmd": "python.exe"}] if q in held else [],
|
|
211
|
-
stop_pid=stop_pid, login_spec=lambda: None, label={"label": "
|
|
211
|
+
stop_pid=stop_pid, login_spec=lambda: None, label={"label": "my-laptop", "kind": "Windows"}, log=deque(maxlen=60))
|
|
212
212
|
return m, stopped
|
|
213
213
|
|
|
214
214
|
|
|
@@ -334,3 +334,58 @@ def test_opening_the_page_asks_for_a_release_again_a_status_poll_does_not():
|
|
|
334
334
|
await m.shutdown()
|
|
335
335
|
await srv.close()
|
|
336
336
|
run(t())
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class TwoAccounts(World):
|
|
340
|
+
"""A second account pairs beside the first (pairings.json holds one entry per account), and unpairs alone."""
|
|
341
|
+
|
|
342
|
+
async def pair(self, argv, *, link_fn=None, out=print, err=print):
|
|
343
|
+
self.pair_calls.append(argv)
|
|
344
|
+
if "--unpair" in argv:
|
|
345
|
+
who = argv[argv.index("--account") + 1] if "--account" in argv else ""
|
|
346
|
+
self.pairings = [p for p in self.pairings if who and p["account"] != who]
|
|
347
|
+
out(f"witbitz-code: ✓ removed \"my-laptop\" from {who or 'every account'}")
|
|
348
|
+
return 0
|
|
349
|
+
await self.scan.wait()
|
|
350
|
+
port = argv[argv.index("--port") + 1]
|
|
351
|
+
self.pairings = self.pairings + [{**DESK, "account": "b@example.com", "idx": {"room": "r2"}, "opencodeUrl": f"http://127.0.0.1:{port}"}]
|
|
352
|
+
out("witbitz-code: ✓ paired \"my-laptop\" with b@example.com (pairings.json)")
|
|
353
|
+
return 0
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_another_account_pairs_beside_the_first_and_one_unpairs_while_the_other_keeps_serving():
|
|
357
|
+
# The owner, 2026-10-22: "can we make it so it can handle multiple accounts?" — the page's Add another account
|
|
358
|
+
async def go():
|
|
359
|
+
w = TwoAccounts([DESK])
|
|
360
|
+
m = w.manager(listening={4096, 4196})
|
|
361
|
+
srv, base = await with_server(m)
|
|
362
|
+
try:
|
|
363
|
+
await m.start_serving()
|
|
364
|
+
async with httpx.AsyncClient() as c:
|
|
365
|
+
assert (await c.post(f"{base}/api/pair", headers=authed(srv), json={})).json() == {"ok": True}
|
|
366
|
+
assert w.pair_calls[-1] == ["--port", "4097"], "the same OpenCode as the first account's — nothing unpaired"
|
|
367
|
+
w.scan.set()
|
|
368
|
+
assert await until(lambda: _state(c, base, srv, "online"))
|
|
369
|
+
st = (await c.get(f"{base}/api/status", headers=authed(srv))).json()
|
|
370
|
+
assert sorted(p["account"] for p in st["paired"]) == ["a@example.com", "b@example.com"]
|
|
371
|
+
# one account leaves; the other stays paired AND reachable
|
|
372
|
+
assert (await c.post(f"{base}/api/unpair", headers=authed(srv), json={"account": "b@example.com"})).json() == {"ok": True}
|
|
373
|
+
assert w.pair_calls[-1] == ["--unpair", "--account", "b@example.com"]
|
|
374
|
+
assert [p["account"] for p in w.pairings] == ["a@example.com"]
|
|
375
|
+
assert await until(lambda: _state(c, base, srv, "online")), "serving resumes for the account that stayed"
|
|
376
|
+
finally:
|
|
377
|
+
await m.shutdown()
|
|
378
|
+
await srv.close()
|
|
379
|
+
|
|
380
|
+
run(go())
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_the_page_offers_another_account_and_an_unpair_per_account():
|
|
384
|
+
from pathlib import Path
|
|
385
|
+
root = Path(ui.__file__).parent / "ui_static"
|
|
386
|
+
html, js = (root / "index.html").read_text(), (root / "app.js").read_text()
|
|
387
|
+
assert 'id="pairAdd"' in html and "Add another account" in html
|
|
388
|
+
assert "show($('pairAdd'), paired && !waiting)" in js
|
|
389
|
+
assert "$('pairAdd').onclick = () => act('/api/pair', {}" in js, "a new code beside the pairings there are — no unpair first"
|
|
390
|
+
assert "if (s.paired.length > 1 && p.account)" in js and "act('/api/unpair', { account: p.account }" in js
|
|
391
|
+
assert "for (const account of accounts) if (!(await act('/api/unpair', { account }))) return" in js, "Unpair all = each account in turn"
|
|
@@ -75,7 +75,7 @@ class Fake:
|
|
|
75
75
|
**{"login_start": lambda: None, "menu_remove": lambda: False, "running": lambda: [], **over}) # never this machine's Windows Startup folder or Start menu
|
|
76
76
|
|
|
77
77
|
|
|
78
|
-
PAIR = {"name": "desk", "account": "a@
|
|
78
|
+
PAIR = {"name": "desk", "account": "a@example.com", "idx": {"room": "r"}, "computerId": "c"}
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
def test_saying_no_changes_nothing():
|
|
@@ -207,7 +207,7 @@ def test_what_runs_witbitz_code_is_the_page_s_manager_and_the_paired_ports_owner
|
|
|
207
207
|
|
|
208
208
|
|
|
209
209
|
def test_windows_finds_opencode_by_name():
|
|
210
|
-
out = '"opencode.exe","19228","Console","1","95,000 K","Running","
|
|
210
|
+
out = '"opencode.exe","19228","Console","1","95,000 K","Running","MY-LAPTOP\\shter","0:00:03","N/A"\n' \
|
|
211
211
|
'"opencode.exe","555","Console","1","1 K","Running","OTHER\\bob","0:00:01","N/A"\n'
|
|
212
212
|
assert un.win_opencode_processes(run=lambda argv: out, user="shter") == [{"pid": 19228, "cmd": "opencode.exe"}]
|
|
213
213
|
|
|
@@ -12,7 +12,7 @@ from witbitz_code import wslstart
|
|
|
12
12
|
|
|
13
13
|
def test_the_shortcut_runs_this_distro_this_user_from_home_then_serve():
|
|
14
14
|
assert wslstart.shortcut_name("UbuntuD") == "Witbitz Code (WSL UbuntuD).lnk" and wslstart.shortcut_name("") == "Witbitz Code (WSL).lnk"
|
|
15
|
-
assert wslstart.wsl_args("UbuntuD", "ubuntu", ["/home/
|
|
15
|
+
assert wslstart.wsl_args("UbuntuD", "ubuntu", ["/home/dana/.local/bin/witbitz-code"]) == "-d UbuntuD -u ubuntu --cd ~ -- /home/dana/.local/bin/witbitz-code serve"
|
|
16
16
|
assert wslstart.wsl_args("", "a b", ["/x y/witbitz-code"]) == '-u "a b" --cd ~ -- "/x y/witbitz-code" serve'
|
|
17
17
|
s = wslstart.enable_script("it's.lnk", "-d X -- /w serve")
|
|
18
18
|
assert "'it''s.lnk'" in s and "$s.WindowStyle = 7" in s and "System32\\wsl.exe" in s and "[Environment]::GetFolderPath('Startup')" in s
|
|
@@ -32,7 +32,7 @@ def test_state_enable_and_disable_read_what_powershell_says():
|
|
|
32
32
|
|
|
33
33
|
@requires_node
|
|
34
34
|
def test_the_javascript_writes_the_same_shortcut():
|
|
35
|
-
cases = [["UbuntuD", "ubuntu", ["/home/
|
|
35
|
+
cases = [["UbuntuD", "ubuntu", ["/home/dana/.local/bin/witbitz-code"]], ["", "a b", ["/usr/bin/node", "/x y/witbitz-code.mjs"]]]
|
|
36
36
|
js = run_node("""
|
|
37
37
|
import { shortcutName, wslArgs, enableScript, stateScript, disableScript } from '@TOOLS@/code-wsl-start.mjs'
|
|
38
38
|
OUT(INPUT.cases.map(([d, u, e]) => [shortcutName(d), wslArgs(d, u, e), enableScript(shortcutName(d), wslArgs(d, u, e)), stateScript(shortcutName(d)), disableScript(shortcutName(d))]))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs
RENAMED
|
File without changes
|
{witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs
RENAMED
|
File without changes
|
|
File without changes
|
{witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs
RENAMED
|
File without changes
|
{witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browser.js
RENAMED
|
File without changes
|
{witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs
RENAMED
|
File without changes
|
{witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-notes.js
RENAMED
|
File without changes
|
{witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-progress.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|