pycodex-ws 0.4.7__tar.gz → 0.4.8__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.7 → pycodex_ws-0.4.8}/PKG-INFO +23 -3
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/README.md +21 -1
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/browser.py +6 -1
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/client.py +8 -1
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/protocol.py +42 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/server.py +56 -4
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pyproject.toml +2 -2
- pycodex_ws-0.4.8/workspace_server/agent_access.py +66 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/app.py +49 -40
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/content_views.mjs +3 -3
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/content_views.mjs +3 -3
- pycodex_ws-0.4.8/workspace_server/owned_process.py +57 -0
- pycodex_ws-0.4.8/workspace_server/runtime_info.py +46 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/workspace.html +678 -30
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/workspaces.py +9 -3
- pycodex_ws-0.4.7/workspace_server/owned_process.py +0 -27
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/.gitignore +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/LICENSE +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/__init__.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/__main__.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/index.html +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_ws/__init__.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_ws/feishu_card.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_ws/feishu_link.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_ws/feishu_oauth.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/__init__.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/__main__.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/content_views.LICENSE.txt +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/content_views.css +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/editor.css +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/editor.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/.gitignore +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/build.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/composer.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/content_views.css +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/editor.css +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/editor.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/editor_context.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/package-lock.json +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/package.json +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/touch_selection.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/view_labels.mjs +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/workspace_views.py +0 -0
- {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/workspaces.html +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pycodex-ws
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.8
|
|
4
4
|
Summary: Browser workspace and optional Feishu integration for python-codex
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.7
|
|
7
7
|
Requires-Dist: pillow>=9.0.0
|
|
8
|
-
Requires-Dist: python-codex==0.4.
|
|
8
|
+
Requires-Dist: python-codex==0.4.8
|
|
9
9
|
Requires-Dist: websockets<16,>=11
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
|
|
@@ -87,14 +87,34 @@ Use a filename prefix and a separate Canvas switch in `workspaces.json`:
|
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
Start it with `pycodex-ws --workspace-config workspaces.json`.
|
|
90
|
+
Sessions appear in a sidebar on the right. Collapsing it hides the titles and
|
|
91
|
+
keeps a clickable column of status dots: running sessions pulse, finished
|
|
92
|
+
sessions show a steady green dot, and unread replies retain their red marker.
|
|
93
|
+
Hover a dot to see its title, or use the sidebar button to expand the titles.
|
|
94
|
+
On narrow screens the expanded sidebar overlays the conversation and returns
|
|
95
|
+
to its collapsed state after selecting or creating a session. On phones the
|
|
96
|
+
collapsed sidebar and chat header are hidden. A compact floating control in
|
|
97
|
+
the upper-right corner shows clickable status dots vertically, plus buttons
|
|
98
|
+
to expand the session list or switch between chat and the board. Both layouts use
|
|
99
|
+
smooth transitions and respect the system's reduced-motion preference.
|
|
100
|
+
Session renaming and closing remain available in the expanded sidebar.
|
|
90
101
|
Relative paths resolve from the configuration directory. This example uses
|
|
91
|
-
`demo.pycodex-ws.json` for session titles and rollout references,
|
|
102
|
+
`demo.pycodex-ws.json` for session titles, directories and rollout references,
|
|
92
103
|
`demo.pycodex-views.json` for file views, and `demo.html` for Canvas.
|
|
93
104
|
All suffixes are appended to the complete prefix, including any dots.
|
|
94
105
|
The resolved `state_path` identifies the workspace; two entries cannot share it.
|
|
95
106
|
Creating a workspace through the management page or API without a prefix
|
|
96
107
|
generates a `/tmp/pcws-*` prefix and saves it in the configuration.
|
|
97
108
|
|
|
109
|
+
Each session starts in `work_dir`. Use `/cd <directory>` while idle to change
|
|
110
|
+
only that session, or `/cd` to show its directory. Relative paths and `~` work;
|
|
111
|
+
enter paths containing spaces without quotes. A small folder indicator inside
|
|
112
|
+
the composer appears only when the directory differs from `work_dir`; hover
|
|
113
|
+
for the full path. Directories are restored after server restart, even for empty sessions. Tools,
|
|
114
|
+
AGENTS instructions and relative chat/Canvas file links use the selected
|
|
115
|
+
session's directory. Existing processes keep their original directory.
|
|
116
|
+
Canvas and workspace state files retain their configured locations.
|
|
117
|
+
|
|
98
118
|
The router directory collects unread workspace shortcuts and their device names
|
|
99
119
|
in a distinct Unread panel at the top. Read workspaces leave that panel.
|
|
100
120
|
Drag a handle in Online devices to reorder devices; both lists share the saved
|
|
@@ -76,14 +76,34 @@ Use a filename prefix and a separate Canvas switch in `workspaces.json`:
|
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
Start it with `pycodex-ws --workspace-config workspaces.json`.
|
|
79
|
+
Sessions appear in a sidebar on the right. Collapsing it hides the titles and
|
|
80
|
+
keeps a clickable column of status dots: running sessions pulse, finished
|
|
81
|
+
sessions show a steady green dot, and unread replies retain their red marker.
|
|
82
|
+
Hover a dot to see its title, or use the sidebar button to expand the titles.
|
|
83
|
+
On narrow screens the expanded sidebar overlays the conversation and returns
|
|
84
|
+
to its collapsed state after selecting or creating a session. On phones the
|
|
85
|
+
collapsed sidebar and chat header are hidden. A compact floating control in
|
|
86
|
+
the upper-right corner shows clickable status dots vertically, plus buttons
|
|
87
|
+
to expand the session list or switch between chat and the board. Both layouts use
|
|
88
|
+
smooth transitions and respect the system's reduced-motion preference.
|
|
89
|
+
Session renaming and closing remain available in the expanded sidebar.
|
|
79
90
|
Relative paths resolve from the configuration directory. This example uses
|
|
80
|
-
`demo.pycodex-ws.json` for session titles and rollout references,
|
|
91
|
+
`demo.pycodex-ws.json` for session titles, directories and rollout references,
|
|
81
92
|
`demo.pycodex-views.json` for file views, and `demo.html` for Canvas.
|
|
82
93
|
All suffixes are appended to the complete prefix, including any dots.
|
|
83
94
|
The resolved `state_path` identifies the workspace; two entries cannot share it.
|
|
84
95
|
Creating a workspace through the management page or API without a prefix
|
|
85
96
|
generates a `/tmp/pcws-*` prefix and saves it in the configuration.
|
|
86
97
|
|
|
98
|
+
Each session starts in `work_dir`. Use `/cd <directory>` while idle to change
|
|
99
|
+
only that session, or `/cd` to show its directory. Relative paths and `~` work;
|
|
100
|
+
enter paths containing spaces without quotes. A small folder indicator inside
|
|
101
|
+
the composer appears only when the directory differs from `work_dir`; hover
|
|
102
|
+
for the full path. Directories are restored after server restart, even for empty sessions. Tools,
|
|
103
|
+
AGENTS instructions and relative chat/Canvas file links use the selected
|
|
104
|
+
session's directory. Existing processes keep their original directory.
|
|
105
|
+
Canvas and workspace state files retain their configured locations.
|
|
106
|
+
|
|
87
107
|
The router directory collects unread workspace shortcuts and their device names
|
|
88
108
|
in a distinct Unread panel at the top. Read workspaces leave that panel.
|
|
89
109
|
Drag a handle in Online devices to reorder devices; both lists share the saved
|
|
@@ -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:
|
|
@@ -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.4.
|
|
7
|
+
version = "0.4.8"
|
|
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.4.
|
|
12
|
+
"python-codex==0.4.8",
|
|
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)
|
|
@@ -74,7 +74,9 @@ from pycodex.utils.event_helpers import (
|
|
|
74
74
|
)
|
|
75
75
|
from pycodex.utils.session_persist import load_display_history
|
|
76
76
|
from pycodex_ws import build_runtime
|
|
77
|
+
from pycodex_router.protocol import trusted_agent_request
|
|
77
78
|
|
|
79
|
+
from .runtime_info import runtime_info_prompt
|
|
78
80
|
from .workspace_views import WorkspaceViewOpener
|
|
79
81
|
from .workspaces import (
|
|
80
82
|
WorkspaceDefinition,
|
|
@@ -201,6 +203,7 @@ class WebSessionView:
|
|
|
201
203
|
self._title = ""
|
|
202
204
|
self._last_final_id = ""
|
|
203
205
|
self._model = "pycodex"
|
|
206
|
+
self._cwd = ""
|
|
204
207
|
self._rollout_path = ""
|
|
205
208
|
self._recorded_rollout_path = ""
|
|
206
209
|
self._input_request = None
|
|
@@ -326,6 +329,7 @@ class WebSessionView:
|
|
|
326
329
|
"status_kind": "spinner" if self._spinner_status else "idle",
|
|
327
330
|
"spinner": self._spinner_status,
|
|
328
331
|
"model": self._model,
|
|
332
|
+
"cwd": self._cwd,
|
|
329
333
|
"rollout_path": self._rollout_path,
|
|
330
334
|
"recorded_rollout_path": self._recorded_rollout_path,
|
|
331
335
|
"input_request": _json_safe(self._input_request),
|
|
@@ -344,6 +348,7 @@ class WebSessionView:
|
|
|
344
348
|
with self._lock:
|
|
345
349
|
return {
|
|
346
350
|
"model": self._model,
|
|
351
|
+
"cwd": self._cwd,
|
|
347
352
|
"running": bool(self._spinner_status),
|
|
348
353
|
"last_final_id": self._last_final_id,
|
|
349
354
|
"spinner": self._spinner_status,
|
|
@@ -357,6 +362,7 @@ class WebSessionView:
|
|
|
357
362
|
if isinstance(event, SessionStateEvent):
|
|
358
363
|
state = event.state
|
|
359
364
|
self._model = state["model"]
|
|
365
|
+
self._cwd = state["cwd"]
|
|
360
366
|
self._rollout_path = state["rollout_path"]
|
|
361
367
|
self._recorded_rollout_path = state["recorded_rollout_path"]
|
|
362
368
|
self._input_request = state["input_request"]
|
|
@@ -714,8 +720,14 @@ class WorkspaceInteractiveSession:
|
|
|
714
720
|
return self.view.snapshot()["rollout_path"]
|
|
715
721
|
|
|
716
722
|
async def restore_from_rollout(
|
|
717
|
-
self,
|
|
723
|
+
self,
|
|
724
|
+
rollout_path: str,
|
|
725
|
+
title: str = "",
|
|
726
|
+
fork: bool = False,
|
|
727
|
+
cwd: "typing.Optional[str]" = None,
|
|
718
728
|
) -> None:
|
|
729
|
+
if cwd is not None:
|
|
730
|
+
self.runtime.set_cwd(cwd)
|
|
719
731
|
if rollout_path:
|
|
720
732
|
self.runtime.resume(rollout_path, title)
|
|
721
733
|
if fork:
|
|
@@ -840,7 +852,11 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
840
852
|
return self._session.rollout_path()
|
|
841
853
|
|
|
842
854
|
async def restore_from_rollout(
|
|
843
|
-
self,
|
|
855
|
+
self,
|
|
856
|
+
rollout_path: str,
|
|
857
|
+
title: str = "",
|
|
858
|
+
fork: bool = False,
|
|
859
|
+
cwd: "typing.Optional[str]" = None,
|
|
844
860
|
) -> None:
|
|
845
861
|
session = self._session
|
|
846
862
|
loop = self._worker_loop
|
|
@@ -848,7 +864,7 @@ class ThreadedWorkspaceInteractiveSession:
|
|
|
848
864
|
return
|
|
849
865
|
|
|
850
866
|
future = asyncio.run_coroutine_threadsafe(
|
|
851
|
-
session.restore_from_rollout(rollout_path, title=title, fork=fork),
|
|
867
|
+
session.restore_from_rollout(rollout_path, title=title, fork=fork, cwd=cwd),
|
|
852
868
|
loop,
|
|
853
869
|
)
|
|
854
870
|
await asyncio.wrap_future(future)
|
|
@@ -997,7 +1013,7 @@ def create_multi_workspace_app(
|
|
|
997
1013
|
|
|
998
1014
|
async def workspace_session(request: Request) -> JSONResponse:
|
|
999
1015
|
entry = _workspace_entry_or_404(registry, request.path_params["workspace_id"])
|
|
1000
|
-
return _session_response(entry.manager, request.
|
|
1016
|
+
return _session_response(entry.manager, request, entry.definition.workspace_id)
|
|
1001
1017
|
|
|
1002
1018
|
async def workspace_message(request: Request) -> JSONResponse:
|
|
1003
1019
|
payload = await _json_object_body(request)
|
|
@@ -1119,7 +1135,7 @@ def _install_auth(
|
|
|
1119
1135
|
root = request.scope.get("root_path", "")
|
|
1120
1136
|
if root and path.startswith(root + "/"):
|
|
1121
1137
|
path = path[len(root):]
|
|
1122
|
-
if path in {"/favicon.ico", "/login"} or _auth_cookie_matches(
|
|
1138
|
+
if path in {"/favicon.ico", "/login"} or trusted_agent_request(request.scope) or _auth_cookie_matches(
|
|
1123
1139
|
token,
|
|
1124
1140
|
request.cookies.get(cookie_name),
|
|
1125
1141
|
):
|
|
@@ -1398,7 +1414,7 @@ def _install_workspace_routes(
|
|
|
1398
1414
|
return _read_session_response(manager, request.path_params["session_id"], payload)
|
|
1399
1415
|
|
|
1400
1416
|
async def session(request: Request) -> JSONResponse:
|
|
1401
|
-
return _session_response(manager, request
|
|
1417
|
+
return _session_response(manager, request)
|
|
1402
1418
|
|
|
1403
1419
|
async def message(request: Request) -> JSONResponse:
|
|
1404
1420
|
payload = await _json_object_body(request)
|
|
@@ -1454,10 +1470,17 @@ def _open_view_response(
|
|
|
1454
1470
|
manager: WorkspaceSessionManager, work_dir: Path, payload: "typing.Dict[str, object]"
|
|
1455
1471
|
) -> JSONResponse:
|
|
1456
1472
|
try:
|
|
1473
|
+
session_id = payload.get("session_id")
|
|
1474
|
+
if session_id is not None:
|
|
1475
|
+
if not isinstance(session_id, str) or not session_id:
|
|
1476
|
+
raise ValueError("session_id must be a nonempty string")
|
|
1477
|
+
work_dir = Path(manager.get(session_id).summary()["cwd"])
|
|
1457
1478
|
view = WorkspaceViewOpener(manager.views, work_dir).open(
|
|
1458
1479
|
payload.get("href"), payload.get("src_path")
|
|
1459
1480
|
)
|
|
1460
1481
|
views = manager.views.list_views()
|
|
1482
|
+
except KeyError:
|
|
1483
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
1461
1484
|
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
1462
1485
|
raise HTTPException(status_code=400, detail=str(error))
|
|
1463
1486
|
return JSONResponse(
|
|
@@ -1623,20 +1646,28 @@ async def _delete_session_response(
|
|
|
1623
1646
|
|
|
1624
1647
|
def _session_response(
|
|
1625
1648
|
manager: "WorkspaceSessionManager",
|
|
1626
|
-
|
|
1649
|
+
request: Request,
|
|
1650
|
+
workspace_id: "typing.Union[str, None]" = None,
|
|
1627
1651
|
) -> JSONResponse:
|
|
1628
1652
|
try:
|
|
1629
|
-
resolved_id = manager.resolve_session_id(session_id)
|
|
1653
|
+
resolved_id = manager.resolve_session_id(request.query_params.get("session_id"))
|
|
1630
1654
|
link = manager.get(resolved_id)
|
|
1631
1655
|
except KeyError:
|
|
1632
1656
|
raise HTTPException(status_code=404, detail="session not found")
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1657
|
+
payload = {
|
|
1658
|
+
"session_id": resolved_id,
|
|
1659
|
+
"sessions": manager.list_sessions(),
|
|
1660
|
+
"snapshot": session_snapshot(link),
|
|
1661
|
+
}
|
|
1662
|
+
if request.query_params.get("runtime_info") == "true":
|
|
1663
|
+
state = request.app.state
|
|
1664
|
+
payload["runtime_info"] = runtime_info_prompt(
|
|
1665
|
+
resolved_id, link.summary(), workspace_id,
|
|
1666
|
+
getattr(state, "local_api_url", None),
|
|
1667
|
+
getattr(state, "router_client", None),
|
|
1668
|
+
getattr(state, "agent_socket_path", None),
|
|
1669
|
+
)
|
|
1670
|
+
return JSONResponse(payload, headers={"Cache-Control": "no-store"})
|
|
1640
1671
|
|
|
1641
1672
|
|
|
1642
1673
|
async def _message_response(
|
|
@@ -1959,7 +1990,7 @@ def _build_workspace_entry(
|
|
|
1959
1990
|
profile=args.profile,
|
|
1960
1991
|
system_prompt=args.system_prompt,
|
|
1961
1992
|
extra_contextual_user_messages=(
|
|
1962
|
-
[_workspace_context_text(definition.board_path
|
|
1993
|
+
[_workspace_context_text(definition.board_path)]
|
|
1963
1994
|
if definition.board_path is not None
|
|
1964
1995
|
else []
|
|
1965
1996
|
),
|
|
@@ -1986,10 +2017,7 @@ def _build_workspace_entry(
|
|
|
1986
2017
|
)
|
|
1987
2018
|
|
|
1988
2019
|
|
|
1989
|
-
def _workspace_context_text(
|
|
1990
|
-
board_path: Path,
|
|
1991
|
-
work_dir: "typing.Union[Path, None]",
|
|
1992
|
-
) -> str:
|
|
2020
|
+
def _workspace_context_text(board_path: Path) -> str:
|
|
1993
2021
|
return (
|
|
1994
2022
|
"Current workspace Canvas file: {0}. "
|
|
1995
2023
|
"Changes you make to this HTML file are shown to the user in real time. "
|
|
@@ -1999,26 +2027,7 @@ def _workspace_context_text(
|
|
|
1999
2027
|
"and diff:left..right. Append #L42 to select a file or left-side line, "
|
|
2000
2028
|
"or #R42 for the right side of a diff. "
|
|
2001
2029
|
"Use workspace://canvas to return to the live Canvas."
|
|
2002
|
-
).format(
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
def _format_board_path_for_prompt(
|
|
2006
|
-
board_path: Path,
|
|
2007
|
-
work_dir: "typing.Union[Path, None]" = None,
|
|
2008
|
-
) -> str:
|
|
2009
|
-
resolved = board_path.resolve()
|
|
2010
|
-
try:
|
|
2011
|
-
relative = os.path.relpath(
|
|
2012
|
-
str(resolved),
|
|
2013
|
-
str(Path(work_dir or Path.cwd()).resolve()),
|
|
2014
|
-
)
|
|
2015
|
-
except ValueError:
|
|
2016
|
-
return str(resolved)
|
|
2017
|
-
if relative == ".":
|
|
2018
|
-
return "."
|
|
2019
|
-
if relative.startswith(".."):
|
|
2020
|
-
return str(resolved)
|
|
2021
|
-
return "./{0}".format(relative)
|
|
2030
|
+
).format(board_path.resolve())
|
|
2022
2031
|
|
|
2023
2032
|
|
|
2024
2033
|
def _render_workspace_shell(
|