forked from retoor/devplacepy
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:
@@ -421,6 +421,36 @@ def test_private_detail_visible_to_admin(app_server):
|
||||
)
|
||||
|
||||
|
||||
def _await_workspace_flag(url, key, expected, timeout=10.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
body = requests.get(url, headers=_h_project_visibility(key)).json()
|
||||
if body["viewer_can_workspace"] is expected:
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def test_detail_json_exposes_viewer_can_workspace(app_server):
|
||||
_, _, owner_key = _signup_project_visibility()
|
||||
_, _, other_key = _signup_project_visibility()
|
||||
slug = _create_project_project_visibility(owner_key, "Workspace Flag")["slug"]
|
||||
url = f"{BASE_URL}/projects/{slug}"
|
||||
previous = get_table("site_settings").find_one(key="workspace_enabled")
|
||||
set_setting("workspace_enabled", "1")
|
||||
try:
|
||||
assert _await_workspace_flag(url, owner_key, True)
|
||||
other = requests.get(url, headers=_h_project_visibility(other_key)).json()
|
||||
assert other["viewer_can_workspace"] is False
|
||||
|
||||
set_setting("workspace_enabled", "0")
|
||||
assert _await_workspace_flag(url, owner_key, False)
|
||||
finally:
|
||||
set_setting(
|
||||
"workspace_enabled", previous.get("value") if previous else "0"
|
||||
)
|
||||
|
||||
|
||||
def test_project_uid_redirects_to_canonical_slug(app_server):
|
||||
slug, uid = _seed_project()
|
||||
r = requests.get(f"{BASE_URL}/projects/{uid}", allow_redirects=False)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.content import can_manage_workspace, can_open_workspace
|
||||
from devplacepy.database import get_table, init_db, set_setting
|
||||
from devplacepy.services.containers import activity, store
|
||||
from devplacepy.services.containers import activity, api, store
|
||||
from devplacepy.services.containers.workspace import (
|
||||
flags,
|
||||
naming,
|
||||
@@ -214,6 +216,171 @@ def test_devii_workspace_tools_are_role_gated():
|
||||
assert names <= admin
|
||||
|
||||
|
||||
def test_opening_a_workspace_publishes_the_editor_tunnel_automatically():
|
||||
project = _project()
|
||||
user = {"uid": OWNER, "username": "owner"}
|
||||
instance = run_async(provision.ensure(project, user))
|
||||
rows = tunnels.list_for_instance(instance["uid"])
|
||||
assert len(rows) == 1
|
||||
editor = rows[0]
|
||||
assert editor["container_port"] == api.EDITOR_DEFAULT_PORT
|
||||
assert editor["hostname"].startswith(f"{api.EDITOR_DEFAULT_PORT}-")
|
||||
assert editor["status"] == tunnels.STATUS_PENDING
|
||||
assert provision.is_editor_tunnel(instance, editor)
|
||||
assert instance["editor_password"]
|
||||
|
||||
|
||||
def test_active_editor_tunnel_notifies_the_owner_with_url_and_password():
|
||||
from devplacepy.services.containers.workspace_service import WorkspaceService
|
||||
|
||||
project = _project()
|
||||
instance = run_async(provision.ensure(project, {"uid": OWNER, "username": "o"}))
|
||||
row = tunnels.list_for_instance(instance["uid"])[0]
|
||||
try:
|
||||
WorkspaceService()._announce_tunnel(row, row["hostname"])
|
||||
sent = list(
|
||||
get_table("notifications").find(
|
||||
user_uid=OWNER, type="workspace", order_by=["-id"]
|
||||
)
|
||||
)
|
||||
assert sent, "owner was not notified"
|
||||
message = sent[0]["message"]
|
||||
assert f"https://{row['hostname']}" in message
|
||||
assert instance["editor_password"] in message
|
||||
assert sent[0]["target_url"].endswith("/workspace")
|
||||
finally:
|
||||
get_table("notifications").delete(user_uid=OWNER)
|
||||
|
||||
|
||||
def test_editor_password_is_pronounceable_and_eight_chars():
|
||||
seen = {api.generate_editor_password() for _ in range(50)}
|
||||
assert len(seen) > 40
|
||||
for password in seen:
|
||||
assert len(password) == 8
|
||||
assert password.isalpha() and password.islower()
|
||||
for index, char in enumerate(password):
|
||||
expected = (
|
||||
api.EDITOR_PASSWORD_CONSONANTS
|
||||
if index % 2 == 0
|
||||
else api.EDITOR_PASSWORD_VOWELS
|
||||
)
|
||||
assert char in expected
|
||||
|
||||
|
||||
def test_editor_runs_with_password_auth_and_an_eight_char_secret():
|
||||
from devplacepy.services.containers import api
|
||||
|
||||
instance = _instance()
|
||||
password = api.ensure_editor_password(instance)
|
||||
assert len(password) == api.EDITOR_PASSWORD_LENGTH == 8
|
||||
assert password.isalnum()
|
||||
|
||||
command = api.editor_command(store.get_instance(instance["uid"]))
|
||||
assert "--auth" in command
|
||||
assert command[command.index("--auth") + 1] == "password"
|
||||
assert "none" not in command
|
||||
|
||||
|
||||
def test_editor_password_is_stable_and_reaches_the_container_env():
|
||||
from devplacepy.services.containers import api
|
||||
|
||||
instance = _instance()
|
||||
first = api.ensure_editor_password(instance)
|
||||
again = api.ensure_editor_password(store.get_instance(instance["uid"]))
|
||||
assert first == again
|
||||
|
||||
env = api.pravda_env(store.get_instance(instance["uid"]))
|
||||
assert env["PASSWORD"] == first
|
||||
|
||||
|
||||
def test_every_workspace_launch_gets_a_password_even_if_never_provisioned():
|
||||
from devplacepy.services.containers import api
|
||||
|
||||
instance = _instance()
|
||||
store.update_instance(instance["uid"], {"editor_password": ""})
|
||||
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
|
||||
assert len(spec.env["PASSWORD"]) == 8
|
||||
assert store.get_instance(instance["uid"])["editor_password"] == spec.env["PASSWORD"]
|
||||
|
||||
|
||||
def test_non_workspace_instance_never_gets_an_editor_password():
|
||||
from devplacepy.services.containers import api
|
||||
|
||||
instance = _instance(is_workspace=0)
|
||||
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
|
||||
assert not spec.env.get("PASSWORD")
|
||||
|
||||
|
||||
def test_awaiting_certificate_only_returns_pending_present_tunnels():
|
||||
instance = _instance()
|
||||
tunnels.create(instance, "web", 8080, OWNER)
|
||||
tunnels.create(instance, "api", 9090, OWNER)
|
||||
active = tunnels.list_for_instance(instance["uid"])[0]
|
||||
tunnels.update(active["uid"], {"status": tunnels.STATUS_ACTIVE})
|
||||
waiting = [row["uid"] for row in tunnels.awaiting_certificate()]
|
||||
assert active["uid"] not in waiting
|
||||
assert len(waiting) == 1
|
||||
|
||||
absent = tunnels.get(waiting[0])
|
||||
tunnels.mark_absent(absent["uid"])
|
||||
assert not tunnels.awaiting_certificate()
|
||||
|
||||
|
||||
def test_certificate_phase_marks_active_on_success_and_failed_on_error(monkeypatch):
|
||||
from devplacepy.services.containers.workspace import certs
|
||||
from devplacepy.services.containers.workspace_service import WorkspaceService
|
||||
|
||||
instance = _instance()
|
||||
tunnels.create(instance, "web", 8080, OWNER)
|
||||
row = tunnels.awaiting_certificate()[0]
|
||||
service = WorkspaceService()
|
||||
|
||||
monkeypatch.setattr(certs, "configured", lambda: True)
|
||||
|
||||
async def _ok(hostname):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(certs, "issue", _ok)
|
||||
run_async(service._issue_tunnel_certificates())
|
||||
assert tunnels.get(row["uid"])["status"] == tunnels.STATUS_ACTIVE
|
||||
|
||||
tunnels.update(row["uid"], {"status": tunnels.STATUS_PENDING})
|
||||
|
||||
async def _boom(hostname):
|
||||
raise certs.CertError("molohttp cert issue failed (503): upstream down")
|
||||
|
||||
monkeypatch.setattr(certs, "issue", _boom)
|
||||
run_async(service._issue_tunnel_certificates())
|
||||
failed = tunnels.get(row["uid"])
|
||||
assert failed["status"] == tunnels.STATUS_FAILED
|
||||
assert "503" in failed["last_error"]
|
||||
|
||||
|
||||
def test_certificate_phase_is_a_noop_without_molohttp_credentials(monkeypatch):
|
||||
from devplacepy.services.containers.workspace import certs
|
||||
from devplacepy.services.containers.workspace_service import WorkspaceService
|
||||
|
||||
instance = _instance()
|
||||
tunnels.create(instance, "web", 8080, OWNER)
|
||||
row = tunnels.awaiting_certificate()[0]
|
||||
monkeypatch.setattr(certs, "configured", lambda: False)
|
||||
run_async(WorkspaceService()._issue_tunnel_certificates())
|
||||
assert tunnels.get(row["uid"])["status"] == tunnels.STATUS_PENDING
|
||||
|
||||
|
||||
def test_workspace_controller_always_returns_a_json_string():
|
||||
from devplacepy.services.devii.registry import CATALOG
|
||||
from devplacepy.services.devii.workspace.controller import WorkspaceController
|
||||
|
||||
controller = WorkspaceController("user", OWNER, admin=True)
|
||||
names = [a.name for a in CATALOG.actions if a.handler == "workspace"]
|
||||
assert names
|
||||
for name in names + ["workspace_does_not_exist"]:
|
||||
result = run_async(controller.dispatch(name, {}))
|
||||
assert isinstance(result, str), f"{name} returned {type(result).__name__}"
|
||||
json.loads(result)
|
||||
|
||||
|
||||
def test_every_confirm_gated_tool_declares_a_confirm_param():
|
||||
from devplacepy.services.devii.actions.dispatcher import CONFIRM_REQUIRED
|
||||
from devplacepy.services.devii.registry import CATALOG
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user