pycodex-ws 0.4.0__tar.gz → 0.4.2__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 (41) hide show
  1. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/PKG-INFO +19 -10
  2. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/README.md +15 -5
  3. pycodex_ws-0.4.2/pycodex_router/__init__.py +1 -0
  4. pycodex_ws-0.4.2/pycodex_router/__main__.py +3 -0
  5. pycodex_ws-0.4.2/pycodex_router/browser.py +107 -0
  6. pycodex_ws-0.4.2/pycodex_router/client.py +255 -0
  7. pycodex_ws-0.4.2/pycodex_router/index.html +438 -0
  8. pycodex_ws-0.4.2/pycodex_router/protocol.py +179 -0
  9. pycodex_ws-0.4.2/pycodex_router/server.py +624 -0
  10. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/pycodex_ws/__init__.py +9 -0
  11. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/pyproject.toml +8 -7
  12. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/app.py +90 -20
  13. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/content_views.mjs +3 -3
  14. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/content_views.mjs +6 -4
  15. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/workspace.html +110 -24
  16. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/workspace_views.py +7 -2
  17. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/workspaces.html +155 -158
  18. pycodex_ws-0.4.0/pycodex_ws/ipython.py +0 -157
  19. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/.gitignore +0 -0
  20. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/LICENSE +0 -0
  21. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/pycodex_ws/feishu_card.py +0 -0
  22. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/pycodex_ws/feishu_link.py +0 -0
  23. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/pycodex_ws/feishu_oauth.py +0 -0
  24. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/__init__.py +0 -0
  25. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/__main__.py +0 -0
  26. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/content_views.LICENSE.txt +0 -0
  27. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/content_views.css +0 -0
  28. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/editor.css +0 -0
  29. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/editor.mjs +0 -0
  30. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/.gitignore +0 -0
  31. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/build.mjs +0 -0
  32. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/composer.mjs +0 -0
  33. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/content_views.css +0 -0
  34. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/editor.css +0 -0
  35. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/editor.mjs +0 -0
  36. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/editor_context.mjs +0 -0
  37. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/package-lock.json +0 -0
  38. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/package.json +0 -0
  39. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/view_labels.mjs +0 -0
  40. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/owned_process.py +0 -0
  41. {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/workspaces.py +0 -0
@@ -1,21 +1,20 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pycodex-ws
3
- Version: 0.4.0
4
- Summary: Browser workspace, Feishu and IPython integrations for python-codex
3
+ Version: 0.4.2
4
+ Summary: Browser workspace and optional Feishu integration for python-codex
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.7
7
- Requires-Dist: ipython>=7
8
- Requires-Dist: lark-oapi
9
7
  Requires-Dist: pillow>=9.0.0
10
- Requires-Dist: python-codex==0.4.0
8
+ Requires-Dist: python-codex==0.4.2
9
+ Requires-Dist: websockets<16,>=11
11
10
  Description-Content-Type: text/markdown
12
11
 
13
12
  # pycodex-ws
14
13
 
15
14
  `pycodex-ws` provides the browser workspace, Canvas and file/diff viewer,
16
- Feishu integration and IPython tools for
15
+ and optional Feishu integration for
17
16
  [`python-codex`](https://pypi.org/project/python-codex/). It installs the exact
18
- matching core version and all integration dependencies.
17
+ matching core version.
19
18
 
20
19
  Pillow is a direct dependency of this package for image preview validation.
21
20
  Core can run without it; `view_image` then sends original image bytes.
@@ -53,8 +52,18 @@ its range, or only the cursor position. Context appears as a separate block amon
53
52
  text; click a block to edit or remove it before sending.
54
53
 
55
54
  Workspace sessions include `/link` and `/unlink` for Feishu. Python callers use
56
- `pycodex_ws.build_runtime(agent)` for the same assembly, or
57
- `from pycodex_ws.ipython import ipython_agent` inside an IPython session.
55
+ `pycodex_ws.build_runtime(agent)` for the same assembly.
56
+
57
+ The Feishu SDK is optional and is not installed automatically. To enable
58
+ `/link`, install it in the same Python environment as the workspace:
59
+
60
+ ```bash
61
+ python3 -m pip install lark-oapi
62
+ ```
63
+
64
+ `/link` checks that `lark_oapi` can be imported before creating a connection.
65
+ If it cannot, the command reports that it is unavailable and shows the manual
66
+ installation command. Other workspace features work without the SDK.
58
67
 
59
68
  For initial Feishu user authorization, run the module included in this package:
60
69
 
@@ -105,6 +114,6 @@ root to install both, then `uv run pycodex-ws --help`. Build this package with
105
114
  `uv build packages/pycodex-ws`.
106
115
 
107
116
  Run `uv run --dev pytest packages/pycodex-ws/tests` from the repository root for
108
- workspace, Feishu, IPython and cross-frontend integration tests. Core tests live
117
+ workspace, Feishu and cross-frontend integration tests. Core tests live
109
118
  in the root `tests/` directory and use the independent `test` dependency group.
110
119
  `uv run --dev pytest` runs both suites; see [test documentation](../../tests/TESTS.md).
@@ -1,9 +1,9 @@
1
1
  # pycodex-ws
2
2
 
3
3
  `pycodex-ws` provides the browser workspace, Canvas and file/diff viewer,
4
- Feishu integration and IPython tools for
4
+ and optional Feishu integration for
5
5
  [`python-codex`](https://pypi.org/project/python-codex/). It installs the exact
6
- matching core version and all integration dependencies.
6
+ matching core version.
7
7
 
8
8
  Pillow is a direct dependency of this package for image preview validation.
9
9
  Core can run without it; `view_image` then sends original image bytes.
@@ -41,8 +41,18 @@ its range, or only the cursor position. Context appears as a separate block amon
41
41
  text; click a block to edit or remove it before sending.
42
42
 
43
43
  Workspace sessions include `/link` and `/unlink` for Feishu. Python callers use
44
- `pycodex_ws.build_runtime(agent)` for the same assembly, or
45
- `from pycodex_ws.ipython import ipython_agent` inside an IPython session.
44
+ `pycodex_ws.build_runtime(agent)` for the same assembly.
45
+
46
+ The Feishu SDK is optional and is not installed automatically. To enable
47
+ `/link`, install it in the same Python environment as the workspace:
48
+
49
+ ```bash
50
+ python3 -m pip install lark-oapi
51
+ ```
52
+
53
+ `/link` checks that `lark_oapi` can be imported before creating a connection.
54
+ If it cannot, the command reports that it is unavailable and shows the manual
55
+ installation command. Other workspace features work without the SDK.
46
56
 
47
57
  For initial Feishu user authorization, run the module included in this package:
48
58
 
@@ -93,6 +103,6 @@ root to install both, then `uv run pycodex-ws --help`. Build this package with
93
103
  `uv build packages/pycodex-ws`.
94
104
 
95
105
  Run `uv run --dev pytest packages/pycodex-ws/tests` from the repository root for
96
- workspace, Feishu, IPython and cross-frontend integration tests. Core tests live
106
+ workspace, Feishu and cross-frontend integration tests. Core tests live
97
107
  in the root `tests/` directory and use the independent `test` dependency group.
98
108
  `uv run --dev pytest` runs both suites; see [test documentation](../../tests/TESTS.md).
@@ -0,0 +1 @@
1
+ """Reverse connections for pycodex workspaces."""
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,107 @@
1
+ """Browser access checks for trusted devices sharing a router origin."""
2
+
3
+ import ipaddress
4
+ import re
5
+ from urllib.parse import urlencode, urlsplit
6
+
7
+ from starlette.exceptions import HTTPException
8
+ from starlette.requests import HTTPConnection
9
+ from starlette.responses import JSONResponse, RedirectResponse
10
+
11
+ from .protocol import route_path
12
+
13
+
14
+ def request_origin(connection):
15
+ try:
16
+ parsed = urlsplit("//" + connection.headers["host"])
17
+ port = parsed.port
18
+ except ValueError:
19
+ raise HTTPException(400, "Invalid router host.")
20
+ host = parsed.hostname
21
+ if (
22
+ not host
23
+ or parsed.username is not None
24
+ or parsed.password is not None
25
+ or parsed.path
26
+ or parsed.query
27
+ or parsed.fragment
28
+ ):
29
+ raise HTTPException(400, "Invalid router host.")
30
+ try:
31
+ ipaddress.ip_address(host)
32
+ except ValueError:
33
+ if len(host) > 253 or any(
34
+ not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
35
+ for label in host.split(".")
36
+ ):
37
+ raise HTTPException(400, "Invalid router host.")
38
+ scheme = "https" if connection.scope["scheme"] in {"https", "wss"} else "http"
39
+ suffix = "" if port in (None, 443 if scheme == "https" else 80) else ":" + str(port)
40
+ authority = "[" + host + "]" if ":" in host else host
41
+ return scheme + "://" + authority + suffix
42
+
43
+
44
+ class BrowserAccess:
45
+ def __init__(self, router, app):
46
+ self.router = router
47
+ self.app = app
48
+
49
+ async def __call__(self, scope, receive, send):
50
+ connection = HTTPConnection(scope)
51
+ origin = request_origin(connection)
52
+
53
+ async def secured_send(message):
54
+ if message["type"] == "http.response.start":
55
+ message = dict(
56
+ message,
57
+ headers=list(message["headers"])
58
+ + [
59
+ (b"content-security-policy", b"frame-ancestors 'self'"),
60
+ (b"x-frame-options", b"SAMEORIGIN"),
61
+ (b"x-content-type-options", b"nosniff"),
62
+ (b"referrer-policy", b"no-referrer"),
63
+ (b"permissions-policy", b"document-domain=()"),
64
+ (b"origin-agent-cluster", b"?1"),
65
+ ],
66
+ )
67
+ await send(message)
68
+
69
+ try:
70
+ if connection.client.host in self.router.blocked_ips:
71
+ raise HTTPException(
72
+ 403,
73
+ "Too many incorrect passwords. This IP is blocked "
74
+ "until the router restarts.",
75
+ )
76
+ if connection.headers.get("origin") not in (None, origin):
77
+ raise HTTPException(
78
+ 403, "Cross-origin router requests are not allowed."
79
+ )
80
+ await self.app(scope, receive, secured_send)
81
+ except HTTPException as exc:
82
+ if scope["type"] == "websocket":
83
+ await send({"type": "websocket.close", "code": 1008})
84
+ else:
85
+ await JSONResponse({"error": exc.detail}, exc.status_code)(
86
+ scope, receive, secured_send
87
+ )
88
+
89
+
90
+ async def require_login(scope, receive, send):
91
+ connection = HTTPConnection(scope)
92
+ if scope["type"] == "websocket":
93
+ await send({"type": "websocket.close", "code": 1008})
94
+ elif "/api/" in route_path(scope) or scope["method"] not in {"GET", "HEAD"}:
95
+ await JSONResponse({"error": "authentication required"}, 401)(
96
+ scope, receive, send
97
+ )
98
+ else:
99
+ node_id = scope["path_params"]["node_id"]
100
+ prefix = scope.get("root_path", "")
101
+ root = prefix[: -len("/n/" + node_id)]
102
+ target = prefix + route_path(scope)
103
+ if connection.url.query:
104
+ target += "?" + connection.url.query
105
+ await RedirectResponse(
106
+ root + "/login?" + urlencode({"next": target}), 303
107
+ )(scope, receive, send)
@@ -0,0 +1,255 @@
1
+ import asyncio
2
+ import logging
3
+ import secrets
4
+ import socket
5
+ from contextlib import asynccontextmanager
6
+
7
+ from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidStatusCode
8
+ from websockets.legacy.client import connect
9
+
10
+ from .protocol import decode, device_id, encode, parse_target, send_event, tunnel_url
11
+
12
+
13
+ class NodeIdInUse(Exception):
14
+ pass
15
+
16
+
17
+ class RouterClient:
18
+ def __init__(
19
+ self,
20
+ app,
21
+ target,
22
+ name,
23
+ port,
24
+ workspaces,
25
+ refresh_seconds=2.0,
26
+ reconnect_seconds=1.0,
27
+ ):
28
+ self.app = app
29
+ address, self._registration_key = parse_target(target)
30
+ self.control_url = tunnel_url(address, "control")
31
+ self.data_url = tunnel_url(address, "data")
32
+ self._identity_key = secrets.token_hex(32)
33
+ self.node_id = device_id(self._identity_key)
34
+ self.name = name
35
+ self.port = port
36
+ self.workspaces = workspaces
37
+ self.refresh_seconds = refresh_seconds
38
+ self.reconnect_seconds = reconnect_seconds
39
+ self.connected = False
40
+ self._task = None
41
+ self._channels = set()
42
+ self._requests = set()
43
+ self._logger = logging.getLogger(__name__)
44
+
45
+ def _connect(self, url):
46
+ return connect(
47
+ url,
48
+ extra_headers={"Authorization": "Bearer " + self._registration_key},
49
+ open_timeout=10,
50
+ close_timeout=2,
51
+ ping_interval=20,
52
+ ping_timeout=20,
53
+ max_size=32 * 1024 * 1024,
54
+ max_queue=4,
55
+ )
56
+
57
+ async def start(self):
58
+ if self._task is not None:
59
+ raise RuntimeError("router client is already started")
60
+ self._task = asyncio.create_task(self._run())
61
+
62
+ async def close(self):
63
+ if self._task is not None:
64
+ self._task.cancel()
65
+ await asyncio.gather(self._task, return_exceptions=True)
66
+ self._task = None
67
+ await self._close_channels()
68
+ if self._requests:
69
+ await asyncio.gather(*tuple(self._requests))
70
+
71
+ async def _close_channels(self):
72
+ await asyncio.gather(*(channel.close() for channel in tuple(self._channels)))
73
+
74
+ async def _run(self):
75
+ delay = self.reconnect_seconds
76
+ while True:
77
+ failure = None
78
+ try:
79
+ async with self._connect(self.control_url) as channel:
80
+ await self._register(channel)
81
+ self.connected = True
82
+ delay = self.reconnect_seconds
83
+ self._logger.info("Workspace connected to router as %s", self.name)
84
+ await self._session(channel)
85
+ except asyncio.CancelledError:
86
+ # close() performs cleanup after stopping this loop.
87
+ raise
88
+ except Exception as exc:
89
+ failure = exc
90
+ finally:
91
+ self.connected = False
92
+
93
+ try:
94
+ await self._close_channels()
95
+ except asyncio.CancelledError:
96
+ raise
97
+ except Exception as exc:
98
+ failure = exc
99
+
100
+ # Retry every failure, including registration and connection cleanup.
101
+ # Peer-supplied close reasons and URLs must not enter the log.
102
+ reason = (
103
+ type(failure).__name__ if failure is not None else "connection closed"
104
+ )
105
+ if isinstance(failure, ConnectionClosed) and failure.rcvd is not None:
106
+ reason += ", close_code={0}".format(failure.rcvd.code)
107
+ elif isinstance(failure, InvalidStatusCode):
108
+ reason += ", status_code={0}".format(failure.status_code)
109
+ self._logger.warning(
110
+ "Router node %s disconnected (%s); retrying in %g seconds",
111
+ self.name,
112
+ reason,
113
+ delay,
114
+ )
115
+ await asyncio.sleep(delay)
116
+ delay = min(delay * 2, 30)
117
+
118
+ async def _register(self, channel):
119
+ await channel.send(
120
+ encode(
121
+ {
122
+ "type": "register",
123
+ "version": 2,
124
+ "identity_key": self._identity_key,
125
+ "name": self.name,
126
+ "port": self.port,
127
+ "workspaces": self.workspaces(),
128
+ }
129
+ )
130
+ )
131
+ reply = decode(await asyncio.wait_for(channel.recv(), 10))
132
+ if reply.get("type") == "rejected":
133
+ raise NodeIdInUse("router node id is still registered")
134
+ if reply.get("type") != "registered" or reply.get("id") != self.node_id:
135
+ raise ValueError("unexpected router registration response")
136
+
137
+ async def _session(self, channel):
138
+ async def receive_requests():
139
+ async for payload in channel:
140
+ message = decode(payload)
141
+ if message.get("type") != "connect":
142
+ raise ValueError("unknown router control message")
143
+ task = asyncio.create_task(self._serve(message["stream_id"]))
144
+ self._requests.add(task)
145
+ task.add_done_callback(self._requests.discard)
146
+
147
+ tasks = [
148
+ asyncio.create_task(self._refresh(channel)),
149
+ asyncio.create_task(receive_requests()),
150
+ ]
151
+ try:
152
+ done, _pending = await asyncio.wait(
153
+ tasks, return_when=asyncio.FIRST_COMPLETED
154
+ )
155
+ for task in done:
156
+ task.result()
157
+ finally:
158
+ for task in tasks:
159
+ task.cancel()
160
+ await asyncio.gather(*tasks, return_exceptions=True)
161
+
162
+ async def _refresh(self, channel):
163
+ while True:
164
+ await asyncio.sleep(self.refresh_seconds)
165
+ await channel.send(
166
+ encode(
167
+ {
168
+ "type": "workspaces",
169
+ "workspaces": self.workspaces(),
170
+ }
171
+ )
172
+ )
173
+
174
+ async def _serve(self, stream_id):
175
+ try:
176
+ async with self._connect(self.data_url) as channel:
177
+ self._channels.add(channel)
178
+ try:
179
+ await channel.send(
180
+ encode(
181
+ {"stream_id": stream_id, "identity_key": self._identity_key}
182
+ )
183
+ )
184
+ opening = decode(await asyncio.wait_for(channel.recv(), 10))
185
+ if opening.get("type") != "open":
186
+ raise ValueError("expected a router request")
187
+ scope = opening["scope"]
188
+ if scope["type"] not in {"http", "websocket"}:
189
+ raise ValueError("unsupported router request type")
190
+
191
+ async def receive():
192
+ try:
193
+ message = decode(await channel.recv())
194
+ except ConnectionClosed:
195
+ if scope["type"] == "http":
196
+ return {"type": "http.disconnect"}
197
+ return {"type": "websocket.disconnect", "code": 1001}
198
+ allowed = (
199
+ {"http.request", "http.disconnect"}
200
+ if scope["type"] == "http"
201
+ else {
202
+ "websocket.connect",
203
+ "websocket.receive",
204
+ "websocket.disconnect",
205
+ }
206
+ )
207
+ if message.get("type") not in allowed:
208
+ raise ValueError("invalid router request event")
209
+ return message
210
+
211
+ async def send(message):
212
+ await send_event(channel.send, message)
213
+
214
+ await self.app(scope, receive, send)
215
+ finally:
216
+ self._channels.discard(channel)
217
+ except (
218
+ ConnectionClosed,
219
+ OSError,
220
+ asyncio.TimeoutError,
221
+ InvalidHandshake,
222
+ ) as exc:
223
+ self._logger.warning("Router request disconnected (%s)", type(exc).__name__)
224
+ except Exception as exc:
225
+ self._logger.error("Router request failed (%s)", type(exc).__name__)
226
+
227
+
228
+ def attach_router(app, registry, target, port):
229
+ def inventory():
230
+ return [
231
+ {
232
+ "id": workspace["id"],
233
+ "work_dir": workspace["work_dir"],
234
+ "state_path": workspace["state_path"],
235
+ "enable_board": workspace["enable_board"],
236
+ "session_count": len(workspace["sessions"]),
237
+ "running": any(session["running"] for session in workspace["sessions"]),
238
+ }
239
+ for workspace in registry.list_workspaces()
240
+ ]
241
+
242
+ client = RouterClient(app, target, socket.gethostname(), port, inventory)
243
+ original_lifespan = app.router.lifespan_context
244
+
245
+ @asynccontextmanager
246
+ async def lifespan(application):
247
+ async with original_lifespan(application) as state:
248
+ await client.start()
249
+ try:
250
+ yield state
251
+ finally:
252
+ await client.close()
253
+
254
+ app.router.lifespan_context = lifespan
255
+ app.state.router_client = client