witbitz-code 1.2.1__tar.gz → 1.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {witbitz_code-1.2.1/src/witbitz_code.egg-info → witbitz_code-1.3.1}/PKG-INFO +25 -17
  2. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/README.md +25 -17
  3. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/pyproject.toml +2 -2
  4. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/__init__.py +1 -1
  5. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/account.py +5 -2
  6. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/auto_runner.py +9 -1
  7. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/cli.py +120 -48
  8. witbitz_code-1.3.1/src/witbitz_code/codekeys.py +285 -0
  9. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/confidential.py +27 -6
  10. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/connector.py +30 -5
  11. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/models.py +8 -0
  12. witbitz_code-1.3.1/src/witbitz_code/offered_models.json +32 -0
  13. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/pair.py +76 -6
  14. witbitz_code-1.3.1/src/witbitz_code/payers.py +111 -0
  15. witbitz_code-1.3.1/src/witbitz_code/uninstall.py +447 -0
  16. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/wizard.py +27 -70
  17. {witbitz_code-1.2.1 → witbitz_code-1.3.1/src/witbitz_code.egg-info}/PKG-INFO +25 -17
  18. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code.egg-info/SOURCES.txt +7 -1
  19. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_cli.py +23 -1
  20. witbitz_code-1.3.1/tests/test_codekeys.py +235 -0
  21. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_connector_e2e.py +30 -0
  22. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_pair_flows.py +61 -9
  23. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_setup.py +33 -39
  24. witbitz_code-1.3.1/tests/test_uninstall.py +164 -0
  25. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/LICENSE +0 -0
  26. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/setup.cfg +0 -0
  27. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/__main__.py +0 -0
  28. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/_js.py +0 -0
  29. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/asks.py +0 -0
  30. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/attachments.py +0 -0
  31. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/auto.py +0 -0
  32. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/codetools.py +0 -0
  33. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/compress.py +0 -0
  34. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/computers.py +0 -0
  35. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/confidential_models.json +0 -0
  36. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/devicelink.py +0 -0
  37. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/folders.py +0 -0
  38. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/gateway.py +0 -0
  39. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/keys.py +0 -0
  40. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/link.py +0 -0
  41. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/net.py +0 -0
  42. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  43. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  44. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  45. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  46. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/outputs.py +0 -0
  47. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/pairings.py +0 -0
  48. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/plugins.py +0 -0
  49. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/policy.py +0 -0
  50. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/qr.py +0 -0
  51. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/receipt.py +0 -0
  52. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/relay.py +0 -0
  53. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/seen.py +0 -0
  54. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/tinfoil_read.py +0 -0
  55. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code/tools_probe.py +0 -0
  56. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  57. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  58. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code.egg-info/requires.txt +0 -0
  59. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/src/witbitz_code.egg-info/top_level.txt +0 -0
  60. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_asks.py +0 -0
  61. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_attachments.py +0 -0
  62. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_auto.py +0 -0
  63. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_codetools.py +0 -0
  64. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_confidential.py +0 -0
  65. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_devicelink.py +0 -0
  66. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_files_and_compress.py +0 -0
  67. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_folders.py +0 -0
  68. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_outputs.py +0 -0
  69. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_plugins.py +0 -0
  70. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_policy.py +0 -0
  71. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_registry.py +0 -0
  72. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_relay_codec.py +0 -0
  73. {witbitz_code-1.2.1 → witbitz_code-1.3.1}/tests/test_tools_probe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.2.1
3
+ Version: 1.3.1
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
@@ -48,28 +48,36 @@ verifier — attestation, Sigstore and TLS pinning — for the confidential mode
48
48
  witbitz-code setup
49
49
  ```
50
50
 
51
- `setup` walks five steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
52
- this computer (scan the QR code in Spaces → Settings → Back up & recovery → Add a device), asks for your **TrustedRouter**
53
- key and an optional **Tinfoil** key (each checked with the provider before it is saved; it shows as `*****` while you
54
- paste), then starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C stops it); next time,
55
- `witbitz-code serve` is enough.
51
+ `setup` walks four steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
52
+ this computer (scan the QR code in Spaces → Settings → Back up & recovery → Add a device), which gives the computer **its
53
+ own keys from your Spaces account**, then starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C
54
+ stops it); next time, `witbitz-code serve` is enough.
56
55
 
57
- Your keys are yours: you pay TrustedRouter and Tinfoil directly, and your Witbitz account never carries them — so a new
58
- computer asks for them once. The TrustedRouter key goes into OpenCode's own credentials (`$XDG_DATA_HOME/opencode/auth.json`,
59
- the file `opencode auth login` writes), the Tinfoil key into `~/.opencode-server.env`. Without a TrustedRouter key,
60
- OpenCode offers only its own free models.
56
+ **No key to paste.** Pairing asks your Spaces account for this computer's own TrustedRouter and Tinfoil keys, capped by the
57
+ providers (today up to $50 of models and $5 of tools a month per computer). They are sent sealed to this computer, kept in
58
+ `~/.witbitz/code/account-keys.json` (0600) and never given to OpenCode: the local proxy puts them on each call. Model calls
59
+ still go from this computer straight to TrustedRouter and Tinfoil. Coding is by invitation for now — an account that is not
60
+ invited gets no keys, and the pairing stops with a sentence saying so. A computer paired before 1.3 has no account keys:
61
+ pair it once more (`witbitz-code pair`).
62
+
63
+ **Several accounts on one computer:** each pays for its own prompts. OpenCode names the conversation on every call it
64
+ makes; the connector knows which account sent each prompt, and the proxy bills the call to that account. A prompt typed in
65
+ OpenCode's own window on this computer is refused ("coding runs from the Spaces app"), and while one account's reply is
66
+ being written another account cannot add to it. This splits the bill fairly between people who share a computer; it is not
67
+ a wall between them (they already share its files and shell).
68
+
69
+ To start over: `witbitz-code uninstall`, then `pipx install witbitz-code` and `witbitz-code setup`.
61
70
 
62
71
  The same thing one step at a time: install OpenCode (`curl -fsSL https://opencode.ai/install | bash`), then
63
- `witbitz-code pair`, `witbitz-code trustedrouter-key`, `witbitz-code tinfoil-key` (optional) and `witbitz-code serve`.
72
+ `witbitz-code pair` and `witbitz-code serve`.
64
73
 
65
74
  | command | what it does |
66
75
  |---|---|
67
76
  | `setup [--port <n>]` | The walk-through above. With `--port`, a pairing made for another port moves to it (no new scan). |
68
- | `trustedrouter-key` | Ask for a TrustedRouter key, check it, and store it in OpenCode's credentials. Restart `serve` to use it. |
69
- | `tinfoil-key` | Ask for a Tinfoil key, check it, and store it. Used from the next message. |
77
+ | `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. |
70
78
  | `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). |
71
- | `serve [--port <n>] [--no-opencode]` | Start OpenCode on `127.0.0.1:<n>` (default 4096) if nothing is listening there, then connect the pairings whose OpenCode is on that port. One OpenCode per port, one `serve` per OpenCode. |
72
- | `status` | List this computer's pairings: name, account, OpenCode address and relay. Secrets are never printed. |
79
+ | `serve [--port <n>] [--no-opencode]` | Start OpenCode on `127.0.0.1:<n>` (default 4096) with your account's keys, then connect the pairings whose OpenCode is on that port. An OpenCode already running there, started some other way, is not used (it cannot have the keys) — close it first. One OpenCode per port, one `serve` per OpenCode. |
80
+ | `status` | List this computer's pairings — name, account, OpenCode address, relay — and each one's coding keys: live, this month's model spend and the caps. Secrets are never printed. |
73
81
  | `rotate [--account <email>]` | Replace the pairing secret(s) without scanning again, then restart `serve`. Devices pick up the new secret on their next sync. |
74
82
  | `unpair [--account <email>]` | Remove this computer from an account. Every device drops it on its next sync. |
75
83
  | `version`, `--help` | |
@@ -117,8 +125,8 @@ all the accounts are yours. If a second account belongs to **another person**, r
117
125
  receipt** to verify for these exact request and response bytes (an attested key, a TEE-verified upstream under a known
118
126
  policy). A receipt that does not verify ends the step with an error, so no tool from that answer runs;
119
127
  - an image or a document a text-only confidential model cannot read is read **as text inside Tinfoil's attested
120
- enclave** with your Tinfoil key — or refused, never sent anywhere less;
121
- - every other model passes through untouched.
128
+ enclave** with the account's Tinfoil key — or refused, never sent anywhere less;
129
+ - every other model Witbitz offers passes through with the account's key; a model Witbitz does not offer is refused.
122
130
 
123
131
  **Auto mode** (Manual · Accept edits · Plan · **Auto** in the Code composer's mode chip, per session) lets this computer answer OpenCode's permission
124
132
  prompts while you are away:
@@ -23,28 +23,36 @@ verifier — attestation, Sigstore and TLS pinning — for the confidential mode
23
23
  witbitz-code setup
24
24
  ```
25
25
 
26
- `setup` walks five steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
27
- this computer (scan the QR code in Spaces → Settings → Back up & recovery → Add a device), asks for your **TrustedRouter**
28
- key and an optional **Tinfoil** key (each checked with the provider before it is saved; it shows as `*****` while you
29
- paste), then starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C stops it); next time,
30
- `witbitz-code serve` is enough.
31
-
32
- Your keys are yours: you pay TrustedRouter and Tinfoil directly, and your Witbitz account never carries them — so a new
33
- computer asks for them once. The TrustedRouter key goes into OpenCode's own credentials (`$XDG_DATA_HOME/opencode/auth.json`,
34
- the file `opencode auth login` writes), the Tinfoil key into `~/.opencode-server.env`. Without a TrustedRouter key,
35
- OpenCode offers only its own free models.
26
+ `setup` walks four steps and skips whatever is already done: it installs OpenCode if it is missing (it asks first), pairs
27
+ this computer (scan the QR code in Spaces → Settings → Back up & recovery → Add a device), which gives the computer **its
28
+ own keys from your Spaces account**, then starts `serve` in the window. Then open Code in Spaces. Leave it running (Ctrl-C
29
+ stops it); next time, `witbitz-code serve` is enough.
30
+
31
+ **No key to paste.** Pairing asks your Spaces account for this computer's own TrustedRouter and Tinfoil keys, capped by the
32
+ providers (today up to $50 of models and $5 of tools a month per computer). They are sent sealed to this computer, kept in
33
+ `~/.witbitz/code/account-keys.json` (0600) and never given to OpenCode: the local proxy puts them on each call. Model calls
34
+ still go from this computer straight to TrustedRouter and Tinfoil. Coding is by invitation for now — an account that is not
35
+ invited gets no keys, and the pairing stops with a sentence saying so. A computer paired before 1.3 has no account keys:
36
+ pair it once more (`witbitz-code pair`).
37
+
38
+ **Several accounts on one computer:** each pays for its own prompts. OpenCode names the conversation on every call it
39
+ makes; the connector knows which account sent each prompt, and the proxy bills the call to that account. A prompt typed in
40
+ OpenCode's own window on this computer is refused ("coding runs from the Spaces app"), and while one account's reply is
41
+ being written another account cannot add to it. This splits the bill fairly between people who share a computer; it is not
42
+ a wall between them (they already share its files and shell).
43
+
44
+ To start over: `witbitz-code uninstall`, then `pipx install witbitz-code` and `witbitz-code setup`.
36
45
 
37
46
  The same thing one step at a time: install OpenCode (`curl -fsSL https://opencode.ai/install | bash`), then
38
- `witbitz-code pair`, `witbitz-code trustedrouter-key`, `witbitz-code tinfoil-key` (optional) and `witbitz-code serve`.
47
+ `witbitz-code pair` and `witbitz-code serve`.
39
48
 
40
49
  | command | what it does |
41
50
  |---|---|
42
51
  | `setup [--port <n>]` | The walk-through above. With `--port`, a pairing made for another port moves to it (no new scan). |
43
- | `trustedrouter-key` | Ask for a TrustedRouter key, check it, and store it in OpenCode's credentials. Restart `serve` to use it. |
44
- | `tinfoil-key` | Ask for a Tinfoil key, check it, and store it. Used from the next message. |
52
+ | `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. |
45
53
  | `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). |
46
- | `serve [--port <n>] [--no-opencode]` | Start OpenCode on `127.0.0.1:<n>` (default 4096) if nothing is listening there, then connect the pairings whose OpenCode is on that port. One OpenCode per port, one `serve` per OpenCode. |
47
- | `status` | List this computer's pairings: name, account, OpenCode address and relay. Secrets are never printed. |
54
+ | `serve [--port <n>] [--no-opencode]` | Start OpenCode on `127.0.0.1:<n>` (default 4096) with your account's keys, then connect the pairings whose OpenCode is on that port. An OpenCode already running there, started some other way, is not used (it cannot have the keys) — close it first. One OpenCode per port, one `serve` per OpenCode. |
55
+ | `status` | List this computer's pairings — name, account, OpenCode address, relay — and each one's coding keys: live, this month's model spend and the caps. Secrets are never printed. |
48
56
  | `rotate [--account <email>]` | Replace the pairing secret(s) without scanning again, then restart `serve`. Devices pick up the new secret on their next sync. |
49
57
  | `unpair [--account <email>]` | Remove this computer from an account. Every device drops it on its next sync. |
50
58
  | `version`, `--help` | |
@@ -92,8 +100,8 @@ all the accounts are yours. If a second account belongs to **another person**, r
92
100
  receipt** to verify for these exact request and response bytes (an attested key, a TEE-verified upstream under a known
93
101
  policy). A receipt that does not verify ends the step with an error, so no tool from that answer runs;
94
102
  - an image or a document a text-only confidential model cannot read is read **as text inside Tinfoil's attested
95
- enclave** with your Tinfoil key — or refused, never sent anywhere less;
96
- - every other model passes through untouched.
103
+ enclave** with the account's Tinfoil key — or refused, never sent anywhere less;
104
+ - every other model Witbitz offers passes through with the account's key; a model Witbitz does not offer is refused.
97
105
 
98
106
  **Auto mode** (Manual · Accept edits · Plan · **Auto** in the Code composer's mode chip, per session) lets this computer answer OpenCode's permission
99
107
  prompts while you are away:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.2.1"
7
+ version = "1.3.1"
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"]
46
+ witbitz_code = ["opencode_plugins/*", "confidential_models.json", "offered_models.json"]
47
47
 
48
48
  [tool.pytest.ini_options]
49
49
  testpaths = ["tests"]
@@ -4,4 +4,4 @@ The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phas
4
4
  vectors and cross-implementation tests.
5
5
  """
6
6
 
7
- __version__ = "1.2.1"
7
+ __version__ = "1.3.1"
@@ -73,13 +73,16 @@ async def publish_computer(*, call: ApiCall, idx: dict, computer_id: str, name:
73
73
 
74
74
 
75
75
  async def unpublish_computer(*, call: ApiCall, idx: dict, computer_id: str, now: float | None = None) -> dict:
76
- """Tombstone this computer in the account's registry (its secret leaves the account). Nothing there → nothing written."""
76
+ """Tombstone this computer in the account's registry (its secret leaves the account). Written even when this doc does not
77
+ list it: an app that seals the list keeps it in 'xcomputers', which this tool cannot read (it holds no account key), and
78
+ an older app emptied this doc — so "not listed" here says nothing about the list the apps show (tools/opencode-pair.mjs
79
+ unpublishComputer). Only a tombstone already here is left alone."""
77
80
  now = _now_ms() if now is None else now
78
81
  rd = await read_doc(call, idx, COMPUTERS_DOC)
79
82
  if not rd["ok"]:
80
83
  return rd
81
84
  cur = norm_registry(rd["state"])["computers"].get(computer_id)
82
- if not cur or cur.get("removed"):
85
+ if cur and cur.get("removed"):
83
86
  return {"ok": True, "noop": True}
84
87
  nxt = without_computer(rd["state"], computer_id, now)
85
88
  wr = await call(_write_body(idx, COMPUTERS_DOC, wrap_index_state({**nxt, "at": now}), "computer unpaired"))
@@ -110,8 +110,12 @@ class AutoRunner:
110
110
  log_path: Path | None, poll_ms: float = 1000, review_timeout_ms: float = 30_000, home: str | None = None,
111
111
  on_verdict: Callable[[dict], Any] = lambda v: None, log: Callable[[str], Any] = lambda m: None,
112
112
  now: Callable[[], int] = _now_ms, scratch_check: Callable[[list[str]], bool] = scratch_on_disk,
113
- list_asks: Callable[[str], Awaitable[list | None]] | None = None) -> None:
113
+ list_asks: Callable[[str], Awaitable[list | None]] | None = None,
114
+ on_review: Callable[[str, bool], Any] | None = None) -> None:
114
115
  self._root = re.sub(r"/+\Z", "", base or "")
116
+ # The review session's model calls are billed to the account that turned Auto on (payers.py): told when the
117
+ # review opens and closes, since nothing from the Spaces app ever posts into it.
118
+ self._on_review = on_review
115
119
  self._auth = auth
116
120
  self._client = client
117
121
  self._state_path = Path(state_path) if state_path else None
@@ -303,6 +307,8 @@ class AutoRunner:
303
307
  return None, label, "could not open a review session"
304
308
  rid = _js.js_string(rid)
305
309
  timed_out = False
310
+ if self._on_review:
311
+ self._on_review(rid, True)
306
312
  try:
307
313
  body = {"model": model, "system": prompt["system"], "tools": {"*": False}, "parts": [{"type": "text", "text": prompt["text"]}]}
308
314
  try:
@@ -321,6 +327,8 @@ class AutoRunner:
321
327
  secs = math.floor(self._review_timeout_ms / 1000 + 0.5) or 1 # Math.round(ms / 1000) || 1
322
328
  return None, label, f"the reviewer did not answer within {secs} s" if timed_out else "the review failed"
323
329
  finally:
330
+ if self._on_review:
331
+ self._on_review(rid, False)
324
332
  if timed_out:
325
333
  try:
326
334
  await self._call("POST", f"/session/{_uri_component(rid)}/abort", directory)
@@ -1,14 +1,13 @@
1
1
  """witbitz-code — connect the Spaces Code section to OpenCode on this computer (docs/opencode-relay.md). The same commands
2
2
  as the single-file Node download (tools/witbitz-code.mjs):
3
3
 
4
- witbitz-code setup [--port 4096] the walk-through: OpenCode, pairing, the TrustedRouter and Tinfoil keys, then serve
4
+ witbitz-code setup [--port 4096] the walk-through: OpenCode, pairing (which brings this computer's keys), then serve
5
5
  witbitz-code pair [--name desk] scan the QR with the Spaces app → this computer joins that account
6
6
  witbitz-code serve [--port 4096] start OpenCode (if it is not running) and the connector
7
- witbitz-code status what this computer is paired with
7
+ witbitz-code status what this computer is paired with, and its coding keys
8
8
  witbitz-code rotate new secrets (no scan) — restart serve afterwards
9
9
  witbitz-code unpair [--account a@b] remove this computer from an account
10
- witbitz-code trustedrouter-key store the TrustedRouter key in OpenCode's credentials (what `opencode auth login` does)
11
- witbitz-code tinfoil-key store the Tinfoil key (confidential reading of images and files)
10
+ witbitz-code uninstall remove witbitz-code from this computer (asks before your keys, notes and sessions)
12
11
  """
13
12
 
14
13
  from __future__ import annotations
@@ -18,6 +17,7 @@ import os
18
17
  import signal
19
18
  import subprocess
20
19
  import sys
20
+ from pathlib import Path
21
21
  from typing import Any
22
22
 
23
23
  import httpx
@@ -25,24 +25,26 @@ import httpx
25
25
  from . import __version__
26
26
  from . import pair as pair_cmd
27
27
  from . import pairings as pp
28
- from . import codetools, confidential, keys, models, plugins, policy, wizard
28
+ from . import codekeys, codetools, confidential, keys, models, plugins, policy, uninstall as uninstall_mod, wizard
29
29
  from ._js import to_number, truthy
30
30
  from .connector import pairings_for_port, start_connector
31
+ from .payers import SessionPayers
31
32
 
32
33
  HELP = f"""witbitz-code {__version__} — reach OpenCode on this computer from the Spaces Code section, end-to-end encrypted.
33
34
 
34
- setup [--port <n>] start here: installs OpenCode if needed, pairs this computer, asks for your TrustedRouter
35
- and Tinfoil keys (each checked before it is saved), then serves
35
+ setup [--port <n>] start here: installs OpenCode if needed, pairs this computer — which gives it its own
36
+ keys from your Spaces account — then serves
36
37
  pair [--name <name>] show a QR code; scan it in Spaces (Settings → Back up & recovery → Add a device)
37
38
  serve [--port <n>] [--no-opencode]
38
- start OpenCode on 127.0.0.1 (unless it is already running) and the connector
39
- status list this computer's pairings
39
+ start OpenCode on 127.0.0.1 with your account's keys, and the connector
40
+ status list this computer's pairings and their coding keys
40
41
  rotate [--account <email>] replace the pairing secret(s) without a scan, then restart serve
41
42
  unpair [--account <email>] remove this computer from an account
42
- trustedrouter-key store your TrustedRouter API key in OpenCode's credentials (same as opencode auth login)
43
- tinfoil-key store your Tinfoil API key (images and files a confidential model cannot read)
43
+ uninstall [--yes] [--remove-keys] [--remove-notes] [--remove-sessions] [--remove-opencode]
44
+ remove witbitz-code from this computer; asks before your keys, notes, sessions and OpenCode
44
45
 
45
46
  Nothing listens on the network: OpenCode stays on 127.0.0.1 and the connector dials out to wss://code-relay.witbitz.chat.
47
+ Model calls go from this computer straight to TrustedRouter and Tinfoil, each billed to the account whose prompt it is.
46
48
  """
47
49
 
48
50
 
@@ -110,13 +112,18 @@ async def _wait_for_stop(stop: _Stop, child: asyncio.subprocess.Process | None)
110
112
  return 0
111
113
 
112
114
 
115
+ # OpenCode needs SOME TrustedRouter key to offer the provider; the proxy replaces it on every call with the key of the
116
+ # account whose prompt it is (docs/code-account-keys.md), so OpenCode never holds a real one.
117
+ OPENCODE_TR_PLACEHOLDER = "witbitz-account-keys"
118
+
119
+
113
120
  async def _start_opencode(exe: str, port: int, media: dict | None = None, tools: tuple[dict, dict] | None = None,
114
121
  extra: dict | None = None) -> asyncio.subprocess.Process:
115
122
  password = pp.read_env_password()
116
123
  # Subagents ask like their parent, and a refused call does not end the turn (policy.py) — merged by OpenCode over the
117
124
  # user's own config, which is never written.
118
125
  tools_env, tools_config = tools or ({}, {}) # codetools.py: the lsp tool, ast-grep on PATH, the hint naming it
119
- env = {**os.environ, **tools_env, **({"OPENCODE_SERVER_PASSWORD": password} if password else {}),
126
+ env = {**os.environ, **tools_env, **({"OPENCODE_SERVER_PASSWORD": password} if password else {}), "TRUSTEDROUTER_API_KEY": OPENCODE_TR_PLACEHOLDER,
120
127
  "OPENCODE_CONFIG_CONTENT": policy.config_content(os.environ.get("OPENCODE_CONFIG_CONTENT"), policy.merge_config(policy.merge_config(media or {}, tools_config), extra))}
121
128
  # The Tinfoil key is witbitz-code's (the confidential proxy reads it). Handed to OpenCode it would be in every shell the
122
129
  # agent runs, and OpenCode would list Tinfoil as a plain provider whose attestation nothing checks (as the Node one).
@@ -169,21 +176,43 @@ async def serve(args: list[str]) -> int:
169
176
  tools_env, tools_config, note = await asyncio.to_thread(codetools.code_tools, plugins.opencode_config_dir())
170
177
  if note:
171
178
  _err(note)
179
+ # THIS COMPUTER'S CODING KEYS (codekeys.py; docs/code-account-keys.md): the only model keys witbitz-code uses. A pairing
180
+ # made before them is not served — every call of its account would be refused.
181
+ unkeyed = [p for p in pairings if not codekeys.for_computer(p.get("computerId") or "")]
182
+ for p in unkeyed:
183
+ _err(f'witbitz-code: "{p.get("name")}" → {p.get("account") or "account"} has no coding keys — this computer must be paired again to use your Spaces account: witbitz-code pair')
184
+ pairings = [p for p in pairings if p not in unkeyed]
185
+ if not pairings:
186
+ return 1
187
+ live_keys = codekeys.LiveKeys([p["computerId"] for p in pairings])
188
+ payers = SessionPayers()
189
+ offered = models.offered()
190
+
191
+ def credential_for(req: Any) -> dict | None:
192
+ # Billed to the account whose prompt started the turn (payers.py); a call nobody here started is refused.
193
+ return live_keys.credential(payers.payer(req.header("x-session-id")[:100], req.header("x-parent-session-id")[:100]))
194
+
172
195
  stop = _Stop()
173
196
  child = None
174
197
  proxy = None
175
198
  live: dict = {"connector": None}
199
+ keys_http = httpx.AsyncClient(timeout=30)
176
200
  try:
177
- # THE CONFIDENTIAL-MODEL PROXY (confidential.py): started BEFORE OpenCode, so OpenCode is pointed at it only when it
178
- # runs — and only where TrustedRouter is connected, or the confidential models would be on the menu of a computer
179
- # that cannot call them.
201
+ # THE PROXY (confidential.py): started BEFORE OpenCode, which is always pointed at it — it puts each account's key on
202
+ # the calls of that account's prompts, enforces the confidential models and refuses a model Witbitz does not offer.
180
203
  pport = models.proxy_port_for(port)
181
204
  try:
182
- proxy = await confidential.start_confidential_proxy(port=pport, log=_err, on_progress=lambda ev: live["connector"] and live["connector"].progress(ev))
205
+ proxy = await confidential.start_confidential_proxy(port=pport, log=_err, on_progress=lambda ev: live["connector"] and live["connector"].progress(ev),
206
+ credential_for=credential_for, offered=lambda m: m in offered)
183
207
  except OSError as e:
184
- _err(f"witbitz-code: could not start the confidential-model proxy on 127.0.0.1:{pport} ({e}) — confidential models are not offered")
185
- route = models.proxy_config(proxy.port) if proxy and keys.has_trustedrouter() else None
186
- if not await is_listening(port) and "--no-opencode" not in args:
208
+ _err(f"witbitz-code: could not start the model proxy on 127.0.0.1:{pport} ({e}) — free that port and run serve again")
209
+ return 1
210
+ route = models.proxy_config(proxy.port)
211
+ if await is_listening(port) and "--no-opencode" not in args:
212
+ _err(f"witbitz-code: OpenCode is already running on 127.0.0.1:{port}, started some other way — it cannot use your account's keys.")
213
+ _err(" Close it, then run witbitz-code serve again (it starts OpenCode itself).")
214
+ return 1
215
+ if "--no-opencode" not in args:
187
216
  exe = wizard.find_opencode()
188
217
  if not exe:
189
218
  _err("witbitz-code: OpenCode is not installed (or not on PATH). Run witbitz-code setup, or install it and run serve again:")
@@ -197,13 +226,10 @@ async def serve(args: list[str]) -> int:
197
226
  await asyncio.sleep(0.25)
198
227
  if stop.event.is_set():
199
228
  return 0
200
- if proxy and child is None and "--no-opencode" not in args:
201
- _err("witbitz-code: OpenCode was already running, so its TrustedRouter calls do not go through the confidential-model proxy — restart it with witbitz-code serve")
202
- elif route and child is not None:
203
- _err(f"witbitz-code: confidential models are enforced (min_privacy + verified receipts){' and read images through Tinfoil' if keys.tinfoil_key() else ' — add a Tinfoil key (witbitz-code tinfoil-key) to read images with text-only ones'}")
204
- elif proxy and not keys.has_trustedrouter():
205
- _err("witbitz-code: TrustedRouter is not connected, so Code offers only OpenCode's own models — run witbitz-code trustedrouter-key")
206
- connector = await start_connector(pairings)
229
+ if child is not None:
230
+ _err("witbitz-code: model calls are paid by each account's own coding keys; confidential models are enforced (min_privacy + verified receipts)")
231
+ connector = await start_connector(pairings, payers=payers)
232
+ live["status"] = asyncio.ensure_future(_check_keys_every(live_keys, keys_http))
207
233
  live["connector"] = connector
208
234
  if proxy:
209
235
  connector.set_confidential_sink(proxy.add_confidential) # the page hands down what the account offers today
@@ -212,32 +238,26 @@ async def serve(args: list[str]) -> int:
212
238
  try:
213
239
  return await _wait_for_stop(stop, child)
214
240
  finally:
241
+ live["status"].cancel()
215
242
  await connector.aclose()
216
243
  finally:
217
244
  if child is not None:
218
245
  await _stop_opencode(child)
219
246
  if proxy is not None:
220
247
  await proxy.close()
248
+ await keys_http.aclose()
221
249
  stop.remove()
222
250
 
223
251
 
224
- async def set_key(label: str, check, save, after: str) -> int:
225
- """`trustedrouter-key` / `tinfoil-key`: ask (it shows as *****), check with the provider, save. A rejected key is not saved."""
226
- key = keys.read_line(f"{label} API key (it shows as *****): ", hidden=True)
227
- if not key:
228
- _err("witbitz-code: no key entered — nothing changed")
229
- return 1
230
- if not keys.valid_key_shape(key):
231
- _err("witbitz-code: that does not look like an API key (no spaces, 8–512 characters) — nothing changed")
232
- return 1
233
- v = await check(key)
234
- if v.get("ok") is False:
235
- _err(f"witbitz-code: {v.get('why')} — nothing changed")
236
- return 1
237
- where = save(key)
238
- unchecked = f" without checking it — {v.get('why')}" if v.get("ok") is None else ""
239
- _err(f"witbitz-code: saved in {where} (only you can read it){unchecked}. {after}")
240
- return 0
252
+ async def _check_keys_every(live_keys: codekeys.LiveKeys, http: httpx.AsyncClient) -> None:
253
+ """At start and every few hours: are the keys live (revoked → refused, paused → refused until it is back on)? In the
254
+ background — the keys file is used meanwhile, and a slow server never holds up serving."""
255
+ while True:
256
+ try:
257
+ await live_keys.check(http, _err)
258
+ except Exception as e: # noqa: BLE001 — a reading that failed is asked again next time
259
+ _err(f"witbitz-code: could not check the coding keys ({str(e)[:80]})")
260
+ await asyncio.sleep(codekeys.STATUS_EVERY_S)
241
261
 
242
262
 
243
263
  async def setup(args: list[str]) -> int:
@@ -253,15 +273,67 @@ async def setup(args: list[str]) -> int:
253
273
  return r.get("served", 0) if not r.get("stopped") else 1
254
274
 
255
275
 
276
+ async def uninstall(args: list[str]) -> int:
277
+ yes = "--yes" in args
278
+ if not yes and not sys.stdin.isatty():
279
+ _err("witbitz-code: uninstall asks before it removes anything — run it in a terminal, or pass --yes")
280
+ return 2
281
+ from . import account as acct, net # noqa: PLC0415
282
+
283
+ client = net.new_client()
284
+ call = net.make_api_call(client)
285
+ closed = {"done": False}
286
+
287
+ async def close_client() -> None:
288
+ if not closed["done"]:
289
+ closed["done"] = True
290
+ await client.aclose()
291
+
292
+ async def remove_package(info: dict) -> dict:
293
+ await close_client() # before pipx deletes the files it would load
294
+ return uninstall_mod.remove_package(info)
295
+
296
+ async def unpair(p: dict) -> dict:
297
+ # This computer's coding keys go back first (codekeys.py) — the signing key that can return them is in the folder
298
+ # uninstall removes.
299
+ entry = codekeys.for_computer(p["computerId"])
300
+ if entry and not await codekeys.revoke(client, entry):
301
+ return {"ok": False, "why": "could not give this computer's coding keys back"}
302
+ if entry:
303
+ codekeys.forget(p["computerId"])
304
+ r = await acct.unpublish_computer(call=call, idx=p["idx"], computer_id=p["computerId"])
305
+ if r.get("ok"):
306
+ pp.write_pairings(pp.remove_pairings(pp.read_pairings(), p.get("account") or "")["doc"])
307
+ return r
308
+
309
+ try:
310
+ r = await uninstall_mod.run_uninstall(uninstall_mod.Deps(
311
+ say=_err, ask=lambda q: keys.read_line(q), yes=yes,
312
+ remove_keys="--remove-keys" in args, remove_notes="--remove-notes" in args,
313
+ remove_sessions="--remove-sessions" in args, remove_opencode="--remove-opencode" in args,
314
+ unpair=unpair, has_keys=uninstall_mod.saved_keys, delete_keys=uninstall_mod.delete_saved_keys,
315
+ notes=uninstall_mod.notes_info, owned_files=uninstall_mod.owned_files, sessions=uninstall_mod.sessions_info,
316
+ delete_sessions=uninstall_mod.delete_sessions, remove_package=remove_package))
317
+ finally:
318
+ await close_client()
319
+ if r.get("done"):
320
+ for d in (pp.pairings_path().parent.parent, Path.home() / ".cache" / "witbitz"): # emptied by now, unless the person keeps something there
321
+ try:
322
+ d.rmdir()
323
+ except OSError:
324
+ pass
325
+ return 0 if r.get("done") else 1
326
+
327
+
256
328
  async def _main(argv: list[str]) -> int:
257
329
  cmd, rest = (argv[0], argv[1:]) if argv else ("", [])
330
+ if cmd == "uninstall":
331
+ return await uninstall(rest)
258
332
  if cmd == "setup":
259
333
  return await setup(rest)
260
- if cmd == "trustedrouter-key":
261
- return await set_key("TrustedRouter", keys.check_trustedrouter_key, keys.save_trustedrouter_key,
262
- "Restart witbitz-code serve so OpenCode picks it up.")
263
- if cmd == "tinfoil-key":
264
- return await set_key("Tinfoil", keys.check_tinfoil_key, keys.save_tinfoil_key, "It is used from the next message — no restart needed.")
334
+ if cmd in ("trustedrouter-key", "tinfoil-key"):
335
+ _err("witbitz-code: keys now come from your Spaces account — this computer gets its own when you pair it: witbitz-code pair")
336
+ return 2
265
337
  if cmd == "pair":
266
338
  return await pair_cmd.main(rest)
267
339
  if cmd == "serve":