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.
Files changed (100) hide show
  1. {witbitz_code-1.6.3/src/witbitz_code.egg-info → witbitz_code-1.6.6}/PKG-INFO +1 -1
  2. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/pyproject.toml +1 -1
  3. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/attachments.py +5 -2
  5. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/auto.py +28 -1
  6. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/auto_runner.py +4 -3
  7. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/cli.py +1 -1
  8. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/keys.py +1 -1
  9. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/mkdir.py +1 -1
  10. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/models.py +10 -0
  11. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/pairings.py +13 -1
  12. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/plugins.py +8 -5
  13. {witbitz_code-1.6.3 → witbitz_code-1.6.6/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  14. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_auto.py +71 -0
  15. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_confidential.py +13 -0
  16. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_plugins.py +10 -0
  17. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_windows_files.py +18 -0
  18. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/LICENSE +0 -0
  19. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/README.md +0 -0
  20. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/setup.cfg +0 -0
  21. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/__main__.py +0 -0
  22. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/_js.py +0 -0
  23. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/account.py +0 -0
  24. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/asks.py +0 -0
  25. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/codekeys.py +0 -0
  26. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/codetools.py +0 -0
  27. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/compress.py +0 -0
  28. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/computers.py +0 -0
  29. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/confidential.py +0 -0
  30. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/confidential_models.json +0 -0
  31. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/connector.py +0 -0
  32. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/devicelink.py +0 -0
  33. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/dirs.py +0 -0
  34. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/folders.py +0 -0
  35. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/gateway.py +0 -0
  36. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  37. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  38. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/link.py +0 -0
  39. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/net.py +0 -0
  40. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/offered_models.json +0 -0
  41. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  42. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  43. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  44. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  45. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/outputs.py +0 -0
  46. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/pair.py +0 -0
  47. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/payers.py +0 -0
  48. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/policy.py +0 -0
  49. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/ports.py +0 -0
  50. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/qr.py +0 -0
  51. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/receipt.py +0 -0
  52. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/relay.py +0 -0
  53. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/seen.py +0 -0
  54. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/tinfoil_read.py +0 -0
  55. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/tools_catalog.json +0 -0
  56. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/tools_probe.py +0 -0
  57. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/toolsetup.py +0 -0
  58. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/ui.py +0 -0
  59. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/ui_static/app.css +0 -0
  60. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/ui_static/app.js +0 -0
  61. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/ui_static/index.html +0 -0
  62. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/uitools.py +0 -0
  63. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/uninstall.py +0 -0
  64. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/winagent.py +0 -0
  65. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/winbg.py +0 -0
  66. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/winconsole.py +0 -0
  67. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/wizard.py +0 -0
  68. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code/wslstart.py +0 -0
  69. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
  70. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  71. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  72. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code.egg-info/requires.txt +0 -0
  73. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/src/witbitz_code.egg-info/top_level.txt +0 -0
  74. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_asks.py +0 -0
  75. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_attachments.py +0 -0
  76. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_cli.py +0 -0
  77. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_codekeys.py +0 -0
  78. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_codetools.py +0 -0
  79. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_connector_e2e.py +0 -0
  80. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_devicelink.py +0 -0
  81. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_dirs.py +0 -0
  82. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_files_and_compress.py +0 -0
  83. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_folders.py +0 -0
  84. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_outputs.py +0 -0
  85. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_pair_flows.py +0 -0
  86. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_policy.py +0 -0
  87. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_ports.py +0 -0
  88. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_ports_win.py +0 -0
  89. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_registry.py +0 -0
  90. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_relay_codec.py +0 -0
  91. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_setup.py +0 -0
  92. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_tools_probe.py +0 -0
  93. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_toolsetup.py +0 -0
  94. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_ui.py +0 -0
  95. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_uitools.py +0 -0
  96. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_uninstall.py +0 -0
  97. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_winagent.py +0 -0
  98. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_winbg.py +0 -0
  99. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_winconsole.py +0 -0
  100. {witbitz_code-1.6.3 → witbitz_code-1.6.6}/tests/test_wslstart.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.6.3
3
+ Version: 1.6.6
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.6.3"
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"
@@ -4,4 +4,4 @@ The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phas
4
4
  vectors and cross-implementation tests.
5
5
  """
6
6
 
7
- __version__ = "1.6.3"
7
+ __version__ = "1.6.6"
@@ -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
- d.mkdir(parents=True, exist_ok=True, mode=0o700)
182
- os.chmod(d, 0o700)
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
- if not isinstance(directory, str) or not directory.startswith("/") or _js.utf16_len(directory) > 1024:
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.mkdir(parents=True, exist_ok=True, mode=0o700)
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.mkdir(parents=True, exist_ok=True, mode=dir_mode)
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
- rel = os.path.relpath(folder, "/" if plain else os.path.abspath(root))
137
- return [
138
- {"permission": "external_directory", "pattern": f"{folder}/*", "action": "allow"},
139
- {"permission": "edit", "pattern": f"{rel}/notes/*", "action": "allow"},
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"}]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.6.3
3
+ Version: 1.6.6
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
@@ -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