witbitz-code 1.7.2__tar.gz → 1.7.4__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 (108) hide show
  1. {witbitz_code-1.7.2/src/witbitz_code.egg-info → witbitz_code-1.7.4}/PKG-INFO +1 -1
  2. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/pyproject.toml +1 -1
  3. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/confidential.py +5 -1
  5. witbitz_code-1.7.4/src/witbitz_code/selfupdate.py +120 -0
  6. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/ui.py +38 -6
  7. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/ui_static/app.js +28 -0
  8. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/ui_static/index.html +7 -0
  9. {witbitz_code-1.7.2 → witbitz_code-1.7.4/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  10. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code.egg-info/SOURCES.txt +2 -0
  11. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_confidential.py +15 -1
  12. witbitz_code-1.7.4/tests/test_selfupdate.py +94 -0
  13. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_ui.py +31 -0
  14. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/LICENSE +0 -0
  15. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/README.md +0 -0
  16. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/setup.cfg +0 -0
  17. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/__main__.py +0 -0
  18. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/_js.py +0 -0
  19. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/account.py +0 -0
  20. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/asks.py +0 -0
  21. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/attachments.py +0 -0
  22. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/auto.py +0 -0
  23. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/auto_runner.py +0 -0
  24. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/cli.py +0 -0
  25. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/codekeys.py +0 -0
  26. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/codetools.py +0 -0
  27. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/compress.py +0 -0
  28. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/computers.py +0 -0
  29. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/confidential_models.json +0 -0
  30. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/connector.py +0 -0
  31. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/devicelink.py +0 -0
  32. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/dirs.py +0 -0
  33. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/folders.py +0 -0
  34. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/gateway.py +0 -0
  35. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  36. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  37. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/images.py +0 -0
  38. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/keys.py +0 -0
  39. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/link.py +0 -0
  40. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/mkdir.py +0 -0
  41. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/models.py +0 -0
  42. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/net.py +0 -0
  43. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/offered_models.json +0 -0
  44. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  45. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  46. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  47. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  48. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/outputs.py +0 -0
  49. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/pair.py +0 -0
  50. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/pairings.py +0 -0
  51. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/payers.py +0 -0
  52. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/plugins.py +0 -0
  53. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/policy.py +0 -0
  54. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/ports.py +0 -0
  55. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/qr.py +0 -0
  56. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/receipt.py +0 -0
  57. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/relay.py +0 -0
  58. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/remind.py +0 -0
  59. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/seccheck.py +0 -0
  60. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/seen.py +0 -0
  61. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/tinfoil_read.py +0 -0
  62. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/tools_catalog.json +0 -0
  63. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/tools_probe.py +0 -0
  64. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/toolsetup.py +0 -0
  65. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/ui_static/app.css +0 -0
  66. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/uitools.py +0 -0
  67. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/uninstall.py +0 -0
  68. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/winagent.py +0 -0
  69. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/winbg.py +0 -0
  70. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/winconsole.py +0 -0
  71. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/wizard.py +0 -0
  72. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code/wslstart.py +0 -0
  73. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  74. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  75. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code.egg-info/requires.txt +0 -0
  76. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/src/witbitz_code.egg-info/top_level.txt +0 -0
  77. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_asks.py +0 -0
  78. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_attachments.py +0 -0
  79. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_auto.py +0 -0
  80. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_cli.py +0 -0
  81. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_codekeys.py +0 -0
  82. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_codetools.py +0 -0
  83. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_connector_e2e.py +0 -0
  84. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_devicelink.py +0 -0
  85. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_dirs.py +0 -0
  86. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_files_and_compress.py +0 -0
  87. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_folders.py +0 -0
  88. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_images.py +0 -0
  89. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_outputs.py +0 -0
  90. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_pair_flows.py +0 -0
  91. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_plugins.py +0 -0
  92. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_policy.py +0 -0
  93. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_ports.py +0 -0
  94. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_ports_win.py +0 -0
  95. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_registry.py +0 -0
  96. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_relay_codec.py +0 -0
  97. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_remind.py +0 -0
  98. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_seccheck.py +0 -0
  99. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_setup.py +0 -0
  100. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_tools_probe.py +0 -0
  101. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_toolsetup.py +0 -0
  102. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_uitools.py +0 -0
  103. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_uninstall.py +0 -0
  104. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_winagent.py +0 -0
  105. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_winbg.py +0 -0
  106. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_winconsole.py +0 -0
  107. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_windows_files.py +0 -0
  108. {witbitz_code-1.7.2 → witbitz_code-1.7.4}/tests/test_wslstart.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.7.2
3
+ Version: 1.7.4
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.7.2"
7
+ version = "1.7.4"
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.7.2"
7
+ __version__ = "1.7.4"
@@ -627,7 +627,11 @@ class ConfidentialProxy:
627
627
  hold = attempt["n"] > 1 or (ends is not None and self._now() / 1000 >= ends - HOLD_BEFORE_LAPSE_SEC)
628
628
  n = self._nonce()
629
629
  raw = dumps(stamp_floor(body)).encode("utf-8")
630
- headers = {**_forward_headers(req.headers), "content-type": "application/json", "x-inference-receipt": n}
630
+ # the forwarded headers WITHOUT their own content-type / x-inference-receipt, whatever their case: OpenCode sends
631
+ # "Content-Type", and two of them made TrustedRouter refuse every confidential call (2026-10-17: a small one 400
632
+ # "could not read request"; a large one closed mid-upload — an empty httpx ReadError, "could not reach TrustedRouter ()")
633
+ fwd = {k: v for k, v in _forward_headers(req.headers).items() if k.lower() not in ("content-type", "x-inference-receipt")}
634
+ headers = {**fwd, "content-type": "application/json", "x-inference-receipt": n}
631
635
  tell("waiting", bytes=len(raw))
632
636
  async with self.client.stream("POST", target, headers=headers, content=raw) as up:
633
637
  if not up.is_success:
@@ -0,0 +1,120 @@
1
+ """Update witbitz-code from its page (the owner, 2026-10-17: a fix on PyPI reached no computer that already had it).
2
+
3
+ The version to run is the one the Windows installer pins (`$WitbitzCode` in https://app.witbitz.chat/code.ps1): that
4
+ installer is served from the OFFICIAL release, so a computer moves when Witbitz releases — never to an upload that has
5
+ not been released, and the same version for a Windows, WSL, Mac or Linux install.
6
+
7
+ How it updates follows how it was installed (uninstall.pipx_install):
8
+ · the Windows installer → run that installer again in a window you can see: it stops this copy, installs the pinned
9
+ version and opens the page again (code.ps1 does all of it already);
10
+ · pipx → `pipx install --force witbitz-code==<v>`, then this manager starts itself again (ui.run re-executes);
11
+ · anything else → the command to run, shown on the page.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from collections.abc import Awaitable, Callable
23
+ from typing import Any
24
+
25
+ from . import __version__, uninstall
26
+
27
+ INSTALLER_URL = "https://app.witbitz.chat/code.ps1"
28
+ CHECK_EVERY_S = 6 * 3600
29
+ _PIN = re.compile(r"""^\s*\$WitbitzCode\s*=\s*'(\d{1,4}\.\d{1,4}\.\d{1,4})'\s*$""", re.M)
30
+
31
+
32
+ def parse_pin(script: str) -> str | None:
33
+ """The version code.ps1 installs — None when the script does not say it the one way it does."""
34
+ m = _PIN.search(script or "")
35
+ return m.group(1) if m else None
36
+
37
+
38
+ def newer(a: str, b: str) -> bool:
39
+ """Is version `a` newer than `b`? (x.y.z only; anything else is never newer.)"""
40
+ try:
41
+ return tuple(int(x) for x in a.split(".")) > tuple(int(x) for x in b.split("."))
42
+ except (ValueError, AttributeError):
43
+ return False
44
+
45
+
46
+ def how(info: dict, version: str) -> dict:
47
+ """What the Update button does here → {"kind": "installer"|"pipx"|"manual", "command"?}."""
48
+ kind = info.get("kind")
49
+ if kind == "windows-installer":
50
+ return {"kind": "installer"}
51
+ if kind == "pipx" and shutil.which("pipx"):
52
+ return {"kind": "pipx"}
53
+ py = os.path.basename(sys.executable) or "python3"
54
+ return {"kind": "manual", "command": f"{py} -m pip install --upgrade witbitz-code=={version}"}
55
+
56
+
57
+ class Updates:
58
+ """The page's update row. `fetch_text(url)` → the installer's text; `run` and `launch` are injected for tests."""
59
+
60
+ def __init__(self, *, fetch_text: Callable[[str], Awaitable[str]], install_info: Callable[[], dict] = uninstall.pipx_install,
61
+ run: Callable[[list], Any] | None = None, launch: Callable[[list], Any] | None = None,
62
+ current: str = __version__, now: Callable[[], float] = time.monotonic) -> None:
63
+ self._fetch, self._info, self._now = fetch_text, install_info, now
64
+ self._run = run or (lambda argv: subprocess.run(argv, capture_output=True, text=True, check=False))
65
+ self._launch = launch or _launch_visible
66
+ self.current = current
67
+ self.latest: str | None = None
68
+ self.checked_at = -1e18
69
+ self.why = ""
70
+ self.restart = False # set after a pipx update: ui.run starts this manager again on its way out
71
+
72
+ async def check(self, force: bool = False) -> None:
73
+ if not force and self._now() - self.checked_at < CHECK_EVERY_S:
74
+ return
75
+ self.checked_at = self._now()
76
+ try:
77
+ v = parse_pin(await self._fetch(INSTALLER_URL))
78
+ if v:
79
+ self.latest, self.why = v, ""
80
+ else:
81
+ self.why = "could not read the released version"
82
+ except Exception as e: # noqa: BLE001 — offline is not a failure of the page
83
+ self.why = f"could not check for updates ({type(e).__name__})"
84
+
85
+ def status(self) -> dict:
86
+ avail = bool(self.latest and newer(self.latest, self.current))
87
+ out: dict = {"current": self.current, "latest": self.latest, "available": avail}
88
+ if avail:
89
+ out.update(how(self._info(), self.latest or ""))
90
+ if self.why:
91
+ out["why"] = self.why
92
+ return out
93
+
94
+ async def update(self) -> dict:
95
+ await self.check(force=True)
96
+ st = self.status()
97
+ if not st["available"]:
98
+ return {"ok": False, "why": self.why or f"witbitz-code {self.current} is the released version"}
99
+ v = st["latest"]
100
+ if st["kind"] == "installer":
101
+ self._launch(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", f"irm {INSTALLER_URL} | iex"])
102
+ return {"ok": True, "kind": "installer", "message": f"The installer is updating witbitz-code to {v} in its own window; this page opens again when it is done."}
103
+ if st["kind"] == "pipx":
104
+ r = self._run(["pipx", "install", "--force", f"witbitz-code=={v}"])
105
+ if getattr(r, "returncode", 1) != 0:
106
+ return {"ok": False, "why": ("pipx: " + ((getattr(r, "stderr", "") or getattr(r, "stdout", "") or "").strip().splitlines() or ["failed"])[-1])[:200]}
107
+ self.restart = True
108
+ return {"ok": True, "kind": "pipx", "message": f"Updated to {v} — witbitz-code starts again in a moment."}
109
+ return {"ok": False, "kind": "manual", "why": "Run this, then start witbitz-code again: " + st["command"]}
110
+
111
+
112
+ def _launch_visible(argv: list) -> None:
113
+ """Windows: a console window of its own, so the person sees the installer work (the manager itself has no window)."""
114
+ flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
115
+ subprocess.Popen(argv, creationflags=flags, cwd=os.path.expanduser("~")) # noqa: S603 — fixed argv, our own installer
116
+
117
+
118
+ def restart_argv() -> list:
119
+ """The same manager again, on the freshly installed code: `python -m witbitz_code <the same arguments>`."""
120
+ return [sys.executable, "-m", "witbitz_code", *sys.argv[1:]]
@@ -35,7 +35,7 @@ from importlib import resources
35
35
  from pathlib import Path
36
36
  from typing import Any, Awaitable, Callable
37
37
 
38
- from . import __version__, devicelink, link, net, ports, qr, seccheck, uitools, uninstall, winbg, wizard, wslstart
38
+ from . import __version__, devicelink, link, net, ports, qr, seccheck, selfupdate, uitools, uninstall, winbg, wizard, wslstart
39
39
  from . import pair as pair_cmd
40
40
  from . import pairings as pp
41
41
 
@@ -128,11 +128,12 @@ class Manager:
128
128
  login_disable: Callable[[dict], bool] = wslstart.startup_disable,
129
129
  port_owners: Callable[[int], list] | None = None, stop_pid: Callable[[int], bool] | None = None,
130
130
  tools: uitools.Tools | None = None, label: dict | None = None, log: deque | None = None,
131
- security: seccheck.SecurityCheck | None = None) -> None:
131
+ security: seccheck.SecurityCheck | None = None, updates: selfupdate.Updates | None = None) -> None:
132
132
  self._serve, self._is_listening, self._pair, self._link = serve, is_listening, pair, link_node
133
133
  self._load, self._keys_line = load_pairings, keys_line
134
134
  self.tools = tools or uitools.Tools()
135
135
  self.security = security or seccheck.SecurityCheck()
136
+ self.updates = updates or selfupdate.Updates(fetch_text=_fetch_text)
136
137
  self._owners = port_owners or ports.port_owners
137
138
  self._stop_pid = stop_pid or uninstall.stop_process
138
139
  self._elsewhere_cache: tuple[float, dict | None] | None = None
@@ -214,7 +215,8 @@ class Manager:
214
215
  el = await self.elsewhere()
215
216
  if el:
216
217
  serving = {"state": "elsewhere", "port": el["port"], "owners": el["owners"]}
217
- return {"version": __version__, "computer": self.label, "paired": paired, "serving": serving, "pair": pair,
218
+ asyncio.ensure_future(self.updates.check()) # at most every few hours (selfupdate.CHECK_EVERY_S); never waited for
219
+ return {"version": __version__, "update": self.updates.status(), "computer": self.label, "paired": paired, "serving": serving, "pair": pair,
218
220
  "login": self.login(), "busy": self.busy, "background": self.background,
219
221
  "tools": await asyncio.to_thread(self.tools.status), "security": self.security.peek(), "log": list(self.log)[-14:]}
220
222
 
@@ -398,6 +400,14 @@ class Manager:
398
400
  asyncio.get_running_loop().call_later(0.3, self.quit_event.set) # after this answer reaches the page
399
401
  return {"ok": True}
400
402
 
403
+ async def update(self) -> dict:
404
+ """The page's Update: the released version (selfupdate.py). After a pipx update this manager stops and ui.run
405
+ starts it again on the new code; the Windows installer stops and reopens it itself."""
406
+ out = await self.updates.update()
407
+ if out.get("ok") and self.updates.restart:
408
+ await self.quit()
409
+ return out
410
+
401
411
  async def shutdown(self) -> None:
402
412
  if self.pair_task and not self.pair_task.done():
403
413
  self.pair_task.cancel()
@@ -536,6 +546,7 @@ class Server:
536
546
  "/api/unpair": lambda: self.m.unpair(str(data.get("account") or "")),
537
547
  "/api/login": lambda: self.m.set_login(bool(data.get("on"))),
538
548
  "/api/quit": self.m.quit,
549
+ "/api/update": self.m.update,
539
550
  "/api/tools/install": self.m.tools.install,
540
551
  "/api/security/fix": lambda: self.m.security.fix(str(data.get("id") or "")),
541
552
  }
@@ -556,6 +567,22 @@ class Server:
556
567
 
557
568
 
558
569
  # ── the command ───────────────────────────────────────────────────────────────────────────────────────────────────────
570
+ async def _fetch_text(url: str) -> str:
571
+ async with net.new_client() as c:
572
+ r = await c.get(url, headers={"cache-control": "no-store"}, timeout=20)
573
+ r.raise_for_status()
574
+ return r.text
575
+
576
+
577
+ RESUME_ENV = "WITBITZ_UI_RESUME" # "<port>:<token>" — a restart after an update keeps the page's address and its sign-in
578
+
579
+
580
+ def _resume() -> tuple[int, str] | None:
581
+ v = os.environ.pop(RESUME_ENV, "")
582
+ port, _, token = v.partition(":")
583
+ return (int(port), token) if port.isdigit() and len(token) >= 16 else None
584
+
585
+
559
586
  def _read_lock() -> dict:
560
587
  try:
561
588
  d = json.loads(lock_path().read_text(encoding="utf-8"))
@@ -599,16 +626,17 @@ async def run(args: list[str], *, serve: Callable[..., Awaitable[int]], is_liste
599
626
  m.background = background
600
627
  stop = asyncio.Event()
601
628
  m.quit_event = stop
602
- srv = Server(m)
629
+ resume = _resume()
630
+ srv = Server(m, token=resume[1] if resume else None)
603
631
  try:
604
- await srv.start()
632
+ await srv.start(*([resume[0]] if resume else []))
605
633
  pp.write_private(lock_path(), json.dumps({"port": srv.port, "token": srv.token, "pid": os.getpid()}) + "\n")
606
634
  print(f"witbitz-code: {m.label['label']} — open {srv.url}", file=sys.stderr, flush=True)
607
635
  print("witbitz-code: running in the background — Quit on the page stops it" if background else
608
636
  "witbitz-code: keep this window open (closing it stops witbitz-code). Ctrl-C stops it.", file=sys.stderr, flush=True)
609
637
  if m.pairings():
610
638
  await m.start_serving()
611
- if not no_browser and not open_browser(srv.url):
639
+ if not resume and not no_browser and not open_browser(srv.url):
612
640
  print("witbitz-code: open the address above in your browser", file=sys.stderr, flush=True)
613
641
  loop = asyncio.get_running_loop()
614
642
  import signal # noqa: PLC0415
@@ -628,3 +656,7 @@ async def run(args: list[str], *, serve: Callable[..., Awaitable[int]], is_liste
628
656
  except OSError:
629
657
  pass
630
658
  sys.stderr = old_err
659
+ if m.updates.restart: # updated by pipx: the same manager again, on the new code, at the same page address
660
+ os.environ[RESUME_ENV] = f"{srv.port}:{srv.token}"
661
+ print("witbitz-code: updated — starting again", file=sys.stderr, flush=True)
662
+ os.execv(sys.executable, selfupdate.restart_argv())
@@ -96,6 +96,7 @@ function render(s) {
96
96
  // Windows, no window (winbg.py): nothing to keep open — Quit is how it stops
97
97
  $('footerText').textContent = s.background ? 'runs in the background — Start menu → Witbitz Code opens this page' : 'keep its window open — closing it stops witbitz-code'
98
98
  show($('quit'), !!s.background)
99
+ renderUpdate(s.update)
99
100
  renderTools(s.tools, paired)
100
101
  renderSecurity(s.security)
101
102
 
@@ -139,6 +140,32 @@ function accountOf() {
139
140
  return pick || null
140
141
  }
141
142
 
143
+ // A released version newer than this one (selfupdate.py): the version the installer pins. Update runs that installer
144
+ // again on Windows, pipx elsewhere; anything else shows the command.
145
+ let updating = '', updatingFrom = ''
146
+ function renderUpdate(u) {
147
+ const card = $('updateCard')
148
+ if (updating && u && u.current !== updatingFrom) { updating = ''; note(`Updated to ${u.current} ✓`, 'ok') }
149
+ if (updating) { show(card, true); show($('update'), false); $('updateText').textContent = updating; return }
150
+ if (!u || !u.available) return show(card, false)
151
+ show(card, true)
152
+ $('updateTitle').textContent = `witbitz-code ${u.latest} is ready`
153
+ $('updateText').textContent = `This computer runs ${u.current}.` + (u.kind === 'manual' ? ' Run this in a terminal, then start witbitz-code again:' : ' Update installs it and starts witbitz-code again — Code in Spaces reconnects by itself.')
154
+ show($('updateCmd'), u.kind === 'manual')
155
+ $('updateCmd').textContent = u.command || ''
156
+ show($('update'), u.kind !== 'manual')
157
+ $('update').disabled = !!state.busy
158
+ }
159
+
160
+ async function update() {
161
+ $('update').disabled = true
162
+ try {
163
+ const r = await api('/api/update', {})
164
+ if (r && r.ok) { updatingFrom = state.version; updating = r.message || 'Updating…'; note(updating, 'ok') } else note((r && r.why) || 'The update did not run.', 'bad')
165
+ } catch (e) { note(e.message, 'bad') }
166
+ await refresh()
167
+ }
168
+
142
169
  // setup's Tools step, on the page (uitools.py): what is missing, what Install runs, its progress.
143
170
  function renderTools(t, paired) {
144
171
  if (!t) return show($('toolsCard'), false)
@@ -213,6 +240,7 @@ async function quit() {
213
240
 
214
241
  document.addEventListener('DOMContentLoaded', () => {
215
242
  $('quit').onclick = quit
243
+ $('update').onclick = update
216
244
  $('toolsInstall').onclick = () => act('/api/tools/install', {}, 'Install them now? Windows may ask once to allow an installer.')
217
245
  $('pair').onclick = () => act('/api/pair', {})
218
246
  $('start').onclick = () => act('/api/serve', { on: true })
@@ -30,6 +30,13 @@
30
30
  <p id="note" class="note" hidden></p>
31
31
  </section>
32
32
 
33
+ <section class="card" id="updateCard" hidden>
34
+ <h2 id="updateTitle">An update is ready</h2>
35
+ <p id="updateText" class="muted"></p>
36
+ <pre id="updateCmd" class="cmd" hidden></pre>
37
+ <div class="actions"><button id="update" type="button" hidden>Update</button></div>
38
+ </section>
39
+
33
40
  <section class="card" id="pairCard" hidden>
34
41
  <h2>Pair with your Spaces account</h2>
35
42
  <ol class="steps">
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.7.2
3
+ Version: 1.7.4
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
@@ -41,6 +41,7 @@ src/witbitz_code/relay.py
41
41
  src/witbitz_code/remind.py
42
42
  src/witbitz_code/seccheck.py
43
43
  src/witbitz_code/seen.py
44
+ src/witbitz_code/selfupdate.py
44
45
  src/witbitz_code/tinfoil_read.py
45
46
  src/witbitz_code/tools_catalog.json
46
47
  src/witbitz_code/tools_probe.py
@@ -91,6 +92,7 @@ tests/test_registry.py
91
92
  tests/test_relay_codec.py
92
93
  tests/test_remind.py
93
94
  tests/test_seccheck.py
95
+ tests/test_selfupdate.py
94
96
  tests/test_setup.py
95
97
  tests/test_tools_probe.py
96
98
  tests/test_toolsetup.py
@@ -110,7 +110,7 @@ class FakeNet:
110
110
  def handler(self, req: httpx.Request):
111
111
  if "googleapis.com/service_accounts" in str(req.url):
112
112
  return httpx.Response(200, json=self.meta["googleJwks"])
113
- self.calls.append({"url": str(req.url), "headers": dict(req.headers), "body": req.content.decode("utf-8")})
113
+ self.calls.append({"url": str(req.url), "headers": dict(req.headers), "raw": list(req.headers.raw), "body": req.content.decode("utf-8")})
114
114
  if self.status != 200:
115
115
  return httpx.Response(self.status, json={"error": {"message": "upstream said no"}})
116
116
  data = self.stream.encode("utf-8")
@@ -176,6 +176,20 @@ def test_a_confidential_answer_streams_through_and_the_receipt_verifies_on_the_r
176
176
  with_proxy(t)
177
177
 
178
178
 
179
+ @needs_fixture
180
+ def test_one_content_type_goes_upstream_whatever_case_opencode_sends_it():
181
+ # 2026-10-17: OpenCode sends "Content-Type"; the proxy forwarded it and added its own "content-type", and TrustedRouter
182
+ # refused every confidential call with two (a small one 400 "could not read request", a large one closed mid-upload —
183
+ # an empty httpx ReadError the owner saw as "could not reach TrustedRouter ()").
184
+ async def t(post, net, _p):
185
+ r = await post(SAY_OK, {"Content-Type": "application/json", "X-Inference-Receipt": "from-the-caller"})
186
+ assert r.status_code == 200
187
+ names = [k.decode().lower() for k, _v in net.calls[0]["raw"]]
188
+ assert names.count("content-type") == 1, names
189
+ assert names.count("x-inference-receipt") == 1 and net.calls[0]["headers"]["x-inference-receipt"] != "from-the-caller"
190
+ with_proxy(t)
191
+
192
+
179
193
  @needs_fixture
180
194
  def test_a_receipt_that_does_not_match_ends_the_step_with_an_error_and_no_tool_runs():
181
195
  meta, stream, _ = fixture()
@@ -0,0 +1,94 @@
1
+ """Update from the page (selfupdate.py): the version the RELEASED installer pins, and the way this copy was installed."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ from witbitz_code import selfupdate as su
8
+
9
+ REPO = Path(__file__).resolve().parents[3]
10
+
11
+
12
+ def run(coro):
13
+ return asyncio.run(coro)
14
+
15
+
16
+ def script(v):
17
+ return f"$ErrorActionPreference = 'Stop'\n $WitbitzCode = '{v}'\n $Git = '2.47.1'\n"
18
+
19
+
20
+ def updates(pin="1.7.4", kind="pipx", current="1.7.3", rc=0, err=""):
21
+ seen = {"run": [], "launch": [], "fetch": 0}
22
+
23
+ async def fetch(url):
24
+ seen["fetch"] += 1
25
+ assert url == su.INSTALLER_URL
26
+ if isinstance(pin, Exception):
27
+ raise pin
28
+ return script(pin)
29
+ u = su.Updates(fetch_text=fetch, install_info=lambda: {"kind": kind}, current=current,
30
+ run=lambda argv: seen["run"].append(argv) or SimpleNamespace(returncode=rc, stderr=err, stdout=""),
31
+ launch=lambda argv: seen["launch"].append(argv))
32
+ return u, seen
33
+
34
+
35
+ def test_the_pin_is_read_from_the_real_installer_as_it_is_written():
36
+ text = (REPO / "spaces" / "public" / "code.ps1").read_text(encoding="utf-8")
37
+ v = su.parse_pin(text)
38
+ assert v and v.count(".") == 2, "code.ps1 must keep its one $WitbitzCode = 'x.y.z' line — the page reads it"
39
+ assert su.parse_pin("$WitbitzCode = \"1.2.3\"") is None and su.parse_pin("no pin here") is None
40
+
41
+
42
+ def test_newer_compares_numbers_not_text():
43
+ assert su.newer("1.7.10", "1.7.9") and su.newer("2.0.0", "1.9.9")
44
+ assert not su.newer("1.7.3", "1.7.3") and not su.newer("1.7.2", "1.7.3") and not su.newer("x", "1.0.0")
45
+
46
+
47
+ def test_nothing_offered_when_this_is_the_released_version_or_newer():
48
+ for pin in ("1.7.3", "1.7.2"):
49
+ u, _ = updates(pin=pin)
50
+ run(u.check())
51
+ assert u.status()["available"] is False
52
+
53
+
54
+ def test_offline_is_a_reading_not_a_failure_and_it_asks_at_most_every_few_hours():
55
+ u, seen = updates(pin=OSError("down"))
56
+ run(u.check()); run(u.check())
57
+ assert seen["fetch"] == 1 and u.status() == {"current": "1.7.3", "latest": None, "available": False, "why": "could not check for updates (OSError)"}
58
+
59
+
60
+ def test_pipx_installs_exactly_the_released_version_then_asks_for_a_restart(monkeypatch):
61
+ monkeypatch.setattr(su.shutil, "which", lambda n: "/usr/bin/pipx")
62
+ u, seen = updates()
63
+ run(u.check())
64
+ assert u.status() == {"current": "1.7.3", "latest": "1.7.4", "available": True, "kind": "pipx"}
65
+ r = run(u.update())
66
+ assert r["ok"] and seen["run"] == [["pipx", "install", "--force", "witbitz-code==1.7.4"]] and u.restart
67
+
68
+
69
+ def test_a_failed_pipx_install_says_why_and_does_not_restart(monkeypatch):
70
+ monkeypatch.setattr(su.shutil, "which", lambda n: "/usr/bin/pipx")
71
+ u, _ = updates(rc=1, err="ERROR: No matching distribution found for witbitz-code==1.7.4")
72
+ r = run(u.update())
73
+ assert not r["ok"] and "No matching distribution" in r["why"] and not u.restart
74
+
75
+
76
+ def test_the_windows_install_runs_the_released_installer_again_in_a_window_of_its_own():
77
+ u, seen = updates(kind="windows-installer")
78
+ r = run(u.update())
79
+ assert r["ok"] and r["kind"] == "installer" and not u.restart and not seen["run"]
80
+ assert seen["launch"] == [["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", f"irm {su.INSTALLER_URL} | iex"]]
81
+
82
+
83
+ def test_any_other_install_is_shown_the_command_never_run():
84
+ u, seen = updates(kind="pip")
85
+ run(u.check())
86
+ st = u.status()
87
+ assert st["kind"] == "manual" and st["command"].endswith("-m pip install --upgrade witbitz-code==1.7.4")
88
+ r = run(u.update())
89
+ assert not r["ok"] and st["command"] in r["why"] and not seen["run"] and not seen["launch"]
90
+
91
+
92
+ def test_the_restart_runs_the_same_manager_with_the_same_arguments(monkeypatch):
93
+ monkeypatch.setattr(su.sys, "argv", ["/x/bin/witbitz-code", "ui", "--hidden"])
94
+ assert su.restart_argv() == [su.sys.executable, "-m", "witbitz_code", "ui", "--hidden"]
@@ -276,3 +276,34 @@ def test_in_wsl_the_page_opener_is_waited_for(monkeypatch):
276
276
  assert ui.open_browser("http://127.0.0.1:4291/?t=x") is True
277
277
  argv, kw = calls[0]
278
278
  assert argv == ["wslview", "http://127.0.0.1:4291/?t=x"] and kw["timeout"] == 15
279
+
280
+
281
+ def test_the_page_offers_the_released_update_and_a_pipx_update_ends_this_manager_for_its_restart(monkeypatch):
282
+ from witbitz_code import selfupdate as su
283
+ monkeypatch.setattr(su.shutil, "which", lambda n: "/usr/bin/pipx")
284
+ ran = []
285
+
286
+ async def fetch(_u):
287
+ return "$WitbitzCode = '9.9.9'\n"
288
+ w = World(pairings=[DESK])
289
+ m = w.manager()
290
+ m.updates = su.Updates(fetch_text=fetch, install_info=lambda: {"kind": "pipx"}, current="1.0.0",
291
+ run=lambda argv: ran.append(argv) or SimpleNamespace(returncode=0, stderr="", stdout=""))
292
+
293
+ async def t():
294
+ import httpx
295
+ m.quit_event = asyncio.Event()
296
+ srv, base = await with_server(m)
297
+ try:
298
+ async with httpx.AsyncClient() as c:
299
+ await m.updates.check()
300
+ s = (await c.get(base + "/api/status", headers=authed(srv))).json()
301
+ assert s["update"] == {"current": "1.0.0", "latest": "9.9.9", "available": True, "kind": "pipx"}
302
+ r = (await c.post(base + "/api/update", json={}, headers=authed(srv, **{"content-type": "application/json"}))).json()
303
+ assert r["ok"] and ran == [["pipx", "install", "--force", "witbitz-code==9.9.9"]]
304
+ await asyncio.wait_for(m.quit_event.wait(), 2)
305
+ assert m.updates.restart
306
+ finally:
307
+ await m.shutdown()
308
+ await srv.close()
309
+ run(t())
File without changes
File without changes
File without changes