witbitz-code 1.3.8__tar.gz → 1.5.0__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.5.0}/PKG-INFO +17 -2
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/README.md +16 -1
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/pyproject.toml +2 -2
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/cli.py +25 -2
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/connector.py +27 -0
- witbitz_code-1.5.0/src/witbitz_code/dirs.py +108 -0
- witbitz_code-1.5.0/src/witbitz_code/mkdir.py +55 -0
- witbitz_code-1.5.0/src/witbitz_code/ui.py +545 -0
- witbitz_code-1.5.0/src/witbitz_code/ui_static/app.css +51 -0
- witbitz_code-1.5.0/src/witbitz_code/ui_static/app.js +147 -0
- witbitz_code-1.5.0/src/witbitz_code/ui_static/index.html +62 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/uninstall.py +5 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/wizard.py +19 -1
- witbitz_code-1.5.0/src/witbitz_code/wslstart.py +135 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0/src/witbitz_code.egg-info}/PKG-INFO +17 -2
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code.egg-info/SOURCES.txt +11 -1
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_connector_e2e.py +23 -0
- witbitz_code-1.5.0/tests/test_dirs.py +60 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_setup.py +22 -1
- witbitz_code-1.5.0/tests/test_ui.py +194 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_uninstall.py +11 -1
- witbitz_code-1.5.0/tests/test_wslstart.py +42 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/LICENSE +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/setup.cfg +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_asks.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_attachments.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_auto.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_cli.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_codetools.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_confidential.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_folders.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_outputs.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_plugins.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_policy.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_ports.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_registry.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_winagent.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.3.8 → witbitz_code-1.5.0}/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
|
+
Version: 1.5.0
|
|
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
|
|
@@ -56,6 +56,13 @@ Poppler, Tesseract, … — with winget, Homebrew or one `sudo apt-get` line; it
|
|
|
56
56
|
starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C
|
|
57
57
|
stops it); next time, `witbitz-code serve` is enough.
|
|
58
58
|
|
|
59
|
+
**Or in your browser:** `witbitz-code ui` (on Windows: Start menu → Witbitz Code) opens a page for this computer — whether
|
|
60
|
+
it is online, which account it is paired with and this month's spend, Pair (the code is shown large on the page), Unpair,
|
|
61
|
+
Pair again, Start / Stop, and Start when I log in (Windows and WSL). It serves while its window is open; closing that window
|
|
62
|
+
stops it, and `witbitz-code ui` again just opens the page. Each copy has its own page — on one laptop Windows and WSL each
|
|
63
|
+
get theirs, titled with the computer it controls. The page answers only this computer, and only the browser the link
|
|
64
|
+
opened (a one-time key traded for a private cookie; no other website can drive it).
|
|
65
|
+
|
|
59
66
|
**No key to paste.** Pairing asks your Spaces account for this computer's own TrustedRouter and Tinfoil keys, capped by the
|
|
60
67
|
providers (today up to $50 of models and $5 of tools a month per computer). They are sent sealed to this computer, kept in
|
|
61
68
|
`~/.witbitz/code/account-keys.json` (0600) and never given to OpenCode: the local proxy puts them on each call. Model calls
|
|
@@ -77,6 +84,7 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
|
|
|
77
84
|
|
|
78
85
|
| command | what it does |
|
|
79
86
|
|---|---|
|
|
87
|
+
| `ui [--no-browser]` | The page above. `--no-browser` serves without opening it (what a login start runs). |
|
|
80
88
|
| `setup [--port <n>]` | The walk-through above. With `--port`, a pairing made for another port moves to it (no new scan). |
|
|
81
89
|
| `uninstall [--yes] [--remove-keys] [--remove-notes] [--remove-sessions] [--remove-opencode]` | Remove witbitz-code from this computer: its coding keys go back to each account, each account lets go of it, `~/.witbitz/code` and what `serve` added to OpenCode are deleted, and the pipx install removes itself. Asks first (Enter keeps) before your saved keys, project notes, OpenCode's sessions and OpenCode itself; never deletes sessions an OpenCode still has open. `--yes` skips the questions and keeps all four unless their flag is given. |
|
|
82
90
|
| `pair [--name <name>] [--port <n> \| --opencode-url <url>]` | Show the QR code. The account that scans it gets this computer. `--name` sets the label your devices show (default: the hostname). |
|
|
@@ -90,7 +98,14 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
|
|
|
90
98
|
|
|
91
99
|
**Windows and WSL on one computer** are two computers to Spaces, and both can run: WSL shares Windows' ports, so the second
|
|
92
100
|
one `setup` meets gets its own (it asks — 4097, and 4197 for its model proxy) and `serve` remembers it. WSL's is named
|
|
93
|
-
"<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`.
|
|
101
|
+
"<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`. In WSL, `setup` also offers to start witbitz-code
|
|
102
|
+
when you log in to Windows (WSL runs only once Windows starts it): a shortcut in the Windows Startup folder opens a minimized
|
|
103
|
+
window running it (`ui --no-browser`: serving, the page there when you want it) — closing that window stops it, and
|
|
104
|
+
`witbitz-code uninstall` removes the shortcut.
|
|
105
|
+
|
|
106
|
+
**Any folder, any drive.** New session in Code opens at your home folder; ‹ Up goes past it (on Windows up to "This
|
|
107
|
+
computer", which lists the drives), and a folder can be typed (`D:\projects`). witbitz-code lists the folder names itself
|
|
108
|
+
— nothing else of the computer — and "New folder" makes one where you are. A cloud computer keeps to its workspace.
|
|
94
109
|
|
|
95
110
|
**More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
|
|
96
111
|
channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
|
|
@@ -30,6 +30,13 @@ Poppler, Tesseract, … — with winget, Homebrew or one `sudo apt-get` line; it
|
|
|
30
30
|
starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C
|
|
31
31
|
stops it); next time, `witbitz-code serve` is enough.
|
|
32
32
|
|
|
33
|
+
**Or in your browser:** `witbitz-code ui` (on Windows: Start menu → Witbitz Code) opens a page for this computer — whether
|
|
34
|
+
it is online, which account it is paired with and this month's spend, Pair (the code is shown large on the page), Unpair,
|
|
35
|
+
Pair again, Start / Stop, and Start when I log in (Windows and WSL). It serves while its window is open; closing that window
|
|
36
|
+
stops it, and `witbitz-code ui` again just opens the page. Each copy has its own page — on one laptop Windows and WSL each
|
|
37
|
+
get theirs, titled with the computer it controls. The page answers only this computer, and only the browser the link
|
|
38
|
+
opened (a one-time key traded for a private cookie; no other website can drive it).
|
|
39
|
+
|
|
33
40
|
**No key to paste.** Pairing asks your Spaces account for this computer's own TrustedRouter and Tinfoil keys, capped by the
|
|
34
41
|
providers (today up to $50 of models and $5 of tools a month per computer). They are sent sealed to this computer, kept in
|
|
35
42
|
`~/.witbitz/code/account-keys.json` (0600) and never given to OpenCode: the local proxy puts them on each call. Model calls
|
|
@@ -51,6 +58,7 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
|
|
|
51
58
|
|
|
52
59
|
| command | what it does |
|
|
53
60
|
|---|---|
|
|
61
|
+
| `ui [--no-browser]` | The page above. `--no-browser` serves without opening it (what a login start runs). |
|
|
54
62
|
| `setup [--port <n>]` | The walk-through above. With `--port`, a pairing made for another port moves to it (no new scan). |
|
|
55
63
|
| `uninstall [--yes] [--remove-keys] [--remove-notes] [--remove-sessions] [--remove-opencode]` | Remove witbitz-code from this computer: its coding keys go back to each account, each account lets go of it, `~/.witbitz/code` and what `serve` added to OpenCode are deleted, and the pipx install removes itself. Asks first (Enter keeps) before your saved keys, project notes, OpenCode's sessions and OpenCode itself; never deletes sessions an OpenCode still has open. `--yes` skips the questions and keeps all four unless their flag is given. |
|
|
56
64
|
| `pair [--name <name>] [--port <n> \| --opencode-url <url>]` | Show the QR code. The account that scans it gets this computer. `--name` sets the label your devices show (default: the hostname). |
|
|
@@ -64,7 +72,14 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
|
|
|
64
72
|
|
|
65
73
|
**Windows and WSL on one computer** are two computers to Spaces, and both can run: WSL shares Windows' ports, so the second
|
|
66
74
|
one `setup` meets gets its own (it asks — 4097, and 4197 for its model proxy) and `serve` remembers it. WSL's is named
|
|
67
|
-
"<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`.
|
|
75
|
+
"<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`. In WSL, `setup` also offers to start witbitz-code
|
|
76
|
+
when you log in to Windows (WSL runs only once Windows starts it): a shortcut in the Windows Startup folder opens a minimized
|
|
77
|
+
window running it (`ui --no-browser`: serving, the page there when you want it) — closing that window stops it, and
|
|
78
|
+
`witbitz-code uninstall` removes the shortcut.
|
|
79
|
+
|
|
80
|
+
**Any folder, any drive.** New session in Code opens at your home folder; ‹ Up goes past it (on Windows up to "This
|
|
81
|
+
computer", which lists the drives), and a folder can be typed (`D:\projects`). witbitz-code lists the folder names itself
|
|
82
|
+
— nothing else of the computer — and "New folder" makes one where you are. A cloud computer keeps to its workspace.
|
|
68
83
|
|
|
69
84
|
**More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
|
|
70
85
|
channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "witbitz-code"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.5.0"
|
|
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"
|
|
@@ -43,7 +43,7 @@ where = ["src"]
|
|
|
43
43
|
|
|
44
44
|
# Our OpenCode plugins, installed by `serve` (plugins.py) — copies of tools/opencode-plugins, kept current by embed.mjs
|
|
45
45
|
[tool.setuptools.package-data]
|
|
46
|
-
witbitz_code = ["opencode_plugins/*", "confidential_models.json", "offered_models.json", "tools_catalog.json"]
|
|
46
|
+
witbitz_code = ["opencode_plugins/*", "confidential_models.json", "offered_models.json", "tools_catalog.json", "ui_static/*"]
|
|
47
47
|
|
|
48
48
|
[tool.pytest.ini_options]
|
|
49
49
|
testpaths = ["tests"]
|
|
@@ -35,6 +35,8 @@ HELP = f"""witbitz-code {__version__} — reach OpenCode on this computer from t
|
|
|
35
35
|
|
|
36
36
|
setup [--port <n>] start here: installs OpenCode if needed, pairs this computer — which gives it its own
|
|
37
37
|
keys from your Spaces account — then serves
|
|
38
|
+
ui [--no-browser] the same in a page in your browser: status, pair / unpair / pair again, start and stop,
|
|
39
|
+
start when I log in (Windows and WSL); it serves while its window is open
|
|
38
40
|
pair [--name <name>] show a QR code; scan it in Spaces (Settings → Back up & recovery → Add a device)
|
|
39
41
|
serve [--port <n>] [--no-opencode]
|
|
40
42
|
start OpenCode on 127.0.0.1 with your account's keys, and the connector
|
|
@@ -149,7 +151,9 @@ async def _stop_opencode(child: asyncio.subprocess.Process) -> None:
|
|
|
149
151
|
await child.wait()
|
|
150
152
|
|
|
151
153
|
|
|
152
|
-
async def serve(args: list[str]) -> int:
|
|
154
|
+
async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = None) -> int:
|
|
155
|
+
"""`stop` and `live_out`: the page's manager (ui.py) runs serve inside itself — it stops it with its own flag instead
|
|
156
|
+
of a signal, and reads the running connector (each pairing's relay state) from live_out."""
|
|
153
157
|
port_n = to_number(_flag(args, "--port", "4096"))
|
|
154
158
|
port = int(port_n) if truthy(port_n) and port_n == port_n and float(port_n).is_integer() else 4096
|
|
155
159
|
everything = pp.load_pairings(log=lambda _m: None)
|
|
@@ -207,7 +211,7 @@ async def serve(args: list[str]) -> int:
|
|
|
207
211
|
# Billed to the account whose prompt started the turn (payers.py); a call nobody here started is refused.
|
|
208
212
|
return live_keys.credential(payers.payer(req.header("x-session-id")[:100], req.header("x-parent-session-id")[:100]))
|
|
209
213
|
|
|
210
|
-
stop = _Stop()
|
|
214
|
+
stop = stop or _Stop()
|
|
211
215
|
child = None
|
|
212
216
|
proxy = None
|
|
213
217
|
live: dict = {"connector": None}
|
|
@@ -257,6 +261,8 @@ async def serve(args: list[str]) -> int:
|
|
|
257
261
|
connector = await start_connector(pairings, payers=payers)
|
|
258
262
|
live["status"] = asyncio.ensure_future(_check_keys_every(live_keys, keys_http))
|
|
259
263
|
live["connector"] = connector
|
|
264
|
+
if live_out is not None:
|
|
265
|
+
live_out["connector"] = connector
|
|
260
266
|
if proxy:
|
|
261
267
|
connector.set_confidential_sink(proxy.add_confidential) # the page hands down what the account offers today
|
|
262
268
|
served = ", ".join(f'"{p.get("name")}" → {p.get("account") or "account"}' for p in pairings)
|
|
@@ -265,6 +271,8 @@ async def serve(args: list[str]) -> int:
|
|
|
265
271
|
return await _wait_for_stop(stop, child)
|
|
266
272
|
finally:
|
|
267
273
|
live["status"].cancel()
|
|
274
|
+
if live_out is not None:
|
|
275
|
+
live_out.pop("connector", None)
|
|
268
276
|
await connector.aclose()
|
|
269
277
|
finally:
|
|
270
278
|
if child is not None:
|
|
@@ -379,6 +387,19 @@ async def uninstall(args: list[str]) -> int:
|
|
|
379
387
|
return 0 if r.get("done") else 1
|
|
380
388
|
|
|
381
389
|
|
|
390
|
+
async def ui_command(args: list[str]) -> int:
|
|
391
|
+
"""This computer's witbitz-code in a page in your browser (ui.py): status, pair / unpair / pair again, start and stop,
|
|
392
|
+
start when I log in. It runs serve inside itself; --no-browser serves without opening the page (a login start)."""
|
|
393
|
+
from . import net, ui # noqa: PLC0415
|
|
394
|
+
|
|
395
|
+
http = net.new_client()
|
|
396
|
+
keys = pair_cmd._KeyCalls(http)
|
|
397
|
+
try:
|
|
398
|
+
return await ui.run(args, serve=serve, is_listening=is_listening, keys_line=lambda p: pair_cmd._keys_line(p, keys))
|
|
399
|
+
finally:
|
|
400
|
+
await http.aclose()
|
|
401
|
+
|
|
402
|
+
|
|
382
403
|
async def _main(argv: list[str]) -> int:
|
|
383
404
|
cmd, rest = (argv[0], argv[1:]) if argv else ("", [])
|
|
384
405
|
if cmd == "uninstall":
|
|
@@ -392,6 +413,8 @@ async def _main(argv: list[str]) -> int:
|
|
|
392
413
|
return await pair_cmd.main(rest)
|
|
393
414
|
if cmd == "serve":
|
|
394
415
|
return await serve(rest)
|
|
416
|
+
if cmd == "ui":
|
|
417
|
+
return await ui_command(rest)
|
|
395
418
|
if cmd == "status":
|
|
396
419
|
return await pair_cmd.main(["--status"])
|
|
397
420
|
if cmd == "rotate":
|
|
@@ -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
|
|
@@ -192,6 +194,7 @@ class PairingServer:
|
|
|
192
194
|
# and run again. Each socket therefore announces a fresh random nonce inside its sealed hello, and a request
|
|
193
195
|
# must carry the CURRENT one: a recording from before a restart or reconnect carries a nonce nobody accepts.
|
|
194
196
|
self.nonce = ""
|
|
197
|
+
self.relay_state = "connecting" # the relay socket's last state — the page (ui.py) shows "online" when every one is open
|
|
195
198
|
self.peer = RelayPeer(secret=pairing["secret"], role="computer", relay=pairing.get("relay") or RELAY_URL,
|
|
196
199
|
on_state=self._on_state, on_peers=self._on_peers, on_message=self._on_message, connect=connect,
|
|
197
200
|
# A page socket fell out of the replay window: its recorded frames would open again — rotate the
|
|
@@ -248,6 +251,7 @@ class PairingServer:
|
|
|
248
251
|
|
|
249
252
|
# ── relay events ──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
250
253
|
def _on_state(self, s: str) -> None:
|
|
254
|
+
self.relay_state = s
|
|
251
255
|
self._log(f"witbitz-code: {self.name} · relay {s}")
|
|
252
256
|
if s == "open":
|
|
253
257
|
self._rotate()
|
|
@@ -396,6 +400,29 @@ class PairingServer:
|
|
|
396
400
|
log=lambda line: self._log(f"opencode-connector: {self.name} · {line}"))
|
|
397
401
|
await reply(st, b)
|
|
398
402
|
return
|
|
403
|
+
# "BROWSE THIS COMPUTER" (dirs.py): folder NAMES anywhere on this computer — above ~ and other drives — for the
|
|
404
|
+
# New-session picker. Answered HERE, never forwarded; less than the agent's shell already sees.
|
|
405
|
+
if bare == DIRS_ROUTE and m.get("m") == "GET":
|
|
406
|
+
q = dict(urllib.parse.parse_qsl(query, keep_blank_values=True))
|
|
407
|
+
st, b = list_dirs(path=q.get("path", ""))
|
|
408
|
+
await reply(st, b)
|
|
409
|
+
return
|
|
410
|
+
# "NEW FOLDER" (mkdir.py = tools/code-mkdir.mjs): one clean leaf under the folder the picker shows. Answered HERE.
|
|
411
|
+
if bare == MKDIR_ROUTE and m.get("m") == "POST":
|
|
412
|
+
q = dict(urllib.parse.parse_qsl(query, keep_blank_values=True))
|
|
413
|
+
if not q.get("directory"):
|
|
414
|
+
await reply(400, {"error": "no home to create under"})
|
|
415
|
+
return
|
|
416
|
+
try:
|
|
417
|
+
parsed = json.loads(m.get("b") or "null")
|
|
418
|
+
except ValueError:
|
|
419
|
+
parsed = None
|
|
420
|
+
if not isinstance(parsed, dict) or not isinstance(parsed.get("name"), str):
|
|
421
|
+
await reply(400, {"error": "no folder name"})
|
|
422
|
+
return
|
|
423
|
+
st, b = mkdir_in(root=q["directory"], rel=q.get("path"), name=parsed["name"])
|
|
424
|
+
await reply(st, b)
|
|
425
|
+
return
|
|
399
426
|
if not allowed_request(m.get("m"), m.get("p")):
|
|
400
427
|
await reply(403, {"error": "not allowed by the connector"})
|
|
401
428
|
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=(",", ":"))
|