witbitz-code 1.2.0__py3-none-any.whl
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/__init__.py +7 -0
- witbitz_code/__main__.py +3 -0
- witbitz_code/_js.py +312 -0
- witbitz_code/account.py +127 -0
- witbitz_code/asks.py +119 -0
- witbitz_code/attachments.py +266 -0
- witbitz_code/auto.py +561 -0
- witbitz_code/auto_runner.py +481 -0
- witbitz_code/cli.py +223 -0
- witbitz_code/codetools.py +128 -0
- witbitz_code/compress.py +25 -0
- witbitz_code/computers.py +189 -0
- witbitz_code/connector.py +818 -0
- witbitz_code/devicelink.py +159 -0
- witbitz_code/folders.py +250 -0
- witbitz_code/link.py +205 -0
- witbitz_code/net.py +67 -0
- witbitz_code/opencode_plugins/notes-init.md +8 -0
- witbitz_code/opencode_plugins/witbitz-media-server.mjs +791 -0
- witbitz_code/opencode_plugins/witbitz-notes.js +356 -0
- witbitz_code/opencode_plugins/witbitz-progress.js +120 -0
- witbitz_code/outputs.py +115 -0
- witbitz_code/pair.py +222 -0
- witbitz_code/pairings.py +214 -0
- witbitz_code/plugins.py +140 -0
- witbitz_code/policy.py +48 -0
- witbitz_code/qr.py +59 -0
- witbitz_code/relay.py +783 -0
- witbitz_code/seen.py +49 -0
- witbitz_code/tools_probe.py +67 -0
- witbitz_code-1.2.0.dist-info/METADATA +145 -0
- witbitz_code-1.2.0.dist-info/RECORD +36 -0
- witbitz_code-1.2.0.dist-info/WHEEL +5 -0
- witbitz_code-1.2.0.dist-info/entry_points.txt +2 -0
- witbitz_code-1.2.0.dist-info/licenses/LICENSE +21 -0
- witbitz_code-1.2.0.dist-info/top_level.txt +1 -0
witbitz_code/pair.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""PAIR this computer with your Spaces account, so the Code section on every device you are signed in on reaches the
|
|
2
|
+
OpenCode running here — through the sealed relay, with nothing typed. The flows of tools/opencode-pair.mjs.
|
|
3
|
+
|
|
4
|
+
What a pairing is (docs/opencode-relay.md §3): a random 32-byte SECRET per (computer, account), stored here in
|
|
5
|
+
~/.witbitz/code/pairings.json (0600) and published into the account's sealed `computers` registry, which every signed-in
|
|
6
|
+
device reads. The relay channel and both encryption keys derive from it; the relay never sees it.
|
|
7
|
+
|
|
8
|
+
Several accounts can pair the same computer — each is a separate entry, secret and channel (§10). OpenCode has no users,
|
|
9
|
+
so every paired account reaches the same sessions, files and shell; this tool says so when a second account pairs.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from . import account as acct
|
|
20
|
+
from . import link, net
|
|
21
|
+
from . import pairings as pp
|
|
22
|
+
from ._js import UNDEFINED, number_str, to_number, truthy
|
|
23
|
+
from .connector import START_HINT
|
|
24
|
+
|
|
25
|
+
LinkFn = Callable[[], Awaitable[Any]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _out(msg: str) -> None:
|
|
29
|
+
print(msg, flush=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _err(msg: str) -> None:
|
|
33
|
+
print(msg, file=sys.stderr, flush=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UsageError(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Args:
|
|
42
|
+
rotate: bool = False
|
|
43
|
+
dry: bool = False
|
|
44
|
+
unpair: bool = False
|
|
45
|
+
status: bool = False
|
|
46
|
+
name: str = ""
|
|
47
|
+
account: str = ""
|
|
48
|
+
opencode_url: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_args(argv: list[str]) -> Args:
|
|
52
|
+
a = Args()
|
|
53
|
+
i = 0
|
|
54
|
+
while i < len(argv):
|
|
55
|
+
x = argv[i]
|
|
56
|
+
nxt = argv[i + 1] if i + 1 < len(argv) else ""
|
|
57
|
+
if x == "--rotate":
|
|
58
|
+
a.rotate = True
|
|
59
|
+
elif x == "--dry-run":
|
|
60
|
+
a.dry = True
|
|
61
|
+
elif x == "--unpair":
|
|
62
|
+
a.unpair = True
|
|
63
|
+
elif x == "--status":
|
|
64
|
+
a.status = True
|
|
65
|
+
elif x in ("--name", "--account", "--opencode-url", "--port"):
|
|
66
|
+
i += 1
|
|
67
|
+
if x == "--name":
|
|
68
|
+
a.name = nxt
|
|
69
|
+
elif x == "--account":
|
|
70
|
+
a.account = nxt
|
|
71
|
+
elif x == "--opencode-url":
|
|
72
|
+
a.opencode_url = nxt
|
|
73
|
+
else:
|
|
74
|
+
port = to_number(nxt)
|
|
75
|
+
if truthy(port):
|
|
76
|
+
a.opencode_url = f"http://127.0.0.1:{number_str(port)}"
|
|
77
|
+
else:
|
|
78
|
+
raise UsageError(f"witbitz-code: unknown argument {x}")
|
|
79
|
+
i += 1
|
|
80
|
+
return a
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ensure_local_password(out: Callable[[str], Any] = _out) -> None:
|
|
84
|
+
path = pp.env_path()
|
|
85
|
+
try:
|
|
86
|
+
text = path.read_text(encoding="utf-8")
|
|
87
|
+
except FileNotFoundError:
|
|
88
|
+
text = ""
|
|
89
|
+
if pp.env_get(text, "OPENCODE_SERVER_PASSWORD"):
|
|
90
|
+
return
|
|
91
|
+
pp.write_in_place(path, pp.env_set(text, "OPENCODE_SERVER_PASSWORD", pp.new_password()))
|
|
92
|
+
out(f"witbitz-code: ✓ minted a local OpenCode password in {path} (0600) — it never leaves this computer")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _publish_entry(call: net.ApiCall, e: dict, out: Callable, err: Callable) -> bool:
|
|
96
|
+
r = await acct.publish_computer(call=call, idx=e["idx"], computer_id=e["computerId"], name=e["name"], relay=e["relay"],
|
|
97
|
+
secret=e["secret"])
|
|
98
|
+
who = e["account"] or "account"
|
|
99
|
+
if not r["ok"]:
|
|
100
|
+
err(f"witbitz-code: ✖ {who}: {r['why']}")
|
|
101
|
+
return False
|
|
102
|
+
out(f"witbitz-code: ✓ \"{e['name']}\" is in {e['account'] or 'the account'}'s computers ({len(r['computers'])} paired)")
|
|
103
|
+
lg = await acct.clear_legacy_direct(call=call, idx=e["idx"])
|
|
104
|
+
if not lg["ok"]:
|
|
105
|
+
out(f"witbitz-code: ⚠ could not clear the old direct-server settings ({lg['why']}) — harmless while a computer is paired")
|
|
106
|
+
elif not lg.get("noop"):
|
|
107
|
+
out("witbitz-code: ✓ cleared the old direct-server address from the account (devices use the relay now)")
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _link_by_qr(link_fn: LinkFn | None, out: Callable, err: Callable) -> dict | None:
|
|
112
|
+
"""The QR ritual: show the Add-a-device QR and wait for a signed-in device to seal the account to this computer. The
|
|
113
|
+
account stays in memory; an expired or failed scan changes nothing."""
|
|
114
|
+
out("witbitz-code: on your phone open Spaces → Settings → Back up & recovery → Add a device, and scan the QR below "
|
|
115
|
+
"with the account to pair.\n")
|
|
116
|
+
account = await (link_fn() if link_fn else link.link_node())
|
|
117
|
+
if not account:
|
|
118
|
+
return None
|
|
119
|
+
idx = account.get("idx")
|
|
120
|
+
if not isinstance(idx, dict) or not truthy(idx.get("room")) or not truthy(idx.get("mk")):
|
|
121
|
+
err("witbitz-code: the scan carried no index-room pointer, so there is nowhere to publish — nothing changed.")
|
|
122
|
+
return None
|
|
123
|
+
return account
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def main(argv: list[str], *, call: net.ApiCall | None = None, link_fn: LinkFn | None = None,
|
|
127
|
+
out: Callable[[str], Any] = _out, err: Callable[[str], Any] = _err) -> int:
|
|
128
|
+
"""pair / --status / --unpair / --rotate. Returns the exit code."""
|
|
129
|
+
try:
|
|
130
|
+
args = parse_args(argv)
|
|
131
|
+
except UsageError as e:
|
|
132
|
+
err(str(e))
|
|
133
|
+
return 2
|
|
134
|
+
client = None
|
|
135
|
+
if call is None and not args.status:
|
|
136
|
+
client = net.new_client()
|
|
137
|
+
call = net.make_api_call(client)
|
|
138
|
+
try:
|
|
139
|
+
return await _run(args, call, link_fn, out, err)
|
|
140
|
+
finally:
|
|
141
|
+
if client is not None:
|
|
142
|
+
await client.aclose()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err: Callable) -> int:
|
|
146
|
+
doc = pp.read_pairings()
|
|
147
|
+
|
|
148
|
+
if args.status:
|
|
149
|
+
if not doc["pairings"]:
|
|
150
|
+
out("witbitz-code: not paired")
|
|
151
|
+
for p in doc["pairings"]:
|
|
152
|
+
out(f"· \"{p.get('name')}\" → {p.get('account') or '(account)'} · OpenCode {p.get('opencodeUrl')} · relay {p.get('relay')}")
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
if args.unpair:
|
|
156
|
+
targets = [p for p in doc["pairings"] if not args.account or p.get("account") == args.account]
|
|
157
|
+
if not targets:
|
|
158
|
+
err(f"witbitz-code: no pairing{' for ' + args.account if args.account else ''} on this computer")
|
|
159
|
+
return 1
|
|
160
|
+
if len(targets) > 1 and not args.account:
|
|
161
|
+
names = ", ".join(str(p.get("account")) for p in targets)
|
|
162
|
+
err(f"witbitz-code: this computer is paired with {len(targets)} accounts ({names}) — pass --account <email>")
|
|
163
|
+
return 1
|
|
164
|
+
if args.dry:
|
|
165
|
+
out(f"witbitz-code: --dry-run — would remove \"{targets[0].get('name')}\" from {targets[0].get('account')}")
|
|
166
|
+
return 0
|
|
167
|
+
ok = True
|
|
168
|
+
for p in targets:
|
|
169
|
+
r = await acct.unpublish_computer(call=call, idx=p["idx"], computer_id=p["computerId"])
|
|
170
|
+
if not r["ok"]:
|
|
171
|
+
ok = False
|
|
172
|
+
err(f"witbitz-code: ✖ {p.get('account')}: {r['why']} — kept the local pairing so you can retry")
|
|
173
|
+
continue
|
|
174
|
+
note = " (it was not listed)" if r.get("noop") else ""
|
|
175
|
+
out(f"witbitz-code: ✓ removed \"{p.get('name')}\" from {p.get('account')}{note} — every device drops it on its next sync")
|
|
176
|
+
pp.write_pairings(pp.remove_pairings(pp.read_pairings(), p.get("account") or "")["doc"])
|
|
177
|
+
out(f"witbitz-code: restart the connector ({START_HINT}) so it stops answering on the old channel.")
|
|
178
|
+
return 0 if ok else 1
|
|
179
|
+
|
|
180
|
+
if args.rotate:
|
|
181
|
+
if not doc["pairings"]:
|
|
182
|
+
err("witbitz-code: nothing to rotate — pair first")
|
|
183
|
+
return 1
|
|
184
|
+
to_rotate = [x for x in doc["pairings"] if not args.account or x.get("account") == args.account]
|
|
185
|
+
if args.dry:
|
|
186
|
+
out(f"witbitz-code: --dry-run — would rotate {len(to_rotate)} pairing(s)")
|
|
187
|
+
return 0
|
|
188
|
+
cur, ok = doc, True
|
|
189
|
+
for p in to_rotate:
|
|
190
|
+
u = pp.upsert_pairing(cur, account=p.get("account") or "", idx=p["idx"], name=p.get("name") or "",
|
|
191
|
+
relay=p.get("relay", UNDEFINED), opencode_url=p.get("opencodeUrl", UNDEFINED), rotate=True)
|
|
192
|
+
if await _publish_entry(call, u["entry"], out, err):
|
|
193
|
+
cur = u["doc"]
|
|
194
|
+
pp.write_pairings(cur)
|
|
195
|
+
else:
|
|
196
|
+
ok = False
|
|
197
|
+
out("witbitz-code: restart the connector so it listens on the new channel(s).")
|
|
198
|
+
return 0 if ok else 1
|
|
199
|
+
|
|
200
|
+
# PAIR — the QR ritual
|
|
201
|
+
if args.dry:
|
|
202
|
+
out("witbitz-code: --dry-run — would show the QR, then publish this computer to the scanning account")
|
|
203
|
+
return 0
|
|
204
|
+
account = await _link_by_qr(link_fn, out, err)
|
|
205
|
+
if not account:
|
|
206
|
+
err("witbitz-code: pairing did not complete — nothing changed.")
|
|
207
|
+
return 1
|
|
208
|
+
u = pp.upsert_pairing(doc, account=account.get("email") or "", idx=account["idx"], name=args.name,
|
|
209
|
+
opencode_url=args.opencode_url or UNDEFINED)
|
|
210
|
+
ensure_local_password(out)
|
|
211
|
+
if not await _publish_entry(call, u["entry"], out, err):
|
|
212
|
+
err("witbitz-code: nothing saved locally — scan again to retry.")
|
|
213
|
+
return 1
|
|
214
|
+
pp.write_pairings(u["doc"])
|
|
215
|
+
e = u["entry"]
|
|
216
|
+
out(f"witbitz-code: ✓ {'paired' if u['isNew'] else 'refreshed'} \"{e['name']}\" with {e['account'] or 'your account'} ({pp.pairings_path()})")
|
|
217
|
+
if u["sharedWith"]:
|
|
218
|
+
out(f"witbitz-code: ⚠ this computer's OpenCode ({e['opencodeUrl']}) is now reachable from {e['account']} AND {', '.join(u['sharedWith'])}.")
|
|
219
|
+
out(" OpenCode has no users: they share every session, file and shell. Fine for your own accounts;")
|
|
220
|
+
out(" for another person run a separate OpenCode (another port and OS user) and pair with --opencode-url.")
|
|
221
|
+
out(f"witbitz-code: next — start OpenCode and the connector: {START_HINT}")
|
|
222
|
+
return 0
|
witbitz_code/pairings.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""This computer's pairings file and the local OpenCode env file — the pure half of tools/opencode-pair.mjs plus the
|
|
2
|
+
connector's loader.
|
|
3
|
+
|
|
4
|
+
~/.witbitz/code/pairings.json (0600) is shared with the JS tools, so it is written byte-for-byte as they write it
|
|
5
|
+
(`JSON.stringify(doc, null, 1) + "\\n"`): either implementation can pair a computer and the other can serve or unpair it.
|
|
6
|
+
|
|
7
|
+
{ "v": 1, "pairings": [ { account, idx: {room, mk}, computerId, secret, name, relay, opencodeUrl } ] }
|
|
8
|
+
|
|
9
|
+
One entry per (computer, account) — several accounts can pair the same computer, each with its own secret and channel.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import socket
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from . import _js
|
|
25
|
+
from ._js import NOT_LT, UNDEFINED, WS, strict_eq, truthy
|
|
26
|
+
from .relay import RELAY_URL, new_relay_secret
|
|
27
|
+
|
|
28
|
+
DEFAULT_OPENCODE_URL = "http://127.0.0.1:4096"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pairings_path() -> Path:
|
|
32
|
+
env = os.environ.get("WITBITZ_CODE_PAIRINGS")
|
|
33
|
+
return Path(env) if env else Path.home() / ".witbitz" / "code" / "pairings.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def env_path() -> Path:
|
|
37
|
+
env = os.environ.get("OPENCODE_ENV_FILE")
|
|
38
|
+
return Path(env) if env else Path.home() / ".opencode-server.env"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── the env file ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
def _assignment(key: str) -> re.Pattern:
|
|
43
|
+
# JS: /^\s*(?:export\s+)?KEY=(.*)$/ on each line split at /\r?\n/ (a Windows-edited file keeps its password). JS `.`
|
|
44
|
+
# still stops at a lone \r or U+2028, so NOT_LT keeps the two tools agreeing on those.
|
|
45
|
+
return re.compile(f"[{WS}]*(?:export[{WS}]+)?{re.escape(key)}=({NOT_LT}*)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def env_get(text: Any, key: str) -> str | None:
|
|
49
|
+
"""The value of KEY in an env file — the LAST assignment wins, as when the file is sourced. Quotes are stripped."""
|
|
50
|
+
rx = _assignment(key)
|
|
51
|
+
v = None
|
|
52
|
+
for line in re.split(r"\r?\n", _js.js_string(text if truthy(text) else "")):
|
|
53
|
+
m = rx.fullmatch(line)
|
|
54
|
+
if m:
|
|
55
|
+
q = re.fullmatch(f"(['\"])({NOT_LT}*)\\1", _js.trim(m.group(1)))
|
|
56
|
+
v = q.group(2) if q else _js.trim(m.group(1))
|
|
57
|
+
return v
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def env_set(text: Any, key: str, value: str) -> str:
|
|
61
|
+
"""Set KEY=value: every existing assignment of KEY is removed, one is appended, every other line is kept verbatim."""
|
|
62
|
+
rx = re.compile(f"[{WS}]*(?:export[{WS}]+)?{re.escape(key)}=")
|
|
63
|
+
keep = [ln for ln in re.split(r"\r?\n", _js.js_string(text if truthy(text) else "")) if not rx.match(ln)]
|
|
64
|
+
while keep and keep[-1] == "":
|
|
65
|
+
keep.pop()
|
|
66
|
+
keep.append(f"{key}={value}")
|
|
67
|
+
return "\n".join(keep) + "\n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_env_password(text: Any) -> str:
|
|
71
|
+
"""OPENCODE_SERVER_PASSWORD from an env file ('' when absent)."""
|
|
72
|
+
return env_get(text, "OPENCODE_SERVER_PASSWORD") or ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_env_password(path: Path | None = None) -> str:
|
|
76
|
+
p = path or env_path()
|
|
77
|
+
try:
|
|
78
|
+
return parse_env_password(p.read_text(encoding="utf-8"))
|
|
79
|
+
except OSError:
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def new_password() -> str:
|
|
84
|
+
"""32 URL-safe characters (192 bits) — the LOCAL OpenCode password; it never leaves this computer."""
|
|
85
|
+
return secrets.token_urlsafe(24)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def new_computer_id() -> str:
|
|
89
|
+
"""16 random bytes, base64url. One per (computer, account) pairing."""
|
|
90
|
+
return secrets.token_urlsafe(16)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def hostname() -> str:
|
|
94
|
+
return socket.gethostname()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── the pairings doc (pure) ───────────────────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
def _usable(p: Any) -> bool:
|
|
99
|
+
idx = p.get("idx") if isinstance(p, dict) else None
|
|
100
|
+
return (isinstance(idx, dict) and truthy(idx.get("room")) and truthy(idx.get("mk"))
|
|
101
|
+
and isinstance(p.get("secret"), str) and isinstance(p.get("computerId"), str))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def norm_pairings(doc: Any) -> dict:
|
|
105
|
+
"""The pairings file as a list-holder; tolerant of a missing or broken file."""
|
|
106
|
+
lst = doc.get("pairings") if isinstance(doc, dict) else None
|
|
107
|
+
return {"v": 1, "pairings": [p for p in lst if _usable(p)] if isinstance(lst, list) else []}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def upsert_pairing(doc: Any, *, idx: dict, account: str = "", name: str = "", relay: Any = UNDEFINED,
|
|
111
|
+
opencode_url: Any = UNDEFINED, rotate: bool = False, mint_id: Callable[[], str] = new_computer_id,
|
|
112
|
+
mint_secret: Callable[[], str] = new_relay_secret) -> dict:
|
|
113
|
+
"""Add this computer for an account, or refresh the existing entry (same id + secret unless `rotate`). Pure.
|
|
114
|
+
→ {doc, entry, isNew, sharedWith}: sharedWith names the other accounts that reach the same OpenCode."""
|
|
115
|
+
relay = RELAY_URL if relay is UNDEFINED else relay
|
|
116
|
+
cur = norm_pairings(doc)
|
|
117
|
+
i = next((k for k, p in enumerate(cur["pairings"]) if strict_eq(p["idx"].get("room"), idx.get("room"))), -1)
|
|
118
|
+
prev = cur["pairings"][i] if i >= 0 else None
|
|
119
|
+
|
|
120
|
+
def pick(v: Any, key: str, dflt: Any) -> Any:
|
|
121
|
+
if truthy(v):
|
|
122
|
+
return v
|
|
123
|
+
return prev[key] if prev is not None and truthy(prev.get(key)) else dflt
|
|
124
|
+
|
|
125
|
+
entry = {
|
|
126
|
+
"account": pick(account, "account", ""),
|
|
127
|
+
"idx": {"room": idx.get("room"), "mk": idx.get("mk")},
|
|
128
|
+
"computerId": prev["computerId"] if prev is not None else mint_id(),
|
|
129
|
+
"secret": prev["secret"] if prev is not None and not rotate else mint_secret(),
|
|
130
|
+
"name": pick(name, "name", None) or hostname(),
|
|
131
|
+
"relay": relay,
|
|
132
|
+
# An OpenCode asked for (`pair --port 4097`) wins; not asked → the entry keeps the one it had (tools/opencode-pair.mjs).
|
|
133
|
+
"opencodeUrl": pick(None if opencode_url is UNDEFINED else opencode_url, "opencodeUrl", DEFAULT_OPENCODE_URL),
|
|
134
|
+
}
|
|
135
|
+
pairings = list(cur["pairings"])
|
|
136
|
+
if i >= 0:
|
|
137
|
+
pairings[i] = entry
|
|
138
|
+
else:
|
|
139
|
+
pairings.append(entry)
|
|
140
|
+
others = [p for p in pairings if not strict_eq(p["idx"].get("room"), idx.get("room"))
|
|
141
|
+
and strict_eq(p.get("opencodeUrl", UNDEFINED), entry["opencodeUrl"])]
|
|
142
|
+
return {"doc": {"v": 1, "pairings": pairings}, "entry": entry, "isNew": prev is None,
|
|
143
|
+
"sharedWith": [p["account"] if truthy(p.get("account")) else "(another account)" for p in others]}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def remove_pairings(doc: Any, account: str = "") -> dict:
|
|
147
|
+
"""Drop the entries matching `account` (email) — or every entry when `account` is empty. Pure."""
|
|
148
|
+
cur = norm_pairings(doc)
|
|
149
|
+
gone = [p for p in cur["pairings"] if not truthy(account) or strict_eq(p.get("account", UNDEFINED), account)]
|
|
150
|
+
kept = [p for p in cur["pairings"] if not any(p is g for g in gone)]
|
|
151
|
+
return {"doc": {"v": 1, "pairings": kept}, "removed": gone}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ── files ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
155
|
+
def read_pairings(path: Path | None = None) -> dict:
|
|
156
|
+
try:
|
|
157
|
+
return norm_pairings(_js.parse((path or pairings_path()).read_text(encoding="utf-8")))
|
|
158
|
+
except (OSError, ValueError):
|
|
159
|
+
return norm_pairings(None)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def write_private(path: Path, text: str, dir_mode: int = 0o700) -> None:
|
|
163
|
+
"""Write a 0600 file atomically: the secret is never readable by others, not even for the moment between write and
|
|
164
|
+
chmod, and a crash never leaves half a file."""
|
|
165
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=dir_mode)
|
|
166
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
|
167
|
+
try:
|
|
168
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
|
|
169
|
+
f.write(text)
|
|
170
|
+
os.chmod(tmp, 0o600)
|
|
171
|
+
os.replace(tmp, path)
|
|
172
|
+
except BaseException:
|
|
173
|
+
try:
|
|
174
|
+
os.unlink(tmp)
|
|
175
|
+
except OSError:
|
|
176
|
+
pass
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def write_in_place(path: Path, text: str) -> None:
|
|
181
|
+
"""Rewrite a file the user may manage (the env file — possibly a symlink): atomically at its real location, 0600
|
|
182
|
+
from the first byte, so there is no moment when a new password sits in a world-readable file and no crash that
|
|
183
|
+
leaves the user's other lines half-written."""
|
|
184
|
+
write_private(Path(os.path.realpath(path)), text)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def serialize_pairings(doc: dict) -> str:
|
|
188
|
+
return _js.stringify(doc, 1) + "\n"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def write_pairings(doc: dict, path: Path | None = None) -> None:
|
|
192
|
+
write_private(path or pairings_path(), serialize_pairings(doc))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def load_pairings(path: Path | None = None, log: Callable[[str], Any] | None = None) -> list[dict]:
|
|
196
|
+
"""The connector's view: the list (empty when absent). Entries without a secret are skipped, loudly."""
|
|
197
|
+
log = log or (lambda m: print(m, file=sys.stderr, flush=True))
|
|
198
|
+
p = path or pairings_path()
|
|
199
|
+
if not p.exists():
|
|
200
|
+
return []
|
|
201
|
+
try:
|
|
202
|
+
doc = _js.parse(p.read_text(encoding="utf-8"))
|
|
203
|
+
except (OSError, ValueError) as e:
|
|
204
|
+
log(f"witbitz-code: {p} is not valid JSON ({e})")
|
|
205
|
+
return []
|
|
206
|
+
lst = doc.get("pairings") if isinstance(doc, dict) else None
|
|
207
|
+
out = []
|
|
208
|
+
for entry in lst if isinstance(lst, list) else []:
|
|
209
|
+
secret = entry.get("secret") if isinstance(entry, dict) else None
|
|
210
|
+
if isinstance(secret, str) and _js.utf16_len(secret) >= 43:
|
|
211
|
+
out.append(entry)
|
|
212
|
+
else:
|
|
213
|
+
log("witbitz-code: skipping a pairing with no secret")
|
|
214
|
+
return out
|
witbitz_code/plugins.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Our OpenCode plugins — project notes and the agent's progress updates — for a computer running the Python connector.
|
|
2
|
+
|
|
3
|
+
The twin of tools/opencode-plugins/install.mjs: the same three files (package data in opencode_plugins/, copied from the
|
|
4
|
+
sources by `node tools/opencode-plugins/embed.mjs`; a test fails when a copy is stale) are written into OpenCode's config
|
|
5
|
+
folder on every `serve`, where OpenCode loads {plugin,plugins}/*.js and {command,commands}/**.md at boot.
|
|
6
|
+
|
|
7
|
+
With the notes plugin installed, the connector allows each session to read its project's notes folder and write into
|
|
8
|
+
notes/ (connector.py, the twin of ruleNotes in tools/opencode-connector.mjs): the plugin makes the agent keep notes at the
|
|
9
|
+
end of a turn, and one note used to cost four approvals.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
from importlib import resources
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# Installed path (under OpenCode's config folder) → the packaged file.
|
|
22
|
+
FILES = {
|
|
23
|
+
"plugins/witbitz-notes.js": "witbitz-notes.js",
|
|
24
|
+
"plugins/witbitz-progress.js": "witbitz-progress.js",
|
|
25
|
+
"commands/notes-init.md": "notes-init.md",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def opencode_config_dir(env: dict | None = None) -> Path:
|
|
30
|
+
"""OpenCode's config folder, as OpenCode finds it: $XDG_CONFIG_HOME/opencode, else ~/.config/opencode (Windows too)."""
|
|
31
|
+
env = os.environ if env is None else env
|
|
32
|
+
return Path(env.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "opencode"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def packaged(name: str) -> str:
|
|
36
|
+
return resources.files("witbitz_code").joinpath("opencode_plugins", name).read_text(encoding="utf-8")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def install(config_dir: Path | None = None) -> list[Path]:
|
|
40
|
+
"""Write the plugin files into OpenCode's config folder; → the installed paths that changed. An identical file is left
|
|
41
|
+
alone; the notes' old confidential-model list is removed (the notes no longer split by model)."""
|
|
42
|
+
root = Path(config_dir) if config_dir else opencode_config_dir()
|
|
43
|
+
changed = []
|
|
44
|
+
for rel, name in FILES.items():
|
|
45
|
+
target = root / rel
|
|
46
|
+
text = packaged(name)
|
|
47
|
+
try:
|
|
48
|
+
if target.read_text(encoding="utf-8") == text:
|
|
49
|
+
continue
|
|
50
|
+
except (OSError, UnicodeDecodeError):
|
|
51
|
+
pass
|
|
52
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
target.write_text(text, encoding="utf-8", newline="")
|
|
54
|
+
changed.append(target)
|
|
55
|
+
try:
|
|
56
|
+
(root / "witbitz-confidential-models.json").unlink()
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
pass
|
|
59
|
+
return changed
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── witbitz-media: image/PDF/video/audio tools as one stdio MCP server (tools/media-mcp/server.mjs; docs/media-mcp.md) ───────
|
|
63
|
+
# The twin of install.mjs installMedia/mediaMcpConfig. The server is JavaScript, so it runs only where `node` is on PATH
|
|
64
|
+
# (OpenCode installed with npm brings it; the standalone OpenCode binary does not) — without node the agent works without it.
|
|
65
|
+
MEDIA_REL = "witbitz-media/server.mjs"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def install_media(config_dir: Path | None = None) -> Path:
|
|
69
|
+
"""Write the media server into OpenCode's config folder (an identical file is left alone); → its path."""
|
|
70
|
+
target = (Path(config_dir) if config_dir else opencode_config_dir()) / MEDIA_REL
|
|
71
|
+
text = packaged("witbitz-media-server.mjs")
|
|
72
|
+
try:
|
|
73
|
+
if target.read_text(encoding="utf-8") == text:
|
|
74
|
+
return target
|
|
75
|
+
except (OSError, UnicodeDecodeError):
|
|
76
|
+
pass
|
|
77
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
target.write_text(text, encoding="utf-8", newline="")
|
|
79
|
+
return target
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def media_mcp_config(server: Path | str, node: str) -> dict:
|
|
83
|
+
"""The OpenCode config block that runs it (merged into OPENCODE_CONFIG_CONTENT; the user's opencode.json is never written)."""
|
|
84
|
+
return {"mcp": {"witbitz-media": {"type": "local", "command": [node, str(server)], "enabled": True}}}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def media_config(config_dir: Path | None = None, which=shutil.which) -> tuple[dict, str]:
|
|
88
|
+
"""→ (the mcp block or {}, a line for the log, or "")."""
|
|
89
|
+
node = which("node")
|
|
90
|
+
if not node:
|
|
91
|
+
return {}, "witbitz-code: no node on PATH — the agent works without the media tools (images, PDFs, video, transcription)"
|
|
92
|
+
try:
|
|
93
|
+
return media_mcp_config(install_media(config_dir), node), ""
|
|
94
|
+
except OSError as e:
|
|
95
|
+
return {}, f"witbitz-code: could not install the media tools ({e}) — the agent works without them"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── project notes: which folder, and the rules that let a session use it (witbitz-notes.js helpers, ported) ──────────────
|
|
99
|
+
def notes_plugin_path(env: dict | None = None) -> Path:
|
|
100
|
+
return opencode_config_dir(env) / "plugins" / "witbitz-notes.js"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def notes_root(env: dict | None = None) -> str:
|
|
104
|
+
env = os.environ if env is None else env
|
|
105
|
+
return env.get("WITBITZ_NOTES_DIR") or str(Path.home() / ".local" / "share" / "witbitz-notes")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def notes_key(root: str) -> str:
|
|
109
|
+
"""`<folder name>-<sha1(root)[:8]>`, exactly as the plugin names it: the JS regex replaces each UTF-16 code unit that is
|
|
110
|
+
not [A-Za-z0-9._-] with one "_" — so a character outside the BMP becomes two."""
|
|
111
|
+
base = os.path.basename(root) or "root"
|
|
112
|
+
safe = "".join(ch if re.fullmatch(r"[A-Za-z0-9._-]", ch) else "_" * (len(ch.encode("utf-16-le")) // 2) for ch in base)
|
|
113
|
+
return f"{safe}-{hashlib.sha1(root.encode('utf-8')).hexdigest()[:8]}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def root_from_session(s: dict | None) -> str:
|
|
117
|
+
"""A session record → its project root: directory minus its `path` (a git session's path is "" or the subfolder, a plain
|
|
118
|
+
folder's path is its directory without the leading "/")."""
|
|
119
|
+
directory = (s or {}).get("directory")
|
|
120
|
+
if not isinstance(directory, str) or not directory:
|
|
121
|
+
return ""
|
|
122
|
+
rel = str((s or {}).get("path") or "")
|
|
123
|
+
if not rel or directory == "/" + rel:
|
|
124
|
+
return directory
|
|
125
|
+
return (directory[: -(len(rel) + 1)] or "/") if directory.endswith("/" + rel) else directory
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def notes_rules(s: dict, root_dir: str) -> list[dict]:
|
|
129
|
+
"""The rules a session needs for its project's notes: reads of the folder, writes into notes/ (never AGENTS.md). The
|
|
130
|
+
edit tool's pattern is relative to the WORKTREE — the git root, or "/" for a plain folder (measured)."""
|
|
131
|
+
root = root_from_session(s)
|
|
132
|
+
if not root:
|
|
133
|
+
return []
|
|
134
|
+
folder = f"{root_dir}/{notes_key(os.path.abspath(root))}"
|
|
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
|
+
]
|
witbitz_code/policy.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The OpenCode config the Code section's approvals depend on — twin of tools/code-opencode-policy.mjs.
|
|
2
|
+
|
|
3
|
+
Read that file's header for the two findings (OpenCode 1.18.30): a subagent does NOT inherit its parent session's `ask`
|
|
4
|
+
rules, so the built-in subagents are told to ask here, per agent and only for what each one ALLOWS (never loosening a
|
|
5
|
+
deny); and a denied tool call ends the turn unless `experimental.continue_loop_on_deny` is set. tests/test_policy.py
|
|
6
|
+
holds this object identical to the JS one.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
SUBAGENT_ASK: dict[str, dict[str, str]] = {
|
|
15
|
+
"explore": {"bash": "ask", "webfetch": "ask", "websearch": "ask"},
|
|
16
|
+
"general": {"bash": "ask", "edit": "ask", "webfetch": "ask", "websearch": "ask", "task": "ask", "skill": "ask"},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
SUBAGENT_GATED: tuple[str, ...] = ("bash", "edit", "webfetch", "websearch")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def policy_config() -> dict[str, Any]:
|
|
23
|
+
"""The config object, fresh each call."""
|
|
24
|
+
return {
|
|
25
|
+
"experimental": {"continue_loop_on_deny": True},
|
|
26
|
+
"agent": {name: {"permission": dict(perms)} for name, perms in SUBAGENT_ASK.items()},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def merge_config(a: dict, b: dict | None) -> dict:
|
|
31
|
+
"""Deep-merge plain dicts (b wins), without mutating either."""
|
|
32
|
+
out = copy.deepcopy(a)
|
|
33
|
+
for k, v in (b or {}).items():
|
|
34
|
+
out[k] = merge_config(out[k], v) if isinstance(v, dict) and isinstance(out.get(k), dict) else copy.deepcopy(v)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_content(existing: str | None = None, extra: dict | None = None) -> str:
|
|
39
|
+
"""OPENCODE_CONFIG_CONTENT for the OpenCode `serve` starts: `extra` (the media tools' mcp block) and the policy, under
|
|
40
|
+
anything the environment already set."""
|
|
41
|
+
base: dict = {}
|
|
42
|
+
if existing:
|
|
43
|
+
try:
|
|
44
|
+
parsed = json.loads(existing)
|
|
45
|
+
base = parsed if isinstance(parsed, dict) else {}
|
|
46
|
+
except ValueError:
|
|
47
|
+
base = {}
|
|
48
|
+
return json.dumps(merge_config(merge_config(base, extra), policy_config()))
|