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.
Files changed (117) hide show
  1. {witbitz_code-1.9.3/src/witbitz_code.egg-info → witbitz_code-1.9.5}/PKG-INFO +1 -1
  2. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/pyproject.toml +1 -1
  3. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/cli.py +1 -1
  5. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/link.py +6 -1
  6. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/net.py +19 -3
  7. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/pair.py +5 -3
  8. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/pairings.py +7 -2
  9. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/ui.py +2 -0
  10. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/ui_static/app.js +24 -7
  11. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/ui_static/index.html +1 -0
  12. {witbitz_code-1.9.3 → witbitz_code-1.9.5/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  13. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_codekeys.py +2 -1
  14. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_devicelink.py +22 -0
  15. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_pair_flows.py +69 -11
  16. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_setup.py +4 -4
  17. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_ui.py +64 -9
  18. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_uninstall.py +2 -2
  19. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_wslstart.py +2 -2
  20. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/LICENSE +0 -0
  21. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/README.md +0 -0
  22. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/setup.cfg +0 -0
  23. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/__main__.py +0 -0
  24. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/_js.py +0 -0
  25. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/account.py +0 -0
  26. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/asks.py +0 -0
  27. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/attachments.py +0 -0
  28. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/auto.py +0 -0
  29. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/auto_runner.py +0 -0
  30. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/browse.py +0 -0
  31. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/browsetree.py +0 -0
  32. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/codekeys.py +0 -0
  33. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/codetools.py +0 -0
  34. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/compress.py +0 -0
  35. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/computers.py +0 -0
  36. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/confidential.py +0 -0
  37. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/confidential_models.json +0 -0
  38. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/connector.py +0 -0
  39. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/devicelink.py +0 -0
  40. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/dirs.py +0 -0
  41. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/folders.py +0 -0
  42. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/gateway.py +0 -0
  43. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  44. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  45. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/images.py +0 -0
  46. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/keys.py +0 -0
  47. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/mkdir.py +0 -0
  48. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/models.py +0 -0
  49. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/offered_models.json +0 -0
  50. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  51. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
  52. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
  53. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
  54. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
  55. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
  56. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  57. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  58. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  59. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/outputs.py +0 -0
  60. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/payers.py +0 -0
  61. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/plugins.py +0 -0
  62. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/policy.py +0 -0
  63. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/ports.py +0 -0
  64. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/qr.py +0 -0
  65. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/receipt.py +0 -0
  66. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/relay.py +0 -0
  67. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/remind.py +0 -0
  68. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/seccheck.py +0 -0
  69. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/seen.py +0 -0
  70. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/selfupdate.py +0 -0
  71. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/tinfoil_read.py +0 -0
  72. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/tools_catalog.json +0 -0
  73. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/tools_probe.py +0 -0
  74. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/toolsetup.py +0 -0
  75. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/ui_static/app.css +0 -0
  76. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/uitools.py +0 -0
  77. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/uninstall.py +0 -0
  78. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/winagent.py +0 -0
  79. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/winbg.py +0 -0
  80. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/winconsole.py +0 -0
  81. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/wizard.py +0 -0
  82. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code/wslstart.py +0 -0
  83. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
  84. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  85. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  86. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/requires.txt +0 -0
  87. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/top_level.txt +0 -0
  88. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_asks.py +0 -0
  89. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_attachments.py +0 -0
  90. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_auto.py +0 -0
  91. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_browse.py +0 -0
  92. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_browsetree.py +0 -0
  93. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_cli.py +0 -0
  94. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_codetools.py +0 -0
  95. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_confidential.py +0 -0
  96. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_connector_e2e.py +0 -0
  97. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_dirs.py +0 -0
  98. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_files_and_compress.py +0 -0
  99. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_folders.py +0 -0
  100. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_images.py +0 -0
  101. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_outputs.py +0 -0
  102. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_plugins.py +0 -0
  103. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_policy.py +0 -0
  104. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_ports.py +0 -0
  105. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_ports_win.py +0 -0
  106. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_registry.py +0 -0
  107. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_relay_codec.py +0 -0
  108. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_remind.py +0 -0
  109. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_seccheck.py +0 -0
  110. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_selfupdate.py +0 -0
  111. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_tools_probe.py +0 -0
  112. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_toolsetup.py +0 -0
  113. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_uitools.py +0 -0
  114. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_winagent.py +0 -0
  115. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_winbg.py +0 -0
  116. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_winconsole.py +0 -0
  117. {witbitz_code-1.9.3 → witbitz_code-1.9.5}/tests/test_windows_files.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.9.3
3
+ Version: 1.9.5
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.9.3"
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"
@@ -4,4 +4,4 @@ The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phas
4
4
  vectors and cross-implementation tests.
5
5
  """
6
6
 
7
- __version__ = "1.9.3"
7
+ __version__ = "1.9.5"
@@ -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 make_api_call(client: httpx.AsyncClient) -> ApiCall:
35
- """POST {op:'state', …} to the account API, as the Spaces app would (same Origin)."""
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), rotate=True)
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, rotate: bool = False, mint_id: Callable[[], str] = new_computer_id,
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
- for (const id of ['pair', 'start', 'stop', 'pairAgain', 'unpair', 'pairCancel', 'pairRetry']) $(id).disabled = busy
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 account = accountOf()
273
- if (account === null) return
274
- act('/api/unpair', { account }, 'Unpair this computer? It leaves your Spaces account, its coding keys go back, and Code on every device drops it.')
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>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.9.3
3
+ Version: 1.9.5
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -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 "AlonASUS" in Code (the owner, 2026-10-09); setup renames WSL's "… (WSL)"
281
- api = MockApi(docs={"computers": with_computer(None, "cmp_wsl_1", {"name": "AlonASUS", "relay": RELAY, "secret": SECRET}, 500)})
282
- entry = {"account": "a@x.com", "idx": IDX, "computerId": "cmp_wsl_1", "secret": SECRET, "name": "AlonASUS", "relay": RELAY,
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, "AlonASUS (WSL)", call=api.call, out=lambda m: None, err=lambda m: None)) is True
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"] == "AlonASUS (WSL)" and reg["computers"]["cmp_wsl_1"]["secret"] == SECRET
288
- assert [p["name"] for p in pp.read_pairings()["pairings"]] == ["AlonASUS (WSL)"]
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": "AlonASUS (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"]] == ["AlonASUS (WSL)"], "nothing renamed here when the account refused"
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: "AlonASUS")
299
+ monkeypatch.setattr(socket, "gethostname", lambda: "my-laptop")
300
300
  monkeypatch.setattr(ports, "on_wsl", lambda: True)
301
- assert pp.hostname() == "AlonASUS (WSL)"
301
+ assert pp.hostname() == "my-laptop (WSL)"
302
302
  monkeypatch.setattr(ports, "on_wsl", lambda: False)
303
- assert pp.hostname() == "AlonASUS"
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@x.com", "opencodeUrl": f"http://127.0.0.1:{port}", "idx": {"room": "r"}, "computerId": "c"})
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@x.com", "opencodeUrl": "http://127.0.0.1:4096", "idx": {"room": "r"}, "computerId": "c"}], keyed={"c"})
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@x.com was removed from your Witbitz account' in line for line in f.said)
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 "AlonASUS" in Code
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": "AlonASUS", "account": "a@x.com", "opencodeUrl": "http://127.0.0.1:4097", "idx": {"room": "r"}, "computerId": "c"}
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 \"AlonASUS\" from a@x.com — every device drops it on its next sync")
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 \"AlonASUS\" with a@x.com (pairings.json)")
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": "AlonASUS", "kind": "Windows"}, log=deque(maxlen=60))
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"] == "AlonASUS" and st["serving"]["state"] == "stopped"
117
- assert st["paired"] == [{"name": "AlonASUS", "account": "a@x.com", "port": 4097, "keys": "coding keys: live · models this month $1.2 of $50 · tools up to $5"}]
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: "AlonASUS")
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 AlonASUS", "kind": "WSL UbuntuD"}
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": "AlonASUS", "kind": "Windows"}, log=deque(maxlen=60))
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@x.com", "idx": {"room": "r"}, "computerId": "c"}
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","ALONASUS\\shter","0:00:03","N/A"\n' \
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/ubuntu/.local/bin/witbitz-code"]) == "-d UbuntuD -u ubuntu --cd ~ -- /home/ubuntu/.local/bin/witbitz-code serve"
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/ubuntu/.local/bin/witbitz-code"]], ["", "a b", ["/usr/bin/node", "/x y/witbitz-code.mjs"]]]
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