loki-mode 6.53.0 → 6.55.0

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 (46) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/bin/postinstall.js +29 -0
  4. package/dashboard/__init__.py +1 -1
  5. package/docs/INSTALLATION.md +1 -1
  6. package/mcp/__init__.py +1 -1
  7. package/package.json +11 -2
  8. package/web-app/Dockerfile +59 -0
  9. package/web-app/alembic.ini +43 -0
  10. package/web-app/auth.py +249 -0
  11. package/web-app/crypto.py +83 -0
  12. package/web-app/deploy/k8s/purple-lab/configmap.yaml +8 -0
  13. package/web-app/deploy/k8s/purple-lab/deployment.yaml +69 -0
  14. package/web-app/deploy/k8s/purple-lab/hpa.yaml +24 -0
  15. package/web-app/deploy/k8s/purple-lab/ingress.yaml +30 -0
  16. package/web-app/deploy/k8s/purple-lab/networkpolicy.yaml +82 -0
  17. package/web-app/deploy/k8s/purple-lab/pdb.yaml +11 -0
  18. package/web-app/deploy/k8s/purple-lab/postgres.yaml +84 -0
  19. package/web-app/deploy/k8s/purple-lab/pvc.yaml +10 -0
  20. package/web-app/deploy/k8s/purple-lab/secret.yaml +13 -0
  21. package/web-app/deploy/k8s/purple-lab/service.yaml +13 -0
  22. package/web-app/deploy/k8s/purple-lab/serviceaccount.yaml +7 -0
  23. package/web-app/dist/assets/{Badge-CnWBUi7C.js → Badge-BDr4DPCT.js} +1 -1
  24. package/web-app/dist/assets/{Button-5ThWFbkO.js → Button-WBFGRnUr.js} +1 -1
  25. package/web-app/dist/assets/{Card-CcTmaOCN.js → Card-DzOT34Rr.js} +1 -1
  26. package/web-app/dist/assets/{HomePage-Dx4Ae0hu.js → HomePage-B8kMCXMB.js} +1 -1
  27. package/web-app/dist/assets/{LoginPage-CRffqZNo.js → LoginPage-D9lCyiqM.js} +1 -1
  28. package/web-app/dist/assets/{NotFoundPage-B1QZ92yR.js → NotFoundPage-DzeZ0uQ6.js} +1 -1
  29. package/web-app/dist/assets/{ProjectPage-BVnDGxXk.js → ProjectPage-C-k0iy0i.js} +14 -14
  30. package/web-app/dist/assets/{ProjectsPage-2Fi6cKB-.js → ProjectsPage-jys_pHzp.js} +1 -1
  31. package/web-app/dist/assets/{SettingsPage-DOzGoyLv.js → SettingsPage-Cz_RXr82.js} +1 -1
  32. package/web-app/dist/assets/{TemplatesPage-B-f1Gfbg.js → TemplatesPage-COnhb_Wq.js} +1 -1
  33. package/web-app/dist/assets/{TerminalOutput-DrKIbiB8.js → TerminalOutput-CmdEXHHd.js} +1 -1
  34. package/web-app/dist/assets/{arrow-left-CFG0TEkb.js → arrow-left-DAZzI0L-.js} +1 -1
  35. package/web-app/dist/assets/{clock-C-GPrW5k.js → clock-BHGf6zSk.js} +1 -1
  36. package/web-app/dist/assets/{external-link-ujbkNBY4.js → external-link-DLYjfP9j.js} +1 -1
  37. package/web-app/dist/assets/{index-B8gGcUMo.js → index-B8Eg1YHL.js} +2 -2
  38. package/web-app/dist/index.html +1 -1
  39. package/web-app/docker-compose.purple-lab.yml +76 -0
  40. package/web-app/migrations/env.py +103 -0
  41. package/web-app/migrations/script.py.mako +25 -0
  42. package/web-app/migrations/versions/.gitkeep +0 -0
  43. package/web-app/migrations/versions/001_initial_schema.py +118 -0
  44. package/web-app/models.py +140 -0
  45. package/web-app/requirements.txt +27 -0
  46. package/web-app/server.py +158 -22
@@ -0,0 +1,140 @@
1
+ """Purple Lab database models.
2
+
3
+ Provides SQLAlchemy models for multi-user cloud deployment.
4
+ When no DATABASE_URL is configured, the system falls back to
5
+ file-based storage (local development mode).
6
+ """
7
+ import os
8
+ import uuid
9
+ from datetime import datetime
10
+
11
+ from sqlalchemy import (
12
+ Boolean,
13
+ Column,
14
+ DateTime,
15
+ ForeignKey,
16
+ Integer,
17
+ JSON,
18
+ String,
19
+ Text,
20
+ )
21
+ from sqlalchemy.dialects.postgresql import UUID
22
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
23
+ from sqlalchemy.orm import DeclarativeBase, relationship
24
+
25
+
26
+ class Base(DeclarativeBase):
27
+ pass
28
+
29
+
30
+ class User(Base):
31
+ __tablename__ = "users"
32
+
33
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
34
+ email = Column(String(255), unique=True, nullable=False, index=True)
35
+ name = Column(String(255))
36
+ avatar_url = Column(String(500))
37
+ provider = Column(String(50)) # "github", "google", "email"
38
+ provider_id = Column(String(255)) # External provider user ID
39
+ password_hash = Column(String(255), nullable=True) # For email/password auth
40
+ created_at = Column(DateTime, default=datetime.utcnow)
41
+ last_login = Column(DateTime)
42
+ is_active = Column(Boolean, default=True)
43
+
44
+ sessions = relationship("Session", back_populates="user")
45
+ projects = relationship("Project", back_populates="user")
46
+
47
+
48
+ class Project(Base):
49
+ __tablename__ = "projects"
50
+
51
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
52
+ user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
53
+ name = Column(String(255), nullable=False)
54
+ description = Column(Text)
55
+ project_dir = Column(String(500), nullable=False)
56
+ created_at = Column(DateTime, default=datetime.utcnow)
57
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
58
+
59
+ user = relationship("User", back_populates="projects")
60
+ sessions = relationship("Session", back_populates="project")
61
+
62
+
63
+ class Session(Base):
64
+ __tablename__ = "sessions"
65
+
66
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
67
+ user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
68
+ project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=True)
69
+ prd_content = Column(Text)
70
+ provider = Column(String(50), default="claude")
71
+ mode = Column(String(50), default="standard")
72
+ status = Column(String(50), default="created") # created, running, paused, completed, failed
73
+ started_at = Column(DateTime, default=datetime.utcnow)
74
+ ended_at = Column(DateTime, nullable=True)
75
+ metadata_json = Column(JSON, default=dict)
76
+
77
+ user = relationship("User", back_populates="sessions")
78
+ project = relationship("Project", back_populates="sessions")
79
+
80
+
81
+ class Secret(Base):
82
+ __tablename__ = "secrets"
83
+
84
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
85
+ user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
86
+ key = Column(String(255), nullable=False)
87
+ encrypted_value = Column(Text, nullable=False) # Fernet encrypted
88
+ created_at = Column(DateTime, default=datetime.utcnow)
89
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
90
+
91
+
92
+ class AuditLog(Base):
93
+ __tablename__ = "audit_log"
94
+
95
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
96
+ user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
97
+ action = Column(String(100), nullable=False) # "session.start", "file.save", etc.
98
+ resource_type = Column(String(50)) # "session", "file", "secret"
99
+ resource_id = Column(String(255))
100
+ details = Column(JSON, default=dict)
101
+ ip_address = Column(String(45))
102
+ created_at = Column(DateTime, default=datetime.utcnow)
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Database connection state
107
+ # ---------------------------------------------------------------------------
108
+
109
+ DATABASE_URL: str | None = None
110
+ engine = None
111
+ async_session_factory: async_sessionmaker | None = None
112
+
113
+
114
+ async def init_db(database_url: str | None = None) -> bool:
115
+ """Initialize database connection. Returns False if no URL configured (file-based fallback)."""
116
+ global DATABASE_URL, engine, async_session_factory
117
+
118
+ url = database_url or os.environ.get("DATABASE_URL")
119
+ if not url:
120
+ return False # No database configured, use file-based fallback
121
+
122
+ DATABASE_URL = url
123
+ engine = create_async_engine(url, echo=False)
124
+ async_session_factory = async_sessionmaker(
125
+ engine, class_=AsyncSession, expire_on_commit=False
126
+ )
127
+
128
+ async with engine.begin() as conn:
129
+ await conn.run_sync(Base.metadata.create_all)
130
+
131
+ return True
132
+
133
+
134
+ async def get_db():
135
+ """Get database session. Yields None if no database configured."""
136
+ if async_session_factory is None:
137
+ yield None
138
+ return
139
+ async with async_session_factory() as session:
140
+ yield session
@@ -0,0 +1,27 @@
1
+ # Core -- required for server.py to start
2
+ fastapi>=0.100.0
3
+ uvicorn[standard]>=0.20.0
4
+ pydantic>=2.0.0
5
+ httpx>=0.24.0
6
+
7
+ # Terminal -- PTY-based interactive terminal
8
+ pexpect>=4.8.0
9
+
10
+ # File watcher -- live file-change notifications via WebSocket
11
+ watchdog>=3.0.0
12
+
13
+ # Database (optional) -- only needed for multi-user cloud deployment.
14
+ # Without DATABASE_URL set, the server uses file-based storage.
15
+ sqlalchemy[asyncio]>=2.0.0
16
+ asyncpg>=0.28.0
17
+ aiosqlite>=0.19.0
18
+ alembic>=1.12.0
19
+
20
+ # Auth (optional) -- only needed when DATABASE_URL is configured.
21
+ # Imports are guarded by try/except so the server starts without them.
22
+ python-jose[cryptography]>=3.3.0
23
+ passlib[bcrypt]>=1.7.4
24
+
25
+ # Encryption (optional) -- Fernet encryption for user secrets.
26
+ # Needed only for the /api/secrets endpoints in cloud mode.
27
+ cryptography>=41.0.0
package/web-app/server.py CHANGED
@@ -482,6 +482,60 @@ class DevServerManager:
482
482
 
483
483
  def __init__(self) -> None:
484
484
  self.servers: Dict[str, dict] = {}
485
+ self._portless_available: Optional[bool] = None
486
+ self._portless_proxy_started = False
487
+
488
+ def _has_portless(self) -> bool:
489
+ """Check if portless CLI is installed (cached)."""
490
+ if self._portless_available is None:
491
+ try:
492
+ subprocess.run(
493
+ ["portless", "--version"],
494
+ capture_output=True, timeout=5,
495
+ )
496
+ self._portless_available = True
497
+ except (FileNotFoundError, subprocess.TimeoutExpired):
498
+ self._portless_available = False
499
+ return self._portless_available
500
+
501
+ def _portless_app_name(self, session_id: str) -> str:
502
+ """Generate a deterministic short app name from session_id."""
503
+ clean = re.sub(r"[^a-zA-Z0-9]", "", session_id)
504
+ return f"lab-{clean[:6].lower()}"
505
+
506
+ def _ensure_portless_proxy(self) -> bool:
507
+ """Start the portless proxy if not already running.
508
+
509
+ Returns True if the proxy is available, False otherwise.
510
+ """
511
+ if self._portless_proxy_started:
512
+ return True
513
+ # Check if port 1355 is already listening
514
+ import socket
515
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
516
+ s.settimeout(1)
517
+ try:
518
+ s.connect(("127.0.0.1", 1355))
519
+ s.close()
520
+ self._portless_proxy_started = True
521
+ return True
522
+ except (ConnectionRefusedError, OSError):
523
+ s.close()
524
+ # Try to start the proxy
525
+ try:
526
+ subprocess.Popen(
527
+ ["portless", "proxy", "start"],
528
+ stdout=subprocess.DEVNULL,
529
+ stderr=subprocess.DEVNULL,
530
+ stdin=subprocess.DEVNULL,
531
+ )
532
+ # Give it a moment to start
533
+ import time as _time
534
+ _time.sleep(1)
535
+ self._portless_proxy_started = True
536
+ return True
537
+ except (FileNotFoundError, OSError):
538
+ return False
485
539
 
486
540
  async def detect_dev_command(self, project_dir: str) -> Optional[dict]:
487
541
  """Detect the dev command from project files."""
@@ -557,7 +611,20 @@ class DevServerManager:
557
611
  if session_id in self.servers:
558
612
  await self.stop(session_id)
559
613
 
614
+ # Try to detect dev command -- check root first, then subdirectories
560
615
  detected = await self.detect_dev_command(project_dir)
616
+ actual_dir = project_dir
617
+ if not detected:
618
+ # Check immediate subdirectories for a project with package.json
619
+ root = Path(project_dir)
620
+ if root.is_dir():
621
+ for subdir in sorted(root.iterdir()):
622
+ if subdir.is_dir() and not subdir.name.startswith('.'):
623
+ sub_detected = await self.detect_dev_command(str(subdir))
624
+ if sub_detected:
625
+ detected = sub_detected
626
+ actual_dir = str(subdir)
627
+ break
561
628
  if not command and not detected:
562
629
  return {"status": "error", "message": "No dev command detected. Provide one explicitly."}
563
630
 
@@ -565,18 +632,39 @@ class DevServerManager:
565
632
  expected_port = detected["expected_port"] if detected else 3000
566
633
  framework = detected["framework"] if detected else "unknown"
567
634
 
635
+ # Auto-install dependencies if needed
636
+ actual_path = Path(actual_dir)
637
+ needs_npm = (actual_path / "package.json").exists() and not (actual_path / "node_modules").exists()
638
+ needs_pip = (actual_path / "requirements.txt").exists() and not (actual_path / "venv").exists()
639
+ if needs_npm:
640
+ # Prepend npm install to the command
641
+ cmd_str = f"npm install && {cmd_str}"
642
+ if needs_pip:
643
+ cmd_str = f"pip install -r requirements.txt && {cmd_str}"
644
+
645
+ # Check if portless is available and proxy is running
646
+ use_portless = False
647
+ portless_app_name = None
648
+ if self._has_portless() and self._ensure_portless_proxy():
649
+ portless_app_name = self._portless_app_name(session_id)
650
+ use_portless = True
651
+ # Wrap the command with portless
652
+ effective_cmd = f"portless {portless_app_name} {cmd_str}"
653
+ else:
654
+ effective_cmd = cmd_str
655
+
568
656
  build_env = {**os.environ}
569
657
  build_env.update(_load_secrets())
570
658
 
571
659
  try:
572
660
  proc = subprocess.Popen(
573
- cmd_str,
661
+ effective_cmd,
574
662
  shell=True,
575
663
  stdout=subprocess.PIPE,
576
664
  stderr=subprocess.STDOUT,
577
665
  stdin=subprocess.DEVNULL,
578
666
  text=True,
579
- cwd=project_dir,
667
+ cwd=actual_dir,
580
668
  env=build_env,
581
669
  **({"start_new_session": True} if sys.platform != "win32"
582
670
  else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}),
@@ -588,12 +676,15 @@ class DevServerManager:
588
676
  "process": proc,
589
677
  "port": None,
590
678
  "expected_port": expected_port,
591
- "command": cmd_str,
679
+ "command": effective_cmd,
680
+ "original_command": cmd_str,
592
681
  "framework": framework,
593
682
  "status": "starting",
594
683
  "pid": proc.pid,
595
684
  "project_dir": project_dir,
596
685
  "output_lines": [],
686
+ "use_portless": use_portless,
687
+ "portless_app_name": portless_app_name,
597
688
  }
598
689
  self.servers[session_id] = server_info
599
690
 
@@ -612,16 +703,22 @@ class DevServerManager:
612
703
  "output": info["output_lines"][-10:] if info["output_lines"] else [],
613
704
  }
614
705
  if info["port"] is not None:
615
- health_ok = await self._health_check(info["port"])
706
+ # For portless, also verify the portless proxy can reach the app
707
+ check_port = info["port"]
708
+ health_ok = await self._health_check(check_port)
616
709
  if health_ok:
617
710
  info["status"] = "running"
618
- return {
711
+ result = {
619
712
  "status": "running",
620
713
  "port": info["port"],
621
- "command": cmd_str,
714
+ "command": effective_cmd,
622
715
  "pid": proc.pid,
623
716
  "url": f"/proxy/{session_id}/",
624
717
  }
718
+ if use_portless and portless_app_name:
719
+ result["portless_url"] = f"http://{portless_app_name}.localhost:1355/"
720
+ result["port"] = 1355
721
+ return result
625
722
 
626
723
  if proc.poll() is not None:
627
724
  server_info["status"] = "error"
@@ -635,24 +732,32 @@ class DevServerManager:
635
732
  if health_ok:
636
733
  server_info["port"] = expected_port
637
734
  server_info["status"] = "running"
638
- return {
735
+ result = {
639
736
  "status": "running",
640
737
  "port": expected_port,
641
- "command": cmd_str,
738
+ "command": effective_cmd,
642
739
  "pid": proc.pid,
643
740
  "url": f"/proxy/{session_id}/",
644
741
  }
742
+ if use_portless and portless_app_name:
743
+ result["portless_url"] = f"http://{portless_app_name}.localhost:1355/"
744
+ result["port"] = 1355
745
+ return result
645
746
 
646
747
  server_info["status"] = "starting"
647
748
  server_info["port"] = expected_port
648
- return {
749
+ result = {
649
750
  "status": "starting",
650
751
  "message": "Server started but port not yet confirmed",
651
752
  "port": expected_port,
652
- "command": cmd_str,
753
+ "command": effective_cmd,
653
754
  "pid": proc.pid,
654
755
  "url": f"/proxy/{session_id}/",
655
756
  }
757
+ if use_portless and portless_app_name:
758
+ result["portless_url"] = f"http://{portless_app_name}.localhost:1355/"
759
+ result["port"] = 1355
760
+ return result
656
761
 
657
762
  async def _monitor_output(self, session_id: str) -> None:
658
763
  """Background task: read dev server stdout and detect port."""
@@ -766,7 +871,7 @@ class DevServerManager:
766
871
  if not alive and info["status"] in ("running", "starting"):
767
872
  info["status"] = "error"
768
873
 
769
- return {
874
+ result = {
770
875
  "running": alive and info["status"] == "running",
771
876
  "status": info["status"],
772
877
  "port": info.get("port"),
@@ -776,6 +881,12 @@ class DevServerManager:
776
881
  "framework": info.get("framework"),
777
882
  "output": info.get("output_lines", [])[-20:],
778
883
  }
884
+ if info.get("use_portless") and info.get("portless_app_name"):
885
+ app_name = info["portless_app_name"]
886
+ result["portless_url"] = f"http://{app_name}.localhost:1355/"
887
+ if alive:
888
+ result["port"] = 1355
889
+ return result
779
890
 
780
891
  async def stop_all(self) -> None:
781
892
  """Stop all dev servers (used on shutdown)."""
@@ -2308,6 +2419,14 @@ async def chat_session(session_id: str, req: ChatRequest) -> JSONResponse:
2308
2419
  break
2309
2420
  # Strip ANSI escape codes for clean display
2310
2421
  clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', raw_line.rstrip("\n"))
2422
+ # Filter out noisy tool-use output lines from Claude
2423
+ stripped = clean.strip()
2424
+ if stripped in ("[Tool: Read]", "[Tool: Bash]", "[Tool: Write]",
2425
+ "[Tool: Edit]", "[Tool: Grep]", "[Tool: Glob]",
2426
+ "[Result]", "[Thinking]"):
2427
+ continue
2428
+ if not stripped:
2429
+ continue
2311
2430
  task.output_lines.append(clean)
2312
2431
  proc.stdout.close()
2313
2432
 
@@ -2630,7 +2749,7 @@ async def get_preview_info(session_id: str) -> JSONResponse:
2630
2749
  info["type"] = "web-app"
2631
2750
  info["entry_file"] = "index.html"
2632
2751
  info["preview_url"] = f"/api/sessions/{session_id}/preview/index.html"
2633
- info["dev_command"] = pkg_scripts.get("dev") or pkg_scripts.get("start")
2752
+ info["dev_command"] = "npm run dev" if "dev" in pkg_scripts else "npm start" if "start" in pkg_scripts else None
2634
2753
  info["description"] = "Web application -- serves HTML/CSS/JS"
2635
2754
  elif is_express or (has_package_json and ("start" in pkg_scripts or "dev" in pkg_scripts) and not has_index_html):
2636
2755
  # API/server project
@@ -2642,7 +2761,7 @@ async def get_preview_info(session_id: str) -> JSONResponse:
2642
2761
  port = int(port_match.group(1))
2643
2762
  info["type"] = "api"
2644
2763
  info["port"] = port
2645
- info["dev_command"] = pkg_scripts.get("dev") or pkg_scripts.get("start")
2764
+ info["dev_command"] = "npm run dev" if "dev" in pkg_scripts else "npm start" if "start" in pkg_scripts else None
2646
2765
  info["description"] = f"API server -- runs on port {port}"
2647
2766
  # Check for swagger/openapi
2648
2767
  for swagger_path in ["swagger.json", "openapi.json", "docs", "api-docs"]:
@@ -2661,7 +2780,7 @@ async def get_preview_info(session_id: str) -> JSONResponse:
2661
2780
  info["description"] = "Static site -- serves HTML directly"
2662
2781
  elif has_package_json and "test" in pkg_scripts:
2663
2782
  info["type"] = "library"
2664
- info["dev_command"] = pkg_scripts.get("test")
2783
+ info["dev_command"] = "npm test"
2665
2784
  info["description"] = "Library/package -- run tests to verify"
2666
2785
  elif has_go_mod:
2667
2786
  info["type"] = "go-app"
@@ -2708,8 +2827,12 @@ async def start_devserver(session_id: str, req: DevServerStartRequest) -> JSONRe
2708
2827
  target = _find_session_dir(session_id)
2709
2828
  if target is None:
2710
2829
  return JSONResponse(status_code=404, content={"error": "Session not found"})
2711
- result = await dev_server_manager.start(session_id, str(target), req.command)
2712
- status_code = 200 if result.get("status") != "error" else 500
2830
+ try:
2831
+ result = await dev_server_manager.start(session_id, str(target), req.command)
2832
+ except Exception as e:
2833
+ logger.error("Dev server start failed: %s", e)
2834
+ result = {"status": "error", "message": str(e)}
2835
+ status_code = 200 if result.get("status") != "error" else 400
2713
2836
  return JSONResponse(content=result, status_code=status_code)
2714
2837
 
2715
2838
 
@@ -2753,8 +2876,17 @@ async def proxy_to_devserver(session_id: str, path: str, request: Request):
2753
2876
  status_code=503,
2754
2877
  )
2755
2878
 
2756
- target_port = server["port"]
2757
- target_url = f"http://127.0.0.1:{target_port}/{path}"
2879
+ # Determine target: portless URL or direct port
2880
+ if server.get("use_portless") and server.get("portless_app_name"):
2881
+ app_name = server["portless_app_name"]
2882
+ target_host = f"{app_name}.localhost"
2883
+ target_port = 1355
2884
+ target_url = f"http://{target_host}:{target_port}/{path}"
2885
+ else:
2886
+ target_port = server["port"]
2887
+ target_host = f"127.0.0.1:{target_port}"
2888
+ target_url = f"http://127.0.0.1:{target_port}/{path}"
2889
+
2758
2890
  if request.url.query:
2759
2891
  target_url += f"?{request.url.query}"
2760
2892
 
@@ -2765,7 +2897,7 @@ async def proxy_to_devserver(session_id: str, path: str, request: Request):
2765
2897
  k: v for k, v in request.headers.items()
2766
2898
  if k.lower() not in skip_headers
2767
2899
  }
2768
- fwd_headers["host"] = f"127.0.0.1:{target_port}"
2900
+ fwd_headers["host"] = target_host
2769
2901
 
2770
2902
  body = await request.body()
2771
2903
 
@@ -2837,9 +2969,13 @@ async def proxy_websocket(websocket: WebSocket, session_id: str, path: str):
2837
2969
  await websocket.close(code=1008, reason="Dev server not running")
2838
2970
  return
2839
2971
 
2840
- target_port = server["port"]
2841
- # Forward the exact sub-path to the upstream dev server
2842
- ws_url = f"ws://127.0.0.1:{target_port}/{path}"
2972
+ # Determine WebSocket target: portless or direct
2973
+ if server.get("use_portless") and server.get("portless_app_name"):
2974
+ app_name = server["portless_app_name"]
2975
+ ws_url = f"ws://{app_name}.localhost:1355/{path}"
2976
+ else:
2977
+ target_port = server["port"]
2978
+ ws_url = f"ws://127.0.0.1:{target_port}/{path}"
2843
2979
 
2844
2980
  await websocket.accept()
2845
2981