witbitz-code 1.9.4__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.4/src/witbitz_code.egg-info → witbitz_code-1.9.5}/PKG-INFO +1 -1
  2. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/pyproject.toml +1 -1
  3. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/cli.py +1 -1
  5. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/link.py +6 -1
  6. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/net.py +19 -3
  7. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/pair.py +5 -3
  8. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/pairings.py +7 -2
  9. {witbitz_code-1.9.4 → witbitz_code-1.9.5/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  10. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_devicelink.py +22 -0
  11. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_pair_flows.py +58 -0
  12. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/LICENSE +0 -0
  13. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/README.md +0 -0
  14. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/setup.cfg +0 -0
  15. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/__main__.py +0 -0
  16. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/_js.py +0 -0
  17. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/account.py +0 -0
  18. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/asks.py +0 -0
  19. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/attachments.py +0 -0
  20. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/auto.py +0 -0
  21. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/auto_runner.py +0 -0
  22. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/browse.py +0 -0
  23. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/browsetree.py +0 -0
  24. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/codekeys.py +0 -0
  25. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/codetools.py +0 -0
  26. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/compress.py +0 -0
  27. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/computers.py +0 -0
  28. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/confidential.py +0 -0
  29. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/confidential_models.json +0 -0
  30. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/connector.py +0 -0
  31. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/devicelink.py +0 -0
  32. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/dirs.py +0 -0
  33. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/folders.py +0 -0
  34. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/gateway.py +0 -0
  35. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  36. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  37. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/images.py +0 -0
  38. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/keys.py +0 -0
  39. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/mkdir.py +0 -0
  40. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/models.py +0 -0
  41. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/offered_models.json +0 -0
  42. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  43. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
  44. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
  45. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
  46. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
  47. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
  48. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  49. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  50. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  51. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/outputs.py +0 -0
  52. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/payers.py +0 -0
  53. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/plugins.py +0 -0
  54. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/policy.py +0 -0
  55. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/ports.py +0 -0
  56. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/qr.py +0 -0
  57. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/receipt.py +0 -0
  58. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/relay.py +0 -0
  59. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/remind.py +0 -0
  60. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/seccheck.py +0 -0
  61. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/seen.py +0 -0
  62. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/selfupdate.py +0 -0
  63. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/tinfoil_read.py +0 -0
  64. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/tools_catalog.json +0 -0
  65. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/tools_probe.py +0 -0
  66. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/toolsetup.py +0 -0
  67. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/ui.py +0 -0
  68. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/ui_static/app.css +0 -0
  69. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/ui_static/app.js +0 -0
  70. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/ui_static/index.html +0 -0
  71. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/uitools.py +0 -0
  72. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/uninstall.py +0 -0
  73. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/winagent.py +0 -0
  74. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/winbg.py +0 -0
  75. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/winconsole.py +0 -0
  76. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/wizard.py +0 -0
  77. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code/wslstart.py +0 -0
  78. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
  79. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  80. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  81. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/requires.txt +0 -0
  82. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/src/witbitz_code.egg-info/top_level.txt +0 -0
  83. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_asks.py +0 -0
  84. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_attachments.py +0 -0
  85. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_auto.py +0 -0
  86. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_browse.py +0 -0
  87. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_browsetree.py +0 -0
  88. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_cli.py +0 -0
  89. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_codekeys.py +0 -0
  90. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_codetools.py +0 -0
  91. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_confidential.py +0 -0
  92. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_connector_e2e.py +0 -0
  93. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_dirs.py +0 -0
  94. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_files_and_compress.py +0 -0
  95. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_folders.py +0 -0
  96. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_images.py +0 -0
  97. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_outputs.py +0 -0
  98. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_plugins.py +0 -0
  99. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_policy.py +0 -0
  100. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_ports.py +0 -0
  101. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_ports_win.py +0 -0
  102. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_registry.py +0 -0
  103. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_relay_codec.py +0 -0
  104. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_remind.py +0 -0
  105. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_seccheck.py +0 -0
  106. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_selfupdate.py +0 -0
  107. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_setup.py +0 -0
  108. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_tools_probe.py +0 -0
  109. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_toolsetup.py +0 -0
  110. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_ui.py +0 -0
  111. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_uitools.py +0 -0
  112. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_uninstall.py +0 -0
  113. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_winagent.py +0 -0
  114. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_winbg.py +0 -0
  115. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_winconsole.py +0 -0
  116. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_windows_files.py +0 -0
  117. {witbitz_code-1.9.4 → witbitz_code-1.9.5}/tests/test_wslstart.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.9.4
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.4"
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.4"
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.9.4
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
@@ -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"
@@ -301,3 +301,61 @@ def test_a_new_pairing_on_wsl_is_named_for_wsl(monkeypatch):
301
301
  assert pp.hostname() == "my-laptop (WSL)"
302
302
  monkeypatch.setattr(ports, "on_wsl", lambda: False)
303
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
File without changes
File without changes
File without changes