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.
Files changed (47) hide show
  1. pycodex_ws-0.5.0/PKG-INFO +121 -0
  2. pycodex_ws-0.5.0/README.md +110 -0
  3. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/browser.py +6 -1
  4. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/client.py +8 -1
  5. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/index.html +11 -1
  6. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/protocol.py +42 -0
  7. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/server.py +56 -4
  8. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pyproject.toml +2 -2
  9. pycodex_ws-0.5.0/workspace_server/agent_access.py +66 -0
  10. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/app.py +95 -48
  11. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/content_views.mjs +3 -3
  12. pycodex_ws-0.5.0/workspace_server/customization.py +80 -0
  13. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/content_views.mjs +6 -4
  14. pycodex_ws-0.5.0/workspace_server/owned_process.py +57 -0
  15. pycodex_ws-0.5.0/workspace_server/runtime_info.py +46 -0
  16. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/workspace.html +1009 -457
  17. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/workspaces.html +11 -1
  18. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/workspaces.py +9 -3
  19. pycodex_ws-0.4.7/PKG-INFO +0 -146
  20. pycodex_ws-0.4.7/README.md +0 -135
  21. pycodex_ws-0.4.7/workspace_server/owned_process.py +0 -27
  22. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/.gitignore +0 -0
  23. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/LICENSE +0 -0
  24. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/__init__.py +0 -0
  25. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_router/__main__.py +0 -0
  26. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_ws/__init__.py +0 -0
  27. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_ws/feishu_card.py +0 -0
  28. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_ws/feishu_link.py +0 -0
  29. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/pycodex_ws/feishu_oauth.py +0 -0
  30. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/__init__.py +0 -0
  31. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/__main__.py +0 -0
  32. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/content_views.LICENSE.txt +0 -0
  33. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/content_views.css +0 -0
  34. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/editor.css +0 -0
  35. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/editor.mjs +0 -0
  36. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/.gitignore +0 -0
  37. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/build.mjs +0 -0
  38. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/composer.mjs +0 -0
  39. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/content_views.css +0 -0
  40. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/editor.css +0 -0
  41. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/editor.mjs +0 -0
  42. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/editor_context.mjs +0 -0
  43. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/package-lock.json +0 -0
  44. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/package.json +0 -0
  45. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/touch_selection.mjs +0 -0
  46. {pycodex_ws-0.4.7 → pycodex_ws-0.5.0}/workspace_server/frontend/view_labels.mjs +0 -0
  47. {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 decode, device_id, encode, parse_target, send_event, tunnel_url
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 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
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
- await websocket.send_text(encode({"type": "registered", "id": node_id}))
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
- if blocked or not _auth_cookie_matches(
380
- self.browser_token, connection.cookies.get("pycodex_router_auth")
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(connect_timeout, max_connections, registration_key, aliases_path)
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"
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.4.7",
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)