witbitz-code 1.14.0__tar.gz → 1.15.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.
Files changed (121) hide show
  1. {witbitz_code-1.14.0/src/witbitz_code.egg-info → witbitz_code-1.15.0}/PKG-INFO +1 -1
  2. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/pyproject.toml +1 -1
  3. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/connector.py +7 -4
  5. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +32 -4
  6. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +2 -1
  7. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/outputs.py +5 -3
  8. {witbitz_code-1.14.0 → witbitz_code-1.15.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  9. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_outputs.py +30 -1
  10. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/LICENSE +0 -0
  11. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/README.md +0 -0
  12. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/setup.cfg +0 -0
  13. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/__main__.py +0 -0
  14. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/_js.py +0 -0
  15. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/account.py +0 -0
  16. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/app.py +0 -0
  17. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/asks.py +0 -0
  18. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/attachments.py +0 -0
  19. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/auto.py +0 -0
  20. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/auto_runner.py +0 -0
  21. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/browse.py +0 -0
  22. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/browsetree.py +0 -0
  23. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/cli.py +0 -0
  24. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/codekeys.py +0 -0
  25. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/codetools.py +0 -0
  26. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/compress.py +0 -0
  27. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/computers.py +0 -0
  28. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/confidential.py +0 -0
  29. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/confidential_models.json +0 -0
  30. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/devicelink.py +0 -0
  31. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/dirs.py +0 -0
  32. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/files.py +0 -0
  33. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/folders.py +0 -0
  34. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/gateway.py +0 -0
  35. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  36. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  37. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/images.py +0 -0
  38. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/keys.py +0 -0
  39. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/link.py +0 -0
  40. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/mkdir.py +0 -0
  41. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/models.py +0 -0
  42. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/net.py +0 -0
  43. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/offered_models.json +0 -0
  44. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  45. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
  46. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
  47. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
  48. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  49. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  50. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  51. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/pair.py +0 -0
  52. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/pairings.py +0 -0
  53. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/payers.py +0 -0
  54. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/plugins.py +0 -0
  55. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/policy.py +0 -0
  56. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/ports.py +0 -0
  57. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/qr.py +0 -0
  58. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/receipt.py +0 -0
  59. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/relay.py +0 -0
  60. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/remind.py +0 -0
  61. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/seccheck.py +0 -0
  62. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/seen.py +0 -0
  63. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/selfupdate.py +0 -0
  64. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/tinfoil_read.py +0 -0
  65. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/tools_catalog.json +0 -0
  66. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/tools_probe.py +0 -0
  67. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/toolsetup.py +0 -0
  68. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/ui.py +0 -0
  69. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/ui_static/app.css +0 -0
  70. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/ui_static/app.js +0 -0
  71. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/ui_static/index.html +0 -0
  72. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/uitools.py +0 -0
  73. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/uninstall.py +0 -0
  74. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/winagent.py +0 -0
  75. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/winbg.py +0 -0
  76. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/winconsole.py +0 -0
  77. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/wizard.py +0 -0
  78. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code/wslstart.py +0 -0
  79. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
  80. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  81. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  82. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code.egg-info/requires.txt +0 -0
  83. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
  84. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_asks.py +0 -0
  85. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_attachments.py +0 -0
  86. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_auto.py +0 -0
  87. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_browse.py +0 -0
  88. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_browsetree.py +0 -0
  89. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_cli.py +0 -0
  90. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_codekeys.py +0 -0
  91. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_codetools.py +0 -0
  92. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_confidential.py +0 -0
  93. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_connector_e2e.py +0 -0
  94. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_devicelink.py +0 -0
  95. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_dirs.py +0 -0
  96. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_files.py +0 -0
  97. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_files_and_compress.py +0 -0
  98. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_folders.py +0 -0
  99. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_images.py +0 -0
  100. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_pair_flows.py +0 -0
  101. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_plugins.py +0 -0
  102. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_policy.py +0 -0
  103. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_ports.py +0 -0
  104. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_ports_win.py +0 -0
  105. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_registry.py +0 -0
  106. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_relay_codec.py +0 -0
  107. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_remind.py +0 -0
  108. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_seccheck.py +0 -0
  109. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_selfupdate.py +0 -0
  110. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_setup.py +0 -0
  111. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_tools_probe.py +0 -0
  112. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_toolsetup.py +0 -0
  113. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_ui.py +0 -0
  114. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_ui_page.py +0 -0
  115. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_uitools.py +0 -0
  116. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_uninstall.py +0 -0
  117. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_winagent.py +0 -0
  118. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_winbg.py +0 -0
  119. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_winconsole.py +0 -0
  120. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_windows_files.py +0 -0
  121. {witbitz_code-1.14.0 → witbitz_code-1.15.0}/tests/test_wslstart.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.14.0
3
+ Version: 1.15.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.14.0"
7
+ version = "1.15.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"
@@ -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.14.0"
7
+ __version__ = "1.15.0"
@@ -164,9 +164,11 @@ class PairingServer:
164
164
  notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
165
165
  model_url: str = "", remind_times: dict | None = None,
166
166
  browse_probe: Callable[[], dict | None] | None = None,
167
- browse_private: Callable[[bool], Awaitable[bool]] | None = None) -> None:
167
+ browse_private: Callable[[bool], Awaitable[bool]] | None = None, screens_root: Path | None = None) -> None:
168
168
  self.pairing = pairing
169
169
  self._browse_private = browse_private # private browsing on/off (browse.py LocalBrowser.set_private)
170
+ # the agent's browser_screenshot writes <home>/.witbitz/screens/<session> (browse-cdp.mjs), served beside the session's folder
171
+ self._screens_root = Path(screens_root) if screens_root else Path.home() / ".witbitz" / "screens"
170
172
  # THE AGENT'S BROWSER ON THIS COMPUTER (browse.py): its /status rides the hello; a change is announced within 2 s
171
173
  self._browse_probe = browse_probe
172
174
  self._browse_said = "" # the last browse report the page was told, as JSON
@@ -413,7 +415,8 @@ class PairingServer:
413
415
  await reply(404, {"error": "no such session on this computer"})
414
416
  return
415
417
  stat_only = q.get("stat") == "1"
416
- st, b = serve_output(directory=session["directory"], path=q.get("path"), stat=stat_only)
418
+ st, b = serve_output(directory=session["directory"], path=q.get("path"), stat=stat_only,
419
+ shots=(self._screens_root / sid).as_posix())
417
420
  self._log_output({"at": int(time.time() * 1000), "session": sid, "digest": hashlib.sha256(q.get("path", "").encode()).hexdigest(),
418
421
  "stat": stat_only, "st": st})
419
422
  await reply(st, b)
@@ -1007,7 +1010,7 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
1007
1010
  notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
1008
1011
  model_url: str = "", remind_times: dict | None = None,
1009
1012
  browse_probe: Callable[[], dict | None] | None = None,
1010
- browse_private: Callable[[bool], Awaitable[bool]] | None = None) -> Connector:
1013
+ browse_private: Callable[[bool], Awaitable[bool]] | None = None, screens_root: Path | None = None) -> Connector:
1011
1014
  """Serve the given pairings until stop()/aclose(). Options exist for tests: flush_ms, log, client, connect,
1012
1015
  request_timeout_ms, max_senders, max_response_bytes, auto_dir, auto_poll_ms."""
1013
1016
  client = client or local_client()
@@ -1023,7 +1026,7 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
1023
1026
  max_senders=max_senders, max_response_bytes=max_response_bytes, auto_dir=auto_dir, auto_poll_ms=auto_poll_ms,
1024
1027
  attach_root=attach_root, attach_max_file_bytes=attach_max_file_bytes, asks_reconcile_ms=asks_reconcile_ms,
1025
1028
  notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers, model_url=model_url, remind_times=remind_times,
1026
- browse_probe=browse_probe, browse_private=browse_private)
1029
+ browse_probe=browse_probe, browse_private=browse_private, screens_root=screens_root)
1027
1030
  await s.start()
1028
1031
  servers.append(s)
1029
1032
  return Connector(servers, client, payers)
@@ -101,7 +101,24 @@ async function snapshot (c, limit = 120) {
101
101
  // The refs are only meaningful next to the snapshot that made them, so they travel with it rather than living in
102
102
  // a cache that a page navigation would quietly invalidate.
103
103
  const REFS_FILE = (process.env.TMPDIR || '/tmp') + '/wb-browse-refs.json'
104
- const { writeFileSync, readFileSync } = await import('node:fs')
104
+ const { writeFileSync, readFileSync, mkdirSync, readdirSync, statSync, rmSync } = await import('node:fs')
105
+ const { join } = await import('node:path')
106
+ const { homedir } = await import('node:os')
107
+
108
+ // THE SCREENSHOTS' FOLDER: <home>/.witbitz/screens/<session> (spaces/public/codeOutputs.js SHOTS_DIR) — the agent's home, as
109
+ // the connector reads it (on a cloud computer /rw/home, on a laptop the person's own). WB_BROWSE_SCREENS moves the root.
110
+ const SHOTS_ROOT = process.env.WB_BROWSE_SCREENS || join(homedir(), '.witbitz', 'screens')
111
+ const shotsDir = (sid) => join(SHOTS_ROOT, sid)
112
+ const SHOT_DAYS = 30
113
+ /** A session's pictures go 30 days after its last one — attachments' rule (tools/code-attachments.mjs). */
114
+ function pruneShots () {
115
+ try {
116
+ for (const n of readdirSync(SHOTS_ROOT)) {
117
+ const d = join(SHOTS_ROOT, n)
118
+ try { if (Date.now() - statSync(d).mtimeMs > SHOT_DAYS * 86400000) rmSync(d, { recursive: true, force: true }) } catch { /* */ }
119
+ }
120
+ } catch { /* none yet */ }
121
+ }
105
122
 
106
123
  // ★ A MISSING refs file is the SAME situation as a stale ref: the agent has not taken a snapshot yet. Letting
107
124
  // readFileSync throw turned that into "ENOENT: no such file or directory", which tells a model nothing it can act
@@ -226,11 +243,22 @@ try {
226
243
  await new Promise((r) => setTimeout(r, 400))
227
244
  } else if (op === 'screenshot') {
228
245
  const { data } = await c.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 })
229
- const path = args.path || ((process.env.TMPDIR || '/tmp') + '/wb-browse-' + Date.now() + '.jpg')
230
- writeFileSync(path, Buffer.from(data, 'base64'))
246
+ // ★ INTO WITBITZ'S OWN FOLDER, ONE PER SESSION (the owner, 2026-10-25: "how can I get a screen snapshot in the chat"):
247
+ // <home>/.witbitz/screens/<session>/ — the connector serves that folder for that session as well as the session's own
248
+ // (tools/code-outputs.mjs), so the picture shows under the agent's answer, and nothing lands in the project. In a temp
249
+ // folder the page could never be shown it: the connector serves nothing outside a session's folders.
250
+ let path = args.path
251
+ if (!path) {
252
+ const sid = /^[A-Za-z0-9_-]{1,128}$/.test(String(args.session || '')) ? args.session : 'no-session'
253
+ const dir = shotsDir(sid)
254
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
255
+ pruneShots()
256
+ path = join(dir, 'screen-' + new Date().toISOString().replace(/[:.]/g, '-') + '.jpg')
257
+ }
258
+ writeFileSync(path, Buffer.from(data, 'base64'), { mode: 0o600 })
231
259
  const { lines } = await snapshot(c, 0).catch(() => ({ lines: [] }))
232
260
  c.close()
233
- out({ ok: true, path, note: 'a picture for a person to look at; act from a snapshot, not from this' })
261
+ out({ ok: true, path, shot: true, note: 'a picture for a person to look at; act from a snapshot, not from this' })
234
262
  } else if (op !== 'snapshot') {
235
263
  out({ error: 'unknown op: ' + op })
236
264
  }
@@ -43,6 +43,7 @@ function render (j) {
43
43
  if (!j) return 'The browser did not answer.'
44
44
  if (j.error) return 'Browser: ' + j.error
45
45
  if (j.closed) return 'The browser is closed — its tabs and any sign-ins are gone. browser_open or browser_navigate starts a fresh one.'
46
+ if (j.shot && j.path) return 'Saved a picture of the page: ' + j.path + ' — the person sees it under your answer.'
46
47
  if (j.cleared) return 'Cleared: the browser\'s history, cookies, cache and site data are gone, and a fresh browser is open — browser_navigate to go on.'
47
48
  if (j.ok && !j.title && !j.url && !(j.elements && j.elements.length) && !j.path) return 'The browser is open' + (j.headed === false ? ' (headless — see it with browser_screenshot)' : j.headed ? ' (a window on this computer\'s screen)' : '') + '. Navigate to a URL next.' // the open op: nothing on screen yet
48
49
  const head = [j.title ? 'Title: ' + j.title : null, j.url ? 'URL: ' + j.url : null].filter(Boolean).join('\n')
@@ -157,7 +158,7 @@ export const WitbitzBrowser = async (ctx = {}) => {
157
158
  browser_screenshot: tool({
158
159
  description: 'Save a picture of the page for the PERSON to look at, and return its path. Do not use this to decide what to click — use a snapshot.',
159
160
  args: {},
160
- execute: () => run('screenshot', {}),
161
+ execute: (args, context) => run('screenshot', { session: (context && context.sessionID) || '' }),
161
162
  }),
162
163
  },
163
164
  }
@@ -72,15 +72,17 @@ def _answer(st: int, o: dict) -> tuple[int, str]:
72
72
 
73
73
 
74
74
  def serve_output(*, directory: Any, path: Any, stat: bool = False, max_bytes: int = MAX_SERVE_BYTES,
75
- any_kind: bool = False) -> tuple[int, str]:
75
+ any_kind: bool = False, shots: str = "") -> tuple[int, str]:
76
76
  """One produced file (or its facts, with `stat`) for the page: (status, JSON body) in the connector's reply shape.
77
77
 
78
78
  `any_kind`: the Files sheet's Download (files.py) — any regular file, not only a kind the page previews; every other
79
- check stays (⇄ tools/code-outputs.mjs)."""
79
+ check stays (⇄ tools/code-outputs.mjs). `shots`: the session's screenshots folder (<home>/.witbitz/screens/<session>,
80
+ browse-cdp.mjs) — served beside its folder with every same check."""
80
81
  if not isinstance(directory, str) or not directory.startswith("/") or not valid_output_path(path):
81
82
  return _answer(400, {"error": "not a file in this session"})
82
- d = re.sub(r"/+\Z", "", posix_normalize(directory))
83
83
  a = posix_normalize(path)
84
+ shot_dir = re.sub(r"/+\Z", "", posix_normalize(shots)) if isinstance(shots, str) and shots.startswith("/") else ""
85
+ d = shot_dir if shot_dir and a.startswith(shot_dir + "/") else re.sub(r"/+\Z", "", posix_normalize(directory))
84
86
  if not d or not a.startswith(d + "/"):
85
87
  return _answer(403, {"error": "that file is outside the session's folder"})
86
88
  if _sensitive_path(a[len(d):]):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.14.0
3
+ Version: 1.15.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
@@ -67,11 +67,30 @@ def _requests(d: Path, outside: Path, root: Path) -> list[dict]:
67
67
  {"directory": str(d), "path": str(d / "out" / "big.pdf"), "stat": False, "maxBytes": 1024},
68
68
  {"directory": str(d), "path": str(d / "out" / "big.pdf"), "stat": True, "maxBytes": 1024},
69
69
  {"directory": str(d), "path": None, "stat": False}]
70
+ # the agent's screenshots: <home>/.witbitz/screens/<session>, served for that session only (2026-10-25)
71
+ mine, theirs = root / "screens" / "ses_A", root / "screens" / "ses_B"
72
+ reqs += [{"directory": str(d), "path": str(mine / "screen-1.jpg"), "stat": False, "shots": str(mine)},
73
+ {"directory": str(d), "path": str(theirs / "screen-2.jpg"), "stat": False, "shots": str(mine)},
74
+ {"directory": str(d), "path": str(mine / "screen-1.jpg"), "stat": False},
75
+ {"directory": str(d), "path": str(mine / "out.pdf"), "stat": False, "shots": str(mine)},
76
+ {"directory": str(d), "path": str(d / "out" / "chart.png"), "stat": False, "shots": str(mine)}]
70
77
  return reqs
71
78
 
72
79
 
80
+ def _screens(root: Path, outside: Path) -> None:
81
+ mine, theirs = root / "screens" / "ses_A", root / "screens" / "ses_B"
82
+ mine.mkdir(parents=True)
83
+ theirs.mkdir(parents=True)
84
+ for p, b in [(mine / "screen-1.jpg", b"JPEGA"), (theirs / "screen-2.jpg", b"JPEGB")]:
85
+ p.write_bytes(b)
86
+ os.utime(p, (1789000000, 1789000000))
87
+ (mine / "out.pdf").symlink_to(outside / "report.pdf")
88
+
89
+
73
90
  def _py(r: dict) -> list:
74
91
  kw = {"max_bytes": r["maxBytes"]} if "maxBytes" in r else {}
92
+ if "shots" in r:
93
+ kw["shots"] = r["shots"]
75
94
  st, b = serve_output(directory=r["directory"], path=r["path"], stat=r["stat"], **kw)
76
95
  return [st, json.loads(b)]
77
96
 
@@ -92,12 +111,13 @@ def test_what_the_python_connector_serves(tmp_path):
92
111
  @requires_node
93
112
  def test_parity_with_the_js_connector(tmp_path):
94
113
  d, outside = _tree(tmp_path)
114
+ _screens(tmp_path, outside)
95
115
  reqs = _requests(d, outside, tmp_path)
96
116
  js = run_node("""
97
117
  import { serveOutput } from '@TOOLS@/code-outputs.mjs'
98
118
  import { outputMime } from '@PUBLIC@/codeOutputs.js'
99
119
  OUT({
100
- served: INPUT.reqs.map((r) => { const o = serveOutput({ directory: r.directory, path: r.path, stat: r.stat, ...(r.maxBytes ? { maxBytes: r.maxBytes } : {}) }); return [o.st, JSON.parse(o.b)] }),
120
+ served: INPUT.reqs.map((r) => { const o = serveOutput({ directory: r.directory, path: r.path, stat: r.stat, ...(r.maxBytes ? { maxBytes: r.maxBytes } : {}), ...(r.shots ? { shots: r.shots } : {}) }); return [o.st, JSON.parse(o.b)] }),
101
121
  mimes: INPUT.exts.map((e) => outputMime('/a/x.' + e)),
102
122
  })
103
123
  """, {"reqs": reqs, "exts": EXTS})
@@ -108,3 +128,12 @@ def test_parity_with_the_js_connector(tmp_path):
108
128
 
109
129
  EXTS = ["pdf", "PNG", "jpg", "jpeg", "gif", "webp", "csv", "tsv", "md", "markdown", "txt", "docx", "doc", "xlsx", "xls", "pptx", "ppt",
110
130
  "odt", "ods", "odp", "rtf", "zip", "epub", "heic", "svg", "html", "htm", "mp3", "wav", "m4a", "mp4", "mov", "ts", "json", ""]
131
+
132
+
133
+ def test_a_screenshot_is_served_for_its_own_session_only(tmp_path):
134
+ d, outside = _tree(tmp_path)
135
+ _screens(tmp_path, outside)
136
+ mine = tmp_path / "screens" / "ses_A"
137
+ assert _py({"directory": str(d), "path": str(mine / "screen-1.jpg"), "stat": False, "shots": str(mine)})[0] == 200
138
+ assert _py({"directory": str(d), "path": str(tmp_path / "screens" / "ses_B" / "screen-2.jpg"), "stat": False, "shots": str(mine)})[0] == 403
139
+ assert _py({"directory": str(d), "path": str(mine / "out.pdf"), "stat": False, "shots": str(mine)})[0] == 403, "a link out"
File without changes
File without changes
File without changes