witbitz-code 1.3.2__tar.gz → 1.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {witbitz_code-1.3.2/src/witbitz_code.egg-info → witbitz_code-1.3.4}/PKG-INFO +3 -2
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/README.md +2 -1
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/pyproject.toml +1 -1
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/cli.py +29 -8
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/codekeys.py +96 -21
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/confidential.py +13 -1
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/pair.py +2 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/payers.py +1 -7
- witbitz_code-1.3.4/src/witbitz_code/ports.py +87 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/uninstall.py +1 -1
- witbitz_code-1.3.4/src/witbitz_code/winagent.py +111 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/wizard.py +54 -5
- {witbitz_code-1.3.2 → witbitz_code-1.3.4/src/witbitz_code.egg-info}/PKG-INFO +3 -2
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code.egg-info/SOURCES.txt +6 -1
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_codekeys.py +115 -0
- witbitz_code-1.3.4/tests/test_ports.py +31 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_setup.py +37 -7
- witbitz_code-1.3.4/tests/test_winagent.py +69 -0
- witbitz_code-1.3.4/tests/test_windows_files.py +28 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/LICENSE +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/setup.cfg +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_asks.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_attachments.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_auto.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_cli.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_codetools.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_confidential.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_folders.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_outputs.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_plugins.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_policy.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_registry.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.3.2 → witbitz_code-1.3.4}/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.4
|
|
4
4
|
Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
|
|
5
5
|
Author: Witbitz
|
|
6
6
|
License-Expression: MIT
|
|
@@ -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.4"
|
|
8
8
|
description = "Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -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),
|
|
@@ -15,8 +15,8 @@ from __future__ import annotations
|
|
|
15
15
|
import base64
|
|
16
16
|
import json
|
|
17
17
|
import os
|
|
18
|
-
import tempfile
|
|
19
18
|
import time
|
|
19
|
+
from email.utils import parsedate_to_datetime
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from typing import Any
|
|
22
22
|
|
|
@@ -114,8 +114,24 @@ def signed_message(op: str, key_id: str, ts: int) -> str:
|
|
|
114
114
|
return f"wbcode-v1|{op}|{key_id}|{ts}" # = codeKeys.mjs signedMessage
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
# 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
|
|
118
|
+
# clock can be: WSL's falls behind while the laptop sleeps (the owner, 2026-10-08 — setup trusted keys the account had taken
|
|
119
|
+
# back, because the check that would have said so was refused). A refusal carries the server's time in its Date header;
|
|
120
|
+
# from then on this process signs to that time. The signature still proves the key; only `ts` moves.
|
|
121
|
+
SKEW_FIX_S = 60
|
|
122
|
+
_clock = {"skew": 0.0}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def reset_clock() -> None:
|
|
126
|
+
_clock["skew"] = 0.0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _now() -> float:
|
|
130
|
+
return time.time() + _clock["skew"]
|
|
131
|
+
|
|
132
|
+
|
|
117
133
|
def signed_body(op: str, entry: dict, now: float | None = None) -> dict:
|
|
118
|
-
ts = int(now if now is not None else
|
|
134
|
+
ts = int(now if now is not None else _now())
|
|
119
135
|
sig = _sign_key(entry).sign(signed_message(op, entry["keyId"], ts).encode())
|
|
120
136
|
return {"keyId": entry["keyId"], "ts": ts, "sig": _b64u(sig)}
|
|
121
137
|
|
|
@@ -130,17 +146,9 @@ def read_all() -> dict:
|
|
|
130
146
|
|
|
131
147
|
|
|
132
148
|
def _write_all(doc: dict) -> None:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
try:
|
|
137
|
-
os.fchmod(fd, 0o600)
|
|
138
|
-
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
139
|
-
f.write(json.dumps(doc, indent=1) + "\n")
|
|
140
|
-
os.replace(tmp, path)
|
|
141
|
-
except BaseException:
|
|
142
|
-
Path(tmp).unlink(missing_ok=True)
|
|
143
|
-
raise
|
|
149
|
+
# pairings.write_private: 0600, atomic, and Windows-safe (Windows Python 3.12 has no os.fchmod — the owner's native
|
|
150
|
+
# Windows pairing died here, the real error hidden by a cleanup that could not delete the still-open temp file).
|
|
151
|
+
pp.write_private(keys_path(), json.dumps(doc, indent=1) + "\n")
|
|
144
152
|
|
|
145
153
|
|
|
146
154
|
def for_computer(computer_id: str) -> dict | None:
|
|
@@ -153,9 +161,10 @@ def save(entry: dict) -> None:
|
|
|
153
161
|
_write_all(doc)
|
|
154
162
|
|
|
155
163
|
|
|
156
|
-
def forget(computer_id: str) -> bool:
|
|
164
|
+
def forget(computer_id: str, key_id: str | None = None) -> bool:
|
|
165
|
+
"""Drop this computer's keys — with `key_id`, only if they are still THOSE keys (a re-pair may have replaced them)."""
|
|
157
166
|
doc = read_all()
|
|
158
|
-
keep = [k for k in doc["keys"] if k.get("computerId") != computer_id]
|
|
167
|
+
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
168
|
if len(keep) == len(doc["keys"]):
|
|
160
169
|
return False
|
|
161
170
|
doc["keys"] = keep
|
|
@@ -214,13 +223,41 @@ async def mint(client: httpx.AsyncClient, *, id_token: str, account: str, comput
|
|
|
214
223
|
return entry
|
|
215
224
|
|
|
216
225
|
|
|
226
|
+
def _server_time(r: httpx.Response) -> float | None:
|
|
227
|
+
try:
|
|
228
|
+
return parsedate_to_datetime(r.headers.get("date") or "").timestamp()
|
|
229
|
+
except (TypeError, ValueError):
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def _signed_post(client: httpx.AsyncClient, path: str, op: str, entry: dict, timeout: float = 10) -> httpx.Response:
|
|
234
|
+
"""A signed call; refused while this computer's clock is off the server's, it is signed again to the server's time."""
|
|
235
|
+
r = await client.post(api_base() + path, json=signed_body(op, entry), timeout=timeout)
|
|
236
|
+
if r.status_code == 401:
|
|
237
|
+
server = _server_time(r)
|
|
238
|
+
if server is not None and abs(server - _now()) > SKEW_FIX_S:
|
|
239
|
+
_clock["skew"] = server - time.time()
|
|
240
|
+
r = await client.post(api_base() + path, json=signed_body(op, entry), timeout=timeout)
|
|
241
|
+
return r
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _why(r: httpx.Response) -> str:
|
|
245
|
+
"""The server's own sentence for a refusal, with its status — what a person reads when the answer could not be had."""
|
|
246
|
+
try:
|
|
247
|
+
msg = str(((r.json() or {}).get("error") or {}).get("message") or "")
|
|
248
|
+
except ValueError:
|
|
249
|
+
msg = ""
|
|
250
|
+
return f"{msg} ({r.status_code})" if msg else f"the account answered {r.status_code}"
|
|
251
|
+
|
|
252
|
+
|
|
217
253
|
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
|
-
|
|
254
|
+
"""{live, why?, month?} — and a Tinfoil key minted or renewed server-side is opened into the entry (the caller saves).
|
|
255
|
+
live None: the answer could not be had, and `why` says why."""
|
|
256
|
+
r = await _signed_post(client, "/status", "status", entry)
|
|
220
257
|
if r.status_code == 404:
|
|
221
258
|
return {"live": False, "why": "revoked"}
|
|
222
259
|
if r.status_code != 200:
|
|
223
|
-
return {"live": None, "why":
|
|
260
|
+
return {"live": None, "why": _why(r)} # unknown — keep going as before, and say why
|
|
224
261
|
d = (r.json() or {}).get("data") or {}
|
|
225
262
|
tf = (d.get("sealed") or {}).get("tinfoil")
|
|
226
263
|
if tf:
|
|
@@ -234,7 +271,7 @@ async def status(client: httpx.AsyncClient, entry: dict) -> dict:
|
|
|
234
271
|
async def revoke(client: httpx.AsyncClient, entry: dict) -> bool:
|
|
235
272
|
"""This computer gives its keys back (unpair, uninstall). True when the server confirmed."""
|
|
236
273
|
try:
|
|
237
|
-
r = await client
|
|
274
|
+
r = await _signed_post(client, "/revoke", "revoke", entry)
|
|
238
275
|
except httpx.HTTPError:
|
|
239
276
|
return False
|
|
240
277
|
return r.status_code in (200, 404) # 404: already gone
|
|
@@ -249,13 +286,39 @@ class LiveKeys:
|
|
|
249
286
|
"""The keys of the pairings `serve` answers for, and what the server last said about each (status every few hours)."""
|
|
250
287
|
|
|
251
288
|
def __init__(self, computer_ids: list[str]) -> None:
|
|
252
|
-
self.
|
|
289
|
+
self.ids = list(computer_ids)
|
|
290
|
+
self.entries: dict[str, dict] = {}
|
|
253
291
|
self.refuse: dict[str, str] = {}
|
|
292
|
+
self.unknown: dict[str, str] = {}
|
|
293
|
+
self._seen: Any = "never"
|
|
294
|
+
self._fresh()
|
|
295
|
+
|
|
296
|
+
def _fresh(self) -> None:
|
|
297
|
+
"""Re-read the keys file when it changed: a re-pair while serve runs replaces this computer's keys (the owner,
|
|
298
|
+
2026-10-08: a running serve kept answering with the old ones — "Invalid API key")."""
|
|
299
|
+
try:
|
|
300
|
+
st = keys_path().stat()
|
|
301
|
+
seen: Any = (st.st_mtime_ns, st.st_size, st.st_ino)
|
|
302
|
+
except OSError:
|
|
303
|
+
seen = None
|
|
304
|
+
if seen == self._seen:
|
|
305
|
+
return
|
|
306
|
+
self._seen = seen
|
|
307
|
+
for c in self.ids:
|
|
308
|
+
e = for_computer(c)
|
|
309
|
+
prev = self.entries.get(c)
|
|
310
|
+
if e:
|
|
311
|
+
if prev and prev.get("keyId") != e.get("keyId"):
|
|
312
|
+
self.refuse.pop(c, None) # new keys: what was said of the old ones is not about these
|
|
313
|
+
self.entries[c] = e
|
|
314
|
+
else:
|
|
315
|
+
self.entries.pop(c, None)
|
|
254
316
|
|
|
255
317
|
def credential(self, computer_id: str | None) -> dict | None:
|
|
256
318
|
"""The proxy's answer for a call owned by `computer_id`: its keys, or {refuse: sentence}. None: no owner."""
|
|
257
319
|
if not computer_id:
|
|
258
320
|
return None
|
|
321
|
+
self._fresh()
|
|
259
322
|
if computer_id in self.refuse:
|
|
260
323
|
return {"refuse": self.refuse[computer_id]}
|
|
261
324
|
e = self.entries.get(computer_id)
|
|
@@ -264,13 +327,25 @@ class LiveKeys:
|
|
|
264
327
|
return {"trustedrouter": e.get("trustedrouter", ""), "tinfoil": e.get("tinfoil", "")}
|
|
265
328
|
|
|
266
329
|
async def check(self, client: httpx.AsyncClient, say: Any = lambda _m: None) -> None:
|
|
330
|
+
self._fresh()
|
|
267
331
|
for cid, e in list(self.entries.items()):
|
|
268
332
|
try:
|
|
269
333
|
st = await status(client, e)
|
|
270
334
|
except httpx.HTTPError:
|
|
271
335
|
continue # unknown: keep going as before; the next check asks again
|
|
336
|
+
if st.get("live") is None:
|
|
337
|
+
if self.unknown.get(cid) != st.get("why"):
|
|
338
|
+
say(f'witbitz-code: could not check the coding keys of "{e.get("name")}" with your account — {st.get("why")}')
|
|
339
|
+
self.unknown[cid] = str(st.get("why"))
|
|
340
|
+
continue
|
|
341
|
+
self.unknown.pop(cid, None)
|
|
272
342
|
if st.get("live") is False and st.get("why") == "revoked":
|
|
273
|
-
|
|
343
|
+
now = for_computer(cid)
|
|
344
|
+
if now and now.get("keyId") != e.get("keyId"): # a re-pair replaced these keys while the account was asked
|
|
345
|
+
self.entries[cid] = now
|
|
346
|
+
self.refuse.pop(cid, None)
|
|
347
|
+
continue
|
|
348
|
+
forget(cid, e.get("keyId"))
|
|
274
349
|
self.entries.pop(cid, None)
|
|
275
350
|
self.refuse[cid] = REVOKED_TEXT
|
|
276
351
|
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 {}
|
|
@@ -16,7 +16,6 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import json
|
|
18
18
|
import os
|
|
19
|
-
import tempfile
|
|
20
19
|
import threading
|
|
21
20
|
from pathlib import Path
|
|
22
21
|
|
|
@@ -49,12 +48,7 @@ class SessionPayers:
|
|
|
49
48
|
if not self._persist:
|
|
50
49
|
return
|
|
51
50
|
try:
|
|
52
|
-
self._path.
|
|
53
|
-
fd, tmp = tempfile.mkstemp(dir=str(self._path.parent), prefix=".session-payers.")
|
|
54
|
-
os.fchmod(fd, 0o600)
|
|
55
|
-
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
56
|
-
f.write(json.dumps({"v": 1, "owners": self._owner}))
|
|
57
|
-
os.replace(tmp, self._path)
|
|
51
|
+
pp.write_private(self._path, json.dumps({"v": 1, "owners": self._owner})) # Windows-safe (no os.fchmod there)
|
|
58
52
|
except OSError:
|
|
59
53
|
pass # memory still knows; a restart forgets, and a forgotten conversation is refused, never misbilled
|
|
60
54
|
|
|
@@ -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
|