pycodex-ws 0.4.7__tar.gz → 0.5.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.
- pycodex_ws-0.5.0/PKG-INFO +121 -0
- pycodex_ws-0.5.0/README.md +110 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/browser.py +6 -1
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/client.py +8 -1
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/index.html +11 -1
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/protocol.py +42 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/server.py +56 -4
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pyproject.toml +2 -2
- pycodex_ws-0.5.0/workspace_server/agent_access.py +66 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/app.py +95 -48
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/content_views.mjs +3 -3
- pycodex_ws-0.5.0/workspace_server/customization.py +80 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/content_views.mjs +6 -4
- pycodex_ws-0.5.0/workspace_server/owned_process.py +57 -0
- pycodex_ws-0.5.0/workspace_server/runtime_info.py +46 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/workspace.html +1009 -457
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/workspaces.html +11 -1
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/workspaces.py +9 -3
- pycodex_ws-0.4.7/PKG-INFO +0 -146
- pycodex_ws-0.4.7/README.md +0 -135
- pycodex_ws-0.4.7/workspace_server/owned_process.py +0 -27
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/.gitignore +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/LICENSE +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/__init__.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/__main__.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_ws/__init__.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_ws/feishu_card.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_ws/feishu_link.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_ws/feishu_oauth.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/__init__.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/__main__.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/content_views.LICENSE.txt +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/content_views.css +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/editor.css +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/editor.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/.gitignore +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/build.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/composer.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/content_views.css +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/editor.css +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/editor.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/editor_context.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/package-lock.json +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/package.json +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/touch_selection.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/view_labels.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/workspace_views.py +0 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pycodex-ws
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Browser workspace and optional Feishu integration for python-codex
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.7
|
|
7
|
+
Requires-Dist: pillow>=9.0.0
|
|
8
|
+
Requires-Dist: python-codex==0.5.0
|
|
9
|
+
Requires-Dist: websockets<16,>=11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# pycodex-ws
|
|
13
|
+
|
|
14
|
+
Browser conversations, a live HTML Canvas, read-only file/diff viewers and
|
|
15
|
+
optional Feishu integration for
|
|
16
|
+
[`python-codex`](https://pypi.org/project/python-codex/).
|
|
17
|
+
Requires Python 3.7+ and installs the matching core version.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python3 -m pip install pycodex-ws
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Create `workspaces.json`:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"workspaces": [
|
|
30
|
+
{"id": "demo", "work_dir": ".", "state_path": "demo", "enable_board": true}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pycodex-ws --listen 127.0.0.1:6007 --workspace-config workspaces.json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Open `http://127.0.0.1:6007`. Use `python3 -m workspace_server` if the command
|
|
40
|
+
is not on `PATH`. Keep stdin open while the server runs; EOF initiates shutdown.
|
|
41
|
+
Model configuration comes from `~/.codex/config.toml`.
|
|
42
|
+
|
|
43
|
+
## Conversations and views
|
|
44
|
+
|
|
45
|
+
The session sidebar starts collapsed. Hover to preview it, click to keep it
|
|
46
|
+
open, and click the toggle or empty space again to close it. Expansion narrows
|
|
47
|
+
the conversation while keeping the board fixed. On phones, use the header
|
|
48
|
+
buttons to switch between chat and board.
|
|
49
|
+
|
|
50
|
+
Use the expanded sidebar to create, rename or close sessions. Status dots show
|
|
51
|
+
running sessions and unread replies. `/cd <directory>` changes the current
|
|
52
|
+
session's working directory while idle; `/model`, `/resume`, `/compact` and
|
|
53
|
+
other core session commands are also available.
|
|
54
|
+
|
|
55
|
+
Click Markdown links to open files and comparisons, for example
|
|
56
|
+
`[source](app.py:42)` or `[changes](diff:HEAD:app.py)`. In a code or diff view,
|
|
57
|
+
select text and choose **Insert into chat** to add editable context to the draft.
|
|
58
|
+
Click a context block to edit or remove it before sending.
|
|
59
|
+
See [view links and supported formats](../../docs/RUNTIME.md#workspace-content-views).
|
|
60
|
+
|
|
61
|
+
When using a router, its directory groups workspaces by device and lists unread
|
|
62
|
+
workspaces at the top. Device handles reorder the list; pencil buttons edit
|
|
63
|
+
device names.
|
|
64
|
+
|
|
65
|
+
The [VS Code extension](extensions/vscode/README.md) uses this backend and is
|
|
66
|
+
distributed separately as a VSIX.
|
|
67
|
+
|
|
68
|
+
## Customize the interface
|
|
69
|
+
|
|
70
|
+
Click the gear button to start a customization conversation with the Agent.
|
|
71
|
+
Custom files live in the server user's `~/.pycodex/frontend/`:
|
|
72
|
+
|
|
73
|
+
- `custom.css` and `custom.js`: optional styles and an ES module, loaded automatically.
|
|
74
|
+
- `workspace.html`: optional full-page override; otherwise the packaged page is used.
|
|
75
|
+
- Other files: available at workspace-relative `frontend/<path>` URLs.
|
|
76
|
+
|
|
77
|
+
Refresh to apply changes and keep the selected conversation. Open the workspace
|
|
78
|
+
with `?builtin=1` to bypass all customizations; click “Re-enable customizations”
|
|
79
|
+
to return. Changes apply to all workspaces for that OS user.
|
|
80
|
+
See the [customization contract](../../docs/RUNTIME.md#frontend-customization).
|
|
81
|
+
|
|
82
|
+
## Storage
|
|
83
|
+
|
|
84
|
+
Paths in `workspaces.json` resolve from the configuration directory.
|
|
85
|
+
`state_path` is a unique filename prefix: the example saves sessions in
|
|
86
|
+
`demo.pycodex-ws.json`, file views in `demo.pycodex-views.json`, and Canvas in
|
|
87
|
+
`demo.html`. Suffixes are appended to the complete prefix, including any dots.
|
|
88
|
+
|
|
89
|
+
Set `enable_board` to `false` to disable Canvas while retaining conversations
|
|
90
|
+
and file views. Session directories and history survive server restarts;
|
|
91
|
+
changing a session's directory does not move Canvas or workspace state files.
|
|
92
|
+
Configuration and restoration details are in the [runtime guide](../../docs/RUNTIME.md).
|
|
93
|
+
|
|
94
|
+
## Feishu
|
|
95
|
+
|
|
96
|
+
Install the optional SDK in the workspace's Python environment, then authorize:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python3 -m pip install lark-oapi
|
|
100
|
+
python3 -m pycodex_ws.feishu_oauth
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Authorization reads app credentials from `~/.codex/.env` and stores the refresh
|
|
104
|
+
token in `~/.codex/.feishu_refresh_token`. In a workspace session, use
|
|
105
|
+
`/link <feishu-email|open_id|chat_id>` to connect a card and `/unlink` to disconnect.
|
|
106
|
+
Other workspace features work without the SDK.
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
From the repository root:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv sync --dev
|
|
114
|
+
uv run pycodex-ws --help
|
|
115
|
+
uv run --dev pytest packages/pycodex-ws/tests
|
|
116
|
+
uv build packages/pycodex-ws
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Python integrations use `pycodex_ws.build_runtime(agent)` to include workspace
|
|
120
|
+
commands. See [runtime contracts](../../docs/RUNTIME.md) and
|
|
121
|
+
[test documentation](../../tests/TESTS.md).
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# pycodex-ws
|
|
2
|
+
|
|
3
|
+
Browser conversations, a live HTML Canvas, read-only file/diff viewers and
|
|
4
|
+
optional Feishu integration for
|
|
5
|
+
[`python-codex`](https://pypi.org/project/python-codex/).
|
|
6
|
+
Requires Python 3.7+ and installs the matching core version.
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
python3 -m pip install pycodex-ws
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Create `workspaces.json`:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"workspaces": [
|
|
19
|
+
{"id": "demo", "work_dir": ".", "state_path": "demo", "enable_board": true}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pycodex-ws --listen 127.0.0.1:6007 --workspace-config workspaces.json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Open `http://127.0.0.1:6007`. Use `python3 -m workspace_server` if the command
|
|
29
|
+
is not on `PATH`. Keep stdin open while the server runs; EOF initiates shutdown.
|
|
30
|
+
Model configuration comes from `~/.codex/config.toml`.
|
|
31
|
+
|
|
32
|
+
## Conversations and views
|
|
33
|
+
|
|
34
|
+
The session sidebar starts collapsed. Hover to preview it, click to keep it
|
|
35
|
+
open, and click the toggle or empty space again to close it. Expansion narrows
|
|
36
|
+
the conversation while keeping the board fixed. On phones, use the header
|
|
37
|
+
buttons to switch between chat and board.
|
|
38
|
+
|
|
39
|
+
Use the expanded sidebar to create, rename or close sessions. Status dots show
|
|
40
|
+
running sessions and unread replies. `/cd <directory>` changes the current
|
|
41
|
+
session's working directory while idle; `/model`, `/resume`, `/compact` and
|
|
42
|
+
other core session commands are also available.
|
|
43
|
+
|
|
44
|
+
Click Markdown links to open files and comparisons, for example
|
|
45
|
+
`[source](app.py:42)` or `[changes](diff:HEAD:app.py)`. In a code or diff view,
|
|
46
|
+
select text and choose **Insert into chat** to add editable context to the draft.
|
|
47
|
+
Click a context block to edit or remove it before sending.
|
|
48
|
+
See [view links and supported formats](../../docs/RUNTIME.md#workspace-content-views).
|
|
49
|
+
|
|
50
|
+
When using a router, its directory groups workspaces by device and lists unread
|
|
51
|
+
workspaces at the top. Device handles reorder the list; pencil buttons edit
|
|
52
|
+
device names.
|
|
53
|
+
|
|
54
|
+
The [VS Code extension](extensions/vscode/README.md) uses this backend and is
|
|
55
|
+
distributed separately as a VSIX.
|
|
56
|
+
|
|
57
|
+
## Customize the interface
|
|
58
|
+
|
|
59
|
+
Click the gear button to start a customization conversation with the Agent.
|
|
60
|
+
Custom files live in the server user's `~/.pycodex/frontend/`:
|
|
61
|
+
|
|
62
|
+
- `custom.css` and `custom.js`: optional styles and an ES module, loaded automatically.
|
|
63
|
+
- `workspace.html`: optional full-page override; otherwise the packaged page is used.
|
|
64
|
+
- Other files: available at workspace-relative `frontend/<path>` URLs.
|
|
65
|
+
|
|
66
|
+
Refresh to apply changes and keep the selected conversation. Open the workspace
|
|
67
|
+
with `?builtin=1` to bypass all customizations; click “Re-enable customizations”
|
|
68
|
+
to return. Changes apply to all workspaces for that OS user.
|
|
69
|
+
See the [customization contract](../../docs/RUNTIME.md#frontend-customization).
|
|
70
|
+
|
|
71
|
+
## Storage
|
|
72
|
+
|
|
73
|
+
Paths in `workspaces.json` resolve from the configuration directory.
|
|
74
|
+
`state_path` is a unique filename prefix: the example saves sessions in
|
|
75
|
+
`demo.pycodex-ws.json`, file views in `demo.pycodex-views.json`, and Canvas in
|
|
76
|
+
`demo.html`. Suffixes are appended to the complete prefix, including any dots.
|
|
77
|
+
|
|
78
|
+
Set `enable_board` to `false` to disable Canvas while retaining conversations
|
|
79
|
+
and file views. Session directories and history survive server restarts;
|
|
80
|
+
changing a session's directory does not move Canvas or workspace state files.
|
|
81
|
+
Configuration and restoration details are in the [runtime guide](../../docs/RUNTIME.md).
|
|
82
|
+
|
|
83
|
+
## Feishu
|
|
84
|
+
|
|
85
|
+
Install the optional SDK in the workspace's Python environment, then authorize:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python3 -m pip install lark-oapi
|
|
89
|
+
python3 -m pycodex_ws.feishu_oauth
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Authorization reads app credentials from `~/.codex/.env` and stores the refresh
|
|
93
|
+
token in `~/.codex/.feishu_refresh_token`. In a workspace session, use
|
|
94
|
+
`/link <feishu-email|open_id|chat_id>` to connect a card and `/unlink` to disconnect.
|
|
95
|
+
Other workspace features work without the SDK.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
From the repository root:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uv sync --dev
|
|
103
|
+
uv run pycodex-ws --help
|
|
104
|
+
uv run --dev pytest packages/pycodex-ws/tests
|
|
105
|
+
uv build packages/pycodex-ws
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Python integrations use `pycodex_ws.build_runtime(agent)` to include workspace
|
|
109
|
+
commands. See [runtime contracts](../../docs/RUNTIME.md) and
|
|
110
|
+
[test documentation](../../tests/TESTS.md).
|
|
@@ -8,7 +8,7 @@ from starlette.exceptions import HTTPException
|
|
|
8
8
|
from starlette.requests import HTTPConnection
|
|
9
9
|
from starlette.responses import JSONResponse, RedirectResponse
|
|
10
10
|
|
|
11
|
-
from .protocol import route_path
|
|
11
|
+
from .protocol import agent_api_request, route_path
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def request_origin(connection):
|
|
@@ -77,6 +77,11 @@ class BrowserAccess:
|
|
|
77
77
|
raise HTTPException(
|
|
78
78
|
403, "Cross-origin router requests are not allowed."
|
|
79
79
|
)
|
|
80
|
+
device = (
|
|
81
|
+
self.router.authenticated_device(connection)
|
|
82
|
+
if agent_api_request(scope) else None
|
|
83
|
+
)
|
|
84
|
+
scope["pycodex.agent_access"] = device is not None
|
|
80
85
|
await self.app(scope, receive, secured_send)
|
|
81
86
|
except HTTPException as exc:
|
|
82
87
|
if scope["type"] == "websocket":
|
|
@@ -7,7 +7,9 @@ from contextlib import asynccontextmanager
|
|
|
7
7
|
from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidStatusCode
|
|
8
8
|
from websockets.legacy.client import connect
|
|
9
9
|
|
|
10
|
-
from .protocol import
|
|
10
|
+
from .protocol import (
|
|
11
|
+
decode, device_id, encode, normalize_api_url, parse_target, send_event, tunnel_url,
|
|
12
|
+
)
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
class NodeIdInUse(Exception):
|
|
@@ -37,6 +39,7 @@ class RouterClient:
|
|
|
37
39
|
self.refresh_seconds = refresh_seconds
|
|
38
40
|
self.reconnect_seconds = reconnect_seconds
|
|
39
41
|
self.connected = False
|
|
42
|
+
self.api_url = None
|
|
40
43
|
self._task = None
|
|
41
44
|
self._channels = set()
|
|
42
45
|
self._requests = set()
|
|
@@ -89,6 +92,7 @@ class RouterClient:
|
|
|
89
92
|
failure = exc
|
|
90
93
|
finally:
|
|
91
94
|
self.connected = False
|
|
95
|
+
self.api_url = None
|
|
92
96
|
|
|
93
97
|
try:
|
|
94
98
|
await self._close_channels()
|
|
@@ -133,6 +137,8 @@ class RouterClient:
|
|
|
133
137
|
raise NodeIdInUse("router node id is still registered")
|
|
134
138
|
if reply.get("type") != "registered" or reply.get("id") != self.node_id:
|
|
135
139
|
raise ValueError("unexpected router registration response")
|
|
140
|
+
api_url = reply.get("api_url")
|
|
141
|
+
self.api_url = normalize_api_url(api_url) if api_url is not None else None
|
|
136
142
|
|
|
137
143
|
async def _session(self, channel):
|
|
138
144
|
async def receive_requests():
|
|
@@ -187,6 +193,7 @@ class RouterClient:
|
|
|
187
193
|
scope = opening["scope"]
|
|
188
194
|
if scope["type"] not in {"http", "websocket"}:
|
|
189
195
|
raise ValueError("unsupported router request type")
|
|
196
|
+
scope["pycodex.agent_access"] = opening.get("agent_access") is True
|
|
190
197
|
|
|
191
198
|
async def receive():
|
|
192
199
|
try:
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
--danger: #b54739;
|
|
20
20
|
--unread-bg: #fff7f4;
|
|
21
21
|
--unread-line: #ead0c8;
|
|
22
|
+
--breathing-duration: 1.2s;
|
|
23
|
+
--breathing-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
|
22
24
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
23
25
|
}
|
|
24
26
|
@media (prefers-color-scheme: dark) {
|
|
@@ -97,7 +99,7 @@
|
|
|
97
99
|
.activity-badge[hidden] { display: none; }
|
|
98
100
|
.workspace-group-name .device-working { margin-left: 8px; white-space: nowrap; }
|
|
99
101
|
.activity-badge[data-state="working"] { color: var(--green); background: var(--green-soft); }
|
|
100
|
-
.activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse
|
|
102
|
+
.activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse var(--breathing-duration) var(--breathing-ease) infinite; }
|
|
101
103
|
@keyframes spinner-pulse {
|
|
102
104
|
0%, 100% { opacity: .45; transform: scale(.82); }
|
|
103
105
|
50% { opacity: 1; transform: scale(1.08); }
|
|
@@ -643,6 +645,14 @@
|
|
|
643
645
|
document.getElementById("activity").textContent = running ? `${running} working` : "";
|
|
644
646
|
}
|
|
645
647
|
|
|
648
|
+
document.addEventListener("animationstart", event => {
|
|
649
|
+
if (event.animationName !== "spinner-pulse") return;
|
|
650
|
+
// Include pseudo-elements and anchor new indicators to the page's breathing phase.
|
|
651
|
+
for (const animation of event.target.getAnimations({subtree: true})) {
|
|
652
|
+
if (animation.animationName === event.animationName) animation.startTime = 0;
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
646
656
|
async function refresh() {
|
|
647
657
|
if (refreshing) return;
|
|
648
658
|
refreshing = true;
|
|
@@ -8,6 +8,29 @@ from http.cookies import SimpleCookie
|
|
|
8
8
|
from urllib.parse import urlsplit, urlunsplit
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
def normalize_api_url(value):
|
|
12
|
+
"""A public HTTP base URL, without credentials or request parameters."""
|
|
13
|
+
try:
|
|
14
|
+
if not isinstance(value, str) or any(
|
|
15
|
+
char.isspace() or ord(char) < 32 or char in "\\?#" for char in value
|
|
16
|
+
):
|
|
17
|
+
raise ValueError()
|
|
18
|
+
parsed = urlsplit(value)
|
|
19
|
+
if (
|
|
20
|
+
parsed.scheme not in {"http", "https"}
|
|
21
|
+
or not parsed.hostname
|
|
22
|
+
or parsed.username is not None
|
|
23
|
+
or parsed.password is not None
|
|
24
|
+
):
|
|
25
|
+
raise ValueError()
|
|
26
|
+
parsed.port
|
|
27
|
+
except ValueError:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
"API URL must be an http(s) URL without credentials, query or fragment"
|
|
30
|
+
) from None
|
|
31
|
+
return value.rstrip("/")
|
|
32
|
+
|
|
33
|
+
|
|
11
34
|
def device_id(identity_key):
|
|
12
35
|
if not isinstance(identity_key, str) or not re.fullmatch(
|
|
13
36
|
"[0-9a-f]{64}", identity_key
|
|
@@ -23,6 +46,25 @@ def route_path(scope):
|
|
|
23
46
|
return path[len(root) :] if root and path.startswith(root + "/") else path
|
|
24
47
|
|
|
25
48
|
|
|
49
|
+
def agent_api_request(scope):
|
|
50
|
+
"""The existing HTTP discovery, message and polling endpoints."""
|
|
51
|
+
if scope["type"] != "http":
|
|
52
|
+
return False
|
|
53
|
+
match = re.fullmatch(
|
|
54
|
+
r"(?:/n/[a-z2-7]{52})?(?:/w/[^/]+)?/api/(workspaces|sessions|session|session/message)",
|
|
55
|
+
route_path(scope),
|
|
56
|
+
)
|
|
57
|
+
return match is not None and scope["method"] == (
|
|
58
|
+
"POST" if match.group(1) == "session/message" else "GET"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def trusted_agent_request(scope):
|
|
63
|
+
# This flag comes only from the private socket or an authenticated tunnel
|
|
64
|
+
# envelope, never from HTTP headers or the forwarded ASGI scope.
|
|
65
|
+
return scope.get("pycodex.agent_access") is True and agent_api_request(scope)
|
|
66
|
+
|
|
67
|
+
|
|
26
68
|
def tunnel_url(target, endpoint):
|
|
27
69
|
parsed = urlsplit(target if "://" in target else "ws://" + target)
|
|
28
70
|
schemes = {"http": "ws", "https": "wss", "ws": "ws", "wss": "wss"}
|
|
@@ -30,9 +30,11 @@ from .protocol import (
|
|
|
30
30
|
decode,
|
|
31
31
|
device_id,
|
|
32
32
|
encode,
|
|
33
|
+
normalize_api_url,
|
|
33
34
|
request_scope,
|
|
34
35
|
response_headers,
|
|
35
36
|
send_event,
|
|
37
|
+
trusted_agent_request,
|
|
36
38
|
)
|
|
37
39
|
|
|
38
40
|
|
|
@@ -154,7 +156,11 @@ class Router:
|
|
|
154
156
|
max_connections=64,
|
|
155
157
|
registration_key=None,
|
|
156
158
|
aliases_path=None,
|
|
159
|
+
api_url=None,
|
|
160
|
+
api_port=None,
|
|
157
161
|
):
|
|
162
|
+
self.api_url = normalize_api_url(api_url) if api_url is not None else None
|
|
163
|
+
self.api_port = api_port
|
|
158
164
|
self.registration_key = (
|
|
159
165
|
_base58_encode(os.urandom(16))
|
|
160
166
|
if registration_key is None
|
|
@@ -257,6 +263,18 @@ class Router:
|
|
|
257
263
|
await websocket.accept()
|
|
258
264
|
return True
|
|
259
265
|
|
|
266
|
+
def authenticated_device(self, connection):
|
|
267
|
+
if "origin" in connection.headers or "sec-fetch-site" in connection.headers:
|
|
268
|
+
return None
|
|
269
|
+
authorization = connection.headers.get("authorization", "")
|
|
270
|
+
if not authorization.startswith("Bearer "):
|
|
271
|
+
return None
|
|
272
|
+
try:
|
|
273
|
+
node_id = device_id(authorization[7:])
|
|
274
|
+
except ValueError:
|
|
275
|
+
return None
|
|
276
|
+
return self.nodes.get(node_id)
|
|
277
|
+
|
|
260
278
|
async def control(self, websocket):
|
|
261
279
|
if not await self.accept_tunnel(websocket):
|
|
262
280
|
return
|
|
@@ -295,7 +313,17 @@ class Router:
|
|
|
295
313
|
aliases[node_id] = ""
|
|
296
314
|
self._save_aliases(aliases)
|
|
297
315
|
self.nodes[node_id] = node
|
|
298
|
-
|
|
316
|
+
api_url = self.api_url
|
|
317
|
+
if api_url is None and self.api_port is not None:
|
|
318
|
+
host = websocket.url.hostname
|
|
319
|
+
if ":" in host:
|
|
320
|
+
host = "[" + host + "]"
|
|
321
|
+
api_url = normalize_api_url(
|
|
322
|
+
"http://{0}:{1}".format(host, self.api_port)
|
|
323
|
+
)
|
|
324
|
+
await websocket.send_text(encode({
|
|
325
|
+
"type": "registered", "id": node_id, "api_url": api_url,
|
|
326
|
+
}))
|
|
299
327
|
while True:
|
|
300
328
|
message = await control_message(websocket)
|
|
301
329
|
if message.get("type") != "workspaces":
|
|
@@ -376,8 +404,12 @@ class Router:
|
|
|
376
404
|
async def proxy(self, scope, receive, send):
|
|
377
405
|
connection = HTTPConnection(scope)
|
|
378
406
|
blocked = connection.client.host in self.blocked_ips
|
|
379
|
-
|
|
380
|
-
|
|
407
|
+
agent_access = trusted_agent_request(scope)
|
|
408
|
+
if blocked or not (
|
|
409
|
+
agent_access
|
|
410
|
+
or _auth_cookie_matches(
|
|
411
|
+
self.browser_token, connection.cookies.get("pycodex_router_auth")
|
|
412
|
+
)
|
|
381
413
|
):
|
|
382
414
|
await require_login(scope, receive, send)
|
|
383
415
|
return
|
|
@@ -423,6 +455,7 @@ class Router:
|
|
|
423
455
|
{
|
|
424
456
|
"type": "open",
|
|
425
457
|
"scope": request_scope(scope),
|
|
458
|
+
"agent_access": agent_access,
|
|
426
459
|
}
|
|
427
460
|
)
|
|
428
461
|
)
|
|
@@ -488,8 +521,12 @@ def create_app(
|
|
|
488
521
|
max_connections=64,
|
|
489
522
|
registration_key=None,
|
|
490
523
|
aliases_path=None,
|
|
524
|
+
api_url=None,
|
|
525
|
+
api_port=None,
|
|
491
526
|
):
|
|
492
|
-
router = Router(
|
|
527
|
+
router = Router(
|
|
528
|
+
connect_timeout, max_connections, registration_key, aliases_path, api_url, api_port
|
|
529
|
+
)
|
|
493
530
|
|
|
494
531
|
async def index(request):
|
|
495
532
|
return HTMLResponse(
|
|
@@ -568,6 +605,14 @@ def build_parser():
|
|
|
568
605
|
parser.add_argument(
|
|
569
606
|
"--tunnel-listen", default="0.0.0.0:6000", help="Device host:port."
|
|
570
607
|
)
|
|
608
|
+
parser.add_argument(
|
|
609
|
+
"--api-url",
|
|
610
|
+
help=(
|
|
611
|
+
"Public browser/API base URL advertised to devices, including any proxy "
|
|
612
|
+
"path prefix. Defaults to the device connection hostname and browser "
|
|
613
|
+
"HTTP port; set this for HTTPS, reverse proxies or port forwarding."
|
|
614
|
+
),
|
|
615
|
+
)
|
|
571
616
|
parser.add_argument(
|
|
572
617
|
"--password",
|
|
573
618
|
required=True,
|
|
@@ -583,6 +628,11 @@ def main(argv=None):
|
|
|
583
628
|
args = parser.parse_args(argv)
|
|
584
629
|
if not args.password:
|
|
585
630
|
parser.error("--password must not be empty")
|
|
631
|
+
if args.api_url is not None:
|
|
632
|
+
try:
|
|
633
|
+
args.api_url = normalize_api_url(args.api_url)
|
|
634
|
+
except ValueError as exc:
|
|
635
|
+
parser.error(str(exc))
|
|
586
636
|
addresses = [parse_listen(args.listen), parse_listen(args.tunnel_listen)]
|
|
587
637
|
if addresses[0][1] != 0 and addresses[0][1] == addresses[1][1]:
|
|
588
638
|
parser.error("browser and device listeners must use different ports")
|
|
@@ -592,6 +642,7 @@ def main(argv=None):
|
|
|
592
642
|
args.password,
|
|
593
643
|
registration_key=load_registration_key(key_path),
|
|
594
644
|
aliases_path=codex_home / "router-aliases.json",
|
|
645
|
+
api_url=args.api_url,
|
|
595
646
|
)
|
|
596
647
|
tunnel_app = create_tunnel_app(app.state.router)
|
|
597
648
|
sockets = []
|
|
@@ -652,6 +703,7 @@ def main(argv=None):
|
|
|
652
703
|
config = listener
|
|
653
704
|
if sockets[0].getsockname()[1] == sockets[1].getsockname()[1]:
|
|
654
705
|
raise ValueError("browser and device listeners must use different ports")
|
|
706
|
+
app.state.router.api_port = sockets[0].getsockname()[1]
|
|
655
707
|
RouterServer(config).run(sockets=sockets)
|
|
656
708
|
finally:
|
|
657
709
|
for listener_socket in sockets:
|
|
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycodex-ws"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Browser workspace and optional Feishu integration for python-codex"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.7"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"python-codex==0.
|
|
12
|
+
"python-codex==0.5.0",
|
|
13
13
|
"pillow>=9.0.0",
|
|
14
14
|
"websockets>=11,<16",
|
|
15
15
|
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Same-user HTTP access to existing local and registered-device APIs."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import unquote, urlsplit, urlunsplit
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from starlette.concurrency import run_in_threadpool
|
|
7
|
+
from starlette.requests import Request
|
|
8
|
+
from starlette.responses import JSONResponse, Response
|
|
9
|
+
|
|
10
|
+
from pycodex_router.protocol import agent_api_request
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgentSocketAccess:
|
|
14
|
+
def __init__(self, app):
|
|
15
|
+
self.app = app
|
|
16
|
+
|
|
17
|
+
async def __call__(self, scope, receive, send):
|
|
18
|
+
socket_path = getattr(self.app.state, "agent_socket_path", None)
|
|
19
|
+
# Uvicorn gets this address from the socket, independently of HTTP headers.
|
|
20
|
+
if (
|
|
21
|
+
scope["type"] != "http" or socket_path is None
|
|
22
|
+
or scope["server"] not in (None, (socket_path, None))
|
|
23
|
+
):
|
|
24
|
+
await self.app(scope, receive, send)
|
|
25
|
+
return
|
|
26
|
+
scope = dict(scope)
|
|
27
|
+
scope["pycodex.agent_access"] = True
|
|
28
|
+
if not scope["path"].startswith("/router/"):
|
|
29
|
+
await self.app(scope, receive, send)
|
|
30
|
+
return
|
|
31
|
+
client = getattr(self.app.state, "router_client", None)
|
|
32
|
+
if client is None or not client.connected or client.api_url is None:
|
|
33
|
+
await JSONResponse({"error": "router API is unavailable"}, 503)(scope, receive, send)
|
|
34
|
+
return
|
|
35
|
+
remote = urlsplit(client.api_url)
|
|
36
|
+
scope["path"] = scope["path"][len("/router"):]
|
|
37
|
+
scope["root_path"] = unquote(remote.path)
|
|
38
|
+
if not scope["path"].startswith(scope["root_path"] + "/") or not agent_api_request(scope):
|
|
39
|
+
await JSONResponse({"error": "not a session API"}, 403)(scope, receive, send)
|
|
40
|
+
return
|
|
41
|
+
url = urlunsplit((
|
|
42
|
+
remote.scheme, remote.netloc,
|
|
43
|
+
scope["raw_path"][len(b"/router"):].decode("ascii"),
|
|
44
|
+
scope["query_string"].decode("ascii"), "",
|
|
45
|
+
))
|
|
46
|
+
body = await Request(scope, receive).body()
|
|
47
|
+
|
|
48
|
+
def forward():
|
|
49
|
+
with requests.Session() as session:
|
|
50
|
+
session.trust_env = False
|
|
51
|
+
result = session.request(
|
|
52
|
+
scope["method"], url, data=body,
|
|
53
|
+
headers={"Authorization": "Bearer " + client._identity_key,
|
|
54
|
+
"Content-Type": "application/json"},
|
|
55
|
+
allow_redirects=False, timeout=(10, 30),
|
|
56
|
+
)
|
|
57
|
+
return Response(
|
|
58
|
+
result.content, result.status_code,
|
|
59
|
+
media_type=result.headers.get("content-type", "application/json"),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
response = await run_in_threadpool(forward)
|
|
64
|
+
except requests.RequestException:
|
|
65
|
+
response = JSONResponse({"error": "router API is unavailable"}, 503)
|
|
66
|
+
await response(scope, receive, send)
|