2026-08-07 10:53:08 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
from .._shared import endpoint, field
|
|
|
|
|
|
|
|
|
|
GROUP = {
|
|
|
|
|
"slug": "workspaces",
|
|
|
|
|
"title": "Dev Workspaces",
|
|
|
|
|
"intro": """
|
|
|
|
|
# Dev Workspaces
|
|
|
|
|
|
2026-08-10 00:23:20 +02:00
|
|
|
A workspace is a browser editor attached to one of your projects. It runs your project files, a
|
|
|
|
|
terminal, and preinstalled Python, Rust, Nim and Swift toolchains. `sudo` and `apt install` work
|
|
|
|
|
with no extra setup; ports below 1024 cannot bind, so use a high port and publish it through a
|
|
|
|
|
tunnel.
|
|
|
|
|
|
|
|
|
|
The editor opens with a **DevPlace Code** terminal already running the `dpc` coding agent and a
|
|
|
|
|
plain shell beside it, and it trusts every folder, so nothing opens in Restricted Mode. Its
|
|
|
|
|
appearance and boot behaviour are your own preferences, readable and writable through the two
|
|
|
|
|
`/workspace/editor` endpoints below and explained on
|
|
|
|
|
[the workspace editor page](/docs/workspace-editor.html). Editor preferences apply on the next
|
|
|
|
|
workspace start.
|
2026-08-07 10:53:08 +02:00
|
|
|
|
|
|
|
|
A **tunnel** publishes one port from inside your container on a public HTTPS hostname of the form
|
|
|
|
|
`<port>-<name>.tunnel.pravda.education`. **Tunnel URLs are public and unauthenticated** - anyone with
|
Route container proxies through the leg that is actually reachable
The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.
Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.
The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.
Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.
Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.
Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.
Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
2026-08-11 20:03:15 +02:00
|
|
|
the link can reach whatever you are serving. Forwarding a port in the editor's **Ports** view creates
|
|
|
|
|
the tunnel for you through the same endpoint; un-forwarding it does not remove the tunnel.
|
2026-08-07 10:53:08 +02:00
|
|
|
|
|
|
|
|
Workspaces are bounded: a count limit per user, a disk quota, an egress quota, and a tunnel limit.
|
|
|
|
|
An idle workspace is warned about, then stopped, then warned again, then removed. Every warning
|
|
|
|
|
arrives as a `workspace` notification and states exactly what happens next and when.
|
|
|
|
|
""",
|
|
|
|
|
"endpoints": [
|
|
|
|
|
endpoint(
|
|
|
|
|
id="workspace-get",
|
|
|
|
|
method="GET",
|
|
|
|
|
path="/projects/{slug}/workspace",
|
|
|
|
|
title="Read workspace",
|
|
|
|
|
summary=(
|
|
|
|
|
"State, quota usage, idle countdown, tunnels and open moderation flags "
|
|
|
|
|
"for your workspace on this project."
|
|
|
|
|
),
|
|
|
|
|
auth="user",
|
|
|
|
|
params=[
|
|
|
|
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
|
|
|
|
],
|
|
|
|
|
sample_response={
|
|
|
|
|
"has_workspace": True,
|
|
|
|
|
"viewer_can_workspace": True,
|
|
|
|
|
"workspace_count": 1,
|
|
|
|
|
"max_workspaces": 2,
|
|
|
|
|
"editor_url": "/projects/my-project/containers/instances/INSTANCE_UID/code/",
|
|
|
|
|
"workspace": {
|
|
|
|
|
"uid": "INSTANCE_UID",
|
|
|
|
|
"status": "running",
|
|
|
|
|
"suspended": False,
|
|
|
|
|
"tunnel_name": "brave-otter",
|
|
|
|
|
"primary_url": "https://brave-otter.tunnel.pravda.education",
|
|
|
|
|
"disk_bytes": 5242880,
|
|
|
|
|
"disk_quota_mb": 2048,
|
|
|
|
|
"disk_percent": 1,
|
|
|
|
|
"egress_bytes": 10240,
|
|
|
|
|
"egress_quota_mb": 10240,
|
|
|
|
|
"egress_percent": 0,
|
|
|
|
|
"idle_stop_minutes": 60,
|
|
|
|
|
"retention_days": 14,
|
|
|
|
|
"max_tunnels": 5,
|
|
|
|
|
"tunnels": [],
|
|
|
|
|
"flags": [],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
endpoint(
|
|
|
|
|
id="workspace-open",
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/projects/{slug}/workspace",
|
|
|
|
|
title="Open or resume workspace",
|
|
|
|
|
summary=(
|
|
|
|
|
"Create the workspace if you have none for this project, otherwise resume "
|
|
|
|
|
"it. Idempotent. Refused when you are at your workspace limit, over disk "
|
|
|
|
|
"quota, or suspended."
|
|
|
|
|
),
|
|
|
|
|
auth="user",
|
|
|
|
|
params=[
|
|
|
|
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
|
|
|
|
],
|
|
|
|
|
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
|
|
|
|
),
|
|
|
|
|
endpoint(
|
|
|
|
|
id="workspace-stop",
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/projects/{slug}/workspace/stop",
|
|
|
|
|
title="Stop workspace",
|
|
|
|
|
summary="Stop the container. Files and tunnels are kept.",
|
|
|
|
|
auth="user",
|
|
|
|
|
params=[
|
|
|
|
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
|
|
|
|
],
|
|
|
|
|
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
|
|
|
|
),
|
|
|
|
|
endpoint(
|
|
|
|
|
id="workspace-delete",
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/projects/{slug}/workspace/delete",
|
|
|
|
|
title="Delete workspace",
|
|
|
|
|
summary="Remove the workspace and its tunnels. An administrator can restore it.",
|
|
|
|
|
auth="user",
|
|
|
|
|
params=[
|
|
|
|
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
|
|
|
|
],
|
|
|
|
|
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
|
|
|
|
),
|
2026-08-10 00:23:20 +02:00
|
|
|
endpoint(
|
|
|
|
|
id="workspace-editor-get",
|
|
|
|
|
method="GET",
|
|
|
|
|
path="/projects/{slug}/workspace/editor",
|
|
|
|
|
title="Read editor profile",
|
|
|
|
|
summary=(
|
|
|
|
|
"The resolved DevPlace editor profile for this workspace: theme, layout, "
|
|
|
|
|
"panel preset, font sizes, zoom, boot terminals, how the editor opens, the "
|
|
|
|
|
"container size, and where each value comes from."
|
|
|
|
|
),
|
|
|
|
|
auth="user",
|
|
|
|
|
params=[
|
|
|
|
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
|
|
|
|
],
|
|
|
|
|
sample_response={
|
|
|
|
|
"editor": {
|
|
|
|
|
"trust_all": True,
|
|
|
|
|
"theme": "devplace-dark",
|
|
|
|
|
"font_size": 14,
|
|
|
|
|
"terminal_font_size": 13,
|
|
|
|
|
"zoom_level": 0,
|
|
|
|
|
"layout": "standard",
|
|
|
|
|
"panel_preset": "tall",
|
|
|
|
|
"boot_agent": "dpc",
|
|
|
|
|
"boot_shell": True,
|
|
|
|
|
"window_mode": "tab",
|
|
|
|
|
"window_width": 1600,
|
|
|
|
|
"window_height": 1000,
|
|
|
|
|
"cpu_millicores": 2000,
|
|
|
|
|
"cpu_cores": 2.0,
|
|
|
|
|
"memory_mb": 2048,
|
|
|
|
|
"disk_quota_mb": 2048,
|
|
|
|
|
"sources": {"theme": "user", "font_size": "site"},
|
|
|
|
|
},
|
|
|
|
|
"restart_required": False,
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
endpoint(
|
|
|
|
|
id="workspace-editor-set",
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/projects/{slug}/workspace/editor",
|
|
|
|
|
title="Set editor preferences",
|
|
|
|
|
summary=(
|
|
|
|
|
"Change your own editor preferences. Only the fields you send are "
|
|
|
|
|
"changed; within those, an empty string or zero means inherit the site "
|
|
|
|
|
"default, and `reset` drops every preference. Applies on the next "
|
|
|
|
|
"workspace start, and the response says whether a restart is needed."
|
|
|
|
|
),
|
|
|
|
|
auth="user",
|
|
|
|
|
params=[
|
|
|
|
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
|
|
|
|
field("theme", "body", "string", False, "devplace-dark",
|
|
|
|
|
"devplace-dark, devplace-light or system."),
|
|
|
|
|
field("layout", "body", "string", False, "standard",
|
|
|
|
|
"standard, terminal-focus or zen."),
|
|
|
|
|
field("panel_preset", "body", "string", False, "tall",
|
|
|
|
|
"short, normal, tall or maximized."),
|
|
|
|
|
field("font_size", "body", "integer", False, "14",
|
|
|
|
|
"Editor font size in pixels. Zero inherits."),
|
|
|
|
|
field("terminal_font_size", "body", "integer", False, "13",
|
|
|
|
|
"Terminal font size in pixels. Zero inherits."),
|
|
|
|
|
field("zoom_level", "body", "integer", False, "0",
|
|
|
|
|
"Window zoom, -5 to 5. Send -99 to inherit."),
|
|
|
|
|
field("boot_agent", "body", "string", False, "dpc",
|
|
|
|
|
"dpc or none."),
|
|
|
|
|
field("boot_shell", "body", "integer", False, "1",
|
|
|
|
|
"1 opens a shell on boot, 0 skips it, -1 inherits."),
|
|
|
|
|
field("window_mode", "body", "string", False, "tab",
|
|
|
|
|
"tab, window or fullscreen."),
|
|
|
|
|
field("window_width", "body", "integer", False, "1600",
|
|
|
|
|
"Editor window width in pixels. Zero inherits."),
|
|
|
|
|
field("window_height", "body", "integer", False, "1000",
|
|
|
|
|
"Editor window height in pixels. Zero inherits."),
|
|
|
|
|
field("reset", "body", "boolean", False, "false",
|
|
|
|
|
"Drop every preference and fall back to the site defaults."),
|
|
|
|
|
],
|
|
|
|
|
sample_response={
|
|
|
|
|
"ok": True,
|
|
|
|
|
"redirect": "/projects/my-project/workspace",
|
|
|
|
|
"data": {"restart_required": True},
|
|
|
|
|
},
|
|
|
|
|
),
|
2026-08-07 10:53:08 +02:00
|
|
|
endpoint(
|
|
|
|
|
id="workspace-tunnels-list",
|
|
|
|
|
method="GET",
|
|
|
|
|
path="/projects/{slug}/workspace/tunnels",
|
|
|
|
|
title="List tunnels",
|
|
|
|
|
summary="Every public tunnel published by this workspace.",
|
|
|
|
|
auth="user",
|
|
|
|
|
params=[
|
|
|
|
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
|
|
|
|
],
|
|
|
|
|
sample_response={
|
|
|
|
|
"tunnels": [
|
|
|
|
|
{
|
|
|
|
|
"uid": "TUNNEL_UID",
|
|
|
|
|
"hostname": "8080-brave-otter.tunnel.pravda.education",
|
|
|
|
|
"label": "web",
|
|
|
|
|
"container_port": 8080,
|
|
|
|
|
"status": "active",
|
|
|
|
|
"cert_status": "valid",
|
|
|
|
|
"request_count": 12,
|
|
|
|
|
"bytes_out": 40960,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
endpoint(
|
|
|
|
|
id="workspace-tunnel-create",
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/projects/{slug}/workspace/tunnels",
|
|
|
|
|
title="Create tunnel",
|
|
|
|
|
summary=(
|
|
|
|
|
"Publish a container port on a public HTTPS hostname. The URL is public "
|
Route container proxies through the leg that is actually reachable
The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.
Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.
The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.
Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.
Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.
Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.
Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
2026-08-11 20:03:15 +02:00
|
|
|
"and unauthenticated. Refused past the tunnel limit. The certificate is "
|
|
|
|
|
"ordered right away, so the hostname answers plain HTTP for a few seconds "
|
|
|
|
|
"before it serves HTTPS. Forwarding a port in the editor calls this for you."
|
2026-08-07 10:53:08 +02:00
|
|
|
),
|
|
|
|
|
auth="user",
|
|
|
|
|
params=[
|
|
|
|
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
|
|
|
|
field("container_port", "body", "integer", True, 8080, "Port inside the container."),
|
|
|
|
|
field("label", "body", "string", False, "web", "Human label."),
|
|
|
|
|
],
|
|
|
|
|
sample_response={
|
|
|
|
|
"ok": True,
|
|
|
|
|
"data": {
|
|
|
|
|
"uid": "TUNNEL_UID",
|
|
|
|
|
"hostname": "8080-brave-otter.tunnel.pravda.education",
|
|
|
|
|
"status": "pending",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
endpoint(
|
|
|
|
|
id="workspace-tunnel-delete",
|
|
|
|
|
method="POST",
|
|
|
|
|
path="/projects/{slug}/workspace/tunnels/{uid}/delete",
|
|
|
|
|
title="Delete tunnel",
|
|
|
|
|
summary="Remove a tunnel. The public URL stops serving immediately.",
|
|
|
|
|
auth="user",
|
|
|
|
|
params=[
|
|
|
|
|
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
|
|
|
|
field("uid", "path", "string", True, "TUNNEL_UID", "Tunnel uid."),
|
|
|
|
|
],
|
|
|
|
|
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
}
|