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.
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/PKG-INFO +19 -10
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/README.md +15 -5
- pycodex_ws-0.4.2/pycodex_router/__init__.py +1 -0
- pycodex_ws-0.4.2/pycodex_router/__main__.py +3 -0
- pycodex_ws-0.4.2/pycodex_router/browser.py +107 -0
- pycodex_ws-0.4.2/pycodex_router/client.py +255 -0
- pycodex_ws-0.4.2/pycodex_router/index.html +438 -0
- pycodex_ws-0.4.2/pycodex_router/protocol.py +179 -0
- pycodex_ws-0.4.2/pycodex_router/server.py +624 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/pycodex_ws/__init__.py +9 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/pyproject.toml +8 -7
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/app.py +90 -20
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/content_views.mjs +3 -3
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/content_views.mjs +6 -4
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/workspace.html +110 -24
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/workspace_views.py +7 -2
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/workspaces.html +155 -158
- pycodex_ws-0.4.0/pycodex_ws/ipython.py +0 -157
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/.gitignore +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/LICENSE +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/pycodex_ws/feishu_card.py +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/pycodex_ws/feishu_link.py +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/pycodex_ws/feishu_oauth.py +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/__init__.py +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/__main__.py +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/content_views.LICENSE.txt +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/content_views.css +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/editor.css +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/editor.mjs +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/.gitignore +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/build.mjs +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/composer.mjs +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/content_views.css +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/editor.css +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/editor.mjs +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/editor_context.mjs +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/package-lock.json +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/package.json +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/frontend/view_labels.mjs +0 -0
- {pycodex_ws-0.4.0 → pycodex_ws-0.4.2}/workspace_server/owned_process.py +0 -0
- {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.
|
|
4
|
-
Summary: Browser workspace
|
|
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.
|
|
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
|
|
15
|
+
and optional Feishu integration for
|
|
17
16
|
[`python-codex`](https://pypi.org/project/python-codex/). It installs the exact
|
|
18
|
-
matching core version
|
|
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
|
|
57
|
-
|
|
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
|
|
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
|
|
4
|
+
and optional Feishu integration for
|
|
5
5
|
[`python-codex`](https://pypi.org/project/python-codex/). It installs the exact
|
|
6
|
-
matching core version
|
|
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
|
|
45
|
-
|
|
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
|
|
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,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
|