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.
Files changed (44) hide show
  1. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/PKG-INFO +23 -3
  2. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/README.md +21 -1
  3. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/browser.py +6 -1
  4. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/client.py +8 -1
  5. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/protocol.py +42 -0
  6. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/server.py +56 -4
  7. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pyproject.toml +2 -2
  8. pycodex_ws-0.4.8/workspace_server/agent_access.py +66 -0
  9. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/app.py +49 -40
  10. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/content_views.mjs +3 -3
  11. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/content_views.mjs +3 -3
  12. pycodex_ws-0.4.8/workspace_server/owned_process.py +57 -0
  13. pycodex_ws-0.4.8/workspace_server/runtime_info.py +46 -0
  14. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/workspace.html +678 -30
  15. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/workspaces.py +9 -3
  16. pycodex_ws-0.4.7/workspace_server/owned_process.py +0 -27
  17. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/.gitignore +0 -0
  18. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/LICENSE +0 -0
  19. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/__init__.py +0 -0
  20. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/__main__.py +0 -0
  21. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_router/index.html +0 -0
  22. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_ws/__init__.py +0 -0
  23. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_ws/feishu_card.py +0 -0
  24. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_ws/feishu_link.py +0 -0
  25. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/pycodex_ws/feishu_oauth.py +0 -0
  26. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/__init__.py +0 -0
  27. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/__main__.py +0 -0
  28. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/content_views.LICENSE.txt +0 -0
  29. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/content_views.css +0 -0
  30. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/editor.css +0 -0
  31. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/editor.mjs +0 -0
  32. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/.gitignore +0 -0
  33. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/build.mjs +0 -0
  34. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/composer.mjs +0 -0
  35. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/content_views.css +0 -0
  36. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/editor.css +0 -0
  37. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/editor.mjs +0 -0
  38. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/editor_context.mjs +0 -0
  39. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/package-lock.json +0 -0
  40. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/package.json +0 -0
  41. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/touch_selection.mjs +0 -0
  42. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/frontend/view_labels.mjs +0 -0
  43. {pycodex_ws-0.4.7 → pycodex_ws-0.4.8}/workspace_server/workspace_views.py +0 -0
  44. {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.7
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.7
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 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:
@@ -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.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.7",
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, rollout_path: str, title: str = "", fork: bool = False
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, rollout_path: str, title: str = "", fork: bool = False
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.query_params.get("session_id"))
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.query_params.get("session_id"))
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
- session_id: "typing.Union[str, None]" = None,
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
- return JSONResponse(
1634
- {
1635
- "session_id": resolved_id,
1636
- "sessions": manager.list_sessions(),
1637
- "snapshot": session_snapshot(link),
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, definition.work_dir)]
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(_format_board_path_for_prompt(board_path, work_dir=work_dir))
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(