witbitz-code 1.6.3__tar.gz → 1.6.6__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.6.3/src/witbitz_code.egg-info → witbitz_code-1.6.6}/PKG-INFO +1 -1
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/pyproject.toml +1 -1
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/attachments.py +5 -2
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/auto.py +28 -1
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/auto_runner.py +4 -3
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/cli.py +1 -1
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/keys.py +1 -1
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/mkdir.py +1 -1
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/models.py +10 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/pairings.py +13 -1
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/plugins.py +8 -5
- {witbitz_code-1.6.3 → witbitz_code-1.6.6/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_auto.py +71 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_confidential.py +13 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_plugins.py +10 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_windows_files.py +18 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/LICENSE +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/README.md +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/setup.cfg +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/icons/witbitz-code.ico +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/ui.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/ui_static/app.css +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/ui_static/app.js +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/ui_static/index.html +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/uitools.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/winbg.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/wslstart.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_asks.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_attachments.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_cli.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_codetools.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_dirs.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_folders.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_outputs.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_policy.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_ports.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_registry.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_setup.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_ui.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_uitools.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_winagent.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_winbg.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_wslstart.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "witbitz-code"
|
|
7
|
-
version = "1.6.
|
|
7
|
+
version = "1.6.6"
|
|
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"
|
|
@@ -15,12 +15,14 @@ import hashlib
|
|
|
15
15
|
import os
|
|
16
16
|
import re
|
|
17
17
|
import shutil
|
|
18
|
+
import sys
|
|
18
19
|
import time
|
|
19
20
|
from decimal import ROUND_HALF_UP, Decimal
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
from typing import Any
|
|
22
23
|
|
|
23
24
|
from . import _js
|
|
25
|
+
from . import pairings as pp
|
|
24
26
|
|
|
25
27
|
NOTE_HEAD = "The user attached files. They are saved on this computer — open them with the read tool."
|
|
26
28
|
ATTACHMENT_ROUTE = "/witbitz/attachment"
|
|
@@ -178,8 +180,9 @@ def stage_message_body(body: Any, *, session_id: Any, root: Path, max_file_bytes
|
|
|
178
180
|
d = Path(root) / session_id
|
|
179
181
|
if Path(root).is_symlink() or d.is_symlink():
|
|
180
182
|
return {"error": {"status": 400, "message": "the attachments folder is a link — refusing to write through it"}}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
+
pp.private_mkdir(d)
|
|
184
|
+
if sys.platform != "win32":
|
|
185
|
+
os.chmod(d, 0o700)
|
|
183
186
|
used = _folder_bytes(d)
|
|
184
187
|
total = _root_bytes(root)
|
|
185
188
|
entries: list[dict] = []
|
|
@@ -206,6 +206,30 @@ _KEY_FILE = re.compile(r"\.(pem|key|p12|pfx|jks|keystore)\Z", re.ASCII | re.IGNO
|
|
|
206
206
|
_SECRETISH = re.compile(r"secret|credential", re.ASCII | re.IGNORECASE)
|
|
207
207
|
|
|
208
208
|
|
|
209
|
+
_WIN_ABS = re.compile(r"\A(?:\\\\\?\\)?([A-Za-z]):[\\/]")
|
|
210
|
+
_WIN_UNC = re.compile(r"\A\\\\(?!\?\\)([^\\/]+)[\\/]([^\\/]+)") # \\server\share — a project on a network share
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def windows_to_posix(path: Any) -> Any:
|
|
214
|
+
"""A Windows path in the form every rule here reads: C:\\Users\\x, C:/Users/x → /c/Users/x (Git Bash's own spelling).
|
|
215
|
+
Every rule tests "absolute" as a leading / — so on Windows (the owner, 2026-10-09: "The auto mode is not working
|
|
216
|
+
(windows)") the project folder was never absolute and Auto would not switch on, and C:/… in a command read as a path
|
|
217
|
+
INSIDE the project. Anything else is returned as it came."""
|
|
218
|
+
if not isinstance(path, str):
|
|
219
|
+
return path
|
|
220
|
+
m = _WIN_ABS.match(path)
|
|
221
|
+
if m:
|
|
222
|
+
return "/" + m.group(1).lower() + "/" + path[m.end():].replace("\\", "/")
|
|
223
|
+
u = _WIN_UNC.match(path) # \\server\share\x → /unc/server/share/x: never one of the /c/… drive spellings
|
|
224
|
+
return "/unc/" + u.group(1).lower() + "/" + u.group(2) + path[u.end():].replace("\\", "/") if u else path
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _windows_project(ctx: dict) -> bool:
|
|
228
|
+
"""A project on Windows: there a backslash separates folders too (..\\..\\x climbs out exactly as ../../x does)."""
|
|
229
|
+
d = str(ctx.get("directory") or "")
|
|
230
|
+
return bool(_WIN_ABS.match(d) or _WIN_UNC.match(d))
|
|
231
|
+
|
|
232
|
+
|
|
209
233
|
def _sensitive_path(abs_path: str) -> bool:
|
|
210
234
|
parts = abs_path.split("/")
|
|
211
235
|
base = parts[-1] or ""
|
|
@@ -219,7 +243,7 @@ def _sensitive_path(abs_path: str) -> bool:
|
|
|
219
243
|
|
|
220
244
|
|
|
221
245
|
def _project_dir(directory: Any) -> str:
|
|
222
|
-
d = _js.js_string(directory) if _js.truthy(directory) else ""
|
|
246
|
+
d = windows_to_posix(_js.js_string(directory)) if _js.truthy(directory) else ""
|
|
223
247
|
return re.sub(r"/+\Z", "", posix_normalize(d))
|
|
224
248
|
|
|
225
249
|
|
|
@@ -246,6 +270,7 @@ def _word_stays_in_project(w: str, ctx: dict) -> bool:
|
|
|
246
270
|
v = v[eq + 1:]
|
|
247
271
|
if not v:
|
|
248
272
|
return True
|
|
273
|
+
v = windows_to_posix(v if not _windows_project(ctx) or v.startswith("~") or _WIN_ABS.match(v) or _WIN_UNC.match(v) else v.replace("\\", "/"))
|
|
249
274
|
if "$" in v or re.search(r"(^|/)\.\.(/|\Z)", v):
|
|
250
275
|
return False
|
|
251
276
|
if v.startswith("~"):
|
|
@@ -333,6 +358,8 @@ def _edit_inside_project(patterns: Any, ctx: dict) -> bool:
|
|
|
333
358
|
for p in patterns:
|
|
334
359
|
if not isinstance(p, str) or not p or re.search(r"[*?\[\]{}]", p):
|
|
335
360
|
return False # a glob is not a precise target
|
|
361
|
+
if _windows_project(ctx):
|
|
362
|
+
p = windows_to_posix(p if _WIN_ABS.match(p) or _WIN_UNC.match(p) else p.replace("\\", "/"))
|
|
336
363
|
a = posix_normalize(p if p.startswith("/") else d + "/" + p)
|
|
337
364
|
r = next((x for x in roots if a.startswith(x + "/")), "")
|
|
338
365
|
if not (r and not _sensitive_path(a[len(r):])):
|
|
@@ -33,7 +33,7 @@ import httpx
|
|
|
33
33
|
|
|
34
34
|
from . import _js
|
|
35
35
|
from .folders import allowed_folders
|
|
36
|
-
from .auto import SCRATCH_DIR, action_for, classify_deterministic, log_record, parse_verdict, reviewer_prompt, scratch_paths
|
|
36
|
+
from .auto import SCRATCH_DIR, windows_to_posix, action_for, classify_deterministic, log_record, parse_verdict, reviewer_prompt, scratch_paths
|
|
37
37
|
from .policy import SUBAGENT_GATED
|
|
38
38
|
|
|
39
39
|
REVIEW_TITLE = "witbitz-auto-review" # the page never lists a session with this title
|
|
@@ -146,7 +146,7 @@ class AutoRunner:
|
|
|
146
146
|
doc = _js.parse(self._state_path.read_text(encoding="utf-8"))
|
|
147
147
|
sessions = doc.get("sessions") if isinstance(doc, dict) else None
|
|
148
148
|
for sid, v in (sessions.items() if isinstance(sessions, dict) else []):
|
|
149
|
-
if sid.startswith("ses") and isinstance(v, dict) and isinstance(v.get("dir"), str) and v["dir"].startswith("/"):
|
|
149
|
+
if sid.startswith("ses") and isinstance(v, dict) and isinstance(v.get("dir"), str) and windows_to_posix(v["dir"]).startswith("/"):
|
|
150
150
|
at = _js.to_number(v.get("at", _js.UNDEFINED))
|
|
151
151
|
self._auto[sid] = {"dir": v["dir"], "at": _js.clean_number(at) if _js.truthy(at) else 0}
|
|
152
152
|
except Exception as e:
|
|
@@ -470,7 +470,8 @@ class AutoRunner:
|
|
|
470
470
|
if not isinstance(session_id, str) or not _SESSION_ID.fullmatch(session_id):
|
|
471
471
|
return False
|
|
472
472
|
if on:
|
|
473
|
-
|
|
473
|
+
# Windows: C:\\Users\\… is absolute too (auto.windows_to_posix); the folder is kept as given for OpenCode's API.
|
|
474
|
+
if not isinstance(directory, str) or not windows_to_posix(directory).startswith("/") or _js.utf16_len(directory) > 1024:
|
|
474
475
|
return False
|
|
475
476
|
self._auto[session_id] = {"dir": directory, "at": self._now()} # an existing key keeps its place, as Map.set does
|
|
476
477
|
else:
|
|
@@ -240,7 +240,7 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
|
|
|
240
240
|
+ (f" — held by {held}: an earlier witbitz-code still running? witbitz-code setup offers to stop it" if held
|
|
241
241
|
else " — free that port and run serve again"))
|
|
242
242
|
return 1
|
|
243
|
-
route = models.proxy_config(proxy.port)
|
|
243
|
+
route = {**models.proxy_config(proxy.port), **models.default_model_config()}
|
|
244
244
|
if await is_listening(port) and "--no-opencode" not in args:
|
|
245
245
|
_err(f"witbitz-code: OpenCode is already running on 127.0.0.1:{port}, started some other way — it cannot use your account's keys.")
|
|
246
246
|
_err(" Close it, then run witbitz-code serve again (it starts OpenCode itself).")
|
|
@@ -119,7 +119,7 @@ def has_trustedrouter(env: dict | None = None) -> bool:
|
|
|
119
119
|
|
|
120
120
|
def save_trustedrouter_key(key: str) -> Path:
|
|
121
121
|
p = auth_path()
|
|
122
|
-
p.parent
|
|
122
|
+
pp.private_mkdir(p.parent)
|
|
123
123
|
text = p.read_text(encoding="utf-8") if p.exists() else ""
|
|
124
124
|
pp.write_private(p, with_auth_key(text, "trustedrouter", key))
|
|
125
125
|
return p
|
|
@@ -47,7 +47,7 @@ def mkdir_in(*, root: object, rel: object, name: object) -> tuple[int, str]:
|
|
|
47
47
|
return _error(403, "that lies outside the root")
|
|
48
48
|
target = P.join(real_parent, str(name))
|
|
49
49
|
try:
|
|
50
|
-
os.mkdir(target, 0o700)
|
|
50
|
+
os.mkdir(target) if win else os.mkdir(target, 0o700) # Windows: inherit — 0o700 makes an owner-only access list (pairings.private_mkdir)
|
|
51
51
|
except FileExistsError:
|
|
52
52
|
return _error(409, "already there")
|
|
53
53
|
except OSError as e:
|
|
@@ -68,6 +68,16 @@ def variants_for(m: dict) -> dict | None:
|
|
|
68
68
|
return {e: {"reasoningEffort": e} for e in efforts} if efforts else None
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
# The model OpenCode starts with when the page has chosen none — the catalog's confidential default, as the Node connector's
|
|
72
|
+
# config names it (tools/opencode-config.mjs; agent/modelCatalog.mjs DEFAULTS.confidential — held equal by test). Without it
|
|
73
|
+
# OpenCode picked a model itself (the owner, 2026-10-09: "Make deepseek 4.1 flash the default for code").
|
|
74
|
+
DEFAULT_MODEL = "deepseek/deepseek-v4.1-flash"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def default_model_config() -> dict:
|
|
78
|
+
return {"model": f"trustedrouter/{DEFAULT_MODEL}"}
|
|
79
|
+
|
|
80
|
+
|
|
71
81
|
def proxy_config(proxy_port: int, cat: list[dict] | None = None) -> dict:
|
|
72
82
|
"""OpenCode config that sends TrustedRouter through the proxy and declares the confidential models — a text-only one
|
|
73
83
|
still receives images (the proxy reads them through Tinfoil). Merged by OpenCode over the user's own config."""
|
|
@@ -165,10 +165,22 @@ def read_pairings(path: Path | None = None) -> dict:
|
|
|
165
165
|
return norm_pairings(None)
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
def private_mkdir(path: Path, mode: int = 0o700, *, parents: bool = True) -> None:
|
|
169
|
+
"""A folder only this user reads — 0700 off Windows. On Windows NO mode: Python 3.12.4+ turns 0o700 into an access list of
|
|
170
|
+
SYSTEM, Administrators and the folder's OWNER, and a folder made from an Administrator window is owned by Administrators —
|
|
171
|
+
the person's own non-admin windows could then not open it (the owner's second desktop, 2026-10-09: uninstall died
|
|
172
|
+
"[WinError 5] Access is denied: …\\.witbitz\\code\\pairings.json"). The folder inherits the user profile's access instead,
|
|
173
|
+
which is already the person's alone."""
|
|
174
|
+
if sys.platform == "win32":
|
|
175
|
+
path.mkdir(parents=parents, exist_ok=True)
|
|
176
|
+
else:
|
|
177
|
+
path.mkdir(parents=parents, exist_ok=True, mode=mode)
|
|
178
|
+
|
|
179
|
+
|
|
168
180
|
def write_private(path: Path, text: str, dir_mode: int = 0o700) -> None:
|
|
169
181
|
"""Write a 0600 file atomically: the secret is never readable by others, not even for the moment between write and
|
|
170
182
|
chmod, and a crash never leaves half a file."""
|
|
171
|
-
path.parent
|
|
183
|
+
private_mkdir(path.parent, dir_mode)
|
|
172
184
|
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
|
173
185
|
try:
|
|
174
186
|
with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
|
|
@@ -133,8 +133,11 @@ def notes_rules(s: dict, root_dir: str) -> list[dict]:
|
|
|
133
133
|
return []
|
|
134
134
|
folder = f"{root_dir}/{notes_key(os.path.abspath(root))}"
|
|
135
135
|
plain = bool(s.get("path")) and s.get("directory") == "/" + str(s.get("path"))
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
136
|
+
read = {"permission": "external_directory", "pattern": f"{folder}/*", "action": "allow"}
|
|
137
|
+
try:
|
|
138
|
+
rel = os.path.relpath(folder, "/" if plain else os.path.abspath(root))
|
|
139
|
+
except ValueError:
|
|
140
|
+
# Windows, the project on another drive or a network share (\\\\192.168.57.47\\Projects — the owner, 2026-10-09:
|
|
141
|
+
# "path is on mount 'C:', start on mount …"): no relative path reaches the notes, so their writes ask; reads stay.
|
|
142
|
+
return [read]
|
|
143
|
+
return [read, {"permission": "edit", "pattern": f"{rel}/notes/*", "action": "allow"}]
|
|
@@ -629,3 +629,74 @@ def test_wiring_the_page_switches_auto_and_the_verdict_comes_back(make, tmp_path
|
|
|
629
629
|
await connector.stop()
|
|
630
630
|
|
|
631
631
|
run(go())
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ── WINDOWS (the owner, 2026-10-09: "The auto mode is not working (windows)") ────────────────────────────────────────────
|
|
635
|
+
# Every rule read "absolute" as a leading /: Auto would not switch on for C:\…, and C:/… in a command read as INSIDE the
|
|
636
|
+
# project. A Windows request now gets exactly the decision its Linux twin gets.
|
|
637
|
+
_WIN_TWINS = [
|
|
638
|
+
("ls src", "ls src"),
|
|
639
|
+
("cat /etc/hosts", "cat C:/Windows/win.ini"),
|
|
640
|
+
("cat /home/u/repo/src/a.py", "cat C:/Users/u/repo/src/a.py"),
|
|
641
|
+
("cat /home/u/repo/.git/config", "cat /c/Users/u/repo/.git/config"),
|
|
642
|
+
]
|
|
643
|
+
_WIN_EDITS = [("src/a.py", "src\\a.py"), ("../../x", "..\\..\\x"), ("/home/u/repo/src/a.py", "C:\\Users\\u\\repo\\src\\a.py"),
|
|
644
|
+
("/home/u/other/a.py", "C:\\Users\\u\\other\\a.py")]
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _stage(req, directory, home):
|
|
648
|
+
r = classify_deterministic({"id": "per_x", "sessionID": "ses_x", "always": [], **req}, directory=directory, home=home)
|
|
649
|
+
return r["stage"] if r else "review"
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@pytest.mark.parametrize("lin,win", _WIN_TWINS)
|
|
653
|
+
def test_a_windows_command_is_judged_like_its_linux_twin(lin, win):
|
|
654
|
+
b = lambda c: {"permission": "bash", "patterns": [c], "metadata": {"command": c}} # noqa: E731
|
|
655
|
+
assert _stage(b(win), "C:\\Users\\u\\repo", "C:\\Users\\u") == _stage(b(lin), "/home/u/repo", "/home/u")
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@pytest.mark.parametrize("lin,win", _WIN_EDITS)
|
|
659
|
+
def test_a_windows_edit_is_judged_like_its_linux_twin(lin, win):
|
|
660
|
+
e = lambda p: {"permission": "edit", "patterns": [p], "metadata": {}} # noqa: E731
|
|
661
|
+
assert _stage(e(win), "C:\\Users\\u\\repo", "C:\\Users\\u") == _stage(e(lin), "/home/u/repo", "/home/u")
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def test_outside_the_project_on_windows_is_never_allowed_by_itself():
|
|
665
|
+
assert _stage({"permission": "bash", "patterns": ["cat C:/Windows/win.ini"], "metadata": {"command": "cat C:/Windows/win.ini"}},
|
|
666
|
+
"C:\\Users\\u\\repo", "C:\\Users\\u") != "fast-allow"
|
|
667
|
+
assert _stage({"permission": "edit", "patterns": ["..\\..\\x"], "metadata": {}}, "C:\\Users\\u\\repo", "C:\\Users\\u") != "fast-allow"
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def test_auto_switches_on_for_a_windows_folder_and_talks_to_opencode_with_it_as_given(tmp_path):
|
|
671
|
+
win = "C:\\Users\\u\\repo"
|
|
672
|
+
|
|
673
|
+
async def body(oc, runner, verdicts, make, client):
|
|
674
|
+
assert runner.set_auto("ses_main", win, True), "C:\\… is a project folder (it was refused: Auto never ran on Windows)"
|
|
675
|
+
assert runner.set_auto("ses_main", "relative\\dir", True) is False
|
|
676
|
+
assert runner.set_auto("ses_main", win, True)
|
|
677
|
+
oc.ask(permission="bash", patterns=["rm -rf ~"], metadata={"command": "rm -rf ~"})
|
|
678
|
+
assert await until(lambda: oc.replies(), 4)
|
|
679
|
+
assert oc.replies()[0]["dir"] == win, "OpenCode is asked with the folder exactly as it named it"
|
|
680
|
+
with_runner(tmp_path, body)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# A project on a network share (the owner's second desktop, 2026-10-09: \\192.168.57.47\Projects) — Auto refused it too.
|
|
684
|
+
_UNC = "\\\\192.168.57.47\\Projects\\geo"
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@pytest.mark.parametrize("req,stage", [
|
|
688
|
+
({"permission": "bash", "patterns": ["ls src"], "metadata": {"command": "ls src"}}, "fast-allow"),
|
|
689
|
+
({"permission": "bash", "patterns": ["cat C:/Windows/win.ini"], "metadata": {"command": "cat C:/Windows/win.ini"}}, "review"),
|
|
690
|
+
({"permission": "edit", "patterns": ["src\\a.py"], "metadata": {}}, "fast-allow"),
|
|
691
|
+
({"permission": "edit", "patterns": ["..\\other\\x"], "metadata": {}}, "review"),
|
|
692
|
+
({"permission": "edit", "patterns": [_UNC + "\\src\\a.py"], "metadata": {}}, "fast-allow"),
|
|
693
|
+
({"permission": "edit", "patterns": ["\\\\192.168.57.47\\Projects\\other\\a.py"], "metadata": {}}, "review"),
|
|
694
|
+
])
|
|
695
|
+
def test_a_project_on_a_network_share_is_judged_like_any_other(req, stage):
|
|
696
|
+
assert _stage(req, _UNC, "C:\\Users\\u") == stage
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def test_auto_switches_on_for_a_network_share(tmp_path):
|
|
700
|
+
async def body(oc, runner, verdicts, make, client):
|
|
701
|
+
assert runner.set_auto("ses_main", _UNC, True)
|
|
702
|
+
with_runner(tmp_path, body)
|
|
@@ -353,3 +353,16 @@ def test_progress_goes_only_to_a_channel_a_page_is_watching():
|
|
|
353
353
|
await asyncio.sleep(0)
|
|
354
354
|
run(main())
|
|
355
355
|
assert len(sent) == 1 and sent[0]["t"] == "progress" and sent[0]["phase"] == "checking" and isinstance(sent[0]["ts"], int)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_opencode_starts_on_the_catalog_s_confidential_default():
|
|
359
|
+
"""The Python connector named no model, so OpenCode picked one itself (the owner, 2026-10-09: "Make deepseek 4.1 flash the
|
|
360
|
+
default for code"). It names the catalog's confidential default — the same one the Node connector's config names."""
|
|
361
|
+
import re
|
|
362
|
+
from pathlib import Path
|
|
363
|
+
from witbitz_code import models
|
|
364
|
+
src = (Path(__file__).resolve().parents[3] / "agent" / "modelCatalog.mjs").read_text(encoding="utf-8")
|
|
365
|
+
want = re.search(r"DEFAULTS = \{[^}]*confidential: '([^']+)'", src).group(1)
|
|
366
|
+
entry = next(m for m in models.catalog() if m["model"] == models.DEFAULT_MODEL)
|
|
367
|
+
assert want in (entry.get("id"), entry["model"].split("/")[-1]), f"catalog default {want!r} vs {models.DEFAULT_MODEL!r}"
|
|
368
|
+
assert models.default_model_config() == {"model": "trustedrouter/deepseek/deepseek-v4.1-flash"}
|
|
@@ -182,3 +182,13 @@ def test_the_installed_media_server_answers_mcp(tmp_path):
|
|
|
182
182
|
replies = {m["id"]: m for m in map(json.loads, out.splitlines())}
|
|
183
183
|
assert replies[1]["result"]["serverInfo"]["name"] == "witbitz-media"
|
|
184
184
|
assert "audio" in [t["name"] for t in replies[2]["result"]["tools"]]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_notes_rules_on_another_drive_keep_the_reads_and_never_raise(monkeypatch):
|
|
188
|
+
"""Windows, a project on a network share and the notes on C: — relpath raises ("path is on mount 'C:', start on mount
|
|
189
|
+
'\\\\192.168.57.47\\Projects'", the owner's second desktop, 2026-10-09) and the session's setup died with it."""
|
|
190
|
+
def cross(*_a, **_k):
|
|
191
|
+
raise ValueError("path is on mount 'C:', start on mount '\\\\192.168.57.47\\Projects'")
|
|
192
|
+
monkeypatch.setattr(plugins.os.path, "relpath", cross)
|
|
193
|
+
rules = plugins.notes_rules({"directory": "/p/geo", "path": ""}, "/n")
|
|
194
|
+
assert [r["permission"] for r in rules] == ["external_directory"], "reads allowed; writes ask (no relative path reaches)"
|
|
@@ -26,3 +26,21 @@ def test_the_keys_and_payers_files_are_written_where_python_has_no_fchmod(monkey
|
|
|
26
26
|
def test_no_module_calls_fchmod():
|
|
27
27
|
hits = [f.name for f in SRC.glob("*.py") if "os.fchmod(" in f.read_text(encoding="utf-8")]
|
|
28
28
|
assert hits == [], f"os.fchmod does not exist on Windows Python before 3.13 — use pairings.write_private: {hits}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_on_windows_private_folders_inherit_instead_of_an_owner_only_access_list(monkeypatch, tmp_path):
|
|
32
|
+
"""Python 3.12.4+ makes mkdir(mode=0o700) an access list of SYSTEM, Administrators and the OWNER; made from an
|
|
33
|
+
Administrator window the owner is Administrators and the person's own windows are locked out (the owner's second
|
|
34
|
+
desktop, 2026-10-09: "[WinError 5] Access is denied: …\\.witbitz\\code\\pairings.json")."""
|
|
35
|
+
import pathlib
|
|
36
|
+
from witbitz_code import pairings
|
|
37
|
+
seen = []
|
|
38
|
+
real = pathlib.Path.mkdir
|
|
39
|
+
monkeypatch.setattr(pathlib.Path, "mkdir", lambda self, *a, **k: seen.append(k.get("mode", a[0] if a else None)) or real(self, parents=True, exist_ok=True))
|
|
40
|
+
monkeypatch.setattr(pairings.sys, "platform", "win32")
|
|
41
|
+
pairings.write_private(tmp_path / "w" / "pairings.json", "{}")
|
|
42
|
+
assert seen and all(m is None for m in seen), "no mode on Windows: the folder inherits the profile's access"
|
|
43
|
+
seen.clear()
|
|
44
|
+
monkeypatch.setattr(pairings.sys, "platform", "linux")
|
|
45
|
+
pairings.write_private(tmp_path / "l" / "pairings.json", "{}")
|
|
46
|
+
assert seen == [0o700], "0700 everywhere else, as before"
|
|
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
|
{witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs
RENAMED
|
File without changes
|
{witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/opencode_plugins/witbitz-notes.js
RENAMED
|
File without changes
|
{witbitz_code-1.6.3 → witbitz_code-1.6.6}/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
|
|
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
|