Make dev workspaces serve a working browser IDE end to end

The workspace feature shipped its routes, agent tools and docs, but the
editor was never reachable: the project page had no entry point, the ppy
image had no code-server binary, no certificate was ever requested for a
tunnel, and both nginx and the proxy dropped what the editor needs.

- Add a VS Code button to the project action row and a Workspace item to
  the overflow menu, gated by can_open_workspace plus a running instance
  (viewer_can_workspace and workspace_editor_url on ProjectDetailOut).
- Install a pinned code-server in ppy.Dockerfile before USER pravda and
  assert it in the build smoke test, so an image that cannot run the
  editor no longer builds green.
- Run the editor with --auth password and a per workspace 8 character
  pronounceable secret, minted once at the ensure_editor_password choke
  point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the
  admin listing shares.
- Publish the editor tunnel when a workspace is created and issue its
  certificate from a new WorkspaceService phase against the molohttp admin
  API, then notify the owner with the live URL and the password. Only
  pending rows are retried, so a broken host cannot burn the ACME failure
  rate limit. Renewal stays molohttp's job.
- Forward the original Host on proxied requests and the client cookie on
  proxied websockets, so code-server scopes its session cookie to the
  public hostname and authenticates the workbench socket.
- Recreate a container stuck in the created state instead of retrying
  docker start forever against an image it can no longer run.
- Return a JSON string from WorkspaceController.dispatch; raw dicts landed
  in a tool message and aborted the turn at the model endpoint.
- Let the nginx catch-all carry websocket upgrades, keeping upstream
  keepalive, so tunnelled apps and the editor both connect.
This commit is contained in:
2026-08-07 13:46:40 +02:00
parent 21f6ae0615
commit 192df12b1d
25 changed files with 716 additions and 26 deletions
+91
View File
@@ -1,6 +1,7 @@
# retoor <retoor@molodetz.nl>
import re
import time
from uuid import uuid4
import pytest
@@ -46,6 +47,11 @@ def _project_for(owner_uid: str, title: str = "WS Project") -> dict:
"title": title,
"description": "workspace host project",
"slug": slug,
"project_type": "software",
"status": "In Development",
"platforms": "",
"is_private": 0,
"read_only": 0,
"stars": 0,
"created_at": "2026-01-01T00:00:00+00:00",
"deleted_at": None,
@@ -70,6 +76,91 @@ def _workspace_for(project: dict, owner_uid: str, **overrides) -> dict:
return store.create_instance(payload)
def test_project_page_offers_the_workspace_entry_point_to_the_owner(alice):
page, user = alice
project = _project_for(_row_for(user)["uid"], "WS Entry")
page.goto(
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
)
page.locator(".project-actions-more").click()
entry = page.locator(".context-menu-item:has-text('Workspace')")
entry.wait_for(state="visible")
entry.click()
page.wait_for_url(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
page.locator(".workspace-page").wait_for(state="visible")
def test_project_page_hides_the_workspace_entry_point_from_a_non_owner(bob):
page, user = bob
project = _project_for(str(uuid4()), "WS Entry Foreign")
page.goto(
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
)
page.locator(".project-actions-more").click()
assert not page.locator(".context-menu-item:has-text('Workspace')").count()
def test_project_page_shows_a_direct_vscode_button_for_a_running_workspace(alice):
page, user = alice
row = _row_for(user)
project = _project_for(row["uid"], "WS Direct")
instance = _workspace_for(project, row["uid"])
page.goto(
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
)
button = page.locator(".project-detail-actions a:has-text('VS Code')")
button.wait_for(state="visible")
expect(button).to_have_attribute("target", "_blank")
expect(button).to_have_attribute(
"href",
f"/projects/{project['slug']}/containers/instances/{instance['uid']}/code/",
)
def test_direct_vscode_button_is_absent_while_the_workspace_is_stopped(alice):
page, user = alice
row = _row_for(user)
project = _project_for(row["uid"], "WS Stopped")
_workspace_for(project, row["uid"], status="stopped", desired_state="stopped")
page.goto(
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
)
page.locator(".project-detail-actions").wait_for(state="visible")
assert not page.locator(".project-detail-actions a:has-text('VS Code')").count()
def _await_workspace_flag(slug: str, key: str, expected: bool) -> bool:
deadline = time.time() + 10.0
url = f"{BASE_URL}/projects/{slug}"
headers = {"Accept": "application/json", "X-API-KEY": key}
while time.time() < deadline:
body = requests.get(url, headers=headers).json()
if body["viewer_can_workspace"] is expected:
return True
time.sleep(0.2)
return False
def test_project_page_hides_the_workspace_entry_point_when_disabled(alice):
page, user = alice
row = _row_for(user)
project = _project_for(row["uid"], "WS Entry Off")
set_setting("workspace_enabled", "0")
try:
assert _await_workspace_flag(project["slug"], row["api_key"], False)
page.goto(
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
)
page.locator(".project-actions-more").click()
assert not page.locator(".context-menu-item:has-text('Workspace')").count()
finally:
set_setting("workspace_enabled", "1")
assert _await_workspace_flag(project["slug"], row["api_key"], True)
def test_workspace_page_offers_creation_to_owner(alice):
page, user = alice
project = _project_for(_row_for(user)["uid"])