witbitz-code 1.3.2__tar.gz → 1.3.3__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.3.2/src/witbitz_code.egg-info → witbitz_code-1.3.3}/PKG-INFO +3 -2
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/README.md +2 -1
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/pyproject.toml +1 -1
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/cli.py +29 -8
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/codekeys.py +93 -9
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/confidential.py +13 -1
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/pair.py +2 -0
- witbitz_code-1.3.3/src/witbitz_code/ports.py +87 -0
- witbitz_code-1.3.3/src/witbitz_code/winagent.py +111 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/wizard.py +54 -5
- {witbitz_code-1.3.2 → witbitz_code-1.3.3/src/witbitz_code.egg-info}/PKG-INFO +3 -2
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/SOURCES.txt +4 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_codekeys.py +115 -0
- witbitz_code-1.3.3/tests/test_ports.py +31 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_setup.py +37 -7
- witbitz_code-1.3.3/tests/test_winagent.py +69 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/LICENSE +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/setup.cfg +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_asks.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_attachments.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_auto.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_cli.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_codetools.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_confidential.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_folders.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_outputs.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_plugins.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_policy.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_registry.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_winconsole.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: witbitz-code
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.3
|
|
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
|
|
@@ -66,7 +66,8 @@ OpenCode's own window on this computer is refused ("coding runs from the Spaces
|
|
|
66
66
|
being written another account cannot add to it. This splits the bill fairly between people who share a computer; it is not
|
|
67
67
|
a wall between them (they already share its files and shell).
|
|
68
68
|
|
|
69
|
-
To start over: `witbitz-code uninstall`, then `pipx install witbitz-code` and `witbitz-code setup`.
|
|
69
|
+
To start over: `witbitz-code uninstall`, then `pipx install witbitz-code` and `witbitz-code setup`. (`pipx uninstall` alone
|
|
70
|
+
leaves the pairing and its keys behind, and does not stop a witbitz-code that is still running — `setup` offers to stop one.)
|
|
70
71
|
|
|
71
72
|
The same thing one step at a time: install OpenCode (`curl -fsSL https://opencode.ai/install | bash`), then
|
|
72
73
|
`witbitz-code pair` and `witbitz-code serve`.
|
|
@@ -41,7 +41,8 @@ OpenCode's own window on this computer is refused ("coding runs from the Spaces
|
|
|
41
41
|
being written another account cannot add to it. This splits the bill fairly between people who share a computer; it is not
|
|
42
42
|
a wall between them (they already share its files and shell).
|
|
43
43
|
|
|
44
|
-
To start over: `witbitz-code uninstall`, then `pipx install witbitz-code` and `witbitz-code setup`.
|
|
44
|
+
To start over: `witbitz-code uninstall`, then `pipx install witbitz-code` and `witbitz-code setup`. (`pipx uninstall` alone
|
|
45
|
+
leaves the pairing and its keys behind, and does not stop a witbitz-code that is still running — `setup` offers to stop one.)
|
|
45
46
|
|
|
46
47
|
The same thing one step at a time: install OpenCode (`curl -fsSL https://opencode.ai/install | bash`), then
|
|
47
48
|
`witbitz-code pair` and `witbitz-code serve`.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "witbitz-code"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.3"
|
|
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"
|
|
@@ -17,6 +17,7 @@ import os
|
|
|
17
17
|
import signal
|
|
18
18
|
import subprocess
|
|
19
19
|
import sys
|
|
20
|
+
import time
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
from typing import Any
|
|
22
23
|
|
|
@@ -25,7 +26,7 @@ import httpx
|
|
|
25
26
|
from . import __version__
|
|
26
27
|
from . import pair as pair_cmd
|
|
27
28
|
from . import pairings as pp
|
|
28
|
-
from . import codekeys, codetools, confidential, keys, models, plugins, policy, uninstall as uninstall_mod, winconsole, wizard
|
|
29
|
+
from . import codekeys, codetools, confidential, keys, models, plugins, policy, uninstall as uninstall_mod, winagent, winconsole, wizard
|
|
29
30
|
from ._js import to_number, truthy
|
|
30
31
|
from .connector import pairings_for_port, start_connector
|
|
31
32
|
from .payers import SessionPayers
|
|
@@ -176,6 +177,11 @@ async def serve(args: list[str]) -> int:
|
|
|
176
177
|
tools_env, tools_config, note = await asyncio.to_thread(codetools.code_tools, plugins.opencode_config_dir())
|
|
177
178
|
if note:
|
|
178
179
|
_err(note)
|
|
180
|
+
# On Windows: UTF-8 Python, Git Bash, the agent's own Python and the note naming them (winagent.py; measured).
|
|
181
|
+
w_add, w_instr, w_note = await asyncio.to_thread(winagent.windows_agent, plugins.opencode_config_dir())
|
|
182
|
+
if w_note:
|
|
183
|
+
_err(w_note)
|
|
184
|
+
tools_env, tools_config = winagent.apply(tools_env, tools_config, w_add, w_instr)
|
|
179
185
|
# THIS COMPUTER'S CODING KEYS (codekeys.py; docs/code-account-keys.md): the only model keys witbitz-code uses. A pairing
|
|
180
186
|
# made before them is not served — every call of its account would be refused.
|
|
181
187
|
unkeyed = [p for p in pairings if not codekeys.for_computer(p.get("computerId") or "")]
|
|
@@ -201,11 +207,22 @@ async def serve(args: list[str]) -> int:
|
|
|
201
207
|
# THE PROXY (confidential.py): started BEFORE OpenCode, which is always pointed at it — it puts each account's key on
|
|
202
208
|
# the calls of that account's prompts, enforces the confidential models and refuses a model Witbitz does not offer.
|
|
203
209
|
pport = models.proxy_port_for(port)
|
|
210
|
+
recheck = {"at": -1e9}
|
|
211
|
+
|
|
212
|
+
def key_refused() -> None: # TrustedRouter refused a key: ask the account now (at most once a minute), not in hours
|
|
213
|
+
if time.monotonic() - recheck["at"] >= 60:
|
|
214
|
+
recheck["at"] = time.monotonic()
|
|
215
|
+
asyncio.ensure_future(live_keys.check(keys_http, _err))
|
|
216
|
+
|
|
204
217
|
try:
|
|
205
218
|
proxy = await confidential.start_confidential_proxy(port=pport, log=_err, on_progress=lambda ev: live["connector"] and live["connector"].progress(ev),
|
|
206
|
-
credential_for=credential_for, offered=lambda m: m in offered
|
|
219
|
+
credential_for=credential_for, offered=lambda m: m in offered,
|
|
220
|
+
on_key_refused=key_refused)
|
|
207
221
|
except OSError as e:
|
|
208
|
-
|
|
222
|
+
held = "; ".join(f"{(o.get('cmd') or 'a program')[:70]} (process {o['pid']})" for o in ports.port_owners(pport))
|
|
223
|
+
_err(f"witbitz-code: could not start the model proxy on 127.0.0.1:{pport} ({e})"
|
|
224
|
+
+ (f" — held by {held}: an earlier witbitz-code still running? witbitz-code setup offers to stop it" if held
|
|
225
|
+
else " — free that port and run serve again"))
|
|
209
226
|
return 1
|
|
210
227
|
route = models.proxy_config(proxy.port)
|
|
211
228
|
if await is_listening(port) and "--no-opencode" not in args:
|
|
@@ -267,15 +284,19 @@ async def setup(args: list[str]) -> int:
|
|
|
267
284
|
|
|
268
285
|
client = net.new_client()
|
|
269
286
|
|
|
270
|
-
async def keys_state(p: dict) ->
|
|
287
|
+
async def keys_state(p: dict) -> dict:
|
|
271
288
|
entry = codekeys.for_computer(p.get("computerId") or "")
|
|
272
289
|
if not entry:
|
|
273
|
-
return "none"
|
|
290
|
+
return {"state": "none"}
|
|
274
291
|
try:
|
|
275
292
|
st = await codekeys.status(client, entry)
|
|
276
|
-
except Exception: # noqa: BLE001 — offline: unknown, never a removal
|
|
277
|
-
return "unknown"
|
|
278
|
-
|
|
293
|
+
except Exception as e: # noqa: BLE001 — offline: unknown, never a removal
|
|
294
|
+
return {"state": "unknown", "why": f"could not reach Witbitz ({str(e)[:80]})"}
|
|
295
|
+
if st.get("live") is True:
|
|
296
|
+
return {"state": "live"}
|
|
297
|
+
if st.get("why") in ("revoked", "paused"):
|
|
298
|
+
return {"state": st["why"]}
|
|
299
|
+
return {"state": "unknown", "why": str(st.get("why") or "no answer")}
|
|
279
300
|
|
|
280
301
|
d = wizard.Deps(
|
|
281
302
|
say=_err, ask=lambda q: keys.read_line(q), secret=lambda q: keys.read_line(q, hidden=True),
|
|
@@ -17,6 +17,7 @@ import json
|
|
|
17
17
|
import os
|
|
18
18
|
import tempfile
|
|
19
19
|
import time
|
|
20
|
+
from email.utils import parsedate_to_datetime
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
from typing import Any
|
|
22
23
|
|
|
@@ -114,8 +115,24 @@ def signed_message(op: str, key_id: str, ts: int) -> str:
|
|
|
114
115
|
return f"wbcode-v1|{op}|{key_id}|{ts}" # = codeKeys.mjs signedMessage
|
|
115
116
|
|
|
116
117
|
|
|
118
|
+
# THIS COMPUTER'S CLOCK, as the server sees it. The server refuses a signed call more than 5 minutes off its own time, and a
|
|
119
|
+
# clock can be: WSL's falls behind while the laptop sleeps (the owner, 2026-10-08 — setup trusted keys the account had taken
|
|
120
|
+
# back, because the check that would have said so was refused). A refusal carries the server's time in its Date header;
|
|
121
|
+
# from then on this process signs to that time. The signature still proves the key; only `ts` moves.
|
|
122
|
+
SKEW_FIX_S = 60
|
|
123
|
+
_clock = {"skew": 0.0}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def reset_clock() -> None:
|
|
127
|
+
_clock["skew"] = 0.0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _now() -> float:
|
|
131
|
+
return time.time() + _clock["skew"]
|
|
132
|
+
|
|
133
|
+
|
|
117
134
|
def signed_body(op: str, entry: dict, now: float | None = None) -> dict:
|
|
118
|
-
ts = int(now if now is not None else
|
|
135
|
+
ts = int(now if now is not None else _now())
|
|
119
136
|
sig = _sign_key(entry).sign(signed_message(op, entry["keyId"], ts).encode())
|
|
120
137
|
return {"keyId": entry["keyId"], "ts": ts, "sig": _b64u(sig)}
|
|
121
138
|
|
|
@@ -153,9 +170,10 @@ def save(entry: dict) -> None:
|
|
|
153
170
|
_write_all(doc)
|
|
154
171
|
|
|
155
172
|
|
|
156
|
-
def forget(computer_id: str) -> bool:
|
|
173
|
+
def forget(computer_id: str, key_id: str | None = None) -> bool:
|
|
174
|
+
"""Drop this computer's keys — with `key_id`, only if they are still THOSE keys (a re-pair may have replaced them)."""
|
|
157
175
|
doc = read_all()
|
|
158
|
-
keep = [k for k in doc["keys"] if k.get("computerId") != computer_id]
|
|
176
|
+
keep = [k for k in doc["keys"] if k.get("computerId") != computer_id or (key_id is not None and k.get("keyId") != key_id)]
|
|
159
177
|
if len(keep) == len(doc["keys"]):
|
|
160
178
|
return False
|
|
161
179
|
doc["keys"] = keep
|
|
@@ -214,13 +232,41 @@ async def mint(client: httpx.AsyncClient, *, id_token: str, account: str, comput
|
|
|
214
232
|
return entry
|
|
215
233
|
|
|
216
234
|
|
|
235
|
+
def _server_time(r: httpx.Response) -> float | None:
|
|
236
|
+
try:
|
|
237
|
+
return parsedate_to_datetime(r.headers.get("date") or "").timestamp()
|
|
238
|
+
except (TypeError, ValueError):
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def _signed_post(client: httpx.AsyncClient, path: str, op: str, entry: dict, timeout: float = 10) -> httpx.Response:
|
|
243
|
+
"""A signed call; refused while this computer's clock is off the server's, it is signed again to the server's time."""
|
|
244
|
+
r = await client.post(api_base() + path, json=signed_body(op, entry), timeout=timeout)
|
|
245
|
+
if r.status_code == 401:
|
|
246
|
+
server = _server_time(r)
|
|
247
|
+
if server is not None and abs(server - _now()) > SKEW_FIX_S:
|
|
248
|
+
_clock["skew"] = server - time.time()
|
|
249
|
+
r = await client.post(api_base() + path, json=signed_body(op, entry), timeout=timeout)
|
|
250
|
+
return r
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _why(r: httpx.Response) -> str:
|
|
254
|
+
"""The server's own sentence for a refusal, with its status — what a person reads when the answer could not be had."""
|
|
255
|
+
try:
|
|
256
|
+
msg = str(((r.json() or {}).get("error") or {}).get("message") or "")
|
|
257
|
+
except ValueError:
|
|
258
|
+
msg = ""
|
|
259
|
+
return f"{msg} ({r.status_code})" if msg else f"the account answered {r.status_code}"
|
|
260
|
+
|
|
261
|
+
|
|
217
262
|
async def status(client: httpx.AsyncClient, entry: dict) -> dict:
|
|
218
|
-
"""{live, why?, month?} — and a Tinfoil key minted or renewed server-side is opened into the entry (the caller saves).
|
|
219
|
-
|
|
263
|
+
"""{live, why?, month?} — and a Tinfoil key minted or renewed server-side is opened into the entry (the caller saves).
|
|
264
|
+
live None: the answer could not be had, and `why` says why."""
|
|
265
|
+
r = await _signed_post(client, "/status", "status", entry)
|
|
220
266
|
if r.status_code == 404:
|
|
221
267
|
return {"live": False, "why": "revoked"}
|
|
222
268
|
if r.status_code != 200:
|
|
223
|
-
return {"live": None, "why":
|
|
269
|
+
return {"live": None, "why": _why(r)} # unknown — keep going as before, and say why
|
|
224
270
|
d = (r.json() or {}).get("data") or {}
|
|
225
271
|
tf = (d.get("sealed") or {}).get("tinfoil")
|
|
226
272
|
if tf:
|
|
@@ -234,7 +280,7 @@ async def status(client: httpx.AsyncClient, entry: dict) -> dict:
|
|
|
234
280
|
async def revoke(client: httpx.AsyncClient, entry: dict) -> bool:
|
|
235
281
|
"""This computer gives its keys back (unpair, uninstall). True when the server confirmed."""
|
|
236
282
|
try:
|
|
237
|
-
r = await client
|
|
283
|
+
r = await _signed_post(client, "/revoke", "revoke", entry)
|
|
238
284
|
except httpx.HTTPError:
|
|
239
285
|
return False
|
|
240
286
|
return r.status_code in (200, 404) # 404: already gone
|
|
@@ -249,13 +295,39 @@ class LiveKeys:
|
|
|
249
295
|
"""The keys of the pairings `serve` answers for, and what the server last said about each (status every few hours)."""
|
|
250
296
|
|
|
251
297
|
def __init__(self, computer_ids: list[str]) -> None:
|
|
252
|
-
self.
|
|
298
|
+
self.ids = list(computer_ids)
|
|
299
|
+
self.entries: dict[str, dict] = {}
|
|
253
300
|
self.refuse: dict[str, str] = {}
|
|
301
|
+
self.unknown: dict[str, str] = {}
|
|
302
|
+
self._seen: Any = "never"
|
|
303
|
+
self._fresh()
|
|
304
|
+
|
|
305
|
+
def _fresh(self) -> None:
|
|
306
|
+
"""Re-read the keys file when it changed: a re-pair while serve runs replaces this computer's keys (the owner,
|
|
307
|
+
2026-10-08: a running serve kept answering with the old ones — "Invalid API key")."""
|
|
308
|
+
try:
|
|
309
|
+
st = keys_path().stat()
|
|
310
|
+
seen: Any = (st.st_mtime_ns, st.st_size, st.st_ino)
|
|
311
|
+
except OSError:
|
|
312
|
+
seen = None
|
|
313
|
+
if seen == self._seen:
|
|
314
|
+
return
|
|
315
|
+
self._seen = seen
|
|
316
|
+
for c in self.ids:
|
|
317
|
+
e = for_computer(c)
|
|
318
|
+
prev = self.entries.get(c)
|
|
319
|
+
if e:
|
|
320
|
+
if prev and prev.get("keyId") != e.get("keyId"):
|
|
321
|
+
self.refuse.pop(c, None) # new keys: what was said of the old ones is not about these
|
|
322
|
+
self.entries[c] = e
|
|
323
|
+
else:
|
|
324
|
+
self.entries.pop(c, None)
|
|
254
325
|
|
|
255
326
|
def credential(self, computer_id: str | None) -> dict | None:
|
|
256
327
|
"""The proxy's answer for a call owned by `computer_id`: its keys, or {refuse: sentence}. None: no owner."""
|
|
257
328
|
if not computer_id:
|
|
258
329
|
return None
|
|
330
|
+
self._fresh()
|
|
259
331
|
if computer_id in self.refuse:
|
|
260
332
|
return {"refuse": self.refuse[computer_id]}
|
|
261
333
|
e = self.entries.get(computer_id)
|
|
@@ -264,13 +336,25 @@ class LiveKeys:
|
|
|
264
336
|
return {"trustedrouter": e.get("trustedrouter", ""), "tinfoil": e.get("tinfoil", "")}
|
|
265
337
|
|
|
266
338
|
async def check(self, client: httpx.AsyncClient, say: Any = lambda _m: None) -> None:
|
|
339
|
+
self._fresh()
|
|
267
340
|
for cid, e in list(self.entries.items()):
|
|
268
341
|
try:
|
|
269
342
|
st = await status(client, e)
|
|
270
343
|
except httpx.HTTPError:
|
|
271
344
|
continue # unknown: keep going as before; the next check asks again
|
|
345
|
+
if st.get("live") is None:
|
|
346
|
+
if self.unknown.get(cid) != st.get("why"):
|
|
347
|
+
say(f'witbitz-code: could not check the coding keys of "{e.get("name")}" with your account — {st.get("why")}')
|
|
348
|
+
self.unknown[cid] = str(st.get("why"))
|
|
349
|
+
continue
|
|
350
|
+
self.unknown.pop(cid, None)
|
|
272
351
|
if st.get("live") is False and st.get("why") == "revoked":
|
|
273
|
-
|
|
352
|
+
now = for_computer(cid)
|
|
353
|
+
if now and now.get("keyId") != e.get("keyId"): # a re-pair replaced these keys while the account was asked
|
|
354
|
+
self.entries[cid] = now
|
|
355
|
+
self.refuse.pop(cid, None)
|
|
356
|
+
continue
|
|
357
|
+
forget(cid, e.get("keyId"))
|
|
274
358
|
self.entries.pop(cid, None)
|
|
275
359
|
self.refuse[cid] = REVOKED_TEXT
|
|
276
360
|
say(f'witbitz-code: the coding keys of "{e.get("name")}" → {e.get("account")} were revoked — pair again to code from that account')
|
|
@@ -457,7 +457,7 @@ class ConfidentialProxy:
|
|
|
457
457
|
reader: Any = "default", log: Callable[[str], Any] = print, policies: set | None = None,
|
|
458
458
|
verify: Callable | None = None, sleep: Callable = asyncio.sleep, on_progress: Callable[[dict], Any] = lambda ev: None,
|
|
459
459
|
cache_marks: bool = True, credential_for: Callable[["_Request"], dict | None] | None = None,
|
|
460
|
-
offered: Callable[[str], bool] | None = None) -> None:
|
|
460
|
+
offered: Callable[[str], bool] | None = None, on_key_refused: Callable[[], Any] = lambda: None) -> None:
|
|
461
461
|
self.port, self.host, self.upstream = port, host, upstream
|
|
462
462
|
self.client = client or httpx.AsyncClient(trust_env=False, timeout=httpx.Timeout(None, connect=15.0))
|
|
463
463
|
self.models = models if models is not None else mdl.confidential_models()
|
|
@@ -473,6 +473,16 @@ class ConfidentialProxy:
|
|
|
473
473
|
# key OpenCode sends is used as is (tests, and a proxy run by hand).
|
|
474
474
|
self._credential_for = credential_for
|
|
475
475
|
self._offered = offered
|
|
476
|
+
# TrustedRouter refused an account's key (401): it was taken back — a removal in the app, a re-pair. serve asks the
|
|
477
|
+
# account at once instead of at its next check, hours later (the owner, 2026-10-08: "Invalid API key").
|
|
478
|
+
self._on_key_refused = on_key_refused
|
|
479
|
+
|
|
480
|
+
def _key_refused(self, status: int) -> None:
|
|
481
|
+
if status == 401 and self._credential_for is not None:
|
|
482
|
+
try:
|
|
483
|
+
self._on_key_refused()
|
|
484
|
+
except Exception: # noqa: BLE001 — a hook must never fail a call
|
|
485
|
+
pass
|
|
476
486
|
|
|
477
487
|
def add_confidential(self, lst: Any) -> int:
|
|
478
488
|
"""Models the account offers as confidential today — added, never removed (models.add_confidential)."""
|
|
@@ -579,6 +589,7 @@ class ConfidentialProxy:
|
|
|
579
589
|
try:
|
|
580
590
|
async with self.client.stream(req.method, target, headers=_forward_headers(req.headers), content=None if req.method in ("GET", "HEAD") else out) as up:
|
|
581
591
|
headers = {k: v for k, v in up.headers.items() if k.lower() not in ("content-encoding", "content-length", "transfer-encoding", "connection")}
|
|
592
|
+
self._key_refused(up.status_code)
|
|
582
593
|
await res.head(up.status_code, headers)
|
|
583
594
|
async for c in up.aiter_bytes():
|
|
584
595
|
await res.write(c)
|
|
@@ -614,6 +625,7 @@ class ConfidentialProxy:
|
|
|
614
625
|
tell("waiting", bytes=len(raw))
|
|
615
626
|
async with self.client.stream("POST", target, headers=headers, content=raw) as up:
|
|
616
627
|
if not up.is_success:
|
|
628
|
+
self._key_refused(up.status_code)
|
|
617
629
|
text = (await up.aread()).decode("utf-8", "replace")
|
|
618
630
|
if res.headers_sent:
|
|
619
631
|
raise Refusal(502, f"{label}: TrustedRouter answered {up.status_code} when asked again ({text[:160]})", "upstream_error")
|
|
@@ -279,6 +279,8 @@ async def _keys_line(p: dict, keys: Any) -> str:
|
|
|
279
279
|
st = await keys.status(entry)
|
|
280
280
|
except Exception as e: # noqa: BLE001 — status is a reading, never a failure of the command
|
|
281
281
|
return f"coding keys: could not ask ({str(e)[:60]})"
|
|
282
|
+
if st.get("live") is None:
|
|
283
|
+
return f"coding keys: could not check with your account — {st.get('why')}"
|
|
282
284
|
if st.get("live") is False:
|
|
283
285
|
return "coding keys: revoked — pair again: witbitz-code pair" if st.get("why") == "revoked" else "coding keys: paused by Witbitz"
|
|
284
286
|
m = st.get("month") or {}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Who is listening on a port of this computer — this user's processes only (= tools/witbitz-code.mjs portOwners).
|
|
2
|
+
|
|
3
|
+
setup asks it before starting: an earlier witbitz-code still running (`pipx uninstall` does not stop it) holds OpenCode's
|
|
4
|
+
port and the model proxy's (port + 100), and keeps answering the phone with the keys it started with (the owner,
|
|
5
|
+
2026-10-08: "could not start the model proxy … address already in use", then "Invalid API key"). Linux reads /proc; macOS
|
|
6
|
+
asks lsof; elsewhere nothing is known and setup says the port is taken.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def port_owners(port: int) -> list[dict]:
|
|
18
|
+
"""[{pid, cmd}] of this user's processes listening on 127.0.0.1/::1 `port` (never this process)."""
|
|
19
|
+
if Path("/proc/net/tcp").exists():
|
|
20
|
+
return _proc_owners(port)
|
|
21
|
+
if shutil.which("lsof"):
|
|
22
|
+
return _lsof_owners(port)
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _proc_owners(port: int) -> list[dict]:
|
|
27
|
+
inodes: set[str] = set()
|
|
28
|
+
for f in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
29
|
+
try:
|
|
30
|
+
lines = Path(f).read_text().splitlines()[1:]
|
|
31
|
+
except OSError:
|
|
32
|
+
continue
|
|
33
|
+
for line in lines:
|
|
34
|
+
c = line.split()
|
|
35
|
+
if len(c) >= 10 and c[3] == "0A" and int(c[1].rsplit(":", 1)[-1], 16) == port: # 0A = LISTEN
|
|
36
|
+
inodes.add(c[9])
|
|
37
|
+
if not inodes:
|
|
38
|
+
return []
|
|
39
|
+
hits = []
|
|
40
|
+
for p in os.listdir("/proc"):
|
|
41
|
+
if not p.isdigit() or int(p) == os.getpid():
|
|
42
|
+
continue
|
|
43
|
+
try:
|
|
44
|
+
fds = os.listdir(f"/proc/{p}/fd")
|
|
45
|
+
except OSError:
|
|
46
|
+
continue # not this user's
|
|
47
|
+
owns = False
|
|
48
|
+
for fd in fds:
|
|
49
|
+
try:
|
|
50
|
+
link = os.readlink(f"/proc/{p}/fd/{fd}")
|
|
51
|
+
except OSError:
|
|
52
|
+
continue
|
|
53
|
+
if link.startswith("socket:[") and link[8:-1] in inodes:
|
|
54
|
+
owns = True
|
|
55
|
+
break
|
|
56
|
+
if not owns:
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
cmd = " ".join(x for x in Path(f"/proc/{p}/cmdline").read_bytes().decode("utf-8", "replace").split("\0") if x)
|
|
60
|
+
except OSError:
|
|
61
|
+
cmd = ""
|
|
62
|
+
hits.append({"pid": int(p), "cmd": cmd})
|
|
63
|
+
return hits
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _lsof_owners(port: int) -> list[dict]:
|
|
67
|
+
try:
|
|
68
|
+
out = subprocess.run(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], capture_output=True, text=True, timeout=10).stdout
|
|
69
|
+
except (OSError, subprocess.SubprocessError):
|
|
70
|
+
return []
|
|
71
|
+
hits = []
|
|
72
|
+
for pid in sorted({int(x) for x in out.split() if x.isdigit()}):
|
|
73
|
+
if pid == os.getpid():
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
cmd = subprocess.run(["ps", "-o", "command=", "-p", str(pid)], capture_output=True, text=True, timeout=5).stdout.strip()
|
|
77
|
+
except (OSError, subprocess.SubprocessError):
|
|
78
|
+
cmd = ""
|
|
79
|
+
hits.append({"pid": pid, "cmd": cmd})
|
|
80
|
+
return hits
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def on_wsl() -> bool:
|
|
84
|
+
try:
|
|
85
|
+
return "microsoft" in Path("/proc/version").read_text().lower()
|
|
86
|
+
except OSError:
|
|
87
|
+
return False
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""What OpenCode gets on Windows, measured (2026-10-08, Windows 11, OpenCode 1.18.30; six teacher tasks with Hebrew data on
|
|
2
|
+
DeepSeek V4.1 Flash, Claude Haiku 4.5 and GLM 5.3 Flash — 48/48 correct with all of this, 45/48 without):
|
|
3
|
+
|
|
4
|
+
- PYTHONUTF8=1 — Windows Python writes cp1252 to a pipe and died printing Hebrew ("'charmap' codec can't encode").
|
|
5
|
+
- Git Bash as the shell (SHELL) where it is installed — a model's bash habits (heredocs, /c/ paths in a shell) just work
|
|
6
|
+
there; in PowerShell 5.1 Haiku opened 7 of 12 runs with a heredoc even when told not to.
|
|
7
|
+
- A Python of its own first on PATH, with a python3 that is real — the bare name is the Microsoft Store stub, which prints
|
|
8
|
+
"Python" and fails. code.ps1 makes it: %LOCALAPPDATA%\\Witbitz\\agent-python, a uv venv with python3.exe beside python.exe.
|
|
9
|
+
- An instructions file saying all that — above all that OpenCode's read/write tools are Windows programs: under Git Bash,
|
|
10
|
+
Haiku wrote "/c/Users/…" with the write tool, which landed in C:\\c\\Users\\… while it reported Done.
|
|
11
|
+
|
|
12
|
+
Off Windows nothing changes. code.ps1 (spaces/public) installs the Git and the Python this looks for."""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ntpath
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Callable
|
|
21
|
+
|
|
22
|
+
INSTRUCTIONS_NAME = "witbitz-windows.md"
|
|
23
|
+
|
|
24
|
+
GIT_BASH = """# This computer
|
|
25
|
+
- Windows. Your shell is Git Bash. In shell commands paths look like /c/Users/name/folder, but the read/write/edit tools are Windows programs and need C:\\Users\\name\\folder (or a path relative to the working directory). Never pass /c/... to those tools.
|
|
26
|
+
- Python: run `python` or `python3` (a Python of your own is first on PATH). If a module is missing, `python -m pip install <name>`.
|
|
27
|
+
- Files with non-English text (e.g. Hebrew) are UTF-8. Prefer Python for CSV, Excel and Word files.
|
|
28
|
+
- pip's "[notice] A new release of pip" line is not an error.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
POWERSHELL = """# This computer
|
|
32
|
+
- Windows. Your shell is Windows PowerShell 5.1. Paths look like C:\\Users\\name\\folder - use that form everywhere, including in the read/write/edit tools.
|
|
33
|
+
- Python: run `python` or `python3` (a Python of your own is first on PATH, when there is one). If a module is missing, `python -m pip install <name>`.
|
|
34
|
+
- Files with non-English text (e.g. Hebrew) are UTF-8. In PowerShell always pass `-Encoding UTF8` to Get-Content, Set-Content, Out-File, Import-Csv and Export-Csv. Prefer Python for CSV, Excel and Word files.
|
|
35
|
+
- PowerShell has no here-documents: `python << 'EOF'` does not work. For anything longer than one line, write a .py file and run `python file.py`.
|
|
36
|
+
- pip's "[notice] A new release of pip" line is not an error.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _witbitz_dir(env: dict) -> str:
|
|
41
|
+
return ntpath.join(env.get("LOCALAPPDATA", ""), "Witbitz")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_git_bash(env: dict, exists: Callable[[str], bool]) -> str | None:
|
|
45
|
+
"""Git Bash: the one code.ps1 installs first, then Git for Windows' usual places."""
|
|
46
|
+
candidates = [env.get("WITBITZ_GIT_BASH", ""), ntpath.join(_witbitz_dir(env), "git", "bin", "bash.exe")]
|
|
47
|
+
for root in (env.get("ProgramFiles", ""), env.get("ProgramW6432", ""), env.get("ProgramFiles(x86)", "")):
|
|
48
|
+
if root:
|
|
49
|
+
candidates.append(ntpath.join(root, "Git", "bin", "bash.exe"))
|
|
50
|
+
if env.get("LOCALAPPDATA"):
|
|
51
|
+
candidates.append(ntpath.join(env["LOCALAPPDATA"], "Programs", "Git", "bin", "bash.exe"))
|
|
52
|
+
return next((c for c in candidates if c and exists(c)), None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def find_agent_python(env: dict, exists: Callable[[str], bool]) -> str | None:
|
|
56
|
+
"""The Scripts folder of the agent's own Python (code.ps1 makes it), or None."""
|
|
57
|
+
home = env.get("WITBITZ_AGENT_PYTHON") or ntpath.join(_witbitz_dir(env), "agent-python")
|
|
58
|
+
scripts = ntpath.join(home, "Scripts")
|
|
59
|
+
return scripts if exists(ntpath.join(scripts, "python.exe")) else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _write_once(f: Path, text: str) -> None:
|
|
63
|
+
try:
|
|
64
|
+
if f.read_text(encoding="utf-8") == text:
|
|
65
|
+
return
|
|
66
|
+
except (OSError, UnicodeDecodeError):
|
|
67
|
+
pass
|
|
68
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
f.write_text(text, encoding="utf-8", newline="")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def windows_agent(config_dir: Path, env: dict | None = None, platform: str | None = None,
|
|
73
|
+
exists: Callable[[str], bool] = os.path.exists) -> tuple[dict, str | None, str]:
|
|
74
|
+
"""→ (env additions — PATH_PREPEND is a list for apply(), the instructions file or None, a line for the log or "")."""
|
|
75
|
+
platform = sys.platform if platform is None else platform
|
|
76
|
+
if platform != "win32":
|
|
77
|
+
return {}, None, ""
|
|
78
|
+
env = os.environ if env is None else env
|
|
79
|
+
add: dict = {"PYTHONUTF8": "1"}
|
|
80
|
+
bash = find_git_bash(env, exists)
|
|
81
|
+
if bash:
|
|
82
|
+
add["SHELL"] = bash
|
|
83
|
+
py = find_agent_python(env, exists)
|
|
84
|
+
if py:
|
|
85
|
+
add["PATH_PREPEND"] = [py]
|
|
86
|
+
f = Path(config_dir) / INSTRUCTIONS_NAME
|
|
87
|
+
instr: str | None = None
|
|
88
|
+
try:
|
|
89
|
+
_write_once(f, GIT_BASH if bash else POWERSHELL)
|
|
90
|
+
instr = str(f)
|
|
91
|
+
except OSError:
|
|
92
|
+
pass
|
|
93
|
+
shell = f"Git Bash ({bash})" if bash else "PowerShell (no Git Bash found)"
|
|
94
|
+
pyline = f"Python {ntpath.dirname(py)}" if py else "no Python of its own (run the installer again to add it)"
|
|
95
|
+
return add, instr, f"witbitz-code: on Windows — shell {shell}, {pyline}, UTF-8 for Python"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def apply(tools_env: dict, tools_config: dict, add: dict, instructions: str | None, base_path: str | None = None) -> tuple[dict, dict]:
|
|
99
|
+
"""Fold windows_agent's result into codetools' (env, config), without changing either: the agent's Python goes first on
|
|
100
|
+
PATH (ahead of ast-grep), and its instructions file joins the list (OpenCode's config merge replaces lists)."""
|
|
101
|
+
env = {k: v for k, v in tools_env.items()}
|
|
102
|
+
for k, v in add.items():
|
|
103
|
+
if k != "PATH_PREPEND":
|
|
104
|
+
env[k] = v
|
|
105
|
+
if add.get("PATH_PREPEND"):
|
|
106
|
+
rest = env.get("PATH", base_path if base_path is not None else os.environ.get("PATH", ""))
|
|
107
|
+
env["PATH"] = ";".join(list(add["PATH_PREPEND"]) + ([rest] if rest else []))
|
|
108
|
+
config = {k: v for k, v in tools_config.items()}
|
|
109
|
+
if instructions:
|
|
110
|
+
config["instructions"] = list(tools_config.get("instructions") or []) + [instructions]
|
|
111
|
+
return env, config
|
|
@@ -21,7 +21,7 @@ from dataclasses import dataclass, field
|
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
from typing import Any
|
|
23
23
|
|
|
24
|
-
from . import codekeys
|
|
24
|
+
from . import codekeys, ports
|
|
25
25
|
from . import pairings as pp
|
|
26
26
|
from .connector import _url_port
|
|
27
27
|
|
|
@@ -74,6 +74,9 @@ class Deps:
|
|
|
74
74
|
keys_state: Callable[[dict], Awaitable[str]] | None = None
|
|
75
75
|
forget_keys: Callable[[dict], Any] = lambda p: codekeys.forget(p.get("computerId") or "")
|
|
76
76
|
is_listening: Callable[[int], Awaitable[bool]] = None # type: ignore[assignment]
|
|
77
|
+
port_owners: Callable[[int], list] = ports.port_owners # [{pid, cmd}] — this user's processes on a port (ports.py)
|
|
78
|
+
stop_process: Callable[[int], bool] = lambda pid: __import__("witbitz_code.uninstall", fromlist=["stop_process"]).stop_process(pid)
|
|
79
|
+
wsl: bool = field(default_factory=ports.on_wsl)
|
|
77
80
|
serve_here: Callable[[int], Awaitable[int]] = None # type: ignore[assignment]
|
|
78
81
|
result: dict = field(default_factory=lambda: {"opencode": False, "paired": False, "keys": False, "running": ""})
|
|
79
82
|
|
|
@@ -126,7 +129,17 @@ async def run_setup(d: Deps) -> dict:
|
|
|
126
129
|
mine = on(everything, port)
|
|
127
130
|
# A pairing the account has let go of (the owner, 2026-10-08: reinstalled after a removal and "it does not present the
|
|
128
131
|
# QR code"): its keys come back revoked, so it is not a pairing any more — say so and scan again. Offline is not that.
|
|
129
|
-
|
|
132
|
+
unchecked: dict[str, str] = {} # computerId → why the account could not be asked (said at step 3, never a silent ✓)
|
|
133
|
+
gone = []
|
|
134
|
+
for p in mine:
|
|
135
|
+
if not (d.keys_state and d.code_keys(p)):
|
|
136
|
+
continue
|
|
137
|
+
st = await d.keys_state(p)
|
|
138
|
+
state, why = (st, "") if isinstance(st, str) else (str(st.get("state") or ""), str(st.get("why") or ""))
|
|
139
|
+
if state == "revoked":
|
|
140
|
+
gone.append(p)
|
|
141
|
+
elif state == "unknown":
|
|
142
|
+
unchecked[p.get("computerId") or ""] = why or "no answer"
|
|
130
143
|
for p in gone:
|
|
131
144
|
d.forget_keys(p)
|
|
132
145
|
if mine and len(gone) == len(mine):
|
|
@@ -157,13 +170,49 @@ async def run_setup(d: Deps) -> dict:
|
|
|
157
170
|
return {**result, "stopped": "keys"}
|
|
158
171
|
caps = (d.code_keys(mine[0]) or {}).get("caps") or {}
|
|
159
172
|
d.say(f" ✓ Issued by your Spaces account (this computer: up to ${caps.get('modelsUsd', '?')} of models and ${caps.get('toolsUsd', '?')} of tools a month)")
|
|
173
|
+
for p in mine:
|
|
174
|
+
why = unchecked.get(p.get("computerId") or "")
|
|
175
|
+
if why:
|
|
176
|
+
d.say(f" ? Could not check them with your account just now — {why}. Using them as they are;")
|
|
177
|
+
d.say(" if your phone shows this computer as removed, run: witbitz-code pair")
|
|
160
178
|
result["keys"] = True
|
|
161
179
|
|
|
162
180
|
# 4 — running (this package has no background service yet: it runs in this window)
|
|
163
181
|
d.say("\n4. Keep it running")
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
182
|
+
# OpenCode's port AND the model proxy's (port + 100): an earlier witbitz-code still running holds both, and keeps answering
|
|
183
|
+
# the phone with the keys it started with (the owner, 2026-10-08: `pipx uninstall` left it running — "could not start
|
|
184
|
+
# the model proxy … address already in use", then "Invalid API key"). This person's own process: offer to stop it.
|
|
185
|
+
for _ in range(6):
|
|
186
|
+
busy = [q for q in (port, port + 100) if await d.is_listening(q)]
|
|
187
|
+
if not busy:
|
|
188
|
+
break
|
|
189
|
+
owners: list[dict] = []
|
|
190
|
+
for q in busy:
|
|
191
|
+
for o in d.port_owners(q):
|
|
192
|
+
if all(o["pid"] != x["pid"] for x in owners):
|
|
193
|
+
owners.append(o)
|
|
194
|
+
if not owners:
|
|
195
|
+
d.say(f" Port {busy[0]} is taken by a program that is not yours{' — on WSL, another Linux distro on this computer shares its ports' if d.wsl else ''}.")
|
|
196
|
+
d.say(f" Run setup with another port: witbitz-code setup --port {port + 1}")
|
|
197
|
+
return {**result, "stopped": "busy"}
|
|
198
|
+
listed = "; ".join(f"{(o.get('cmd') or 'a program')[:70]} (process {o['pid']})" for o in owners)
|
|
199
|
+
d.say(f" Already running here: {listed}.")
|
|
200
|
+
d.say(" That is an earlier witbitz-code or an OpenCode started some other way — it would keep answering your phone with")
|
|
201
|
+
d.say(" other keys. witbitz-code starts OpenCode itself, with this computer's keys from your account.")
|
|
202
|
+
a = str(d.ask(f" Stop {'them' if len(owners) > 1 else 'it'} now? [Y/n] — or type s to stop here: ") or "").strip().lower()
|
|
203
|
+
if a.startswith("s"):
|
|
204
|
+
d.say(" Stopped. Run setup again when you are ready.")
|
|
205
|
+
return {**result, "stopped": "busy"}
|
|
206
|
+
if a.startswith("n"):
|
|
207
|
+
if str(d.ask(" Close it yourself, then press Enter — or type s to stop here: ") or "").strip().lower().startswith("s"):
|
|
208
|
+
d.say(" Stopped. Run setup again when you are ready.")
|
|
209
|
+
return {**result, "stopped": "busy"}
|
|
210
|
+
continue
|
|
211
|
+
for o in owners:
|
|
212
|
+
d.say(f" ✓ Stopped process {o['pid']}" if d.stop_process(o["pid"]) else f" ✖ Could not stop process {o['pid']}")
|
|
213
|
+
else:
|
|
214
|
+
d.say(f" Ports {port} and {port + 100} are still in use. Close what holds them, then run setup again.")
|
|
215
|
+
return {**result, "stopped": "busy"}
|
|
167
216
|
d.say("\nDone. Starting witbitz-code in this window — keep it open (Ctrl-C stops it). Open Spaces → ☰ → Code on your phone.\n")
|
|
168
217
|
result["running"] = "here"
|
|
169
218
|
result["port"] = port
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: witbitz-code
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.3
|
|
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
|
|
@@ -66,7 +66,8 @@ OpenCode's own window on this computer is refused ("coding runs from the Spaces
|
|
|
66
66
|
being written another account cannot add to it. This splits the bill fairly between people who share a computer; it is not
|
|
67
67
|
a wall between them (they already share its files and shell).
|
|
68
68
|
|
|
69
|
-
To start over: `witbitz-code uninstall`, then `pipx install witbitz-code` and `witbitz-code setup`.
|
|
69
|
+
To start over: `witbitz-code uninstall`, then `pipx install witbitz-code` and `witbitz-code setup`. (`pipx uninstall` alone
|
|
70
|
+
leaves the pairing and its keys behind, and does not stop a witbitz-code that is still running — `setup` offers to stop one.)
|
|
70
71
|
|
|
71
72
|
The same thing one step at a time: install OpenCode (`curl -fsSL https://opencode.ai/install | bash`), then
|
|
72
73
|
`witbitz-code pair` and `witbitz-code serve`.
|
|
@@ -31,6 +31,7 @@ src/witbitz_code/pairings.py
|
|
|
31
31
|
src/witbitz_code/payers.py
|
|
32
32
|
src/witbitz_code/plugins.py
|
|
33
33
|
src/witbitz_code/policy.py
|
|
34
|
+
src/witbitz_code/ports.py
|
|
34
35
|
src/witbitz_code/qr.py
|
|
35
36
|
src/witbitz_code/receipt.py
|
|
36
37
|
src/witbitz_code/relay.py
|
|
@@ -38,6 +39,7 @@ src/witbitz_code/seen.py
|
|
|
38
39
|
src/witbitz_code/tinfoil_read.py
|
|
39
40
|
src/witbitz_code/tools_probe.py
|
|
40
41
|
src/witbitz_code/uninstall.py
|
|
42
|
+
src/witbitz_code/winagent.py
|
|
41
43
|
src/witbitz_code/winconsole.py
|
|
42
44
|
src/witbitz_code/wizard.py
|
|
43
45
|
src/witbitz_code.egg-info/PKG-INFO
|
|
@@ -65,9 +67,11 @@ tests/test_outputs.py
|
|
|
65
67
|
tests/test_pair_flows.py
|
|
66
68
|
tests/test_plugins.py
|
|
67
69
|
tests/test_policy.py
|
|
70
|
+
tests/test_ports.py
|
|
68
71
|
tests/test_registry.py
|
|
69
72
|
tests/test_relay_codec.py
|
|
70
73
|
tests/test_setup.py
|
|
71
74
|
tests/test_tools_probe.py
|
|
72
75
|
tests/test_uninstall.py
|
|
76
|
+
tests/test_winagent.py
|
|
73
77
|
tests/test_winconsole.py
|
|
@@ -142,6 +142,121 @@ def test_live_keys_follow_what_the_server_says():
|
|
|
142
142
|
assert live.credential("cmp_1") == {"refuse": codekeys.REVOKED_TEXT} and codekeys.for_computer("cmp_1") is None, "revoked: forgotten here too"
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
|
|
146
|
+
# ── 1.3.3: the laptop's clock, a refusal that says why, keys that change under a running serve ──────────────────────────
|
|
147
|
+
# The owner, 2026-10-08, on WSL: setup said "✓ Issued" over keys the account had taken back, a running serve answered with
|
|
148
|
+
# them ("Invalid API key"), and a re-pair's keys never reached it. A WSL clock falls behind while the laptop sleeps, and the
|
|
149
|
+
# server refuses a signed call more than 5 minutes off — so the answer never came, and nothing said why.
|
|
150
|
+
def _server(skew_s: float, answer: dict, calls: list):
|
|
151
|
+
"""A /status + /revoke that keeps its own clock (local + skew) and refuses a call more than 300 s off, as codeKeys.mjs."""
|
|
152
|
+
from email.utils import formatdate
|
|
153
|
+
import time as _t
|
|
154
|
+
|
|
155
|
+
def handler(req):
|
|
156
|
+
body = json.loads(req.content or b"{}")
|
|
157
|
+
calls.append((req.url.path, body.get("ts")))
|
|
158
|
+
now = _t.time() + skew_s
|
|
159
|
+
date = {"date": formatdate(now, usegmt=True)}
|
|
160
|
+
if abs(int(body.get("ts", 0)) - now) > 300:
|
|
161
|
+
return httpx.Response(401, headers=date, json={"error": {"code": "unauthorized", "message": "the request is too old or from the future — check this computer's clock"}})
|
|
162
|
+
return httpx.Response(200, headers=date, json={"data": answer})
|
|
163
|
+
return handler
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_a_laptop_whose_clock_is_off_still_gets_the_answer_and_signs_to_the_servers_time_after():
|
|
167
|
+
from fakes import give_code_keys
|
|
168
|
+
|
|
169
|
+
codekeys.reset_clock()
|
|
170
|
+
e = give_code_keys("cmp_1")
|
|
171
|
+
calls = []
|
|
172
|
+
async def go():
|
|
173
|
+
async with client_for(_server(-3600, {"live": False, "why": "revoked"}, calls)) as c: # this computer is an hour AHEAD
|
|
174
|
+
first = await codekeys.status(c, e)
|
|
175
|
+
second = await codekeys.status(c, e)
|
|
176
|
+
gone = await codekeys.revoke(c, e)
|
|
177
|
+
return first, second, gone
|
|
178
|
+
first, second, gone = run(go())
|
|
179
|
+
assert first == {"live": False, "why": "revoked"} and second == first and gone is True
|
|
180
|
+
assert len(calls) == 4, f"one refused call, then every call signed to the server's clock: {calls}"
|
|
181
|
+
codekeys.reset_clock()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_an_answer_that_cannot_be_had_says_why():
|
|
185
|
+
from fakes import give_code_keys
|
|
186
|
+
|
|
187
|
+
codekeys.reset_clock()
|
|
188
|
+
e = give_code_keys("cmp_1")
|
|
189
|
+
def refuse(req):
|
|
190
|
+
return httpx.Response(401, json={"error": {"code": "unauthorized", "message": "the signature does not match this computer"}})
|
|
191
|
+
async def go():
|
|
192
|
+
async with client_for(refuse) as c:
|
|
193
|
+
return await codekeys.status(c, e)
|
|
194
|
+
st = run(go())
|
|
195
|
+
assert st["live"] is None and "signature does not match" in st["why"]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _replace_keys(computer_id: str, kid: str, tr: str) -> dict:
|
|
199
|
+
from fakes import give_code_keys
|
|
200
|
+
|
|
201
|
+
e = give_code_keys(computer_id)
|
|
202
|
+
e = {**e, "keyId": e["userId"] + "." + kid, "kid": kid, "trustedrouter": tr}
|
|
203
|
+
codekeys.save(e)
|
|
204
|
+
return e
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_a_running_serve_picks_up_the_keys_a_new_pairing_saved():
|
|
208
|
+
from fakes import give_code_keys
|
|
209
|
+
|
|
210
|
+
give_code_keys("cmp_1")
|
|
211
|
+
live = codekeys.LiveKeys(["cmp_1"])
|
|
212
|
+
assert live.credential("cmp_1")["trustedrouter"] == "sk-tr-made-up-cmp_1"
|
|
213
|
+
_replace_keys("cmp_1", "k_" + "2" * 24, "sk-tr-the-new-one")
|
|
214
|
+
assert live.credential("cmp_1")["trustedrouter"] == "sk-tr-the-new-one", "the new pairing's key, without a restart"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_the_old_keys_revocation_never_forgets_the_new_pairings_keys():
|
|
218
|
+
from fakes import give_code_keys
|
|
219
|
+
|
|
220
|
+
give_code_keys("cmp_1")
|
|
221
|
+
live = codekeys.LiveKeys(["cmp_1"])
|
|
222
|
+
landed = {}
|
|
223
|
+
def answer(req): # the re-pair lands while the check is asking about the OLD keys — the server took those back
|
|
224
|
+
landed.setdefault("new", _replace_keys("cmp_1", "k_" + "2" * 24, "sk-tr-the-new-one"))
|
|
225
|
+
return httpx.Response(200, json={"data": {"live": False, "why": "revoked"}})
|
|
226
|
+
async def check():
|
|
227
|
+
async with client_for(answer) as c:
|
|
228
|
+
await live.check(c, lambda _m: None)
|
|
229
|
+
run(check())
|
|
230
|
+
new = landed["new"]
|
|
231
|
+
assert (codekeys.for_computer("cmp_1") or {}).get("keyId") == new["keyId"], "the new pairing's keys are still on disk"
|
|
232
|
+
assert live.credential("cmp_1")["trustedrouter"] == "sk-tr-the-new-one"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_a_key_trustedrouter_refuses_makes_the_proxy_say_so_at_once_for_both_kinds_of_model():
|
|
237
|
+
# a removal in the app (or a re-pair) takes the keys back; serve asked the account only every few hours, so the phone
|
|
238
|
+
# read TrustedRouter's "Invalid API key" until then. Now the proxy tells serve, which asks the account at once.
|
|
239
|
+
told = []
|
|
240
|
+
def upstream(req):
|
|
241
|
+
return httpx.Response(401, json={"error": {"message": "Invalid API key"}})
|
|
242
|
+
async def gate_ok(): # TrustedRouter's gateway attestation is its own test (test_confidential.py)
|
|
243
|
+
return {"ok": True}
|
|
244
|
+
async def go(model):
|
|
245
|
+
p = await cf.start_confidential_proxy(upstream="https://tr.test", client=httpx.AsyncClient(transport=httpx.MockTransport(upstream)),
|
|
246
|
+
reader=None, log=lambda _l: None, credential_for=lambda req: {"trustedrouter": "sk-old"},
|
|
247
|
+
on_key_refused=lambda: told.append(model), gate=gate_ok)
|
|
248
|
+
try:
|
|
249
|
+
async with httpx.AsyncClient(timeout=10) as oc:
|
|
250
|
+
r = await oc.post(f"http://127.0.0.1:{p.port}/v1/chat/completions", json={"model": model, "messages": []}, headers={"x-session-id": "s"})
|
|
251
|
+
return r.status_code
|
|
252
|
+
finally:
|
|
253
|
+
await p.close()
|
|
254
|
+
plain = run(go("openai/gpt-6-sol"))
|
|
255
|
+
conf_model = next(iter(mdl.confidential_models()))
|
|
256
|
+
conf = run(go(conf_model))
|
|
257
|
+
assert plain == 401 and conf == 401 and told == ["openai/gpt-6-sol", conf_model]
|
|
258
|
+
|
|
259
|
+
|
|
145
260
|
# ── who pays ─────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
146
261
|
def test_each_turn_is_its_accounts_and_another_account_cannot_steer_into_it():
|
|
147
262
|
p = SessionPayers()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""ports.port_owners against a real listening process (setup's step 4 asks it before offering to stop an earlier run)."""
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from witbitz_code import ports
|
|
12
|
+
|
|
13
|
+
pytestmark = pytest.mark.skipif(not Path("/proc/net/tcp").exists() and sys.platform != "darwin", reason="Linux /proc or macOS lsof")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_the_process_listening_on_a_port_is_named_and_a_free_port_has_nobody():
|
|
17
|
+
with socket.socket() as s:
|
|
18
|
+
s.bind(("127.0.0.1", 0))
|
|
19
|
+
port = s.getsockname()[1]
|
|
20
|
+
child = subprocess.Popen([sys.executable, "-c", f"import socket,time;s=socket.socket();s.bind(('127.0.0.1',{port}));s.listen();time.sleep(30)"])
|
|
21
|
+
try:
|
|
22
|
+
for _ in range(50):
|
|
23
|
+
hits = ports.port_owners(port)
|
|
24
|
+
if hits:
|
|
25
|
+
break
|
|
26
|
+
time.sleep(0.1)
|
|
27
|
+
assert [h["pid"] for h in hits] == [child.pid] and "socket" in hits[0]["cmd"]
|
|
28
|
+
finally:
|
|
29
|
+
child.kill()
|
|
30
|
+
child.wait()
|
|
31
|
+
assert ports.port_owners(port) == []
|
|
@@ -118,7 +118,9 @@ class Fake:
|
|
|
118
118
|
find_opencode=lambda: "/usr/bin/opencode" if self._oc else "", can_install_opencode=lambda: False,
|
|
119
119
|
all_pairings=lambda: list(self._pairings), pair=pair, move_pairing=lambda p, port: None,
|
|
120
120
|
code_keys=lambda p: {"caps": {"modelsUsd": 50, "toolsUsd": 5}} if p.get("computerId") in self._keyed else None,
|
|
121
|
-
|
|
121
|
+
serve_here=serve_here,
|
|
122
|
+
# never this machine's real processes: the real lookup finds whatever listens on 4096 HERE (an OpenCode does)
|
|
123
|
+
**{"is_listening": listening, "port_owners": lambda port: [], "stop_process": lambda pid: False, "wsl": False, **over})
|
|
122
124
|
|
|
123
125
|
|
|
124
126
|
def test_a_new_computer_is_paired_gets_its_keys_from_the_account_and_is_served_without_a_key_prompt():
|
|
@@ -162,11 +164,38 @@ def test_paired_for_another_port_uses_it():
|
|
|
162
164
|
assert r["port"] == 4097 and f.served == [4097] and f.paired == []
|
|
163
165
|
|
|
164
166
|
|
|
165
|
-
def
|
|
167
|
+
def test_a_port_taken_by_a_program_that_is_not_yours_stops_setup_with_another_port_to_use():
|
|
166
168
|
f = Fake(listening=True)
|
|
167
|
-
run(wizard.run_setup(f.deps()))
|
|
168
|
-
assert
|
|
169
|
-
assert any("
|
|
169
|
+
r = run(wizard.run_setup(f.deps()))
|
|
170
|
+
assert r["stopped"] == "busy" and f.served == []
|
|
171
|
+
assert any("Port 4096 is taken by a program that is not yours" in line for line in f.said)
|
|
172
|
+
assert any("witbitz-code setup --port 4097" in line for line in f.said)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_an_earlier_witbitz_code_still_running_is_named_and_stopped_both_ports_then_this_one_serves():
|
|
176
|
+
# the owner, 2026-10-08: `pipx uninstall` left the old serve running — its OpenCode on 4096, its proxy on 4196
|
|
177
|
+
held = {4096: {"pid": 11, "cmd": "opencode serve --port 4096"}, 4196: {"pid": 10, "cmd": "/usr/bin/python3 witbitz-code serve"}}
|
|
178
|
+
f = Fake(pairings=[{"name": "desk", "opencodeUrl": "http://127.0.0.1:4096", "idx": {"room": "r"}, "computerId": "c"}], keyed={"c"}, answers=[""])
|
|
179
|
+
stopped = []
|
|
180
|
+
async def listening(port):
|
|
181
|
+
return port in held
|
|
182
|
+
def stop(pid):
|
|
183
|
+
stopped.append(pid)
|
|
184
|
+
for q in [q for q, o in held.items() if o["pid"] == pid]:
|
|
185
|
+
held.pop(q)
|
|
186
|
+
return True
|
|
187
|
+
r = run(wizard.run_setup(f.deps(is_listening=listening, port_owners=lambda port: [held[port]] if port in held else [], stop_process=stop)))
|
|
188
|
+
assert sorted(stopped) == [10, 11] and f.served == [4096] and not r.get("stopped")
|
|
189
|
+
assert any("Already running here: opencode serve --port 4096 (process 11); /usr/bin/python3 witbitz-code serve (process 10)" in line for line in f.said)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_s_stops_setup_and_leaves_the_running_one_alone():
|
|
193
|
+
held = {4096: {"pid": 11, "cmd": "opencode serve"}}
|
|
194
|
+
f = Fake(answers=["s"])
|
|
195
|
+
async def listening(port):
|
|
196
|
+
return port in held
|
|
197
|
+
r = run(wizard.run_setup(f.deps(is_listening=listening, port_owners=lambda port: [held[port]] if port in held else [], stop_process=lambda pid: (_ for _ in ()).throw(AssertionError("never")))))
|
|
198
|
+
assert r["stopped"] == "busy" and f.served == []
|
|
170
199
|
|
|
171
200
|
|
|
172
201
|
def test_a_computer_removed_from_the_account_is_paired_again_and_shows_the_qr():
|
|
@@ -184,9 +213,10 @@ def test_a_computer_removed_from_the_account_is_paired_again_and_shows_the_qr():
|
|
|
184
213
|
assert not any(line.startswith(" ✓ Paired") for line in f.said[:f.said.index(next(x for x in f.said if "was removed" in x))])
|
|
185
214
|
|
|
186
215
|
|
|
187
|
-
def
|
|
216
|
+
def test_keys_that_cannot_be_checked_are_not_taken_for_a_removal_and_setup_says_why():
|
|
188
217
|
f = Fake(pairings=[{"name": "desk", "opencodeUrl": "http://127.0.0.1:4096", "idx": {"room": "r"}, "computerId": "c"}], keyed={"c"})
|
|
189
218
|
async def keys_state(p):
|
|
190
|
-
return "unknown"
|
|
219
|
+
return {"state": "unknown", "why": "the signature does not match this computer (401)"}
|
|
191
220
|
r = run(wizard.run_setup(f.deps(keys_state=keys_state, forget_keys=lambda p: f._keyed.discard(p["computerId"]))))
|
|
192
221
|
assert f.paired == [] and r["keys"] and f.served == [4096]
|
|
222
|
+
assert any("Could not check them with your account just now — the signature does not match this computer (401)" in line for line in f.said)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""What OpenCode gets on Windows (benchmark 2026-10-08, Windows 11, OpenCode 1.18.30, six teacher tasks with Hebrew data,
|
|
2
|
+
DeepSeek V4.1 Flash / Claude Haiku 4.5 / GLM 5.3 Flash): PYTHONUTF8=1 (without it Windows Python crashed printing Hebrew),
|
|
3
|
+
Git Bash as the shell (a model's bash habits just work there), a Python of its own first on PATH with a real python3 (the
|
|
4
|
+
bare name is the Microsoft Store stub), and an instructions file naming all that — 48/48 correct with them, 45/48 without;
|
|
5
|
+
Haiku on Git Bash 68 → 3 failed commands."""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from witbitz_code import winagent
|
|
10
|
+
|
|
11
|
+
LOCAL = r"C:\Users\t\AppData\Local"
|
|
12
|
+
ENV = {"LOCALAPPDATA": LOCAL, "ProgramFiles": r"C:\Program Files", "PATH": r"C:\Windows\system32"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def exists_of(*paths):
|
|
16
|
+
have = {p.lower() for p in paths}
|
|
17
|
+
return lambda p: str(p).lower() in have
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_nothing_changes_off_windows(tmp_path):
|
|
21
|
+
cfg = tmp_path / "opencode" # its own folder: conftest makes tmp_path/home for every test
|
|
22
|
+
add, instr, note = winagent.windows_agent(cfg, env=ENV, platform="linux", exists=exists_of())
|
|
23
|
+
assert (add, instr, note) == ({}, None, "")
|
|
24
|
+
assert not cfg.exists(), "no file written off Windows"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_git_bash_python_and_utf8_on_windows(tmp_path):
|
|
28
|
+
bash = r"C:\Program Files\Git\bin\bash.exe"
|
|
29
|
+
py = LOCAL + r"\Witbitz\agent-python\Scripts\python.exe"
|
|
30
|
+
add, instr, note = winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of(bash, py))
|
|
31
|
+
assert add["PYTHONUTF8"] == "1"
|
|
32
|
+
assert add["SHELL"] == bash
|
|
33
|
+
assert add["PATH_PREPEND"] == [LOCAL + r"\Witbitz\agent-python\Scripts"]
|
|
34
|
+
text = Path(instr).read_text(encoding="utf-8")
|
|
35
|
+
assert "Git Bash" in text and r"C:\Users" in text and "/c/" in text, "the read/write tools need C:\\ paths, never /c/"
|
|
36
|
+
assert "Git Bash" in note and "agent-python" in note
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_witbitz_own_git_wins_over_none_and_powershell_is_the_fallback(tmp_path):
|
|
40
|
+
own = LOCAL + r"\Witbitz\git\bin\bash.exe"
|
|
41
|
+
add, _, _ = winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of(own))
|
|
42
|
+
assert add["SHELL"] == own
|
|
43
|
+
add, instr, note = winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of())
|
|
44
|
+
assert "SHELL" not in add and "PATH_PREPEND" not in add and add["PYTHONUTF8"] == "1"
|
|
45
|
+
text = Path(instr).read_text(encoding="utf-8")
|
|
46
|
+
assert "PowerShell" in text and "-Encoding UTF8" in text and "<<" in text
|
|
47
|
+
assert "no Python" in note
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_the_instructions_file_is_rewritten_only_when_it_changes(tmp_path):
|
|
51
|
+
_, instr, _ = winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of())
|
|
52
|
+
p = Path(instr)
|
|
53
|
+
before = p.stat().st_mtime_ns
|
|
54
|
+
winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of())
|
|
55
|
+
assert p.stat().st_mtime_ns == before
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_apply_puts_python_ahead_of_ast_grep_and_adds_to_the_instructions():
|
|
59
|
+
tools_env = {"PATH": r"C:\ast;C:\Windows\system32", "OPENCODE_EXPERIMENTAL_LSP_TOOL": "true"}
|
|
60
|
+
tools_config = {"instructions": [r"C:\cfg\witbitz-tools.md"]}
|
|
61
|
+
add = {"PYTHONUTF8": "1", "SHELL": r"C:\Git\bin\bash.exe", "PATH_PREPEND": [r"C:\py\Scripts"]}
|
|
62
|
+
env, config = winagent.apply(tools_env, tools_config, add, r"C:\cfg\witbitz-windows.md", base_path=r"C:\Windows\system32")
|
|
63
|
+
assert env["PATH"] == r"C:\py\Scripts;C:\ast;C:\Windows\system32"
|
|
64
|
+
assert env["SHELL"] == r"C:\Git\bin\bash.exe" and env["PYTHONUTF8"] == "1" and "PATH_PREPEND" not in env
|
|
65
|
+
assert config["instructions"] == [r"C:\cfg\witbitz-tools.md", r"C:\cfg\witbitz-windows.md"]
|
|
66
|
+
assert tools_config["instructions"] == [r"C:\cfg\witbitz-tools.md"], "the caller's config is not changed"
|
|
67
|
+
# no ast-grep: PATH starts from the base
|
|
68
|
+
env, config = winagent.apply({}, {}, add, None, base_path=r"C:\Windows\system32")
|
|
69
|
+
assert env["PATH"] == r"C:\py\Scripts;C:\Windows\system32" and config == {}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs
RENAMED
|
File without changes
|
{witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/witbitz-notes.js
RENAMED
|
File without changes
|
{witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/witbitz-progress.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|