witbitz-code 1.9.4__tar.gz → 1.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {witbitz_code-1.9.4/src/witbitz_code.egg-info → witbitz_code-1.10.0}/PKG-INFO +1 -1
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/pyproject.toml +1 -1
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/browse.py +3 -2
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/cli.py +50 -29
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/link.py +6 -1
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/net.py +19 -3
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/pair.py +21 -10
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/pairings.py +75 -5
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/ui.py +81 -52
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/ui_static/app.js +22 -17
- {witbitz_code-1.9.4 → witbitz_code-1.10.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_devicelink.py +22 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_pair_flows.py +111 -2
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_ui.py +21 -11
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/LICENSE +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/README.md +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/setup.cfg +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/browsetree.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/images.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/remind.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/seccheck.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/selfupdate.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/ui_static/app.css +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/ui_static/index.html +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/uitools.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/winbg.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/wslstart.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_asks.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_attachments.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_auto.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_browse.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_browsetree.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_cli.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_codetools.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_confidential.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_dirs.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_folders.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_images.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_outputs.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_plugins.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_policy.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_ports.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_registry.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_remind.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_seccheck.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_selfupdate.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_setup.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_uitools.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_winagent.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_winbg.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_windows_files.py +0 -0
- {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_wslstart.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "witbitz-code"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.10.0"
|
|
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"
|
|
@@ -125,7 +125,7 @@ class LocalBrowser:
|
|
|
125
125
|
|
|
126
126
|
|
|
127
127
|
async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Callable[[str], Any] = print,
|
|
128
|
-
node: str | None = None) -> LocalBrowser | None:
|
|
128
|
+
node: str | None = None, profile: Path | None = None) -> LocalBrowser | None:
|
|
129
129
|
"""Start it; → a LocalBrowser (env for OpenCode, probe for the hello), or None when this computer has no node."""
|
|
130
130
|
node = node or shutil.which("node")
|
|
131
131
|
if not node:
|
|
@@ -140,7 +140,8 @@ async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Ca
|
|
|
140
140
|
keys = [browse_key(p["secret"]) for p in pairings if p.get("secret")] if watchable else []
|
|
141
141
|
ctl, gate = _free_port(), _free_port()
|
|
142
142
|
env = {**os.environ, "WB_BROWSE_CTL_PORT": str(ctl), "WB_BROWSE_GATE_PORT": str(gate), "WB_BROWSE_KEYS": ",".join(keys),
|
|
143
|
-
"WB_BROWSE_PUBLISHER": str(paths["browse-publisher.mjs"]), "WB_BROWSE_PARENT": str(os.getpid())
|
|
143
|
+
"WB_BROWSE_PUBLISHER": str(paths["browse-publisher.mjs"]), "WB_BROWSE_PARENT": str(os.getpid()),
|
|
144
|
+
**({"WB_BROWSE_PROFILE": str(profile)} if profile is not None else {})} # one Chromium profile per account's OpenCode
|
|
144
145
|
proc = await asyncio.create_subprocess_exec(node, str(paths["browse-local.mjs"]), env=env, stdin=asyncio.subprocess.DEVNULL,
|
|
145
146
|
stdout=asyncio.subprocess.DEVNULL)
|
|
146
147
|
b = LocalBrowser(proc, ctl, {"WB_BROWSE_NODE": node, "WB_BROWSE_CDP_CLI": str(paths["browse-cdp.mjs"]),
|
|
@@ -124,7 +124,7 @@ OPENCODE_TR_PLACEHOLDER = "witbitz-account-keys"
|
|
|
124
124
|
|
|
125
125
|
|
|
126
126
|
async def _start_opencode(exe: str, port: int, media: dict | None = None, tools: tuple[dict, dict] | None = None,
|
|
127
|
-
extra: dict | None = None) -> asyncio.subprocess.Process:
|
|
127
|
+
extra: dict | None = None, store: Path | None = None) -> asyncio.subprocess.Process:
|
|
128
128
|
password = pp.read_env_password()
|
|
129
129
|
# Subagents ask like their parent, and a refused call does not end the turn (policy.py) — merged by OpenCode over the
|
|
130
130
|
# user's own config, which is never written.
|
|
@@ -134,6 +134,9 @@ async def _start_opencode(exe: str, port: int, media: dict | None = None, tools:
|
|
|
134
134
|
# The Tinfoil key is witbitz-code's (the confidential proxy reads it). Handed to OpenCode it would be in every shell the
|
|
135
135
|
# agent runs, and OpenCode would list Tinfoil as a plain provider whose attestation nothing checks (as the Node one).
|
|
136
136
|
env.pop("TINFOIL_API_KEY", None)
|
|
137
|
+
if store is not None: # this account's own sessions (pairings.store_dir) — OpenCode keeps its data under XDG_DATA_HOME
|
|
138
|
+
(store / "data").mkdir(parents=True, exist_ok=True)
|
|
139
|
+
env["XDG_DATA_HOME"] = str(store / "data")
|
|
137
140
|
# The resolved path, not "opencode": on Windows the npm install is opencode.cmd, which a bare name does not find.
|
|
138
141
|
# Its own process group there, so stopping it can take the whole tree (cmd.exe → node) with it.
|
|
139
142
|
extra = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if sys.platform == "win32" else {}
|
|
@@ -154,6 +157,9 @@ async def _stop_opencode(child: asyncio.subprocess.Process) -> None:
|
|
|
154
157
|
await child.wait()
|
|
155
158
|
|
|
156
159
|
|
|
160
|
+
_SETUP_LOCK = asyncio.Lock()
|
|
161
|
+
|
|
162
|
+
|
|
157
163
|
async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = None) -> int:
|
|
158
164
|
"""`stop` and `live_out`: the page's manager (ui.py) runs serve inside itself — it stops it with its own flag instead
|
|
159
165
|
of a signal, and reads the running connector (each pairing's relay state) from live_out."""
|
|
@@ -167,6 +173,15 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
|
|
|
167
173
|
paired_ports = sorted({wizard.port_of(p) for p in everything})
|
|
168
174
|
if "--port" not in args and len(paired_ports) == 1:
|
|
169
175
|
port = paired_ports[0]
|
|
176
|
+
elif "--port" not in args and len(paired_ports) > 1:
|
|
177
|
+
# Several accounts, each its own OpenCode (the owner, 2026-10-22): serve them all here, one Ctrl-C stops them all;
|
|
178
|
+
# one that ends stops the others (a half-served computer reads as working).
|
|
179
|
+
stop = stop or _Stop()
|
|
180
|
+
runs = [asyncio.ensure_future(serve([*args, "--port", str(pt)], stop=stop)) for pt in paired_ports]
|
|
181
|
+
done, _ = await asyncio.wait(runs, return_when=asyncio.FIRST_COMPLETED)
|
|
182
|
+
stop.event.set()
|
|
183
|
+
codes = await asyncio.gather(*runs, return_exceptions=True)
|
|
184
|
+
return next((c for c in codes if isinstance(c, int) and c), 0) if all(isinstance(c, int) for c in codes) else 1
|
|
170
185
|
# One OpenCode per port, one connector per OpenCode: two `serve`s (one per account) must never both answer a channel.
|
|
171
186
|
pairings = pairings_for_port(everything, port)
|
|
172
187
|
if not pairings:
|
|
@@ -174,30 +189,33 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
|
|
|
174
189
|
return 1
|
|
175
190
|
# Windows: a tool installed since this window opened (setup's Tools step, winget) is on the registry's PATH, not yet on
|
|
176
191
|
# this process's — read it, so the agent's OpenCode finds it (= witbitz-code.mjs serve)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
192
|
+
# The setup every OpenCode shares (plugins, tools, the Windows agent) — one serve at a time: the page runs one serve per
|
|
193
|
+
# account's OpenCode, and two writing the same plugin folder or fetching the same tool at once would collide.
|
|
194
|
+
async with _SETUP_LOCK:
|
|
195
|
+
reg = toolsetup.registry_path()
|
|
196
|
+
if reg:
|
|
197
|
+
os.environ["PATH"] = toolsetup.merged_path(os.environ.get("PATH", ""), reg)
|
|
198
|
+
# Our OpenCode plugins — project notes, and the agent's progress updates the Code section shows between its work rows —
|
|
199
|
+
# written into OpenCode's config folder on every start (plugins.py). OpenCode reads them when it starts, so one already
|
|
200
|
+
# running picks them up at its next restart.
|
|
201
|
+
try:
|
|
202
|
+
if plugins.install():
|
|
203
|
+
_err(f"witbitz-code: installed OpenCode plugins (project notes, progress updates) in {plugins.opencode_config_dir()}")
|
|
204
|
+
except OSError as e:
|
|
205
|
+
_err(f"witbitz-code: could not install the OpenCode plugins in {plugins.opencode_config_dir()} ({e}) — the agent works without progress updates or notes")
|
|
206
|
+
# The media tools (plugins.py media_config) — one MCP server of image/PDF/video/audio tools, run with this computer's node.
|
|
207
|
+
media, note = plugins.media_config()
|
|
208
|
+
if note:
|
|
209
|
+
_err(note)
|
|
210
|
+
# Structure (codetools.py): OpenCode's lsp tool, ast-grep fetched once (pinned), and the hint that names it.
|
|
211
|
+
tools_env, tools_config, note = await asyncio.to_thread(codetools.code_tools, plugins.opencode_config_dir())
|
|
212
|
+
if note:
|
|
213
|
+
_err(note)
|
|
214
|
+
# On Windows: UTF-8 Python, Git Bash, the agent's own Python and the note naming them (winagent.py; measured).
|
|
215
|
+
w_add, w_instr, w_note = await asyncio.to_thread(winagent.windows_agent, plugins.opencode_config_dir())
|
|
216
|
+
if w_note:
|
|
217
|
+
_err(w_note)
|
|
218
|
+
tools_env, tools_config = winagent.apply(tools_env, tools_config, w_add, w_instr)
|
|
201
219
|
# THIS COMPUTER'S CODING KEYS (codekeys.py; docs/code-account-keys.md): the only model keys witbitz-code uses. A pairing
|
|
202
220
|
# made before them is not served — every call of its account would be refused.
|
|
203
221
|
unkeyed = [p for p in pairings if not codekeys.for_computer(p.get("computerId") or "")]
|
|
@@ -207,7 +225,10 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
|
|
|
207
225
|
if not pairings:
|
|
208
226
|
return 1
|
|
209
227
|
live_keys = codekeys.LiveKeys([p["computerId"] for p in pairings])
|
|
210
|
-
|
|
228
|
+
# THIS ACCOUNT'S STORE (pairings.store_dir): its own OpenCode sessions, browser profile and session owners — or the
|
|
229
|
+
# default ones for the account that had them first. Pairings sharing a port (made before 2026-10-22) share the first's.
|
|
230
|
+
store = pp.store_dir(pairings[0], everything)
|
|
231
|
+
payers = SessionPayers(store / "session-payers.json") if store is not None else SessionPayers()
|
|
211
232
|
offered = models.offered()
|
|
212
233
|
|
|
213
234
|
def credential_for(req: Any) -> dict | None:
|
|
@@ -252,7 +273,7 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
|
|
|
252
273
|
browse_mod.remove()
|
|
253
274
|
else:
|
|
254
275
|
try:
|
|
255
|
-
browser = await browse_mod.start(pairings, log=_err)
|
|
276
|
+
browser = await browse_mod.start(pairings, log=_err, profile=(store / "browse-profile") if store is not None else None)
|
|
256
277
|
except OSError as e:
|
|
257
278
|
_err(f"witbitz-code: the agent's browser could not start ({e}) — the agent works without it")
|
|
258
279
|
if browser:
|
|
@@ -264,7 +285,7 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
|
|
|
264
285
|
_err(" npm install -g opencode-ai or curl -fsSL https://opencode.ai/install | bash")
|
|
265
286
|
return 1
|
|
266
287
|
_err(f"witbitz-code: starting OpenCode on 127.0.0.1:{port}")
|
|
267
|
-
child = await _start_opencode(exe, port, media, (tools_env, tools_config), route)
|
|
288
|
+
child = await _start_opencode(exe, port, media, (tools_env, tools_config), route, store)
|
|
268
289
|
for _ in range(40):
|
|
269
290
|
if stop.event.is_set() or child.returncode is not None or await is_listening(port):
|
|
270
291
|
break
|
|
@@ -384,7 +405,7 @@ async def uninstall(args: list[str]) -> int:
|
|
|
384
405
|
return {"ok": False, "why": "could not give this computer's coding keys back"}
|
|
385
406
|
if entry:
|
|
386
407
|
codekeys.forget(p["computerId"])
|
|
387
|
-
r = await acct.unpublish_computer(call=call, idx=p["idx"], computer_id=p["computerId"])
|
|
408
|
+
r = await acct.unpublish_computer(call=net.call_for(call, p.get("origin")), idx=p["idx"], computer_id=p["computerId"])
|
|
388
409
|
if r.get("ok"):
|
|
389
410
|
pp.write_pairings(pp.remove_pairings(pp.read_pairings(), p.get("account") or "")["doc"])
|
|
390
411
|
return r
|
|
@@ -156,6 +156,7 @@ async def _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_pa
|
|
|
156
156
|
out(f" waiting up to {round(wait_s)}s for your device to seal the account to this computer…\n")
|
|
157
157
|
until = time.monotonic() + wait_s
|
|
158
158
|
warned = False
|
|
159
|
+
found = ""
|
|
159
160
|
while time.monotonic() < until:
|
|
160
161
|
await asyncio.sleep(poll_s)
|
|
161
162
|
r = None
|
|
@@ -168,7 +169,7 @@ async def _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_pa
|
|
|
168
169
|
err(f"witbitz-code: note — {origin} has no device-link relay; ignoring it.")
|
|
169
170
|
warned = True
|
|
170
171
|
if isinstance(rr, dict) and truthy(rr.get("ok")):
|
|
171
|
-
r = rr
|
|
172
|
+
r, found = rr, origin
|
|
172
173
|
break
|
|
173
174
|
if r is None:
|
|
174
175
|
continue # 404 on all = not sealed yet
|
|
@@ -184,6 +185,10 @@ async def _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_pa
|
|
|
184
185
|
if not account:
|
|
185
186
|
err("witbitz-code: the reply carried no account key.")
|
|
186
187
|
return None
|
|
188
|
+
# THE APP IT CAME FROM (the owner, 2026-10-22: a second account paired from the hosted app → "computers read refused
|
|
189
|
+
# (404: not_found)"): the relay that held the reply is that app's own address, and its rooms answer only their own app
|
|
190
|
+
# since 2026-10-18 — so this account's calls speak as it (pairings.json `origin`, net.call_for).
|
|
191
|
+
account["origin"] = found
|
|
187
192
|
via = "link" if account["idx"] else ""
|
|
188
193
|
if not account["idx"]:
|
|
189
194
|
out("witbitz-code: no index pointer in the link — recovering it from your account backup… ", end="")
|
|
@@ -4,6 +4,7 @@ retried or cached)."""
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
from collections.abc import Awaitable, Callable
|
|
8
9
|
from dataclasses import dataclass
|
|
9
10
|
from typing import Any
|
|
@@ -31,10 +32,24 @@ def _json_of(r: httpx.Response) -> Any:
|
|
|
31
32
|
return _js.parse(r.content.decode("utf-8-sig", "replace")) # fetch's r.json(): BOM dropped, then JSON.parse
|
|
32
33
|
|
|
33
34
|
|
|
34
|
-
def
|
|
35
|
-
"""
|
|
35
|
+
def app_origin(o: Any) -> str:
|
|
36
|
+
"""A pairing's app address, when it is one: https and nothing but scheme://host. Else '' (the default app)."""
|
|
37
|
+
return o if isinstance(o, str) and re.fullmatch(r"https://[A-Za-z0-9.-]+(:[0-9]+)?", o) else ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def call_for(call: Any, origin: Any) -> Any:
|
|
41
|
+
"""The same account call, speaking as the app `origin` pairs through (its rooms answer only their own app's addresses —
|
|
42
|
+
the hosted app's since 2026-10-18). A call without that ability (a test's fake) is returned unchanged."""
|
|
43
|
+
o = app_origin(origin)
|
|
44
|
+
f = getattr(call, "for_origin", None)
|
|
45
|
+
return f(o) if o and callable(f) else call
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def make_api_call(client: httpx.AsyncClient, origin: str | None = None) -> ApiCall:
|
|
49
|
+
"""POST {op:'state', …} to the account API, as the Spaces app would (same Origin). `origin`: the app this account was
|
|
50
|
+
paired from (a pairing's `origin`); none → RC_ORIGIN, else the regular app."""
|
|
36
51
|
api = os.environ.get("RC_BASE") or API
|
|
37
|
-
origin = os.environ.get("RC_ORIGIN") or ORIGIN
|
|
52
|
+
origin = app_origin(origin) or os.environ.get("RC_ORIGIN") or ORIGIN
|
|
38
53
|
|
|
39
54
|
async def call(body: dict) -> ApiReply:
|
|
40
55
|
r = await client.post(api, content=_js.stringify(body).encode("utf-8"),
|
|
@@ -45,6 +60,7 @@ def make_api_call(client: httpx.AsyncClient) -> ApiCall:
|
|
|
45
60
|
j = None
|
|
46
61
|
return ApiReply(r.status_code, j)
|
|
47
62
|
|
|
63
|
+
call.for_origin = lambda o: make_api_call(client, o) # type: ignore[attr-defined]
|
|
48
64
|
return call
|
|
49
65
|
|
|
50
66
|
|
|
@@ -46,6 +46,7 @@ class Args:
|
|
|
46
46
|
status: bool = False
|
|
47
47
|
name: str = ""
|
|
48
48
|
account: str = ""
|
|
49
|
+
computer: str = ""
|
|
49
50
|
opencode_url: str = ""
|
|
50
51
|
|
|
51
52
|
|
|
@@ -63,9 +64,11 @@ def parse_args(argv: list[str]) -> Args:
|
|
|
63
64
|
a.unpair = True
|
|
64
65
|
elif x == "--status":
|
|
65
66
|
a.status = True
|
|
66
|
-
elif x in ("--name", "--account", "--opencode-url", "--port"):
|
|
67
|
+
elif x in ("--name", "--account", "--computer", "--opencode-url", "--port"):
|
|
67
68
|
i += 1
|
|
68
|
-
if x == "--
|
|
69
|
+
if x == "--computer": # one pairing by its computer id (the page's Unpair on a row)
|
|
70
|
+
a.computer = nxt
|
|
71
|
+
elif x == "--name":
|
|
69
72
|
a.name = nxt
|
|
70
73
|
elif x == "--account":
|
|
71
74
|
a.account = nxt
|
|
@@ -94,6 +97,7 @@ def ensure_local_password(out: Callable[[str], Any] = _out) -> None:
|
|
|
94
97
|
|
|
95
98
|
|
|
96
99
|
async def _publish_entry(call: net.ApiCall, e: dict, out: Callable, err: Callable) -> bool:
|
|
100
|
+
call = net.call_for(call, e.get("origin")) # this account's app (the hosted app's rooms refuse the regular one)
|
|
97
101
|
r = await acct.publish_computer(call=call, idx=e["idx"], computer_id=e["computerId"], name=e["name"], relay=e["relay"],
|
|
98
102
|
secret=e["secret"])
|
|
99
103
|
who = e["account"] or "account"
|
|
@@ -197,11 +201,12 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
197
201
|
return 0
|
|
198
202
|
|
|
199
203
|
if args.unpair:
|
|
200
|
-
targets = [p for p in doc["pairings"] if
|
|
204
|
+
targets = [p for p in doc["pairings"] if p.get("computerId") == args.computer] if args.computer else \
|
|
205
|
+
[p for p in doc["pairings"] if not args.account or p.get("account") == args.account]
|
|
201
206
|
if not targets:
|
|
202
207
|
err(f"witbitz-code: no pairing{' for ' + args.account if args.account else ''} on this computer")
|
|
203
208
|
return 1
|
|
204
|
-
if len(targets) > 1 and not args.account:
|
|
209
|
+
if len(targets) > 1 and not args.account and not args.computer:
|
|
205
210
|
names = ", ".join(str(p.get("account")) for p in targets)
|
|
206
211
|
err(f"witbitz-code: this computer is paired with {len(targets)} accounts ({names}) — pass --account <email>")
|
|
207
212
|
return 1
|
|
@@ -219,15 +224,15 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
219
224
|
continue
|
|
220
225
|
if entry:
|
|
221
226
|
codekeys.forget(p["computerId"])
|
|
222
|
-
_forget_payer(p["computerId"])
|
|
223
|
-
r = await acct.unpublish_computer(call=call, idx=p["idx"], computer_id=p["computerId"])
|
|
227
|
+
_forget_payer(p["computerId"], p)
|
|
228
|
+
r = await acct.unpublish_computer(call=net.call_for(call, p.get("origin")), idx=p["idx"], computer_id=p["computerId"])
|
|
224
229
|
if not r["ok"]:
|
|
225
230
|
ok = False
|
|
226
231
|
err(f"witbitz-code: ✖ {p.get('account')}: {r['why']} — kept the local pairing so you can retry")
|
|
227
232
|
continue
|
|
228
233
|
note = " (it was already removed)" if r.get("noop") else ""
|
|
229
234
|
out(f"witbitz-code: ✓ removed \"{p.get('name')}\" from {p.get('account')}{note} — every device drops it on its next sync")
|
|
230
|
-
pp.write_pairings(pp.remove_pairings(pp.read_pairings(), p
|
|
235
|
+
pp.write_pairings(pp.remove_pairings(pp.read_pairings(), computer_id=p["computerId"])["doc"]) # this one only
|
|
231
236
|
out(f"witbitz-code: restart the connector ({START_HINT}) so it stops answering on the old channel.")
|
|
232
237
|
return 0 if ok else 1
|
|
233
238
|
|
|
@@ -242,7 +247,8 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
242
247
|
cur, ok = doc, True
|
|
243
248
|
for p in to_rotate:
|
|
244
249
|
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),
|
|
250
|
+
relay=p.get("relay", UNDEFINED), opencode_url=p.get("opencodeUrl", UNDEFINED),
|
|
251
|
+
origin=p.get("origin", UNDEFINED), rotate=True)
|
|
246
252
|
if await _publish_entry(call, u["entry"], out, err):
|
|
247
253
|
cur = u["doc"]
|
|
248
254
|
pp.write_pairings(cur)
|
|
@@ -259,8 +265,10 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
259
265
|
if not account:
|
|
260
266
|
err("witbitz-code: pairing did not complete — nothing changed.")
|
|
261
267
|
return 1
|
|
268
|
+
port = None if args.opencode_url else pp.next_port(doc, account["idx"]["room"]) # another account: its own OpenCode
|
|
262
269
|
u = pp.upsert_pairing(doc, account=account.get("email") or "", idx=account["idx"], name=args.name,
|
|
263
|
-
opencode_url=args.opencode_url or UNDEFINED)
|
|
270
|
+
opencode_url=args.opencode_url or (f"http://127.0.0.1:{port}" if port else UNDEFINED),
|
|
271
|
+
origin=account.get("origin") or UNDEFINED)
|
|
264
272
|
# THIS COMPUTER'S CODING KEYS from the account (codekeys.py; docs/code-account-keys.md) — the only way it gets model
|
|
265
273
|
# keys. Asked with the sign-in the scan carried, before anything is published: an account that cannot give keys (not
|
|
266
274
|
# invited, too many computers, …) leaves nothing behind.
|
|
@@ -308,7 +316,10 @@ async def _keys_line(p: dict, keys: Any) -> str:
|
|
|
308
316
|
return f"coding keys: live · models this month ${used} of ${m.get('modelsCapUsd', '?')} · tools up to ${m.get('toolsCapUsd', '?')}"
|
|
309
317
|
|
|
310
318
|
|
|
311
|
-
def _forget_payer(computer_id: str) -> None:
|
|
319
|
+
def _forget_payer(computer_id: str, p: dict | None = None) -> None:
|
|
312
320
|
from .payers import SessionPayers # noqa: PLC0415
|
|
313
321
|
|
|
314
322
|
SessionPayers().forget_computer(computer_id)
|
|
323
|
+
store = pp.store_dir(p, pp.read_pairings()["pairings"]) if p else None
|
|
324
|
+
if store is not None and (store / "session-payers.json").exists(): # its own store's record too (cli.serve)
|
|
325
|
+
SessionPayers(store / "session-payers.json").forget_computer(computer_id)
|
|
@@ -11,6 +11,8 @@ One entry per (computer, account) — several accounts can pair the same compute
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
14
16
|
import os
|
|
15
17
|
import re
|
|
16
18
|
import secrets
|
|
@@ -114,8 +116,8 @@ def norm_pairings(doc: Any) -> dict:
|
|
|
114
116
|
|
|
115
117
|
|
|
116
118
|
def upsert_pairing(doc: Any, *, idx: dict, account: str = "", name: str = "", relay: Any = UNDEFINED,
|
|
117
|
-
opencode_url: Any = UNDEFINED,
|
|
118
|
-
mint_secret: Callable[[], str] = new_relay_secret) -> dict:
|
|
119
|
+
opencode_url: Any = UNDEFINED, origin: Any = UNDEFINED, rotate: bool = False,
|
|
120
|
+
mint_id: Callable[[], str] = new_computer_id, mint_secret: Callable[[], str] = new_relay_secret) -> dict:
|
|
119
121
|
"""Add this computer for an account, or refresh the existing entry (same id + secret unless `rotate`). Pure.
|
|
120
122
|
→ {doc, entry, isNew, sharedWith}: sharedWith names the other accounts that reach the same OpenCode."""
|
|
121
123
|
relay = RELAY_URL if relay is UNDEFINED else relay
|
|
@@ -138,6 +140,11 @@ def upsert_pairing(doc: Any, *, idx: dict, account: str = "", name: str = "", re
|
|
|
138
140
|
# An OpenCode asked for (`pair --port 4097`) wins; not asked → the entry keeps the one it had (tools/opencode-pair.mjs).
|
|
139
141
|
"opencodeUrl": pick(None if opencode_url is UNDEFINED else opencode_url, "opencodeUrl", DEFAULT_OPENCODE_URL),
|
|
140
142
|
}
|
|
143
|
+
# The app this account pairs through (its rooms answer only their own app — net.call_for). Absent = the regular app, so an
|
|
144
|
+
# entry written before 2026-10-22 (or by an older tool) reads as before, byte for byte.
|
|
145
|
+
app = pick(None if origin is UNDEFINED else origin, "origin", "")
|
|
146
|
+
if truthy(app):
|
|
147
|
+
entry["origin"] = app
|
|
141
148
|
pairings = list(cur["pairings"])
|
|
142
149
|
if i >= 0:
|
|
143
150
|
pairings[i] = entry
|
|
@@ -149,14 +156,77 @@ def upsert_pairing(doc: Any, *, idx: dict, account: str = "", name: str = "", re
|
|
|
149
156
|
"sharedWith": [p["account"] if truthy(p.get("account")) else "(another account)" for p in others]}
|
|
150
157
|
|
|
151
158
|
|
|
152
|
-
def remove_pairings(doc: Any, account: str = "") -> dict:
|
|
153
|
-
"""Drop the entries matching `account` (email) — or every entry when `account` is empty
|
|
159
|
+
def remove_pairings(doc: Any, account: str = "", computer_id: str = "") -> dict:
|
|
160
|
+
"""Drop the entries matching `account` (email) — or every entry when `account` is empty; with `computer_id`, only that
|
|
161
|
+
pairing (one email can be paired twice: once from each app — the owner, 2026-10-22). Pure."""
|
|
154
162
|
cur = norm_pairings(doc)
|
|
155
|
-
gone = [p for p in cur["pairings"] if
|
|
163
|
+
gone = [p for p in cur["pairings"] if strict_eq(p.get("computerId", UNDEFINED), computer_id)] if truthy(computer_id) else \
|
|
164
|
+
[p for p in cur["pairings"] if not truthy(account) or strict_eq(p.get("account", UNDEFINED), account)]
|
|
156
165
|
kept = [p for p in cur["pairings"] if not any(p is g for g in gone)]
|
|
157
166
|
return {"doc": {"v": 1, "pairings": kept}, "removed": gone}
|
|
158
167
|
|
|
159
168
|
|
|
169
|
+
# The hosted ("vaulted") app's origins (the tenant t_a7f1); any other origin, or none, is the regular app.
|
|
170
|
+
VAULTED_ORIGINS = ("https://spaces.witbitz.chat", "https://app.witbitz.chat", "https://witbitz-vault-prod.pages.dev",
|
|
171
|
+
"https://witbitz-spaces-vaulted.pages.dev")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def app_label(p: Any) -> str:
|
|
175
|
+
"""Which app a pairing belongs to, in words — two pairings of one email differ only in this (the owner, 2026-10-22)."""
|
|
176
|
+
return "vaulted app" if isinstance(p, dict) and p.get("origin") in VAULTED_ORIGINS else "regular app"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# EACH ACCOUNT ITS OWN OPENCODE (the owner, 2026-10-22: one account's sessions showed in the other's — "they should be
|
|
180
|
+
# separate"). A pairing's account + app name its store: OpenCode's data (sessions), the agent's browser profile and who pays
|
|
181
|
+
# for each session. The store OpenCode used before (its default) stays with the account that had it — the first one this
|
|
182
|
+
# computer saw, recorded once in stores.json — so nothing anyone had moves; every other account gets a folder of its own,
|
|
183
|
+
# and pairing the same account again finds it again.
|
|
184
|
+
def store_key(p: Any) -> str:
|
|
185
|
+
p = p if isinstance(p, dict) else {}
|
|
186
|
+
return hashlib.sha256(f"{p.get('account') or ''}\n{p.get('origin') or ''}".encode()).hexdigest()[:16]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def next_port(doc: Any, room: str) -> int | None:
|
|
190
|
+
"""The OpenCode port for a NEW pairing of `room`'s account: None = keep (already paired, or the first one here → 4096);
|
|
191
|
+
else the first port no other pairing uses — each account its own OpenCode (= the page's _pair_port)."""
|
|
192
|
+
cur = norm_pairings(doc)["pairings"]
|
|
193
|
+
if not cur or any(p["idx"]["room"] == room for p in cur):
|
|
194
|
+
return None
|
|
195
|
+
used = set()
|
|
196
|
+
for p in cur:
|
|
197
|
+
m = re.search(r":(\d+)/*$", str(p.get("opencodeUrl") or "http://127.0.0.1:4096"))
|
|
198
|
+
used.add(int(m.group(1)) if m else 4096)
|
|
199
|
+
return next((n for n in range(4096, 4196) if n not in used), None)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def stores_path() -> Path:
|
|
203
|
+
return pairings_path().parent / "stores.json"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def default_store_owner(pairings: list[dict]) -> str:
|
|
207
|
+
"""The store key that keeps OpenCode's default data — read, or (first time) the oldest pairing's, written once."""
|
|
208
|
+
try:
|
|
209
|
+
doc = json.loads(stores_path().read_text(encoding="utf-8"))
|
|
210
|
+
if isinstance(doc, dict) and isinstance(doc.get("default"), str) and doc["default"]:
|
|
211
|
+
return doc["default"]
|
|
212
|
+
except (OSError, ValueError):
|
|
213
|
+
pass
|
|
214
|
+
if not pairings:
|
|
215
|
+
return ""
|
|
216
|
+
key = store_key(pairings[0])
|
|
217
|
+
try:
|
|
218
|
+
write_private(stores_path(), json.dumps({"v": 1, "default": key}))
|
|
219
|
+
except OSError:
|
|
220
|
+
pass
|
|
221
|
+
return key
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def store_dir(p: Any, pairings: list[dict]) -> Path | None:
|
|
225
|
+
"""None = OpenCode's default store (the first account's); else this account's own folder."""
|
|
226
|
+
key = store_key(p)
|
|
227
|
+
return None if key == default_store_owner(pairings) else pairings_path().parent / "accounts" / key
|
|
228
|
+
|
|
229
|
+
|
|
160
230
|
# ── files ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
161
231
|
def read_pairings(path: Path | None = None) -> dict:
|
|
162
232
|
try:
|