witbitz-code 1.3.8__tar.gz → 1.3.9__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.8/src/witbitz_code.egg-info → witbitz_code-1.3.9}/PKG-INFO +5 -1
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/README.md +4 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/pyproject.toml +1 -1
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/connector.py +25 -0
- witbitz_code-1.3.9/src/witbitz_code/dirs.py +108 -0
- witbitz_code-1.3.9/src/witbitz_code/mkdir.py +55 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9/src/witbitz_code.egg-info}/PKG-INFO +5 -1
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code.egg-info/SOURCES.txt +3 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_connector_e2e.py +23 -0
- witbitz_code-1.3.9/tests/test_dirs.py +60 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/LICENSE +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/setup.cfg +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/cli.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_asks.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_attachments.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_auto.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_cli.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_codetools.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_confidential.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_folders.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_outputs.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_plugins.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_policy.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_ports.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_registry.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_setup.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_winagent.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.3.9}/tests/test_windows_files.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.9
|
|
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
|
|
@@ -92,6 +92,10 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
|
|
|
92
92
|
one `setup` meets gets its own (it asks — 4097, and 4197 for its model proxy) and `serve` remembers it. WSL's is named
|
|
93
93
|
"<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`.
|
|
94
94
|
|
|
95
|
+
**Any folder, any drive.** New session in Code opens at your home folder; ‹ Up goes past it (on Windows up to "This
|
|
96
|
+
computer", which lists the drives), and a folder can be typed (`D:\projects`). witbitz-code lists the folder names itself
|
|
97
|
+
— nothing else of the computer — and "New folder" makes one where you are. A cloud computer keeps to its workspace.
|
|
98
|
+
|
|
95
99
|
**More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
|
|
96
100
|
channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
|
|
97
101
|
all the accounts are yours. If a second account belongs to **another person**, run a separate OpenCode for them
|
|
@@ -66,6 +66,10 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
|
|
|
66
66
|
one `setup` meets gets its own (it asks — 4097, and 4197 for its model proxy) and `serve` remembers it. WSL's is named
|
|
67
67
|
"<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`.
|
|
68
68
|
|
|
69
|
+
**Any folder, any drive.** New session in Code opens at your home folder; ‹ Up goes past it (on Windows up to "This
|
|
70
|
+
computer", which lists the drives), and a folder can be typed (`D:\projects`). witbitz-code lists the folder names itself
|
|
71
|
+
— nothing else of the computer — and "New folder" makes one where you are. A cloud computer keeps to its workspace.
|
|
72
|
+
|
|
69
73
|
**More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
|
|
70
74
|
channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
|
|
71
75
|
all the accounts are yours. If a second account belongs to **another person**, run a separate OpenCode for them
|
|
@@ -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.9"
|
|
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"
|
|
@@ -40,6 +40,8 @@ from .outputs import OUTPUT_ROUTE, serve_output
|
|
|
40
40
|
from .plugins import notes_plugin_path as default_notes_plugin, notes_root as default_notes_root, notes_rules
|
|
41
41
|
from .payers import SessionPayers
|
|
42
42
|
from .seen import SEEN_ROUTE, merge_seen, norm_seen
|
|
43
|
+
from .dirs import DIRS_ROUTE, list_dirs
|
|
44
|
+
from .mkdir import MKDIR_ROUTE, mkdir_in
|
|
43
45
|
from .pairings import DEFAULT_OPENCODE_URL, hostname, read_env_password
|
|
44
46
|
from .relay import RELAY_URL, Connect, RelayPeer, allowed_event_path, allowed_request, project_response
|
|
45
47
|
from .tools_probe import probe_tools
|
|
@@ -396,6 +398,29 @@ class PairingServer:
|
|
|
396
398
|
log=lambda line: self._log(f"opencode-connector: {self.name} · {line}"))
|
|
397
399
|
await reply(st, b)
|
|
398
400
|
return
|
|
401
|
+
# "BROWSE THIS COMPUTER" (dirs.py): folder NAMES anywhere on this computer — above ~ and other drives — for the
|
|
402
|
+
# New-session picker. Answered HERE, never forwarded; less than the agent's shell already sees.
|
|
403
|
+
if bare == DIRS_ROUTE and m.get("m") == "GET":
|
|
404
|
+
q = dict(urllib.parse.parse_qsl(query, keep_blank_values=True))
|
|
405
|
+
st, b = list_dirs(path=q.get("path", ""))
|
|
406
|
+
await reply(st, b)
|
|
407
|
+
return
|
|
408
|
+
# "NEW FOLDER" (mkdir.py = tools/code-mkdir.mjs): one clean leaf under the folder the picker shows. Answered HERE.
|
|
409
|
+
if bare == MKDIR_ROUTE and m.get("m") == "POST":
|
|
410
|
+
q = dict(urllib.parse.parse_qsl(query, keep_blank_values=True))
|
|
411
|
+
if not q.get("directory"):
|
|
412
|
+
await reply(400, {"error": "no home to create under"})
|
|
413
|
+
return
|
|
414
|
+
try:
|
|
415
|
+
parsed = json.loads(m.get("b") or "null")
|
|
416
|
+
except ValueError:
|
|
417
|
+
parsed = None
|
|
418
|
+
if not isinstance(parsed, dict) or not isinstance(parsed.get("name"), str):
|
|
419
|
+
await reply(400, {"error": "no folder name"})
|
|
420
|
+
return
|
|
421
|
+
st, b = mkdir_in(root=q["directory"], rel=q.get("path"), name=parsed["name"])
|
|
422
|
+
await reply(st, b)
|
|
423
|
+
return
|
|
399
424
|
if not allowed_request(m.get("m"), m.get("p")):
|
|
400
425
|
await reply(403, {"error": "not allowed by the connector"})
|
|
401
426
|
return
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Which folders are in <path> — the New-session picker's "browse this computer" (= tools/code-dirs.mjs; the page side is
|
|
2
|
+
spaces/public/codeDirs.js). GET /witbitz/dirs?path=<absolute folder> → {path, parent, dirs: [{name, path}]}; no path = the
|
|
3
|
+
top: on Windows the drives (path '', each drive a folder), elsewhere '/'. Folder NAMES only, answered by the connector and
|
|
4
|
+
never forwarded to OpenCode (its listing refuses anything above the folder it was asked about) — the owner, 2026-10-09:
|
|
5
|
+
"How can I reach other directories? Or drives (like D:)".
|
|
6
|
+
|
|
7
|
+
Left out: a name starting with "." or "$", and a link/junction that cannot be listed (Windows keeps legacy junctions like
|
|
8
|
+
"Application Data" that deny listing). parent: the folder above; at a drive's root '' (= the drives); at / None.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import ntpath
|
|
15
|
+
import os
|
|
16
|
+
import posixpath
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
DIRS_ROUTE = "/witbitz/dirs"
|
|
22
|
+
MAX_DIRS = 2000
|
|
23
|
+
_WIN_ABS = re.compile(r"^[A-Za-z]:[\\/]")
|
|
24
|
+
_SKIP = {"System Volume Information"}
|
|
25
|
+
|
|
26
|
+
Entry = tuple # (name, is_dir, is_link)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _json(o: object) -> str:
|
|
30
|
+
return json.dumps(o, ensure_ascii=False, separators=(",", ":"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _error(st: int, message: str) -> tuple[int, str]:
|
|
34
|
+
return st, _json({"error": message})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _scandir(p: str) -> list[Entry]:
|
|
38
|
+
out = []
|
|
39
|
+
with os.scandir(p) as it:
|
|
40
|
+
for e in it:
|
|
41
|
+
link = e.is_symlink() or bool(getattr(e, "is_junction", lambda: False)())
|
|
42
|
+
try:
|
|
43
|
+
d = e.is_dir(follow_symlinks=False)
|
|
44
|
+
except OSError:
|
|
45
|
+
d = False
|
|
46
|
+
out.append((e.name, d, link))
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def win_drives(exists: Callable[[str], bool] = os.path.exists) -> list[str]:
|
|
51
|
+
"""The drive letters with a root folder here (C:, D:, …)."""
|
|
52
|
+
out = []
|
|
53
|
+
for c in range(65, 91):
|
|
54
|
+
d = chr(c) + ":"
|
|
55
|
+
try:
|
|
56
|
+
if exists(d + "\\"):
|
|
57
|
+
out.append(d)
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _listable(readdir: Callable[[str], list], p: str) -> bool:
|
|
64
|
+
try:
|
|
65
|
+
readdir(p)
|
|
66
|
+
return True
|
|
67
|
+
except OSError:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def list_dirs(*, path: str = "", platform: str | None = None, readdir: Callable[[str], list[Entry]] = _scandir,
|
|
72
|
+
is_dir: Callable[[str], bool] = os.path.isdir, drives: Callable[[], list[str]] = win_drives) -> tuple[int, str]:
|
|
73
|
+
"""The folders in `path` (absolute; '' = the top) → (status, JSON body)."""
|
|
74
|
+
win = (sys.platform if platform is None else platform) == "win32"
|
|
75
|
+
P = ntpath if win else posixpath
|
|
76
|
+
p = path if isinstance(path, str) else ""
|
|
77
|
+
if not p:
|
|
78
|
+
if win:
|
|
79
|
+
return 200, _json({"path": "", "parent": None, "dirs": [{"name": d, "path": d + "\\"} for d in drives()]})
|
|
80
|
+
p = "/"
|
|
81
|
+
if len(p) > 4096 or "\0" in p or not (_WIN_ABS.match(p) if win else p.startswith("/")):
|
|
82
|
+
return _error(400, "not a full folder path")
|
|
83
|
+
abs_ = P.normpath(p)
|
|
84
|
+
drive, _ = P.splitdrive(abs_)
|
|
85
|
+
root = (drive + "\\") if win else "/"
|
|
86
|
+
if win and abs_ == drive:
|
|
87
|
+
abs_ = root
|
|
88
|
+
try:
|
|
89
|
+
entries = readdir(abs_)
|
|
90
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
91
|
+
return _error(404, "no such folder on this computer")
|
|
92
|
+
except PermissionError:
|
|
93
|
+
return _error(403, "this computer does not let you open that folder")
|
|
94
|
+
except OSError as e:
|
|
95
|
+
return _error(500, f"could not list it ({str(e)[:120]})")
|
|
96
|
+
dirs = []
|
|
97
|
+
for name, d, link in entries:
|
|
98
|
+
name = str(name or "")
|
|
99
|
+
if not name or name.startswith(".") or name.startswith("$") or name in _SKIP:
|
|
100
|
+
continue
|
|
101
|
+
full = P.join(abs_, name)
|
|
102
|
+
if (is_dir(full) and _listable(readdir, full)) if link else d:
|
|
103
|
+
dirs.append({"name": name, "path": full})
|
|
104
|
+
if len(dirs) >= MAX_DIRS:
|
|
105
|
+
break
|
|
106
|
+
dirs.sort(key=lambda x: (x["name"].lower(), x["name"]))
|
|
107
|
+
parent = ("" if win else None) if abs_ == root else P.dirname(abs_)
|
|
108
|
+
return 200, _json({"path": abs_, "parent": parent, "dirs": dirs})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
""""New folder" in the New-session picker — tools/code-mkdir.mjs in Python (the page side is spaces/public/codeMkdir.js).
|
|
2
|
+
POST /witbitz/mkdir?directory=<root>&path=<rel> {name} creates ONE clean leaf under root/rel, answered by the connector and
|
|
3
|
+
never forwarded to OpenCode. Until 1.3.9 the Python connector had no such route, so the button failed on every pipx install.
|
|
4
|
+
Checks, in this order: one clean leaf name; the target's parent's REAL path is inside the root's (a link cannot smuggle the
|
|
5
|
+
write out); mkdir 0700, refused if it already exists. Windows roots (C:\\Users\\…) work as POSIX ones do.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import ntpath
|
|
12
|
+
import os
|
|
13
|
+
import posixpath
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
MKDIR_ROUTE = "/witbitz/mkdir"
|
|
17
|
+
_CLEAN = re.compile(r"^[A-Za-z0-9_ ()-][A-Za-z0-9._ ()-]*$")
|
|
18
|
+
_WIN_ABS = re.compile(r"^[A-Za-z]:[\\/]")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _error(st: int, message: str) -> tuple[int, str]:
|
|
22
|
+
return st, json.dumps({"error": message}, ensure_ascii=False, separators=(",", ":"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _clean(n: object) -> bool:
|
|
26
|
+
s = str(n or "") if isinstance(n, str) else ""
|
|
27
|
+
return bool(s) and len(s) <= 120 and bool(_CLEAN.match(s)) and ".." not in s and not re.search(r"[/\\\0]", s) and not re.search(r"[^ -~]", s)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def mkdir_in(*, root: object, rel: object, name: object) -> tuple[int, str]:
|
|
31
|
+
if not _clean(name):
|
|
32
|
+
return _error(400, "a folder name: no /, . or .., and not hidden")
|
|
33
|
+
win = isinstance(root, str) and bool(_WIN_ABS.match(root))
|
|
34
|
+
if not isinstance(root, str) or not (win or root.startswith("/")):
|
|
35
|
+
return _error(400, "not a root folder")
|
|
36
|
+
P = ntpath if win else posixpath
|
|
37
|
+
rel_path = re.sub(r"^[\\/]", "", re.sub(r"[\\/]+$", "", rel)) if isinstance(rel, str) and rel and rel != "." else ""
|
|
38
|
+
target_abs = P.normpath(P.join(root, rel_path, str(name)))
|
|
39
|
+
if not os.path.isdir(root):
|
|
40
|
+
return _error(404, "the root folder is not on this computer")
|
|
41
|
+
parent = P.dirname(target_abs)
|
|
42
|
+
if not os.path.isdir(parent):
|
|
43
|
+
return _error(404, "the parent folder is not on this computer")
|
|
44
|
+
real_root, real_parent = os.path.realpath(root), os.path.realpath(parent)
|
|
45
|
+
fold = (lambda s: s.lower()) if win else (lambda s: s)
|
|
46
|
+
if fold(real_parent) != fold(real_root) and not fold(real_parent).startswith(fold(real_root).rstrip("\\/") + P.sep):
|
|
47
|
+
return _error(403, "that lies outside the root")
|
|
48
|
+
target = P.join(real_parent, str(name))
|
|
49
|
+
try:
|
|
50
|
+
os.mkdir(target, 0o700)
|
|
51
|
+
except FileExistsError:
|
|
52
|
+
return _error(409, "already there")
|
|
53
|
+
except OSError as e:
|
|
54
|
+
return _error(500, f"could not create it here ({str(e)[:120]})")
|
|
55
|
+
return 200, json.dumps({"ok": True, "path": target}, ensure_ascii=False, separators=(",", ":"))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: witbitz-code
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.9
|
|
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
|
|
@@ -92,6 +92,10 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
|
|
|
92
92
|
one `setup` meets gets its own (it asks — 4097, and 4197 for its model proxy) and `serve` remembers it. WSL's is named
|
|
93
93
|
"<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`.
|
|
94
94
|
|
|
95
|
+
**Any folder, any drive.** New session in Code opens at your home folder; ‹ Up goes past it (on Windows up to "This
|
|
96
|
+
computer", which lists the drives), and a folder can be typed (`D:\projects`). witbitz-code lists the folder names itself
|
|
97
|
+
— nothing else of the computer — and "New folder" makes one where you are. A cloud computer keeps to its workspace.
|
|
98
|
+
|
|
95
99
|
**More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
|
|
96
100
|
channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
|
|
97
101
|
all the accounts are yours. If a second account belongs to **another person**, run a separate OpenCode for them
|
|
@@ -18,10 +18,12 @@ src/witbitz_code/confidential.py
|
|
|
18
18
|
src/witbitz_code/confidential_models.json
|
|
19
19
|
src/witbitz_code/connector.py
|
|
20
20
|
src/witbitz_code/devicelink.py
|
|
21
|
+
src/witbitz_code/dirs.py
|
|
21
22
|
src/witbitz_code/folders.py
|
|
22
23
|
src/witbitz_code/gateway.py
|
|
23
24
|
src/witbitz_code/keys.py
|
|
24
25
|
src/witbitz_code/link.py
|
|
26
|
+
src/witbitz_code/mkdir.py
|
|
25
27
|
src/witbitz_code/models.py
|
|
26
28
|
src/witbitz_code/net.py
|
|
27
29
|
src/witbitz_code/offered_models.json
|
|
@@ -63,6 +65,7 @@ tests/test_codetools.py
|
|
|
63
65
|
tests/test_confidential.py
|
|
64
66
|
tests/test_connector_e2e.py
|
|
65
67
|
tests/test_devicelink.py
|
|
68
|
+
tests/test_dirs.py
|
|
66
69
|
tests/test_files_and_compress.py
|
|
67
70
|
tests/test_folders.py
|
|
68
71
|
tests/test_outputs.py
|
|
@@ -323,6 +323,29 @@ async def conformance(rig: Rig, connector) -> None:
|
|
|
323
323
|
assert (await client.call("POST", "/witbitz/seen", "not an object"))["st"] == 400
|
|
324
324
|
assert (await client.call("PUT", "/witbitz/seen", {}))["st"] == 403
|
|
325
325
|
assert json.loads((await client.call("GET", "/witbitz/seen"))["b"]) == {"since": 3000, "at": {"ses_a": 9000, "ses_b": 8000, "ses_c": 6000}}
|
|
326
|
+
|
|
327
|
+
# BROWSE + NEW FOLDER (dirs.py = tools/code-dirs.mjs, mkdir.py = tools/code-mkdir.mjs): the New-session picker past ~
|
|
328
|
+
# (the owner, 2026-10-09: "Or drives (like D:)"), answered by the connector itself — the same on both. Before 1.3.9
|
|
329
|
+
# the Python connector had no mkdir route at all: "New folder" was refused on every pipx install.
|
|
330
|
+
top = Path(tempfile.mkdtemp(prefix="wb-conf-dirs-"))
|
|
331
|
+
for d in ("Beta", "alpha", ".hidden", "$junk"):
|
|
332
|
+
(top / d).mkdir()
|
|
333
|
+
(top / "f.txt").write_text("x")
|
|
334
|
+
listing = await client.call("GET", "/witbitz/dirs?path=" + urllib.parse.quote(str(top) + "/", safe=""))
|
|
335
|
+
assert listing["st"] == 200, listing
|
|
336
|
+
assert json.loads(listing["b"]) == {"path": str(top), "parent": str(top.parent),
|
|
337
|
+
"dirs": [{"name": "alpha", "path": str(top / "alpha")}, {"name": "Beta", "path": str(top / "Beta")}]}
|
|
338
|
+
assert json.loads((await client.call("GET", "/witbitz/dirs"))["b"])["path"] == "/"
|
|
339
|
+
assert (await client.call("GET", "/witbitz/dirs?path=rel%2Fx"))["st"] == 400
|
|
340
|
+
assert (await client.call("GET", "/witbitz/dirs?path=" + urllib.parse.quote(str(top / "nope"), safe="")))["st"] == 404
|
|
341
|
+
mk = lambda: "/witbitz/mkdir?directory=" + urllib.parse.quote(str(top), safe="") + "&path=." # noqa: E731
|
|
342
|
+
made = await client.call("POST", mk(), {"name": "new one"})
|
|
343
|
+
assert made["st"] == 200 and (top / "new one").is_dir() and json.loads(made["b"])["ok"] is True
|
|
344
|
+
assert (await client.call("POST", mk(), {"name": "new one"}))["st"] == 409
|
|
345
|
+
assert (await client.call("POST", mk(), {"name": "..\\escape"}))["st"] == 400
|
|
346
|
+
assert (await client.call("POST", mk(), {"nope": 1}))["st"] == 400
|
|
347
|
+
assert (await client.call("POST", "/witbitz/mkdir?directory=rel&path=.", {"name": "x"}))["st"] == 400
|
|
348
|
+
assert not any(h.get("url", "").startswith("/witbitz") for h in rig.oc.seen_copy()), "OpenCode never sees the routes"
|
|
326
349
|
finally:
|
|
327
350
|
await client.peer.aclose()
|
|
328
351
|
await connector.stop()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""dirs.py — the New-session picker's "browse this computer" — held to tools/code-dirs.mjs on Windows-shaped fakes (the real
|
|
2
|
+
folders and both connectors are in test_connector_e2e's conformance sequence). The owner, 2026-10-09: "Or drives (like D:)"."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from conftest import requires_node, run_node
|
|
9
|
+
|
|
10
|
+
from witbitz_code import dirs, mkdir
|
|
11
|
+
|
|
12
|
+
WIN_FS = {
|
|
13
|
+
"C:\\": [("Users", True, False), ("$Recycle.Bin", True, False), ("System Volume Information", True, False), ("pagefile.sys", False, False)],
|
|
14
|
+
"C:\\Users\\shter": [("Documents", True, False), ("Application Data", False, True), ("OneDrive", False, True), ("zeta", True, False), ("Alpha", True, False)],
|
|
15
|
+
"C:\\Users\\shter\\OneDrive": [],
|
|
16
|
+
}
|
|
17
|
+
CASES = [{"path": ""}, {"path": "C:/"}, {"path": "C:\\Users\\shter\\"}, {"path": "/home"}, {"path": "C:\\Windows\\nope"}, {"path": "D:"}]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def win_readdir(p: str) -> list:
|
|
21
|
+
if p in WIN_FS:
|
|
22
|
+
return WIN_FS[p]
|
|
23
|
+
raise (PermissionError if p.endswith("Application Data") else FileNotFoundError)(p)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def py(case: dict) -> dict:
|
|
27
|
+
st, b = dirs.list_dirs(path=case["path"], platform="win32", readdir=win_readdir, is_dir=lambda p: True, drives=lambda: ["C:", "D:"])
|
|
28
|
+
return {"st": st, **json.loads(b)}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_windows_drives_roots_and_junctions_that_deny_listing():
|
|
32
|
+
assert py({"path": ""}) == {"st": 200, "path": "", "parent": None, "dirs": [{"name": "C:", "path": "C:\\"}, {"name": "D:", "path": "D:\\"}]}
|
|
33
|
+
c = py({"path": "C:/"})
|
|
34
|
+
assert c["path"] == "C:\\" and c["parent"] == "" and c["dirs"] == [{"name": "Users", "path": "C:\\Users"}]
|
|
35
|
+
h = py({"path": "C:\\Users\\shter\\"})
|
|
36
|
+
assert h["parent"] == "C:\\Users" and [d["name"] for d in h["dirs"]] == ["Alpha", "Documents", "OneDrive", "zeta"]
|
|
37
|
+
assert py({"path": "/home"})["st"] == 400 and py({"path": "C:\\Windows\\nope"})["st"] == 404
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@requires_node
|
|
41
|
+
def test_the_javascript_lists_the_same():
|
|
42
|
+
js = run_node("""
|
|
43
|
+
import { listDirs } from '@TOOLS@/code-dirs.mjs'
|
|
44
|
+
const fs = INPUT.fs
|
|
45
|
+
const readdir = (p) => {
|
|
46
|
+
if (p in fs) return fs[p].map(([name, dir, link]) => ({ name, isDirectory: () => dir, isSymbolicLink: () => link }))
|
|
47
|
+
throw Object.assign(new Error(p), { code: p.endsWith('Application Data') ? 'EPERM' : 'ENOENT' })
|
|
48
|
+
}
|
|
49
|
+
OUT(INPUT.cases.map((c) => { const o = listDirs({ path: c.path, platform: 'win32', readdir, isDir: () => true, drives: () => ['C:', 'D:'] }); return { st: o.st, ...JSON.parse(o.b) } }))
|
|
50
|
+
""", {"fs": WIN_FS, "cases": CASES})
|
|
51
|
+
assert js == [py(c) for c in CASES]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_new_folder_names_are_one_clean_leaf_on_both_kinds_of_root(tmp_path):
|
|
55
|
+
for bad in ["..", ".x", "a/b", "a\\b", "\\x", "", "a\x00b", "é"]:
|
|
56
|
+
assert mkdir.mkdir_in(root=str(tmp_path), rel=".", name=bad)[0] == 400, bad
|
|
57
|
+
assert mkdir.mkdir_in(root="relative", rel=".", name="x")[0] == 400
|
|
58
|
+
assert mkdir.mkdir_in(root="C:\\Users\\nobody-here", rel=".", name="x")[0] == 404, "a Windows root is a root (not on this computer)"
|
|
59
|
+
assert mkdir.mkdir_in(root=str(tmp_path), rel="..", name="x")[0] == 403, "never above the root"
|
|
60
|
+
assert mkdir.mkdir_in(root=str(tmp_path), rel=".", name="ok one")[0] == 200 and (tmp_path / "ok one").is_dir()
|
|
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
|
{witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs
RENAMED
|
File without changes
|
{witbitz_code-1.3.8 → witbitz_code-1.3.9}/src/witbitz_code/opencode_plugins/witbitz-notes.js
RENAMED
|
File without changes
|
{witbitz_code-1.3.8 → witbitz_code-1.3.9}/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
|