Update
Some checks failed
DevPlace CI / test (push) Has been cancelled

This commit is contained in:
retoor 2026-08-10 00:23:20 +02:00
parent 2bdcf6528f
commit 6cac64a3f6
63 changed files with 3499 additions and 68 deletions

View File

@ -90,6 +90,7 @@ devplacepy/
| `/reports` | Content reporting: `POST /reports/{target_type}/{target_uid}` files a report against any user-generated surface, `GET /reports/mine` lists the reports you filed and their outcome, `GET /reports/reasons` serves the reason registry so every client renders the same dialog |
| `/admin/moderation` | Admin **Moderation** queue: reported content oldest-open-first with the response-window badge, one report per detail page with the offender's history, triage (`/status`) and decisions (`/decide`) |
| `/workspaces/index` | Public index of every workspace published to the ingress proxy, with owner, project, maturity label and direct link |
| `/projects/{slug}/workspace` | A member's dev workspace for a project: open/start/stop/delete, quota and idle status, public tunnels, and the **Editor** card holding their editor preferences (`GET`/`POST /projects/{slug}/workspace/editor`) |
| `/block` | Block/unblock a user: hides all of their posts, comments and messages from you everywhere except their own profile, and stops them notifying you. Also reachable directly from every content action bar |
| `/mute` | Mute/unmute a user: stops them creating notifications for you while their content stays visible |
| `/leaderboard` | Contributor ranking by total stars earned |
@ -444,6 +445,51 @@ and its full configuration are documented automatically - including future servi
**Runtime data** (container workspaces and zip archives) lives in `DEVPLACE_DATA_DIR` (default `data/`), **outside the package and never served via `/static`**. The docker daemon must be able to bind-mount the data dir for `/app`.
### Dev Workspaces and the browser editor
A **workspace** is a member-facing container running the DevPlace browser editor, layered on the
container runtime above. It is opened from a project's **Workspace** page and reached at
`/projects/{slug}/workspace`; the editor itself is proxied at
`/projects/{slug}/containers/instances/{uid}/code/`, and an **Editor** button appears on the project
page whenever the workspace is running.
The editor is `code-server`, rebranded as DevPlace end to end: the application name, the browser tab
icon and PWA icons, the login page styling, and `product.json` all carry DevPlace, and a bundled
built-in extension ships the **DevPlace Dark** and **DevPlace Light** themes (generated from the
site's own design tokens), a **Get started on DevPlace** walkthrough, a project status bar item and
five `DevPlace:` commands. Nothing in the interface identifies as code-server.
**On boot** two terminals open: a focused **DevPlace Code** terminal already running `dpc`, the
coding agent baked into the image, and a plain login shell beside it with the Python, Rust, Nim and
Swift toolchains on `PATH`. Both are configurable, and `bash` stays the default profile for
terminals the member opens later.
The workspace opens straight onto the member's files rather than a welcome page, and the editor's
own built-in chat assistant is suppressed so `dpc` is the only agent on offer and every token it
spends is ledgered against the member's DevPlace account. `dpc`'s own working files (`.dpc/`,
`dpc.log`) are in `SYNC_SKIP_NAMES`, so running an agent on every boot never pollutes the project.
**Every workspace is trusted.** VS Code Restricted Mode is disabled at the command line and in the
seeded settings, so nothing prompts and automatic tasks run. This is a deliberate default with a
real consequence (a project's own `.vscode/tasks.json` will run on folder open), it is documented to
members on `/docs/workspace-editor.html`, and an administrator can restore Restricted Mode site-wide
with the `workspace_editor_trust_all` setting.
**Four sizes are configurable through one resolver.** Editor and terminal font size plus zoom, the
editor layout and terminal panel preset, whether the editor opens in a tab or a sized window, and the
container's CPU, memory and disk. The first three are the member's own preferences on their workspace
page (and over the API, and through Devii's `workspace_editor_get` / `workspace_editor_set`); the
container size is part of the administrator-set workspace quota. Each preference resolves instance
override, then the member's row, then the site setting, then the built-in default, and the page shows
which of those each value came from.
**A member edit is never overwritten.** DevPlace seeds the editor's `settings.json` from the host
before each launch and records exactly what it wrote; on the next launch it updates only the keys
whose current value is still the one it wrote. A setting the member changed inside the editor is
theirs permanently, while a change to the site default still reaches everyone who has expressed no
preference. Preferences apply on the next workspace start, and the page says so and offers the
restart.
### Async job framework and zip downloads
`services/jobs/` is the standard way to run blocking work asynchronously and hand the caller a result URL. A shared `jobs` table is the queue (discriminated by `kind`); `queue.enqueue()` inserts a `pending` row from any worker, the lock-owning worker processes jobs in `JobService.run_once` (reap, recover orphans, refill up to a concurrency limit, prune expired), and status is polled from the database. Retention is built in: each job service deletes its own expired artifacts via a `cleanup` hook (default 7 days, admin-configurable). To add a kind, subclass `JobService`, set `kind`, and implement `process()` and `cleanup()`.

View File

@ -65,6 +65,10 @@ UNREPORTABLE_TABLES: dict[str, str] = {
"email_accounts": "private mailbox credentials",
"instance_schedules": "child rows of a reportable workspace instance",
"workspace_flags": "moderation records, not authored content",
"workspace_quota_rules": "administrator-set limits, not authored content",
"workspace_editor_prefs": (
"private per-user editor configuration, never shown to another member"
),
"content_reports": "moderation records, readable only by the reporter and moderators",
"moderation_actions": "moderation records, not authored content",
"content_maturity": "moderation labels, not authored content",

View File

@ -121,6 +121,17 @@ def init_db():
_index(db, "comments", "idx_comments_user_uid", ["user_uid"])
_index(db, "comments", "idx_comments_created_at", ["created_at"])
_index(db, "votes", "idx_votes_target", ["target_uid", "target_type"])
messages = get_table("messages")
for column, example in (
("uid", ""),
("sender_uid", ""),
("receiver_uid", ""),
("content", ""),
("read", False),
("created_at", ""),
):
if not messages.has_column(column):
messages.create_column_by_example(column, example)
_index(db, "messages", "idx_messages_sender", ["sender_uid"])
_index(db, "messages", "idx_messages_receiver", ["receiver_uid"])
_index(
@ -588,6 +599,10 @@ def init_db():
("flag_reason", ""),
("suspended_at", ""),
("suspended_by", ""),
("boot_marker", ""),
("workspace_cpu_millicores", 0),
("workspace_memory_mb", 0),
("workspace_disk_quota_mb", 0),
):
if not instances.has_column(column):
instances.create_column_by_example(column, example)
@ -628,12 +643,36 @@ def init_db():
("egress_quota_mb", 0),
("idle_stop_minutes", 0),
("retention_days", 0),
("cpu_millicores", 0),
("memory_mb", 0),
("created_at", ""),
("updated_at", ""),
):
if not quota_rules.has_column(column):
quota_rules.create_column_by_example(column, example)
editor_prefs = get_table("workspace_editor_prefs")
for column, example in (
("uid", ""),
("owner_kind", ""),
("owner_id", ""),
("font_size", 0),
("terminal_font_size", 0),
("zoom_level", -99),
("theme", ""),
("layout", ""),
("panel_preset", ""),
("window_mode", ""),
("window_width", 0),
("window_height", 0),
("boot_agent", ""),
("boot_shell", -1),
("created_at", ""),
("updated_at", ""),
):
if not editor_prefs.has_column(column):
editor_prefs.create_column_by_example(column, example)
flags = get_table("workspace_flags")
for column, example in (
("uid", ""),
@ -666,6 +705,12 @@ def init_db():
"idx_workspace_quota_owner",
["owner_kind", "owner_id"],
)
_index(
db,
"workspace_editor_prefs",
"idx_workspace_editor_prefs_owner",
["owner_kind", "owner_id"],
)
_index(db, "workspace_flags", "idx_workspace_flags_open", ["status", "created_at"])
_index(
db,

View File

@ -25,6 +25,8 @@ SOFT_DELETE_TABLES = [
"instance_schedules",
"tunnels",
"workspace_flags",
"workspace_quota_rules",
"workspace_editor_prefs",
"backup_schedules",
"devii_conversations",
"devii_tasks",

View File

@ -8,10 +8,17 @@ GROUP = {
"intro": """
# Dev Workspaces
A workspace is a browser VS Code environment 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.
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.
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
@ -101,6 +108,88 @@ arrives as a `workspace` notification and states exactly what happens next and w
],
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
),
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},
},
),
endpoint(
id="workspace-tunnels-list",
method="GET",

View File

@ -959,6 +959,21 @@ class TunnelForm(BaseModel):
container_port: int = Field(default=0, ge=0, le=65535)
class EditorPrefsForm(BaseModel):
font_size: int = Field(default=0, ge=0, le=48)
terminal_font_size: int = Field(default=0, ge=0, le=48)
zoom_level: int = Field(default=-99, ge=-99, le=5)
theme: str = Field(default="", max_length=32)
layout: str = Field(default="", max_length=32)
panel_preset: str = Field(default="", max_length=32)
window_mode: str = Field(default="", max_length=32)
window_width: int = Field(default=0, ge=0, le=7680)
window_height: int = Field(default=0, ge=0, le=4320)
boot_agent: str = Field(default="", max_length=32)
boot_shell: int = Field(default=-1, ge=-1, le=1)
reset: bool = False
class WorkspaceQuotaForm(BaseModel):
owner_id: str = Field(default="", max_length=36)
label: str = Field(default="", max_length=64)
@ -968,6 +983,8 @@ class WorkspaceQuotaForm(BaseModel):
egress_quota_mb: int = Field(default=0, ge=0)
idle_stop_minutes: int = Field(default=0, ge=0)
retention_days: int = Field(default=0, ge=0)
cpu_millicores: int = Field(default=0, ge=0, le=64000)
memory_mb: int = Field(default=0, ge=0, le=1048576)
class WorkspaceFlagForm(BaseModel):

View File

@ -666,6 +666,9 @@ IMPORT_SKIP_NAMES = {
SYNC_SKIP_NAMES = IMPORT_SKIP_NAMES | {
".devplace_boot.py",
".devplace_boot.sh",
".devplace",
".dpc",
"dpc.log",
}
IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024

View File

@ -8,16 +8,24 @@ from fastapi.responses import JSONResponse
from devplacepy.database import db, get_table, get_users_by_uids
from devplacepy.dependencies import json_or_form
from devplacepy.models import (
EditorPrefsForm,
WorkspaceFlagForm,
WorkspaceQuotaForm,
WorkspaceSuspendForm,
)
from devplacepy.responses import action_result, json_error, respond
from devplacepy.routers.admin._shared import deny_senior, is_senior_admin
from devplacepy.schemas import AdminWorkspacesOut
from devplacepy.seo import base_seo_context
from devplacepy.services.audit import record as audit
from devplacepy.services.containers import store
from devplacepy.services.containers.workspace import flags, provision, quota, tunnels
from devplacepy.services.containers.workspace import (
editor,
flags,
provision,
quota,
tunnels,
)
from devplacepy.utils import create_notification, generate_uid, not_found, require_admin
router = APIRouter()
@ -237,6 +245,43 @@ async def admin_flag_resolve(request: Request, flag_uid: str, status: str = "res
return action_result(request, "/admin/workspaces")
@router.post("/workspaces/{uid}/editor")
async def admin_workspace_editor(
request: Request,
uid: str,
data: Annotated[EditorPrefsForm, Depends(json_or_form(EditorPrefsForm))],
):
admin = require_admin(request)
if not isinstance(admin, dict):
return admin
instance = _instance_or_404(uid)
owner_uid = instance.get("workspace_owner_uid", "")
owner = get_users_by_uids([owner_uid]).get(owner_uid) if owner_uid else None
if is_senior_admin(admin, owner):
return deny_senior(
request,
admin,
owner_uid,
owner,
"container.workspace.editor.update",
"/admin/workspaces",
)
if not owner_uid:
return json_error(400, "this workspace has no owner")
if data.reset:
editor.reset_prefs(owner_uid, admin["uid"])
else:
editor.save_prefs(
owner_uid, data.model_dump(exclude={"reset"}, exclude_unset=True)
)
_audit(request, admin, "container.workspace.editor.update", instance)
return action_result(
request,
"/admin/workspaces",
data={"editor": editor.view(owner_uid, instance)},
)
@router.post("/workspaces/quota")
async def admin_workspace_quota(
request: Request,

View File

@ -58,6 +58,12 @@ DOCS_PAGES = [
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "workspace-editor",
"title": "The workspace editor",
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "feed",
"title": "The feed",

View File

@ -2,7 +2,7 @@
from typing import Annotated
from fastapi import APIRouter, Form, Request, WebSocket
from fastapi import APIRouter, Depends, Form, Request, WebSocket
from starlette.responses import Response
from devplacepy.content import (
@ -10,13 +10,14 @@ from devplacepy.content import (
can_open_workspace,
)
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.models import TunnelForm
from devplacepy.dependencies import json_or_form
from devplacepy.models import EditorPrefsForm, TunnelForm
from devplacepy.responses import action_result, json_error, respond
from devplacepy.schemas import WorkspaceOut
from devplacepy.services.audit import record as audit
from devplacepy.services.containers import activity, api, forward, store
from devplacepy.services.containers.api import ContainerError
from devplacepy.services.containers.workspace import provision, quota, tunnels
from devplacepy.services.containers.workspace import editor, provision, quota, tunnels
from devplacepy.services.containers.workspace.provision import WorkspaceError
from devplacepy.utils import not_found, require_user
@ -25,6 +26,12 @@ from ._shared import audit_instance, fail
router = APIRouter()
def _restart_required(instance: dict, profile: editor.EditorProfile) -> bool:
if instance.get("status") != store.ST_RUNNING:
return False
return editor.restart_required(instance, profile)
def _project_or_404(slug: str) -> dict:
project = resolve_by_slug(get_table("projects"), slug)
if not project:
@ -64,6 +71,7 @@ async def workspace_page(request: Request, slug: str):
_guard(request, project, user, "container.workspace.open")
instance = provision.find_for_project(project["uid"], user["uid"])
limits = quota.resolve(user["uid"])
profile = editor.resolve(user["uid"], instance)
context = {
"project": project,
"workspace": provision.view(instance) if instance else None,
@ -79,6 +87,10 @@ async def workspace_page(request: Request, slug: str):
"editor_password": (
api.ensure_editor_password(instance) if instance else ""
),
"editor": editor.view(user["uid"], instance),
"restart_required": (
_restart_required(instance, profile) if instance else False
),
"user": user,
}
return respond(request, "workspace.html", context, model=WorkspaceOut)
@ -151,6 +163,63 @@ async def workspace_delete(request: Request, slug: str):
return action_result(request, f"/projects/{slug}/workspace")
@router.get("/{slug}/workspace/editor")
async def editor_prefs_read(request: Request, slug: str):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
profile = editor.resolve(user["uid"], instance)
return {
"editor": editor.view(user["uid"], instance),
"restart_required": _restart_required(instance, profile),
}
@router.post("/{slug}/workspace/editor")
async def editor_prefs_write(
request: Request,
slug: str,
data: Annotated[EditorPrefsForm, Depends(json_or_form(EditorPrefsForm))],
):
user = require_user(request)
if isinstance(user, Response):
return user
project = _project_or_404(slug)
instance = _workspace_or_404(project, user)
if not can_manage_workspace(instance, project, user):
return json_error(403, "Not allowed to manage this workspace")
owner_uid = instance.get("workspace_owner_uid") or user["uid"]
if data.reset:
editor.reset_prefs(owner_uid, user["uid"])
summary = f"{user['username']} reset their workspace editor preferences"
else:
editor.save_prefs(
owner_uid, data.model_dump(exclude={"reset"}, exclude_unset=True)
)
summary = f"{user['username']} updated their workspace editor preferences"
audit_instance(
request,
user,
"container.workspace.editor.update",
instance,
project,
summary=summary,
)
profile = editor.resolve(owner_uid, instance)
return action_result(
request,
f"/projects/{slug}/workspace",
data={
"editor": editor.view(owner_uid, instance),
"restart_required": _restart_required(instance, profile),
},
)
@router.get("/{slug}/workspace/tunnels")
async def tunnel_list(request: Request, slug: str):
user = require_user(request)

View File

@ -176,17 +176,24 @@ async def projects_page(
model=ProjectsOut,
)
def _editor_url(project: dict, user: dict) -> str:
def _editor_launch(project: dict, user: dict) -> dict:
from devplacepy.services.containers import store
from devplacepy.services.containers.workspace import provision
from devplacepy.services.containers.workspace import editor, provision
blank = {"url": "", "mode": "tab", "width": 0, "height": 0}
instance = provision.find_for_project(project["uid"], user["uid"])
if not instance or instance.get("suspended_at"):
return ""
return blank
if instance.get("status") != store.ST_RUNNING:
return ""
return blank
slug = project["slug"] or project["uid"]
return f"/projects/{slug}/containers/instances/{instance['uid']}/code/"
profile = editor.resolve(user["uid"], instance)
return {
"url": f"/projects/{slug}/containers/instances/{instance['uid']}/code/",
"mode": profile.window_mode,
"width": profile.window_width,
"height": profile.window_height,
}
@router.get("/{project_slug}", response_class=HTMLResponse)
@ -226,9 +233,12 @@ async def project_detail(request: Request, project_slug: str, before: str = None
schemas=[website_schema(base), software_application_schema(project, base)],
)
viewer_can_workspace = can_open_workspace(project, user)
workspace_editor_url = (
_editor_url(project, user) if viewer_can_workspace else ""
editor_launch = (
_editor_launch(project, user)
if viewer_can_workspace
else {"url": "", "mode": "tab", "width": 0, "height": 0}
)
workspace_editor_url = editor_launch["url"]
parent = get_fork_parent(project["uid"])
forked_from = (
{
@ -276,6 +286,9 @@ async def project_detail(request: Request, project_slug: str, before: str = None
"viewer_can_containers": can_view_project_containers(project, user),
"viewer_can_workspace": viewer_can_workspace,
"workspace_editor_url": workspace_editor_url,
"workspace_editor_mode": editor_launch["mode"],
"workspace_editor_width": editor_launch["width"],
"workspace_editor_height": editor_launch["height"],
"forked_from": forked_from,
"fork_count": count_forks(project["uid"]),
"file_count": count_files(project["uid"]),

View File

@ -74,6 +74,7 @@ from devplacepy.schemas.containers import (
AdminWorkspacesOut,
BotFrameOut,
ContainersOut,
EditorProfileOut,
InstanceOut,
ScheduleOut,
TunnelOut,

View File

@ -132,6 +132,26 @@ class WorkspaceFlagOut(_Out):
created_at: str = ""
class EditorProfileOut(_Out):
trust_all: bool = True
theme: str = ""
font_size: int = 0
terminal_font_size: int = 0
zoom_level: int = 0
layout: str = ""
panel_preset: str = ""
boot_agent: str = ""
boot_shell: bool = True
window_mode: str = ""
window_width: int = 0
window_height: int = 0
cpu_millicores: int = 0
cpu_cores: float = 0.0
memory_mb: int = 0
disk_quota_mb: int = 0
sources: dict = {}
class WorkspaceViewOut(_Out):
uid: str = ""
name: str = ""
@ -153,6 +173,7 @@ class WorkspaceViewOut(_Out):
max_tunnels: int = 0
tunnels: list[TunnelOut] = []
flags: list[WorkspaceFlagOut] = []
editor: Optional[EditorProfileOut] = None
class WorkspaceOut(_Out):
@ -164,6 +185,8 @@ class WorkspaceOut(_Out):
max_workspaces: int = 0
editor_url: str = ""
editor_password: str = ""
editor: Optional[EditorProfileOut] = None
restart_required: bool = False
user: Optional[Any] = None

View File

@ -169,6 +169,9 @@ class ProjectDetailOut(_Out):
viewer_can_containers: bool = False
viewer_can_workspace: bool = False
workspace_editor_url: Optional[str] = None
workspace_editor_mode: Optional[str] = None
workspace_editor_width: Optional[int] = None
workspace_editor_height: Optional[int] = None
forked_from: Optional[dict] = None
fork_count: int = 0
file_count: int = 0

View File

@ -112,6 +112,136 @@ An instance's shell is NOT inline - it is a floating `<container-terminal>` (`st
**Minimize/Normalize.** Geometry presets (`_presetGeometry(w,h)`, smallest-usable / comfortable), exposed both as titlebar buttons (`data-win="minimize|normalize"`) and menu items on every window.
## Editor profile and branding (`workspace/editor.py`, `files/vscode/`)
The browser editor is a DevPlace product surface, not stock code-server. Three layers own it, and
the split is the design: the deterministic part is host-side and unit-testable without Docker, the
cosmetic part is an extension that fails soft.
| Layer | Owns | Fails how |
|---|---|---|
| **Host** `workspace/editor.py` | Resolving the profile, seeding `settings.json`, building the argv, setting container CPU and memory | Deterministic, unit-tested against a temp state dir, no container needed |
| **Image** `ppy.Dockerfile` + `files/vscode/` | Branding assets, patched `product.json`, the bundled extension | Verified by the build smoke test; an image that cannot brand cannot build green |
| **Extension** `files/vscode/devplace-workspace/` | Boot terminals, panel layout, status bar, walkthrough, `DevPlace:` commands | Each stage in its own try/catch to a `DevPlace` output channel. A failure costs the terminals, never the editor |
**One resolver, exactly like `quota.resolve`.** `editor.resolve(owner_uid, instance) -> EditorProfile`
is the only place a `workspace_editor_*` setting is read. Order is user preference row, then site
setting, then built-in default; the container size (`cpu_millicores`, `memory_mb`, `disk_quota_mb`)
comes from `quota.resolve` so all four "sizes" live on one object. `editor.view()` adds
`source_map()` so every surface can say where a value came from. Never read one of those settings at
a call site.
**Inherit sentinels are explicit, never truthiness.** `workspace_editor_prefs` stores `""` / `0` for
"inherit", but zoom level `0` is a real value, so its sentinel is `-99` (`INHERIT_ZOOM`), and
`boot_shell` uses `-1` (`INHERIT_FLAG`) because `0` means off. `_inherits(key, row)` is the single
predicate; a bare `if value:` here would silently ignore a member who wants zoom 0 or no shell.
**`merge_managed` is the contract that a member edit is never overwritten** and it is a pure
function, which is why it is exhaustively unit-tested. DevPlace writes a key only when it is absent
or still equal to the value DevPlace wrote last time, recorded in
`{state}/data/User/.devplace-managed.json`. So raising a site default reaches everyone who never
expressed a preference and nobody who did. Do not replace this with a plain merge or a full rewrite.
**Seeding runs at launch, in `run_spec_for`**, alongside `ensure_editor_password` - the one point
every workspace launch passes through, so a workspace created before this feature is seeded on its
next boot. `stamp_boot_marker` writes a fresh `instances.boot_marker` there too; it reaches the
container as `DEVPLACE_CONTAINER_BOOT` and is what makes the extension's boot terminals idempotent
across browser reloads. Because seeding happens at launch, a preference change applies on the **next
start**: the workspace page compares the resolved profile against `{state}/devplace-editor.json`
(`editor.restart_required`) and shows a restart banner rather than pretending it applied.
**Nothing DevPlace writes goes to `/app`.** `/app` round-trips into the member's project through
`sync_dir_bidirectional`, so a `.vscode/tasks.json` there would land in their repository. Every
artefact goes under `WORKSPACE_STATE_DIR`, which is a separate bind mount and never synced. This is
why boot terminals are an extension rather than a folder-open task.
**The agent's own working files are in `SYNC_SKIP_NAMES` for the same reason.** `dpc` writes `.dpc/`
and `dpc.log` into its working directory, which is `/app`. That was harmless while `dpc` only ran
when a member typed it; now that it starts on every workspace boot, those artefacts would be
imported into every project on the next sync. `project_files.SYNC_SKIP_NAMES` therefore carries
`.dpc`, `dpc.log` and `.devplace` (the tunnel manifest directory, which was already being written
and already leaking) alongside the `.devplace_boot.*` entries. Any future in-container tool that
writes state next to the member's code needs the same entry.
**The boot marker degrades to once-per-extension-host, never to "always".** `BootTerminals` is
guarded by `DEVPLACE_CONTAINER_BOOT`, but an instance created before that column existed injects an
empty value. The guard used to treat an empty marker as "not booted yet" and opened a fresh pair of
terminals on **every browser reload** - caught by driving one container with three consecutive
Playwright sessions and finding six terminal tabs. The fallback is now `host-${process.pid}` of the
extension host, which survives a browser reload and changes when the container restarts, which is
exactly the intended semantic.
**Trust is disabled at three layers** and gated by one kill switch, `workspace_editor_trust_all`
(default on): the `--disable-workspace-trust` flag, the seeded `security.workspace.trust.*` settings,
and the extension's `contributes.configurationDefaults`. The third is belt and braces only -
`security.workspace.trust.enabled` is application-scoped and VS Code restricts which scopes an
extension may re-default - so never let it be the only layer. Turning the switch off restores
Restricted Mode with no code change and no image rebuild. It also enables
`task.allowAutomaticTasks`, so a project's own `runOn: folderOpen` task will run; that consequence is
documented to members on `/docs/workspace-editor.html` and must stay documented.
**Panel height is a preset, not a pixel value, and that is a hard constraint.** VS Code stores part
sizes in the workbench grid inside `state.vscdb`, an undocumented and version-unstable internal
SQLite database. Writing it from the host is rejected. The extension drives
`workbench.action.toggleMaximizedPanel` / `increaseViewSize` instead, so the four presets are named
honestly as presets in the UI. Do not "improve" this by writing `state.vscdb`.
**The extension is a built-in, copied to
`/usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace`.** Built-ins are always
enabled, cannot be uninstalled, need no install step and survive workspace recreation because they
live in the image. `--builtin-extensions-dir` is deliberately NOT used: it has a known upstream
defect where extensions loaded through it present as disabled. There is no build step, no npm and no
bundler - a VS Code extension is a directory with a `package.json` and an entry point, and it runs in
code-server's Node remote extension host, so `main` applies.
**`extension.js` is CommonJS, and it is the one file in this repository that may be.** The VS Code
extension host loads CommonJS; it is not frontend code and is never served to a browser. Every other
house rule applies unchanged. Four small classes (`Profile`, `BootTerminals`, `Layout`, `Presence`)
and an `activate` that runs each through `stage()`, which owns the try/catch and logs to a
`DevPlace` output channel.
**The activation stages are awaited in order, and `Layout` never opens a panel of its own.** Firing
them concurrently is what produced a stray third terminal in the first live build: `Layout` called
`workbench.action.focusPanel` before `BootTerminals` had created anything, and VS Code answered by
spawning its own default `bash`. `Layout.apply(panelIsOpen)` therefore resizes only when the boot
terminals actually opened the panel, and `activate` awaits `terminals` before `layout`. Verified by
driving a real container with Playwright: the tab list must read exactly
`pravda@workspace` + `DevPlace Code`.
**A workspace suppresses the editor's own AI assistant.** Recent VS Code ships a chat panel in the
secondary sidebar, which opened by default with Microsoft branding, "AI responses may be inaccurate"
copy, and a competing agent right beside `dpc`. `editor.FOREIGN_AI_SETTINGS` turns it off
(`chat.disableAIFeatures`, `chat.commandCenter.enabled`, `workbench.secondarySideBar.defaultVisibility`)
and `workbench.startupEditor` is `none` so a workspace opens straight onto the member's code with the
agent terminal ready, rather than onto a welcome page listing a "Get Started with VS Code"
walkthrough. Unknown keys are ignored by VS Code, so these stay safe across version bumps. The
DevPlace walkthrough is still contributed and reachable from Help and the command palette.
**Branding is six things**, all baked in: the `--app-name` / `--welcome-text` /
`--disable-getting-started-override` flags in `editor.argv`; the favicon and PWA icon set generated
from `static/icon-512.png` into `files/vscode/branding/`; `devplace-login.css` appended to
code-server's `login.css`; `product.patch.json` merged key-wise into `product.json` (additive, so an
unnamed upstream key survives a bump); the `DevPlace Dark` / `DevPlace Light` themes generated from
`static/css/variables.css`; and the walkthrough, status bar item and five `DevPlace:` commands. The
login stylesheet is the **single sanctioned exception to the no-colour-literals rule** - code-server
serves it outside the application and cannot read `variables.css`, so the tokens are restated as
literals with a comment naming each one. Do not spread that exception anywhere else.
**Theme token mapping** (`variables.css` -> VS Code), recorded here because a JSON theme cannot carry
a comment: `--bg-primary` -> `editor.background`; `--bg-secondary` -> `sideBar`/`activityBar`;
`--bg-card` -> `editorWidget`/`panel`; `--accent` -> `focusBorder`/`button.background`/`progressBar`;
`--accent-light` -> `list.activeSelectionBackground`; `--text-primary` -> `foreground`;
`--text-secondary` -> `descriptionForeground`; `--border` -> every `*.border`;
`--success`/`--warning`/`--danger`/`--info` -> the ANSI green/yellow/red/blue. `DevPlace Light` is a
derived light palette (DevPlace ships no light tokens); keep the two in step by construction.
**Adding an editor setting** touches five places: `editor.DEFAULTS` + `SETTING_KEYS`, a `ConfigField`
on `WorkspaceService` (group `Editor`), the `workspace_editor_prefs` ensure block and
`editor.PREF_COLUMNS`, `EditorPrefsForm` + `EditorProfileOut`, and the `settings_for` map or the
extension. A `select` `ConfigField` MUST use `options=[{"value":..., "label":...}]` - plain strings
crash `docs_api.build_services_group`, which `docs_search` indexes, which 500s the docs search page.
## Pravda image (load-bearing, workspace ownership)
The `ppy` image (`ppy.Dockerfile`, built by `make ppy`, context `devplacepy/services/containers/files`) is a `python:3.13-slim-bookworm` base with Playwright plus a broad set of common Python libraries preinstalled, plus CLI tools (`tmux`, `apache2-utils` for `ab`, `procps`/`htop`/`iftop`/`iotop`, `netcat-openbsd` for `nc`, `zip`/`unzip`, `fakeroot`, git/curl/wget/vim/ack).
@ -280,7 +410,7 @@ query, so a literal `#` in the path makes the reconstructed URL treat the query
**Editor persistence.** code-server's user-data and extensions live in
`config.WORKSPACE_STATE_DIR/<instance uid>`, bind-mounted at `WORKSPACE_STATE_MOUNT`, so extensions
survive container recreation. `api.editor_command` builds the argv; `run_spec_for` prefers it over
survive container recreation. `editor.argv` builds the argv; `run_spec_for` prefers it over
the boot-script/boot-command chain when `is_workspace` and `editor_port` are set.
**Activity and egress are the presence pattern.** `activity.py` keeps a per-worker monotonic dict and
@ -356,8 +486,9 @@ fields wired to nothing, which is why every tunnel sat at `pending` with no cert
required field makes an anonymous request 422 instead of 401 and `tests/api/auth/matrix.py` fails.
Give the field a default and validate it inside the handler after `require_user`.
**The editor opens in a new tab, directly.** The project detail page renders an inline **VS Code**
button in `.project-detail-actions` (`target="_blank"`) straight to the code-server proxy
**The editor opens through one shared partial.** The project detail page renders an inline **Editor**
button in `.project-detail-actions` via `templates/_editor_open.html` (`target="_blank"`, plus the
`data-editor-*` attributes `EditorLauncher` reads) straight to the code-server proxy
`/projects/{slug}/containers/instances/{uid}/code/`, built by `_editor_url` in
`routers/projects/index.py` and carried as `workspace_editor_url` on the context and
`ProjectDetailOut`. It is emitted ONLY when the viewer passes `can_open_workspace` AND their
@ -382,9 +513,10 @@ page that links to it and the setting turned on.
list, start/stop, suspend/unsuspend with a required reason, raise/resolve/dismiss flags, per-user
quota rules. `base_seo_context` takes `breadcrumbs`/`schemas`, not `canonical`/`schema`.
**Devii** has 15 tools under `handler="workspace"`; five are in `CONFIRM_REQUIRED` and every one of
**Devii** has 17 tools under `handler="workspace"`; six are in `CONFIRM_REQUIRED` and every one of
them declares a `confirm` param (schemas are `additionalProperties: false`, so a gated tool without it
loops forever).
loops forever). `workspace_editor_get` / `workspace_editor_set` cover the editor profile;
`workspace_editor_set` is gated because it changes how every workspace the member opens behaves.
**Opening a workspace publishes its editor on a public tunnel, automatically.** `provision.ensure`
does three things after creating the instance: `api.ensure_editor_password`, then
@ -399,7 +531,7 @@ password. This is a deliberate departure from the plane-A/plane-B split describe
is now reachable publicly, which is only acceptable **because** `--auth password` is on - never
reintroduce `--auth none` while the editor tunnel is auto-published.
**The editor is password-protected, per workspace.** `api.editor_command` runs code-server with
**The editor is password-protected, per workspace.** `editor.argv` runs code-server with
`--auth password`, and `api.pravda_env` injects the secret as `PASSWORD` (the variable code-server
reads). The secret is an 8-character **pronounceable** token from `api.generate_editor_password()`,
built as four consonant-vowel pairs (`ronebamu`, `zipesodu`) so a user can read it once and retype it
@ -419,7 +551,7 @@ the instance, so putting it there would hand every user's editor password to any
an unauthenticated public shell: `--auth none` was safe only while the editor was reachable solely
through the session-authenticated proxy route, and a user can publish a tunnel to the editor port.
**code-server lives in the `ppy` image, and nothing else supplies it.** `api.editor_command` makes
**code-server lives in the `ppy` image, and nothing else supplies it.** `editor.argv` makes
`code-server` the container's argv, so an image without it fails `docker run` with exit **127**
(`executable file not found in $PATH`) and the reconciler records `crashed` / `launch_failed` with an
empty `container_id` - the container process never existed. It is installed in `ppy.Dockerfile` in
@ -428,9 +560,11 @@ it with no elevation): version pinned in the single `ARG CODE_SERVER_VERSION`, a
`dpkg --print-architecture`, release tarball unpacked to `/usr/local/lib/code-server` with a symlink
at `/usr/local/bin/code-server`. `code-server --version` is in the build smoke-test loop and the
symlink is in the executable-check list, so an image that cannot run the editor can never build
green. Bumping the version is a one-line `ARG` change plus `make ppy`. **`workspace_editor_version`
(a `ConfigField` on `WorkspaceService`) is currently read by nothing** - the version is the image's,
not a runtime setting; wire it up or drop it before relying on it.
green. Bumping the version is a one-line `ARG` change plus `make ppy`, and the branding smoke test
(below) re-verifies the four CLI flags, the media file names and the `product.json` keys, so a
version that breaks any of them cannot build green either. (`workspace_editor_version` was a
`ConfigField` read by nothing and has been removed: the version is a property of the shared image,
not a runtime setting, and a live control that changes nothing is a silent failure.)
**A container stuck in `created` is recreated, never retried forever.** The reconciler's
`desired=running` branch reaches `backend.start()` for `ps.state == "created"`. That call is wrapped:

View File

@ -443,7 +443,7 @@ def pravda_env(instance: dict) -> dict:
def workspace_env(instance: dict, base_url: str) -> dict:
from devplacepy import database
from devplacepy.database import get_setting
from devplacepy.services.containers.workspace import naming, quota
from devplacepy.services.containers.workspace import editor, naming, quota
if not instance.get("is_workspace"):
return {"DEVPLACE_WORKSPACE": ""}
@ -476,9 +476,12 @@ def workspace_env(instance: dict, base_url: str) -> dict:
gallery = get_setting("workspace_extensions_gallery", "").strip()
editor_port = int(instance.get("editor_port") or 0)
profile = editor.resolve(owner_uid, instance)
env = {
"DEVPLACE_WORKSPACE": "1",
"DEVPLACE_WORKSPACE_UID": instance.get("uid") or "",
"DEVPLACE_CONTAINER_BOOT": instance.get("boot_marker") or "",
**editor.env_for(profile),
"DEVPLACE_WORKSPACE_URL": workspace_url,
"DEVPLACE_WORKSPACE_OWNER": owner_name,
"DEVPLACE_WORKSPACE_OWNER_UID": owner_uid,
@ -546,27 +549,28 @@ def ensure_editor_password(instance: dict) -> str:
return password
def editor_command(instance: dict) -> list[str]:
port = int(instance.get("editor_port") or EDITOR_DEFAULT_PORT)
return [
"code-server",
"--bind-addr",
f"0.0.0.0:{port}",
"--auth",
"password",
"--disable-telemetry",
"--disable-update-check",
"--user-data-dir",
f"{WORKSPACE_STATE_MOUNT}/data",
"--extensions-dir",
f"{WORKSPACE_STATE_MOUNT}/extensions",
WORKSPACE_MOUNT,
]
def stamp_boot_marker(instance: dict) -> str:
from devplacepy.utils import generate_uid
marker = generate_uid()
store.update_instance(instance["uid"], {"boot_marker": marker})
instance["boot_marker"] = marker
return marker
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
from devplacepy.services.containers.workspace import editor
profile = None
cpu_limit = instance.get("cpu_limit", "")
mem_limit = instance.get("mem_limit", "")
if instance.get("is_workspace"):
ensure_editor_password(instance)
stamp_boot_marker(instance)
profile = editor.resolve(instance.get("workspace_owner_uid", ""), instance)
editor.seed_state(instance, profile)
cpu_limit = profile.cpu_limit() or cpu_limit
mem_limit = profile.mem_limit() or mem_limit
env = {**json.loads(instance.get("env_json") or "{}"), **pravda_env(instance)}
ports = [
PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
@ -584,8 +588,8 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
)
language = (instance.get("boot_language") or "none").strip().lower()
boot = (instance.get("boot_command") or "").strip()
if instance.get("is_workspace") and int(instance.get("editor_port") or 0):
command = editor_command(instance)
if profile and int(instance.get("editor_port") or 0):
command = editor.argv(instance, profile)
elif language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
@ -601,8 +605,8 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
PROJECT_LABEL: instance["project_uid"],
},
env=env,
cpu_limit=instance.get("cpu_limit", ""),
mem_limit=instance.get("mem_limit", ""),
cpu_limit=cpu_limit,
mem_limit=mem_limit,
ports=ports,
mounts=mounts,
restart_policy=instance.get("restart_policy", "never"),

View File

@ -0,0 +1,79 @@
/* retoor <retoor@molodetz.nl> */
/* devplace-login-theme */
/* DevPlace palette, restated as literals because code-server serves this file
outside the application and cannot read static/css/variables.css.
--bg-primary #080413 --bg-card #1a1030 --bg-input #140b26
--accent #ff6b35 --accent-hover #ff7d4d
--text-primary #f4eefb --text-secondary #b8a8d0
--border rgba(255,255,255,0.08) --radius 12px */
body {
background: linear-gradient(135deg, #080413 0%, #160a28 50%, #080413 100%);
color: #f4eefb;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.center-container {
background: transparent;
}
.card-box {
background: #1a1030;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.header .main {
color: #f4eefb;
font-weight: 700;
}
.header .sub,
.content .links,
.content .links a {
color: #b8a8d0;
}
.content .links a:hover {
color: #ff6b35;
}
.field input,
.password-input {
background: #140b26;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
color: #f4eefb;
}
.field input::placeholder {
color: #7a6a90;
}
.field input:focus,
.password-input:focus {
border-color: #ff6b35;
outline: none;
}
.field .submit,
.submit {
background: #ff6b35;
border: none;
border-radius: 12px;
color: #ffffff;
font-weight: 600;
}
.field .submit:hover,
.submit:hover {
background: #ff7d4d;
}
.error-display,
.error {
background: rgba(229, 57, 53, 0.16);
border-radius: 12px;
color: #ffb4b2;
}

View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<style>
.plate { fill: #080413; }
.mark { stroke: #ff6b35; }
@media (prefers-color-scheme: light) {
.plate { fill: #fdfbff; }
.mark { stroke: #d1481a; }
}
</style>
<rect class="plate" width="64" height="64" rx="14"/>
<path class="mark" d="M18 16h13c11 0 18 6.5 18 16s-7 16-18 16H18z" fill="none" stroke-width="7" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#080413"/>
<path d="M18 16h13c11 0 18 6.5 18 16s-7 16-18 16H18z" fill="none" stroke="#ff6b35" stroke-width="7" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -0,0 +1,252 @@
// retoor <retoor@molodetz.nl>
const fs = require("fs");
const vscode = require("vscode");
const AGENT_PATH = "/usr/bin/dpc";
const AGENT_TERMINAL = "DevPlace Code";
const SHELL_TERMINAL = "pravda@workspace";
const BOOT_KEY = "devplace.bootMarker";
const PANEL_STEPS = { short: 0, normal: 2, tall: 5, maximized: 0 };
class Profile {
constructor() {
this.data = Object.assign(
{
theme: "devplace-dark",
layout: "standard",
panel_preset: "tall",
boot_agent: "dpc",
boot_shell: true,
trust_all: true,
},
this.fromFile(),
this.fromEnv(),
);
}
fromFile() {
const path = process.env.DEVPLACE_EDITOR_PROFILE;
if (!path) return {};
try {
const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
return parsed && parsed.editor ? parsed.editor : {};
} catch (error) {
return {};
}
}
fromEnv() {
const values = {};
if (process.env.DEVPLACE_EDITOR_PANEL_PRESET) {
values.panel_preset = process.env.DEVPLACE_EDITOR_PANEL_PRESET;
}
if (process.env.DEVPLACE_EDITOR_BOOT_AGENT) {
values.boot_agent = process.env.DEVPLACE_EDITOR_BOOT_AGENT;
}
if (process.env.DEVPLACE_EDITOR_BOOT_SHELL) {
values.boot_shell = process.env.DEVPLACE_EDITOR_BOOT_SHELL === "1";
}
return values;
}
get bootMarker() {
return process.env.DEVPLACE_CONTAINER_BOOT || `host-${process.pid}`;
}
get wantsAgent() {
return this.data.boot_agent === "dpc" && fs.existsSync(AGENT_PATH);
}
get wantsShell() {
return Boolean(this.data.boot_shell);
}
get panelPreset() {
return this.data.panel_preset || "tall";
}
}
class BootTerminals {
constructor(profile, memento) {
this.profile = profile;
this.memento = memento;
}
alreadyBooted() {
return this.memento.get(BOOT_KEY) === this.profile.bootMarker;
}
async open() {
if (this.alreadyBooted()) return false;
await this.memento.update(BOOT_KEY, this.profile.bootMarker);
const shell = this.profile.wantsShell ? this.createShell() : null;
const agent = this.profile.wantsAgent ? this.createAgent() : null;
if (agent) agent.show(true);
else if (shell) shell.show(true);
return Boolean(agent || shell);
}
createAgent() {
return vscode.window.createTerminal({
name: AGENT_TERMINAL,
shellPath: AGENT_PATH,
iconPath: new vscode.ThemeIcon("rocket"),
isTransient: false,
});
}
createShell() {
return vscode.window.createTerminal({
name: SHELL_TERMINAL,
shellPath: "/bin/bash",
shellArgs: ["-l"],
iconPath: new vscode.ThemeIcon("terminal-bash"),
isTransient: false,
});
}
}
class Layout {
constructor(profile) {
this.profile = profile;
}
async apply(panelIsOpen) {
const preset = this.profile.panelPreset;
if (!panelIsOpen) return;
if (preset === "maximized") {
await vscode.commands.executeCommand("workbench.action.toggleMaximizedPanel");
return;
}
const steps = PANEL_STEPS[preset] === undefined ? 5 : PANEL_STEPS[preset];
for (let index = 0; index < steps; index += 1) {
await vscode.commands.executeCommand("workbench.action.increaseViewSize");
}
}
}
class Presence {
constructor(context) {
this.context = context;
this.item = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
100,
);
}
get projectTitle() {
return process.env.DEVPLACE_PROJECT_TITLE || "DevPlace";
}
register() {
this.item.text = `$(rocket) ${this.projectTitle}`;
this.item.tooltip = this.tooltip();
this.item.command = "devplace.openProject";
this.item.show();
this.context.subscriptions.push(this.item);
this.registerCommands();
}
tooltip() {
const owner = process.env.DEVPLACE_WORKSPACE_OWNER || "";
const name = process.env.DEVPLACE_TUNNEL_NAME || "";
return `DevPlace workspace ${name}${owner ? ` for ${owner}` : ""}`;
}
registerCommands() {
const commands = {
"devplace.runAgent": () => this.runAgent(),
"devplace.openProject": () => this.open(process.env.DEVPLACE_PROJECT_URL),
"devplace.openWorkspacePage": () =>
this.open(process.env.DEVPLACE_WORKSPACE_URL),
"devplace.openDocs": () => this.openDocs(),
"devplace.showTunnels": () => this.showTunnels(),
};
for (const [name, handler] of Object.entries(commands)) {
this.context.subscriptions.push(
vscode.commands.registerCommand(name, handler),
);
}
}
runAgent() {
const terminal = vscode.window.createTerminal({
name: AGENT_TERMINAL,
shellPath: AGENT_PATH,
iconPath: new vscode.ThemeIcon("rocket"),
});
terminal.show(true);
}
openDocs() {
const base = process.env.DEVPLACE_BASE_URL || "";
this.open(base ? `${base}/docs/workspace-editor.html` : "");
}
open(url) {
if (!url) {
vscode.window.showWarningMessage(
"DevPlace has not published a site URL for this workspace yet.",
);
return;
}
vscode.env.openExternal(vscode.Uri.parse(url));
}
async showTunnels() {
const path = process.env.DEVPLACE_TUNNEL_MANIFEST;
const rows = this.readManifest(path);
if (!rows.length) {
vscode.window.showInformationMessage(
"This workspace has no public tunnels yet.",
);
return;
}
const picked = await vscode.window.showQuickPick(
rows.map((row) => ({
label: row.label || row.hostname,
description: row.url,
detail: `port ${row.container_port} - ${row.status}`,
url: row.url,
})),
{ placeHolder: "Open a public tunnel" },
);
if (picked) this.open(picked.url);
}
readManifest(path) {
if (!path) return [];
try {
const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
return Array.isArray(parsed.tunnels) ? parsed.tunnels : [];
} catch (error) {
return [];
}
}
}
async function stage(output, name, run) {
try {
return await run();
} catch (error) {
output.appendLine(`${name} failed: ${error}`);
return undefined;
}
}
async function activate(context) {
const output = vscode.window.createOutputChannel("DevPlace");
context.subscriptions.push(output);
const profile = new Profile();
await stage(output, "presence", () => new Presence(context).register());
const opened = await stage(output, "terminals", () =>
new BootTerminals(profile, context.workspaceState).open(),
);
await stage(output, "layout", () => new Layout(profile).apply(Boolean(opened)));
}
function deactivate() {}
module.exports = { activate, deactivate };

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,156 @@
{
"name": "devplace-workspace",
"displayName": "DevPlace",
"description": "DevPlace workspace integration: the dpc coding agent, project links and DevPlace branding.",
"version": "1.0.0",
"publisher": "devplace",
"author": "retoor <retoor@molodetz.nl>",
"license": "SEE LICENSE IN https://pravda.education/docs/terms.html",
"engines": {
"vscode": "^1.80.0"
},
"categories": [
"Other",
"Themes"
],
"icon": "media/devplace-icon.png",
"main": "./extension.js",
"activationEvents": [
"onStartupFinished"
],
"capabilities": {
"untrustedWorkspaces": {
"supported": true
},
"virtualWorkspaces": true
},
"contributes": {
"configurationDefaults": {
"security.workspace.trust.enabled": false,
"security.workspace.trust.startupPrompt": "never",
"security.workspace.trust.banner": "never",
"security.workspace.trust.emptyWindow": true,
"security.workspace.trust.untrustedFiles": "open",
"task.allowAutomaticTasks": "on",
"telemetry.telemetryLevel": "off",
"update.mode": "none",
"workbench.tips.enabled": false,
"extensions.autoCheckUpdates": false,
"workbench.colorTheme": "DevPlace Dark",
"terminal.integrated.defaultProfile.linux": "bash",
"workbench.startupEditor": "none",
"chat.disableAIFeatures": true,
"chat.commandCenter.enabled": false,
"workbench.secondarySideBar.defaultVisibility": "hidden"
},
"themes": [
{
"label": "DevPlace Dark",
"uiTheme": "vs-dark",
"path": "./themes/devplace-dark.json"
},
{
"label": "DevPlace Light",
"uiTheme": "vs",
"path": "./themes/devplace-light.json"
}
],
"commands": [
{
"command": "devplace.runAgent",
"title": "Start DevPlace Code (dpc)",
"category": "DevPlace"
},
{
"command": "devplace.openProject",
"title": "Open project on DevPlace",
"category": "DevPlace"
},
{
"command": "devplace.openWorkspacePage",
"title": "Open workspace settings",
"category": "DevPlace"
},
{
"command": "devplace.showTunnels",
"title": "Show public tunnels",
"category": "DevPlace"
},
{
"command": "devplace.openDocs",
"title": "Open the DevPlace editor guide",
"category": "DevPlace"
}
],
"viewsWelcome": [
{
"view": "workbench.explorer.emptyView",
"contents": "This workspace holds your DevPlace project files.\n[Open project on DevPlace](command:devplace.openProject)\n[Start DevPlace Code](command:devplace.runAgent)"
}
],
"walkthroughs": [
{
"id": "devplace.getStarted",
"title": "Get started on DevPlace",
"description": "Your workspace, your agent, and how to publish what you build.",
"steps": [
{
"id": "agent",
"title": "Meet dpc, your coding agent",
"description": "A DevPlace Code terminal is already running. Ask it to build something.\n[Start another agent](command:devplace.runAgent)",
"media": {
"markdown": "walkthrough/agent.md"
},
"completionEvents": [
"onCommand:devplace.runAgent"
]
},
{
"id": "files",
"title": "Your files are your project",
"description": "Everything under /app syncs back to your DevPlace project.",
"media": {
"markdown": "walkthrough/files.md"
},
"completionEvents": [
"onSettingChanged:files.autoSave"
]
},
{
"id": "tunnels",
"title": "Publish a port",
"description": "Serve on a high port and publish it on a public HTTPS address.\n[Show my tunnels](command:devplace.showTunnels)",
"media": {
"markdown": "walkthrough/tunnels.md"
},
"completionEvents": [
"onCommand:devplace.showTunnels"
]
},
{
"id": "toolchains",
"title": "Install anything",
"description": "sudo and apt install work here, with Python, Rust, Nim and Swift preinstalled.",
"media": {
"markdown": "walkthrough/toolchains.md"
},
"completionEvents": [
"onCommand:workbench.action.terminal.sendSequence"
]
},
{
"id": "limits",
"title": "Quotas and idle stop",
"description": "Your workspace has a size and stops when idle.\n[Open workspace settings](command:devplace.openWorkspacePage)",
"media": {
"markdown": "walkthrough/limits.md"
},
"completionEvents": [
"onCommand:devplace.openWorkspacePage"
]
}
]
}
]
}
}

View File

@ -0,0 +1,223 @@
{
"name": "DevPlace Dark",
"type": "dark",
"colors": {
"editor.background": "#080413",
"editor.foreground": "#f4eefb",
"editorLineNumber.foreground": "#7a6a90",
"editorLineNumber.activeForeground": "#ff6b35",
"editorCursor.foreground": "#ff6b35",
"editor.selectionBackground": "#ff6b352e",
"editor.selectionHighlightBackground": "#ff6b351f",
"editor.lineHighlightBackground": "#120821",
"editor.findMatchBackground": "#ff6b3559",
"editor.findMatchHighlightBackground": "#ff6b3530",
"editorIndentGuide.background1": "#ffffff14",
"editorIndentGuide.activeBackground1": "#ffffff24",
"editorWhitespace.foreground": "#ffffff14",
"editorRuler.foreground": "#ffffff14",
"editorWidget.background": "#1a1030",
"editorWidget.border": "#ffffff14",
"editorSuggestWidget.background": "#1a1030",
"editorSuggestWidget.selectedBackground": "#241640",
"editorHoverWidget.background": "#1a1030",
"editorGroup.border": "#ffffff14",
"editorGroupHeader.tabsBackground": "#120821",
"editorGroupHeader.noTabsBackground": "#120821",
"editorGutter.addedBackground": "#4caf50",
"editorGutter.modifiedBackground": "#ff9800",
"editorGutter.deletedBackground": "#e53935",
"editorError.foreground": "#e53935",
"editorWarning.foreground": "#ff9800",
"editorInfo.foreground": "#42a5f5",
"editorBracketMatch.background": "#ff6b3524",
"editorBracketMatch.border": "#ff6b35",
"foreground": "#f4eefb",
"descriptionForeground": "#b8a8d0",
"disabledForeground": "#7a6a90",
"errorForeground": "#e53935",
"focusBorder": "#ff6b35",
"selection.background": "#ff6b3559",
"widget.shadow": "#00000073",
"icon.foreground": "#b8a8d0",
"sash.hoverBorder": "#ff6b35",
"activityBar.background": "#120821",
"activityBar.foreground": "#f4eefb",
"activityBar.inactiveForeground": "#7a6a90",
"activityBar.border": "#ffffff14",
"activityBarBadge.background": "#ff6b35",
"activityBarBadge.foreground": "#ffffff",
"sideBar.background": "#120821",
"sideBar.foreground": "#b8a8d0",
"sideBar.border": "#ffffff14",
"sideBarTitle.foreground": "#f4eefb",
"sideBarSectionHeader.background": "#1a1030",
"sideBarSectionHeader.foreground": "#f4eefb",
"list.activeSelectionBackground": "#ff6b351f",
"list.activeSelectionForeground": "#f4eefb",
"list.inactiveSelectionBackground": "#241640",
"list.hoverBackground": "#241640",
"list.highlightForeground": "#ff6b35",
"list.errorForeground": "#e53935",
"list.warningForeground": "#ff9800",
"tree.indentGuidesStroke": "#ffffff14",
"statusBar.background": "#120821",
"statusBar.foreground": "#b8a8d0",
"statusBar.border": "#ffffff14",
"statusBar.noFolderBackground": "#120821",
"statusBar.debuggingBackground": "#ff6b35",
"statusBar.debuggingForeground": "#ffffff",
"statusBarItem.remoteBackground": "#ff6b35",
"statusBarItem.remoteForeground": "#ffffff",
"statusBarItem.hoverBackground": "#ffffff0d",
"titleBar.activeBackground": "#080413",
"titleBar.activeForeground": "#f4eefb",
"titleBar.inactiveBackground": "#080413",
"titleBar.inactiveForeground": "#7a6a90",
"titleBar.border": "#ffffff14",
"menu.background": "#1a1030",
"menu.foreground": "#f4eefb",
"menu.selectionBackground": "#241640",
"menubar.selectionBackground": "#241640",
"tab.activeBackground": "#080413",
"tab.activeForeground": "#f4eefb",
"tab.activeBorderTop": "#ff6b35",
"tab.inactiveBackground": "#120821",
"tab.inactiveForeground": "#7a6a90",
"tab.border": "#ffffff14",
"tab.hoverBackground": "#241640",
"panel.background": "#1a1030",
"panel.border": "#ffffff14",
"panelTitle.activeForeground": "#f4eefb",
"panelTitle.activeBorder": "#ff6b35",
"panelTitle.inactiveForeground": "#7a6a90",
"terminal.background": "#080413",
"terminal.foreground": "#f4eefb",
"terminal.selectionBackground": "#ff6b352e",
"terminalCursor.foreground": "#ff6b35",
"terminal.ansiBlack": "#120821",
"terminal.ansiRed": "#e53935",
"terminal.ansiGreen": "#4caf50",
"terminal.ansiYellow": "#ff9800",
"terminal.ansiBlue": "#42a5f5",
"terminal.ansiMagenta": "#ff4f8b",
"terminal.ansiCyan": "#00bcd4",
"terminal.ansiWhite": "#f4eefb",
"terminal.ansiBrightBlack": "#7a6a90",
"terminal.ansiBrightRed": "#ff5252",
"terminal.ansiBrightGreen": "#69d16d",
"terminal.ansiBrightYellow": "#ffab00",
"terminal.ansiBrightBlue": "#6fc0ff",
"terminal.ansiBrightMagenta": "#ff7dab",
"terminal.ansiBrightCyan": "#4dd8e8",
"terminal.ansiBrightWhite": "#ffffff",
"button.background": "#ff6b35",
"button.foreground": "#ffffff",
"button.hoverBackground": "#ff7d4d",
"button.secondaryBackground": "#241640",
"button.secondaryForeground": "#f4eefb",
"badge.background": "#ff6b35",
"badge.foreground": "#ffffff",
"progressBar.background": "#ff6b35",
"input.background": "#140b26",
"input.foreground": "#f4eefb",
"input.border": "#ffffff14",
"input.placeholderForeground": "#7a6a90",
"inputOption.activeBorder": "#ff6b35",
"inputValidation.errorBackground": "#e5393526",
"inputValidation.errorBorder": "#e53935",
"dropdown.background": "#140b26",
"dropdown.foreground": "#f4eefb",
"dropdown.border": "#ffffff14",
"checkbox.background": "#140b26",
"checkbox.border": "#ffffff14",
"scrollbarSlider.background": "#ffffff14",
"scrollbarSlider.hoverBackground": "#ffffff24",
"scrollbarSlider.activeBackground": "#ff6b3559",
"quickInput.background": "#1a1030",
"quickInputList.focusBackground": "#241640",
"notifications.background": "#1a1030",
"notifications.border": "#ffffff14",
"notificationCenterHeader.background": "#120821",
"peekView.border": "#ff6b35",
"peekViewEditor.background": "#120821",
"peekViewResult.background": "#1a1030",
"gitDecoration.modifiedResourceForeground": "#ff9800",
"gitDecoration.deletedResourceForeground": "#e53935",
"gitDecoration.untrackedResourceForeground": "#4caf50",
"gitDecoration.ignoredResourceForeground": "#7a6a90",
"gitDecoration.conflictingResourceForeground": "#ff4f8b",
"minimap.findMatchHighlight": "#ff6b35",
"welcomePage.background": "#080413",
"welcomePage.progress.foreground": "#ff6b35",
"welcomePage.tileBackground": "#1a1030",
"welcomePage.tileHoverBackground": "#241640",
"textLink.foreground": "#ff6b35",
"textLink.activeForeground": "#ff7d4d",
"textBlockQuote.background": "#120821",
"textCodeBlock.background": "#120821",
"textPreformat.foreground": "#ff4f8b"
},
"tokenColors": [
{
"scope": ["comment", "punctuation.definition.comment"],
"settings": { "foreground": "#7a6a90", "fontStyle": "italic" }
},
{
"scope": ["string", "string.quoted", "meta.embedded.assembly"],
"settings": { "foreground": "#4caf50" }
},
{
"scope": ["constant.numeric", "constant.language", "constant.character"],
"settings": { "foreground": "#ffab00" }
},
{
"scope": ["keyword", "keyword.control", "storage", "storage.type"],
"settings": { "foreground": "#ff4f8b" }
},
{
"scope": ["entity.name.function", "support.function", "meta.function-call"],
"settings": { "foreground": "#ff6b35" }
},
{
"scope": ["entity.name.type", "entity.name.class", "support.class", "support.type"],
"settings": { "foreground": "#00bcd4" }
},
{
"scope": ["variable", "variable.other", "meta.definition.variable"],
"settings": { "foreground": "#f4eefb" }
},
{
"scope": ["variable.parameter", "variable.other.property"],
"settings": { "foreground": "#b8a8d0" }
},
{
"scope": ["entity.name.tag", "punctuation.definition.tag"],
"settings": { "foreground": "#ff4f8b" }
},
{
"scope": ["entity.other.attribute-name"],
"settings": { "foreground": "#42a5f5" }
},
{
"scope": ["invalid", "invalid.illegal"],
"settings": { "foreground": "#e53935" }
},
{
"scope": ["markup.heading", "entity.name.section"],
"settings": { "foreground": "#ff6b35", "fontStyle": "bold" }
},
{
"scope": ["markup.bold"],
"settings": { "fontStyle": "bold" }
},
{
"scope": ["markup.italic"],
"settings": { "fontStyle": "italic" }
},
{
"scope": ["markup.inline.raw", "markup.fenced_code"],
"settings": { "foreground": "#00bcd4" }
}
]
}

View File

@ -0,0 +1,223 @@
{
"name": "DevPlace Light",
"type": "light",
"colors": {
"editor.background": "#fdfbff",
"editor.foreground": "#1b1230",
"editorLineNumber.foreground": "#8c7fa4",
"editorLineNumber.activeForeground": "#d1481a",
"editorCursor.foreground": "#d1481a",
"editor.selectionBackground": "#ff6b3529",
"editor.selectionHighlightBackground": "#ff6b3517",
"editor.lineHighlightBackground": "#f2edfa",
"editor.findMatchBackground": "#ff6b354d",
"editor.findMatchHighlightBackground": "#ff6b3526",
"editorIndentGuide.background1": "#1b123014",
"editorIndentGuide.activeBackground1": "#1b123029",
"editorWhitespace.foreground": "#1b123014",
"editorRuler.foreground": "#1b123014",
"editorWidget.background": "#ffffff",
"editorWidget.border": "#1b123014",
"editorSuggestWidget.background": "#ffffff",
"editorSuggestWidget.selectedBackground": "#f2edfa",
"editorHoverWidget.background": "#ffffff",
"editorGroup.border": "#1b123014",
"editorGroupHeader.tabsBackground": "#f4f0fb",
"editorGroupHeader.noTabsBackground": "#f4f0fb",
"editorGutter.addedBackground": "#2f8b33",
"editorGutter.modifiedBackground": "#c77700",
"editorGutter.deletedBackground": "#c62828",
"editorError.foreground": "#c62828",
"editorWarning.foreground": "#c77700",
"editorInfo.foreground": "#1976d2",
"editorBracketMatch.background": "#ff6b3524",
"editorBracketMatch.border": "#d1481a",
"foreground": "#1b1230",
"descriptionForeground": "#5c4f74",
"disabledForeground": "#8c7fa4",
"errorForeground": "#c62828",
"focusBorder": "#d1481a",
"selection.background": "#ff6b354d",
"widget.shadow": "#1b123024",
"icon.foreground": "#5c4f74",
"sash.hoverBorder": "#d1481a",
"activityBar.background": "#f4f0fb",
"activityBar.foreground": "#1b1230",
"activityBar.inactiveForeground": "#8c7fa4",
"activityBar.border": "#1b123014",
"activityBarBadge.background": "#d1481a",
"activityBarBadge.foreground": "#ffffff",
"sideBar.background": "#f4f0fb",
"sideBar.foreground": "#5c4f74",
"sideBar.border": "#1b123014",
"sideBarTitle.foreground": "#1b1230",
"sideBarSectionHeader.background": "#ece5f7",
"sideBarSectionHeader.foreground": "#1b1230",
"list.activeSelectionBackground": "#ff6b351f",
"list.activeSelectionForeground": "#1b1230",
"list.inactiveSelectionBackground": "#ece5f7",
"list.hoverBackground": "#ece5f7",
"list.highlightForeground": "#d1481a",
"list.errorForeground": "#c62828",
"list.warningForeground": "#c77700",
"tree.indentGuidesStroke": "#1b123014",
"statusBar.background": "#f4f0fb",
"statusBar.foreground": "#5c4f74",
"statusBar.border": "#1b123014",
"statusBar.noFolderBackground": "#f4f0fb",
"statusBar.debuggingBackground": "#d1481a",
"statusBar.debuggingForeground": "#ffffff",
"statusBarItem.remoteBackground": "#d1481a",
"statusBarItem.remoteForeground": "#ffffff",
"statusBarItem.hoverBackground": "#1b12300d",
"titleBar.activeBackground": "#fdfbff",
"titleBar.activeForeground": "#1b1230",
"titleBar.inactiveBackground": "#fdfbff",
"titleBar.inactiveForeground": "#8c7fa4",
"titleBar.border": "#1b123014",
"menu.background": "#ffffff",
"menu.foreground": "#1b1230",
"menu.selectionBackground": "#ece5f7",
"menubar.selectionBackground": "#ece5f7",
"tab.activeBackground": "#fdfbff",
"tab.activeForeground": "#1b1230",
"tab.activeBorderTop": "#d1481a",
"tab.inactiveBackground": "#f4f0fb",
"tab.inactiveForeground": "#8c7fa4",
"tab.border": "#1b123014",
"tab.hoverBackground": "#ece5f7",
"panel.background": "#ffffff",
"panel.border": "#1b123014",
"panelTitle.activeForeground": "#1b1230",
"panelTitle.activeBorder": "#d1481a",
"panelTitle.inactiveForeground": "#8c7fa4",
"terminal.background": "#fdfbff",
"terminal.foreground": "#1b1230",
"terminal.selectionBackground": "#ff6b3529",
"terminalCursor.foreground": "#d1481a",
"terminal.ansiBlack": "#1b1230",
"terminal.ansiRed": "#c62828",
"terminal.ansiGreen": "#2f8b33",
"terminal.ansiYellow": "#c77700",
"terminal.ansiBlue": "#1976d2",
"terminal.ansiMagenta": "#c2185b",
"terminal.ansiCyan": "#00838f",
"terminal.ansiWhite": "#f4f0fb",
"terminal.ansiBrightBlack": "#5c4f74",
"terminal.ansiBrightRed": "#e53935",
"terminal.ansiBrightGreen": "#4caf50",
"terminal.ansiBrightYellow": "#ff9800",
"terminal.ansiBrightBlue": "#42a5f5",
"terminal.ansiBrightMagenta": "#ff4f8b",
"terminal.ansiBrightCyan": "#00bcd4",
"terminal.ansiBrightWhite": "#ffffff",
"button.background": "#d1481a",
"button.foreground": "#ffffff",
"button.hoverBackground": "#e2551f",
"button.secondaryBackground": "#ece5f7",
"button.secondaryForeground": "#1b1230",
"badge.background": "#d1481a",
"badge.foreground": "#ffffff",
"progressBar.background": "#d1481a",
"input.background": "#ffffff",
"input.foreground": "#1b1230",
"input.border": "#1b123014",
"input.placeholderForeground": "#8c7fa4",
"inputOption.activeBorder": "#d1481a",
"inputValidation.errorBackground": "#c6282826",
"inputValidation.errorBorder": "#c62828",
"dropdown.background": "#ffffff",
"dropdown.foreground": "#1b1230",
"dropdown.border": "#1b123014",
"checkbox.background": "#ffffff",
"checkbox.border": "#1b123014",
"scrollbarSlider.background": "#1b123014",
"scrollbarSlider.hoverBackground": "#1b123029",
"scrollbarSlider.activeBackground": "#ff6b354d",
"quickInput.background": "#ffffff",
"quickInputList.focusBackground": "#ece5f7",
"notifications.background": "#ffffff",
"notifications.border": "#1b123014",
"notificationCenterHeader.background": "#f4f0fb",
"peekView.border": "#d1481a",
"peekViewEditor.background": "#f4f0fb",
"peekViewResult.background": "#ffffff",
"gitDecoration.modifiedResourceForeground": "#c77700",
"gitDecoration.deletedResourceForeground": "#c62828",
"gitDecoration.untrackedResourceForeground": "#2f8b33",
"gitDecoration.ignoredResourceForeground": "#8c7fa4",
"gitDecoration.conflictingResourceForeground": "#c2185b",
"minimap.findMatchHighlight": "#d1481a",
"welcomePage.background": "#fdfbff",
"welcomePage.progress.foreground": "#d1481a",
"welcomePage.tileBackground": "#ffffff",
"welcomePage.tileHoverBackground": "#f2edfa",
"textLink.foreground": "#d1481a",
"textLink.activeForeground": "#e2551f",
"textBlockQuote.background": "#f4f0fb",
"textCodeBlock.background": "#f4f0fb",
"textPreformat.foreground": "#c2185b"
},
"tokenColors": [
{
"scope": ["comment", "punctuation.definition.comment"],
"settings": { "foreground": "#8c7fa4", "fontStyle": "italic" }
},
{
"scope": ["string", "string.quoted", "meta.embedded.assembly"],
"settings": { "foreground": "#2f8b33" }
},
{
"scope": ["constant.numeric", "constant.language", "constant.character"],
"settings": { "foreground": "#c77700" }
},
{
"scope": ["keyword", "keyword.control", "storage", "storage.type"],
"settings": { "foreground": "#c2185b" }
},
{
"scope": ["entity.name.function", "support.function", "meta.function-call"],
"settings": { "foreground": "#d1481a" }
},
{
"scope": ["entity.name.type", "entity.name.class", "support.class", "support.type"],
"settings": { "foreground": "#00838f" }
},
{
"scope": ["variable", "variable.other", "meta.definition.variable"],
"settings": { "foreground": "#1b1230" }
},
{
"scope": ["variable.parameter", "variable.other.property"],
"settings": { "foreground": "#5c4f74" }
},
{
"scope": ["entity.name.tag", "punctuation.definition.tag"],
"settings": { "foreground": "#c2185b" }
},
{
"scope": ["entity.other.attribute-name"],
"settings": { "foreground": "#1976d2" }
},
{
"scope": ["invalid", "invalid.illegal"],
"settings": { "foreground": "#c62828" }
},
{
"scope": ["markup.heading", "entity.name.section"],
"settings": { "foreground": "#d1481a", "fontStyle": "bold" }
},
{
"scope": ["markup.bold"],
"settings": { "fontStyle": "bold" }
},
{
"scope": ["markup.italic"],
"settings": { "fontStyle": "italic" }
},
{
"scope": ["markup.inline.raw", "markup.fenced_code"],
"settings": { "foreground": "#00838f" }
}
]
}

View File

@ -0,0 +1,12 @@
# DevPlace Code
`dpc` is the coding agent that ships with every DevPlace workspace. It is already running in the
**DevPlace Code** terminal at the bottom of this window.
Ask it for what you want in plain language. It reads and writes the files in `/app`, runs commands,
and installs what it needs.
Every token it spends is metered against your own DevPlace account through the platform AI gateway.
Nothing leaves DevPlace.
Open another agent at any time from the terminal dropdown, or with **DevPlace: Start DevPlace Code**.

View File

@ -0,0 +1,9 @@
# Your files are your project
The folder open in this editor is `/app`, and it is your DevPlace project.
Files sync both ways on a short cycle: what you write here appears in the project file browser on
DevPlace, and what you change on DevPlace appears here. Whichever side is newer wins, and nothing is
ever deleted by the sync.
A read-only project exports to the workspace but never imports back.

View File

@ -0,0 +1,10 @@
# Quotas and idle stop
Your workspace has a size: CPU, memory and disk, all set by your DevPlace quota. Egress and the
number of tunnels are bounded too.
It stops on its own after a period with no activity, and is removed after a longer period of being
stopped. You are warned before each step, and a warning always says exactly what happens and when.
Everything on this page is on your DevPlace workspace page, together with the editor preferences
that control this window.

View File

@ -0,0 +1,9 @@
# Install anything
`sudo` and `apt install` work here with no extra setup, and nothing you install can break the
workspace for anyone else.
Preinstalled: Python with a broad library set and Playwright, Rust, Nim, Swift, plus `git`, `tmux`,
`vim`, `curl`, `htop` and the usual command line tools.
Run `apt update` once before your first `apt install`.

View File

@ -0,0 +1,11 @@
# Publish a port
Run your server on a high port, then publish that port from the workspace page. DevPlace gives it a
public HTTPS address of the form `<port>-<name>.tunnel.pravda.education`.
**A tunnel is public and unauthenticated.** Anyone with the link reaches whatever you are serving.
Your live addresses are always listed in `/app/.devplace/tunnels.json`, and
**DevPlace: Show public tunnels** opens any of them.
Ports below 1024 cannot bind in a workspace. Use a high port.

View File

@ -0,0 +1,13 @@
{
"nameShort": "DevPlace",
"nameLong": "DevPlace Workspace",
"applicationName": "devplace",
"dataFolderName": ".devplace-editor",
"reportIssueUrl": "https://pravda.education/issues",
"documentationUrl": "https://pravda.education/docs/workspace-editor.html",
"licenseUrl": "https://pravda.education/docs/terms.html",
"privacyStatementUrl": "https://pravda.education/docs/privacy.html",
"twitterUrl": "",
"requestFeatureUrl": "https://pravda.education/issues",
"licenseName": "DevPlace Terms of Service"
}

View File

@ -1,5 +1,5 @@
# retoor <retoor@molodetz.nl>
from . import flags, naming, provision, quota, tunnels
from . import editor, flags, naming, provision, quota, tunnels
__all__ = ["flags", "naming", "provision", "quota", "tunnels"]
__all__ = ["editor", "flags", "naming", "provision", "quota", "tunnels"]

View File

@ -0,0 +1,463 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
from devplacepy import config
from devplacepy.database import get_int_setting, get_setting, get_table
from devplacepy.services.containers import store
from devplacepy.services.containers.backend.base import (
WORKSPACE_MOUNT,
WORKSPACE_STATE_MOUNT,
)
from . import quota
logger = logging.getLogger(__name__)
PREFS_TABLE = "workspace_editor_prefs"
APP_NAME = "DevPlace"
WELCOME_TEXT = "Sign in to your DevPlace workspace"
EDITOR_DEFAULT_PORT = 8443
PROFILE_FILE = "devplace-editor.json"
MANAGED_FILE = ".devplace-managed.json"
INHERIT_TEXT = ""
INHERIT_INT = 0
INHERIT_ZOOM = -99
INHERIT_FLAG = -1
THEME_CHOICES = ("devplace-dark", "devplace-light", "system")
LAYOUT_CHOICES = ("standard", "terminal-focus", "zen")
PANEL_CHOICES = ("short", "normal", "tall", "maximized")
WINDOW_CHOICES = ("tab", "window", "fullscreen")
AGENT_CHOICES = ("dpc", "none")
CHOICES = {
"theme": THEME_CHOICES,
"layout": LAYOUT_CHOICES,
"panel_preset": PANEL_CHOICES,
"window_mode": WINDOW_CHOICES,
"boot_agent": AGENT_CHOICES,
}
SENTINELS = {
"zoom_level": INHERIT_ZOOM,
"boot_shell": INHERIT_FLAG,
}
THEME_LABELS = {
"devplace-dark": "DevPlace Dark",
"devplace-light": "DevPlace Light",
}
LAYOUTS = {
"standard": {
"workbench.activityBar.location": "default",
"workbench.sideBar.location": "left",
"workbench.panel.defaultLocation": "bottom",
"editor.minimap.enabled": True,
"breadcrumbs.enabled": True,
},
"terminal-focus": {
"workbench.activityBar.location": "top",
"workbench.sideBar.location": "left",
"workbench.panel.defaultLocation": "bottom",
"editor.minimap.enabled": False,
"breadcrumbs.enabled": True,
},
"zen": {
"workbench.activityBar.location": "hidden",
"workbench.sideBar.location": "left",
"workbench.panel.defaultLocation": "bottom",
"editor.minimap.enabled": False,
"breadcrumbs.enabled": False,
},
}
FOREIGN_AI_SETTINGS = {
"chat.disableAIFeatures": True,
"chat.commandCenter.enabled": False,
"workbench.secondarySideBar.defaultVisibility": "hidden",
}
TRUST_SETTINGS = {
"security.workspace.trust.enabled": False,
"security.workspace.trust.startupPrompt": "never",
"security.workspace.trust.banner": "never",
"security.workspace.trust.emptyWindow": True,
"security.workspace.trust.untrustedFiles": "open",
"task.allowAutomaticTasks": "on",
}
BOUNDS = {
"font_size": (8, 48),
"terminal_font_size": (8, 48),
"zoom_level": (-5, 5),
"window_width": (640, 7680),
"window_height": (480, 4320),
}
SETTING_KEYS = {
"trust_all": "workspace_editor_trust_all",
"theme": "workspace_editor_theme",
"font_size": "workspace_editor_font_size",
"terminal_font_size": "workspace_editor_terminal_font_size",
"zoom_level": "workspace_editor_zoom_level",
"layout": "workspace_editor_layout",
"panel_preset": "workspace_editor_panel_preset",
"boot_agent": "workspace_editor_boot_agent",
"boot_shell": "workspace_editor_boot_shell",
"window_mode": "workspace_editor_window_mode",
"window_width": "workspace_editor_window_width",
"window_height": "workspace_editor_window_height",
}
DEFAULTS = {
"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,
}
PREF_COLUMNS = (
"font_size",
"terminal_font_size",
"zoom_level",
"theme",
"layout",
"panel_preset",
"window_mode",
"window_width",
"window_height",
"boot_agent",
"boot_shell",
)
OPTIONAL_FLAGS = (
"--app-name",
"--welcome-text",
"--disable-getting-started-override",
"--disable-workspace-trust",
)
SOURCE_SITE = "site"
SOURCE_USER = "user"
@dataclass(frozen=True)
class EditorProfile:
trust_all: bool
theme: str
font_size: int
terminal_font_size: int
zoom_level: int
layout: str
panel_preset: str
boot_agent: str
boot_shell: bool
window_mode: str
window_width: int
window_height: int
cpu_millicores: int
memory_mb: int
disk_quota_mb: int
def as_dict(self) -> dict:
return asdict(self)
def cpu_cores(self) -> float:
return round(self.cpu_millicores / 1000, 3)
def cpu_limit(self) -> str:
return quota.format_cpu(self.cpu_millicores)
def mem_limit(self) -> str:
return quota.format_memory(self.memory_mb)
def _clamp(key: str, value: int) -> int:
bounds = BOUNDS.get(key)
if not bounds:
return value
low, high = bounds
return max(low, min(high, value))
def _choice(value: str, choices: tuple[str, ...], fallback: str) -> str:
cleaned = (value or "").strip().lower()
return cleaned if cleaned in choices else fallback
def prefs_for(owner_uid: str) -> dict | None:
if not owner_uid:
return None
return get_table(PREFS_TABLE).find_one(
owner_kind="user", owner_id=owner_uid, deleted_at=None
)
def _inherits(key: str, row: dict | None) -> bool:
if not row:
return True
value = row.get(key)
if value is None:
return True
if key in SENTINELS:
return int(value) == SENTINELS[key]
if isinstance(DEFAULTS[key], str):
return not str(value).strip()
return int(value) == INHERIT_INT
def _site_value(key: str):
default = DEFAULTS[key]
setting = SETTING_KEYS[key]
if isinstance(default, bool):
return get_setting(setting, "1" if default else "0") == "1"
if isinstance(default, int):
return get_int_setting(setting, default)
return get_setting(setting, default)
def _normalize(key: str, value):
default = DEFAULTS[key]
if key in CHOICES:
return _choice(value, CHOICES[key], default)
if isinstance(default, bool):
if isinstance(value, bool):
return value
try:
return bool(int(value))
except (TypeError, ValueError):
return bool(value)
if isinstance(default, int):
try:
return _clamp(key, int(value))
except (TypeError, ValueError):
return default
return str(value)
def source_map(owner_uid: str = "") -> dict[str, str]:
row = prefs_for(owner_uid)
sources = {key: SOURCE_SITE for key in SETTING_KEYS}
for key in PREF_COLUMNS:
if not _inherits(key, row):
sources[key] = SOURCE_USER
return sources
def resolve(owner_uid: str = "", instance: dict | None = None) -> EditorProfile:
row = prefs_for(owner_uid)
values = {}
for key in SETTING_KEYS:
value = _site_value(key)
if key in PREF_COLUMNS and not _inherits(key, row):
value = row.get(key)
values[key] = _normalize(key, value)
limits = quota.resolve(owner_uid, instance)
return EditorProfile(
cpu_millicores=limits.cpu_millicores,
memory_mb=limits.memory_mb,
disk_quota_mb=limits.disk_quota_mb,
**values,
)
def settings_for(profile: EditorProfile) -> dict:
settings = {
"editor.fontSize": profile.font_size,
"terminal.integrated.fontSize": profile.terminal_font_size,
"window.zoomLevel": profile.zoom_level,
"telemetry.telemetryLevel": "off",
"update.mode": "none",
"workbench.tips.enabled": False,
"workbench.startupEditor": "none",
"extensions.autoCheckUpdates": False,
**FOREIGN_AI_SETTINGS,
"terminal.integrated.defaultProfile.linux": "bash",
"terminal.integrated.profiles.linux": {
"bash": {"path": "/bin/bash", "args": ["-l"], "icon": "terminal-bash"},
"DevPlace Code": {"path": "/usr/bin/dpc", "icon": "rocket"},
},
}
settings.update(LAYOUTS[profile.layout])
if profile.theme in THEME_LABELS:
settings["workbench.colorTheme"] = THEME_LABELS[profile.theme]
if profile.trust_all:
settings.update(TRUST_SETTINGS)
return settings
def merge_managed(current: dict, managed: dict, desired: dict) -> tuple[dict, dict]:
merged = dict(current)
for key, value in desired.items():
if key not in merged or merged[key] == managed.get(key):
merged[key] = value
return merged, dict(desired)
def _read_json(path: Path) -> dict:
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def state_dir(instance: dict) -> Path:
return config.WORKSPACE_STATE_DIR / instance["uid"]
def profile_payload(instance: dict, profile: EditorProfile) -> dict:
return {
"app_name": APP_NAME,
"workspace_uid": instance.get("uid", ""),
"boot_marker": instance.get("boot_marker", ""),
"editor": profile.as_dict(),
}
def seed_state(instance: dict, profile: EditorProfile) -> bool:
root = state_dir(instance)
user_dir = root / "data" / "User"
try:
user_dir.mkdir(parents=True, exist_ok=True)
settings_path = user_dir / "settings.json"
managed_path = user_dir / MANAGED_FILE
settings, managed = merge_managed(
_read_json(settings_path),
_read_json(managed_path),
settings_for(profile),
)
settings_path.write_text(json.dumps(settings, indent=2, sort_keys=True))
managed_path.write_text(json.dumps(managed, indent=2, sort_keys=True))
(root / PROFILE_FILE).write_text(
json.dumps(profile_payload(instance, profile), indent=2, sort_keys=True)
)
except OSError as error:
logger.warning("workspace editor seed failed for %s: %s", instance.get("uid"), error)
return False
return True
def argv(instance: dict, profile: EditorProfile) -> list[str]:
port = int(instance.get("editor_port") or EDITOR_DEFAULT_PORT)
command = [
"code-server",
"--bind-addr",
f"0.0.0.0:{port}",
"--auth",
"password",
"--app-name",
APP_NAME,
"--welcome-text",
WELCOME_TEXT,
"--disable-telemetry",
"--disable-update-check",
"--disable-getting-started-override",
]
if profile.trust_all:
command.append("--disable-workspace-trust")
command += [
"--user-data-dir",
f"{WORKSPACE_STATE_MOUNT}/data",
"--extensions-dir",
f"{WORKSPACE_STATE_MOUNT}/extensions",
WORKSPACE_MOUNT,
]
return command
def env_for(profile: EditorProfile) -> dict:
return {
"DEVPLACE_EDITOR_APP_NAME": APP_NAME,
"DEVPLACE_EDITOR_PROFILE": f"{WORKSPACE_STATE_MOUNT}/{PROFILE_FILE}",
"DEVPLACE_EDITOR_THEME": profile.theme,
"DEVPLACE_EDITOR_FONT_SIZE": str(profile.font_size),
"DEVPLACE_EDITOR_TERMINAL_FONT_SIZE": str(profile.terminal_font_size),
"DEVPLACE_EDITOR_ZOOM_LEVEL": str(profile.zoom_level),
"DEVPLACE_EDITOR_LAYOUT": profile.layout,
"DEVPLACE_EDITOR_PANEL_PRESET": profile.panel_preset,
"DEVPLACE_EDITOR_BOOT_AGENT": profile.boot_agent,
"DEVPLACE_EDITOR_BOOT_SHELL": "1" if profile.boot_shell else "0",
"DEVPLACE_EDITOR_TRUST_ALL": "1" if profile.trust_all else "0",
}
def booted_profile(instance: dict) -> dict:
return _read_json(state_dir(instance) / PROFILE_FILE).get("editor") or {}
def restart_required(instance: dict, profile: EditorProfile) -> bool:
booted = booted_profile(instance)
if not booted:
return False
return booted != profile.as_dict()
def save_prefs(owner_uid: str, payload: dict) -> dict:
from devplacepy.utils import generate_uid
table = get_table(PREFS_TABLE)
row = prefs_for(owner_uid)
values = {key: payload[key] for key in PREF_COLUMNS if key in payload}
stamp = store.now()
if row:
table.update({"uid": row["uid"], "updated_at": stamp, **values}, ["uid"])
return table.find_one(uid=row["uid"])
uid = generate_uid()
table.insert(
{
"uid": uid,
"owner_kind": "user",
"owner_id": owner_uid,
"created_at": stamp,
"updated_at": stamp,
"deleted_at": None,
"deleted_by": None,
**{key: _blank(key) for key in PREF_COLUMNS},
**values,
}
)
return table.find_one(uid=uid)
def reset_prefs(owner_uid: str, actor_uid: str) -> bool:
row = prefs_for(owner_uid)
if not row:
return False
get_table(PREFS_TABLE).update(
{"uid": row["uid"], "deleted_at": store.now(), "deleted_by": actor_uid}, ["uid"]
)
return True
def _blank(key: str):
if key in SENTINELS:
return SENTINELS[key]
if isinstance(DEFAULTS[key], str):
return INHERIT_TEXT
return INHERIT_INT
def view(owner_uid: str = "", instance: dict | None = None) -> dict:
profile = resolve(owner_uid, instance)
payload = profile.as_dict()
payload["cpu_cores"] = profile.cpu_cores()
payload["sources"] = source_map(owner_uid)
return payload

View File

@ -6,11 +6,10 @@ import asyncio
import json
from pathlib import Path
from devplacepy import config
from devplacepy.database import get_table
from devplacepy.services.containers import api, store
from . import flags, naming, quota, tunnels
from . import editor, flags, naming, quota, tunnels
MANIFEST_DIRECTORY = ".devplace"
MANIFEST_NAME = "tunnels.json"
@ -193,12 +192,9 @@ def write_manifest(instance: dict) -> None:
return
def state_dir(instance: dict) -> Path:
return config.WORKSPACE_STATE_DIR / instance["uid"]
def view(instance: dict, viewer_is_admin: bool = False) -> dict:
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
def view(instance: dict) -> dict:
owner_uid = instance.get("workspace_owner_uid", "")
limits = quota.resolve(owner_uid, instance)
disk_used = int(instance.get("disk_bytes") or 0)
egress_used = int(instance.get("egress_bytes") or 0)
return {
@ -226,4 +222,5 @@ def view(instance: dict, viewer_is_admin: bool = False) -> dict:
"max_tunnels": limits.max_tunnels,
"tunnels": tunnels.list_for_instance(instance["uid"]),
"flags": flags.list_flags(instance_uid=instance["uid"]),
"editor": editor.view(owner_uid, instance),
}

View File

@ -18,6 +18,8 @@ DEFAULTS: dict[str, int] = {
"retention_days": 14,
"purge_after_days": 7,
"disk_warn_percent": 80,
"cpu_millicores": 2000,
"memory_mb": 2048,
}
SETTING_KEYS: dict[str, str] = {
@ -30,6 +32,8 @@ SETTING_KEYS: dict[str, str] = {
"retention_days": "workspace_retention_days",
"purge_after_days": "workspace_purge_after_days",
"disk_warn_percent": "workspace_disk_warn_percent",
"cpu_millicores": "workspace_cpu_millicores",
"memory_mb": "workspace_memory_mb",
}
RULE_COLUMNS = (
@ -39,8 +43,28 @@ RULE_COLUMNS = (
"egress_quota_mb",
"idle_stop_minutes",
"retention_days",
"cpu_millicores",
"memory_mb",
)
INSTANCE_OVERRIDE_COLUMNS = (
"cpu_millicores",
"memory_mb",
"disk_quota_mb",
)
def format_cpu(millicores: int) -> str:
if millicores <= 0:
return ""
return f"{millicores / 1000:.3f}".rstrip("0").rstrip(".")
def format_memory(megabytes: int) -> str:
if megabytes <= 0:
return ""
return f"{megabytes}m"
@dataclass(frozen=True)
class Limits:
@ -53,6 +77,8 @@ class Limits:
retention_days: int
purge_after_days: int
disk_warn_percent: int
cpu_millicores: int
memory_mb: int
def disk_quota_bytes(self) -> int:
return self.disk_quota_mb * 1024 * 1024
@ -60,6 +86,12 @@ class Limits:
def egress_quota_bytes(self) -> int:
return self.egress_quota_mb * 1024 * 1024
def cpu_limit(self) -> str:
return format_cpu(self.cpu_millicores)
def mem_limit(self) -> str:
return format_memory(self.memory_mb)
def _global_value(key: str) -> int:
return get_int_setting(SETTING_KEYS[key], DEFAULTS[key])
@ -81,7 +113,7 @@ def resolve(user_uid: str = "", instance: dict | None = None) -> Limits:
override = rule.get(key)
if override:
value = int(override)
if instance:
if instance and key in INSTANCE_OVERRIDE_COLUMNS:
instance_override = instance.get(f"workspace_{key}")
if instance_override:
value = int(instance_override)

View File

@ -77,9 +77,6 @@ class WorkspaceService(BaseService):
config_fields = [
ConfigField("workspace_enabled", "Enabled", type="bool", default="0",
group="General", help="Master switch for the workspace feature."),
ConfigField("workspace_editor_version", "Editor version", default="",
group="General",
help="code-server release. Empty means the image default."),
ConfigField("workspace_extensions_gallery", "Extensions gallery", type="text",
default="", group="General",
help="EXTENSIONS_GALLERY JSON. Empty means the default."),
@ -112,6 +109,61 @@ class WorkspaceService(BaseService):
default="10240", minimum=0, group="Quotas"),
ConfigField("workspace_disk_warn_percent", "Disk warn percent", type="int",
default="80", minimum=1, maximum=100, group="Quotas"),
ConfigField("workspace_cpu_millicores", "CPU per workspace (millicores)",
type="int", default="2000", minimum=250, group="Quotas",
help="1000 millicores is one core. Applied as the docker --cpus limit."),
ConfigField("workspace_memory_mb", "Memory per workspace (MB)", type="int",
default="2048", minimum=256, group="Quotas",
help="Applied as the docker --memory limit."),
ConfigField("workspace_editor_trust_all", "Trust every workspace", type="bool",
default="1", group="Editor",
help="Disables VS Code Restricted Mode. Turning this off restores "
"the workspace trust prompt and blocks automatic tasks."),
ConfigField("workspace_editor_theme", "Editor theme", type="select",
default="devplace-dark",
options=[{"value": "devplace-dark", "label": "DevPlace Dark"},
{"value": "devplace-light", "label": "DevPlace Light"},
{"value": "system", "label": "Leave to the member"}],
group="Editor"),
ConfigField("workspace_editor_font_size", "Editor font size", type="int",
default="14", minimum=8, maximum=48, group="Editor"),
ConfigField("workspace_editor_terminal_font_size", "Terminal font size",
type="int", default="13", minimum=8, maximum=48, group="Editor"),
ConfigField("workspace_editor_zoom_level", "Zoom level", type="int",
default="0", minimum=-5, maximum=5, group="Editor",
help="VS Code window zoom. Each step is about 20 percent."),
ConfigField("workspace_editor_layout", "Editor layout", type="select",
default="standard",
options=[{"value": "standard", "label": "Standard"},
{"value": "terminal-focus", "label": "Terminal focus"},
{"value": "zen", "label": "Zen"}],
group="Editor"),
ConfigField("workspace_editor_panel_preset", "Terminal panel size",
type="select", default="tall",
options=[{"value": "short", "label": "Short"},
{"value": "normal", "label": "Normal"},
{"value": "tall", "label": "Tall"},
{"value": "maximized", "label": "Maximized"}],
group="Editor"),
ConfigField("workspace_editor_boot_agent", "Agent on boot", type="select",
default="dpc",
options=[{"value": "dpc", "label": "DevPlace Code (dpc)"},
{"value": "none", "label": "None"}],
group="Editor"),
ConfigField("workspace_editor_boot_shell", "Shell on boot", type="bool",
default="1", group="Editor",
help="Opens a plain login shell beside the agent terminal."),
ConfigField("workspace_editor_window_mode", "Open editor in", type="select",
default="tab",
options=[{"value": "tab", "label": "A new tab"},
{"value": "window", "label": "A sized window"},
{"value": "fullscreen", "label": "A fullscreen window"}],
group="Editor"),
ConfigField("workspace_editor_window_width", "Editor window width", type="int",
default="1600", minimum=640, maximum=7680, group="Editor"),
ConfigField("workspace_editor_window_height", "Editor window height",
type="int", default="1000", minimum=480, maximum=4320,
group="Editor"),
ConfigField("workspace_idle_warn_minutes", "Idle warn (minutes)", type="int",
default="45", minimum=1, group="Lifecycle"),
ConfigField("workspace_idle_stop_minutes", "Idle stop (minutes)", type="int",

View File

@ -41,6 +41,7 @@ CONFIRM_REQUIRED = {
"tunnel_delete",
"workspace_flag_resolve",
"workspace_suspend",
"workspace_editor_set",
"project_set_private",
"project_set_readonly",
"customize_set_css",
@ -171,6 +172,17 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
"Deleting customizations cannot be undone. Ask the user to confirm, then call again with "
"confirm=true."
)
if name == "workspace_editor_set":
if arguments.get("reset"):
return ToolInputError(
"Resetting drops every editor preference and returns the workspace to the "
"site defaults. Ask the user to confirm, then call again with confirm=true."
)
return ToolInputError(
"Editor preferences change how every workspace this member opens looks and "
"behaves, and they apply on the next workspace start. Show the user the exact "
"values you are about to set, then call again with confirm=true."
)
if name == "notification_reset":
return ToolInputError(
"Resetting clears every notification preference and restores the platform defaults; it "

View File

@ -140,6 +140,53 @@ WORKSPACE_ACTIONS: tuple[Action, ...] = (
arg("egress_quota_mb", "Egress quota in MB.", kind="integer"),
arg("idle_stop_minutes", "Idle stop window in minutes.", kind="integer"),
arg("retention_days", "Retention in days.", kind="integer"),
arg("cpu_millicores", "CPU limit in millicores. 1000 is one core.", kind="integer"),
arg("memory_mb", "Memory limit in MB.", kind="integer"),
),
),
Action(
name="workspace_editor_get",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
read_only=True,
summary=(
"Read the resolved DevPlace editor profile for a workspace: theme, layout, "
"font sizes, zoom, boot terminals, how the editor opens, the container size, "
"and where each value comes from."
),
params=(
SLUG,
arg("username", "Administrators only. Read another member's profile."),
),
),
Action(
name="workspace_editor_set",
method="LOCAL",
path="",
handler="workspace",
requires_auth=True,
summary=(
"Change the caller's DevPlace editor preferences. Omitted fields are left "
"alone; an empty string or zero means inherit the site default. Applies on "
"the next workspace start."
),
params=(
SLUG,
arg("theme", "devplace-dark, devplace-light or system."),
arg("layout", "standard, terminal-focus or zen."),
arg("panel_preset", "short, normal, tall or maximized."),
arg("font_size", "Editor font size in pixels.", kind="integer"),
arg("terminal_font_size", "Terminal font size in pixels.", kind="integer"),
arg("zoom_level", "Window zoom level, -5 to 5.", kind="integer"),
arg("boot_agent", "dpc to open the agent terminal on boot, none to skip it."),
arg("boot_shell", "1 to open a plain shell on boot, 0 to skip it.", kind="integer"),
arg("window_mode", "tab, window or fullscreen."),
arg("window_width", "Editor window width in pixels.", kind="integer"),
arg("window_height", "Editor window height in pixels.", kind="integer"),
arg("reset", "Drop every preference and fall back to the site defaults.", kind="boolean"),
CONFIRM,
),
),
Action(

View File

@ -8,6 +8,7 @@ from typing import Any
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.services.containers import store
from devplacepy.services.containers.workspace import (
editor,
flags,
provision,
quota,
@ -139,6 +140,50 @@ class WorkspaceController:
provision.write_manifest(instance)
return {"ok": True, "deleted": uid}
def _editor_target(self, args: dict) -> str:
username = args.get("username", "")
if not username:
return self.owner_id
if not self.admin:
raise WorkspaceError(
"only administrators may read another user's editor profile"
)
other = self._user_by_name(username)
if not other:
raise WorkspaceError(f"user not found: {username}")
return other["uid"]
def _workspace_editor_get(self, args: dict) -> Any:
target = self._editor_target(args)
instance = provision.find_for_project(
self._project_uid(args.get("project_slug", "")), target
)
return editor.view(target, instance)
def _workspace_editor_set(self, args: dict) -> Any:
if not self.owner_id:
raise WorkspaceError("sign in to change editor preferences")
if args.get("reset"):
editor.reset_prefs(self.owner_id, self.owner_id)
return {"ok": True, "reset": True, "editor": editor.view(self.owner_id)}
payload = {
key: args[key] for key in editor.PREF_COLUMNS if args.get(key) is not None
}
if not payload:
raise WorkspaceError("no editor preference was supplied")
editor.save_prefs(self.owner_id, payload)
return {
"ok": True,
"editor": editor.view(self.owner_id),
"applies": "on the next workspace start",
}
def _project_uid(self, slug: str) -> str:
project = self._project(slug)
if not project:
raise WorkspaceError(f"project not found: {slug}")
return project["uid"]
def _workspace_quota_get(self, args: dict) -> Any:
target = self.owner_id
username = args.get("username", "")

View File

@ -111,6 +111,92 @@
border-left: 3px solid var(--text-secondary);
}
.workspace-editor-heading {
margin: var(--space-lg) 0 var(--space-xs);
color: var(--text-primary);
}
.workspace-editor-restart {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
border-left: 3px solid var(--warning);
border-radius: var(--radius);
background: var(--overlay-light);
color: var(--text-primary);
}
.workspace-editor-summary {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-sm);
list-style: none;
margin: 0 0 var(--space-md);
padding: 0;
}
.workspace-editor-summary li {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
}
.workspace-editor-summary span {
color: var(--text-secondary);
font-size: 0.85rem;
}
.workspace-editor-summary strong {
color: var(--text-primary);
text-transform: capitalize;
}
.workspace-editor-summary em {
color: var(--text-muted);
font-size: 0.75rem;
font-style: normal;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.workspace-editor-form {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-sm);
}
.workspace-editor-form label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
color: var(--text-secondary);
font-size: 0.9rem;
}
.workspace-editor-actions {
grid-column: 1 / -1;
display: flex;
gap: var(--space-sm);
}
.workspace-editor-reset {
margin-top: var(--space-sm);
}
@media (max-width: 768px) {
.workspace-editor-summary,
.workspace-editor-form {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.workspace-meters {
flex-direction: column;

View File

@ -42,6 +42,7 @@ import { LiveNotifications } from "./LiveNotifications.js";
import { PresenceManager } from "./PresenceManager.js";
import { OnlineUsers } from "./OnlineUsers.js";
import { LocalTime } from "./LocalTime.js";
import { EditorLauncher } from "./EditorLauncher.js";
import { ScrollMemory } from "./ScrollMemory.js";
import { GameFarm } from "./GameFarm.js";
import { Accessibility } from "./Accessibility.js";
@ -112,6 +113,7 @@ class Application {
this.onlineUsers = new OnlineUsers(this.pubsub);
this.localTime = new LocalTime();
this.scrollMemory = new ScrollMemory();
this.editorLauncher = new EditorLauncher();
this.gameFarm = new GameFarm();
this.overflowTabs = new OverflowTabs();
}

View File

@ -0,0 +1,58 @@
// retoor <retoor@molodetz.nl>
export class EditorLauncher {
constructor() {
document.addEventListener("click", (event) => this.onClick(event));
}
onClick(event) {
const trigger = event.target.closest("[data-editor-open]");
if (!trigger) return;
const mode = trigger.dataset.editorMode || "tab";
if (mode === "tab") return;
const opened = window.open(
trigger.href,
trigger.dataset.editorName || "devplace-editor",
this.features(mode, trigger),
);
if (!opened) return;
event.preventDefault();
opened.focus();
}
features(mode, trigger) {
const size = this.size(mode, trigger);
const left = Math.max(0, Math.round((window.screen.availWidth - size.width) / 2));
const top = Math.max(0, Math.round((window.screen.availHeight - size.height) / 2));
return [
`width=${size.width}`,
`height=${size.height}`,
`left=${left}`,
`top=${top}`,
"noopener",
"resizable=yes",
"scrollbars=yes",
].join(",");
}
size(mode, trigger) {
if (mode === "fullscreen") {
return {
width: window.screen.availWidth,
height: window.screen.availHeight,
};
}
return {
width: this.clamp(trigger.dataset.editorWidth, 640, window.screen.availWidth),
height: this.clamp(trigger.dataset.editorHeight, 480, window.screen.availHeight),
};
}
clamp(raw, minimum, maximum) {
const value = parseInt(raw, 10);
if (!Number.isFinite(value)) return maximum;
return Math.max(minimum, Math.min(maximum, value));
}
}
export default EditorLauncher;

View File

@ -20,7 +20,7 @@ export class WorkspaceManager {
bind() {
this.root.addEventListener("submit", (event) => {
const form = event.target.closest("form");
if (!form || form.dataset.confirm) return;
if (!form || form.dataset.confirm || form.dataset.native !== undefined) return;
event.preventDefault();
this.send(form);
});

View File

@ -0,0 +1,10 @@
{# retoor <retoor@molodetz.nl> #}
<a href="{{ _url }}" target="_blank" rel="noopener" class="{{ _class or 'btn btn-primary' }}"
data-editor-open
data-editor-mode="{{ _mode or 'tab' }}"
data-editor-width="{{ _width or 1600 }}"
data-editor-height="{{ _height or 1000 }}"
data-editor-name="devplace-editor-{{ _uid }}">
{%- if _icon %}<span class="icon">{{ _icon }}</span><span class="label"> {{ _label or 'Editor' }}</span>
{%- else %}{{ _label or 'Open editor' }}{% endif -%}
</a>

View File

@ -21,6 +21,8 @@
<th>Status</th>
<th>Disk</th>
<th>Egress</th>
<th>Size</th>
<th>Editor</th>
<th>Tunnels</th>
<th>Flags</th>
<th>Actions</th>
@ -41,6 +43,8 @@
</td>
<td>{{ ws.disk_percent }}% of {{ ws.disk_quota_mb }} MB</td>
<td>{{ ws.egress_percent }}% of {{ ws.egress_quota_mb }} MB</td>
<td>{{ ws.editor.cpu_cores }} CPU / {{ ws.editor.memory_mb }} MB</td>
<td>{{ ws.editor.theme }} / {{ ws.editor.layout }}</td>
<td>{{ ws.tunnels|length }}</td>
<td>{{ ws.flags|length }}</td>
<td class="admin-actions">
@ -73,7 +77,7 @@
</td>
</tr>
{% else %}
<tr><td colspan="9">No workspaces yet.</td></tr>
<tr><td colspan="11">No workspaces yet.</td></tr>
{% endfor %}
</tbody>
</table>

View File

@ -105,6 +105,11 @@ to the platform AI gateway, so **all of its AI usage is metered through your own
account**. There is no separate key to manage and nothing to configure: it is plug
and play.
In a **workspace** you do not even have to start it. The DevPlace editor opens a
**DevPlace Code** terminal running `dpc` for you the moment the workspace boots, with
a plain shell beside it. See [The workspace editor](/docs/workspace-editor.html) for
the boot terminals, the trust policy, and every size you can change.
## Container environment keys
Every container is launched with these variables already set. Scripts and agents

View File

@ -0,0 +1,117 @@
<div class="docs-content" data-render>
# The workspace editor
Every DevPlace workspace opens a full editor in your browser. It is branded DevPlace,
it starts a coding agent for you, and it is configured from your DevPlace account
rather than from inside the editor.
Open one from a project's **Workspace** page, or with the **Editor** button on the
project itself once the workspace is running.
## What opens on boot
When your workspace starts, two terminals open at the bottom of the window:
- **DevPlace Code** runs [`dpc`](/docs/getting-started-vibing.html), the coding agent
that ships in every workspace. It has focus, so you can type a request straight
away. Every token it spends is metered against your own DevPlace account.
- **pravda@workspace** is an ordinary login shell, so the Python, Rust, Nim and Swift
toolchains are all on your `PATH`.
New terminals you open later are plain shells. To start another agent, pick
**DevPlace Code** from the terminal dropdown, or run the command
**DevPlace: Start DevPlace Code**.
The workspace opens straight onto your files with the terminal ready, not onto a welcome
page, and the editor's own built-in chat assistant is switched off: `dpc` is the assistant
here, and it runs on your DevPlace account. The files `dpc` keeps for itself (`.dpc/` and
`dpc.log`) stay in the container and are never copied into your project.
You can turn either of them off. See **Your preferences** below.
## Every workspace is trusted
VS Code normally opens an unfamiliar folder in **Restricted Mode**, which disables
tasks, debugging and most extensions until you click to trust it. DevPlace turns
that off: your workspace is yours, so it is trusted from the first second and
nothing prompts you.
**This has a real consequence, and you should know it.** Automatic tasks are enabled
too, so if a project you open contains a `.vscode/tasks.json` with a
`"runOn": "folderOpen"` task, that task runs when the folder opens. If you are about
to open code you did not write and do not trust, read that file first.
An administrator can restore Restricted Mode for the whole site from the workspace
service settings.
## Size
Four separate things have a size, and they are set in two different places.
| What | Set by | Where |
|---|---|---|
| Editor font size, terminal font size, zoom | You | Your workspace page |
| Editor layout and terminal panel size | You | Your workspace page |
| How the editor opens (tab or sized window) | You | Your workspace page |
| CPU, memory and disk | An administrator | Your workspace quota |
Your own preferences follow you into every workspace you open. The container size is
part of your quota and is shown on the same page so you always know what you have.
## Your preferences
The **Editor** card on your workspace page holds them all:
- **Theme** - DevPlace Dark, DevPlace Light, or leave it to you (pick any theme from
inside the editor and DevPlace will not touch it again).
- **Layout** - Standard, Terminal focus, or Zen.
- **Terminal panel** - Short, Normal, Tall or Maximized.
- **Editor font size**, **Terminal font size**, **Zoom level**.
- **Agent on boot** and **Shell on boot**.
- **Open editor in** - a new tab, a sized window, or a fullscreen window, with the
width and height for the sized case.
Every field has a **Site default** option. Choosing it removes your preference and
lets the administrator's value apply again, including any future change to it.
**Reset to site defaults** does that for all of them at once.
Over the API and through Devii the same rule applies field by field: only the fields
you send are changed, and a field you send as empty or zero goes back to inheriting.
### They apply on the next start
Editor settings are read when the workspace container boots. After you save, the page
tells you if a restart is needed and gives you the buttons to do it.
### DevPlace never overwrites a setting you changed yourself
If you change something inside the editor, that value is yours from then on. DevPlace
only writes a setting it wrote itself last time, so a change to the site default
reaches everyone who has not expressed an opinion and no one who has.
## Doing it from Devii
Devii can read and change these for you:
- *"what is my workspace editor set to"* runs `workspace_editor_get`.
- *"make my workspace editor font 18 and use the light theme"* runs
`workspace_editor_set`. It will show you the exact values and ask before saving.
## Commands inside the editor
Press `F1` and type `DevPlace` for the full list:
| Command | What it does |
|---|---|
| **DevPlace: Start DevPlace Code** | Opens another `dpc` terminal |
| **DevPlace: Open project on DevPlace** | Your project page |
| **DevPlace: Open workspace settings** | Your workspace page |
| **DevPlace: Show public tunnels** | Pick one of your live public addresses |
| **DevPlace: Open the DevPlace editor guide** | This page |
## Related
- [Get started with vibing](/docs/getting-started-vibing.html) - the container
runtime, the agents, and publishing what you build.
- The **Dev Workspaces** API group for the same settings over HTTP.
</div>

View File

@ -71,7 +71,15 @@
<div class="project-detail-actions">
<a href="/projects/{{ project['slug'] or project['uid'] }}/files" class="project-star-btn"><span class="icon">&#x1F4C1;</span><span class="label"> Files ({{ file_count }} files)</span></a>
{% if workspace_editor_url %}
<a href="{{ workspace_editor_url }}" target="_blank" rel="noopener" class="project-star-btn"><span class="icon">&#x1F4BB;</span><span class="label"> VS Code</span></a>
{% set _url = workspace_editor_url %}
{% set _uid = project['uid'] %}
{% set _class = "project-star-btn" %}
{% set _icon = "&#x1F4BB;"|safe %}
{% set _mode = workspace_editor_mode %}
{% set _width = workspace_editor_width %}
{% set _height = workspace_editor_height %}
{% set _label = "Editor" %}
{% include "_editor_open.html" %}
{% endif %}
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}"><span class="icon">&#x1F517;</span><span class="label"> Share</span></button>
{% if user %}

View File

@ -8,6 +8,7 @@
{% block content %}
<div class="workspace-page" data-workspace-root
data-slug="{{ project.slug or project.uid }}"
data-workspace-uid="{{ workspace.uid if has_workspace else '' }}"
data-has-workspace="{{ 1 if has_workspace else 0 }}">
<h1 class="workspace-title">Workspace: {{ project.title }}</h1>
@ -60,7 +61,14 @@
</p>
<div class="workspace-actions">
{% if workspace.status == "running" %}
<a class="btn btn-primary" href="{{ editor_url }}" target="_blank" rel="noopener">Open editor</a>
{% set _url = editor_url %}
{% set _uid = workspace.uid %}
{% set _class = "btn btn-primary" %}
{% set _mode = editor.window_mode %}
{% set _width = editor.window_width %}
{% set _height = editor.window_height %}
{% set _label = "Open editor" %}
{% include "_editor_open.html" %}
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop">
<button type="submit" class="btn">Stop</button>
</form>
@ -79,6 +87,128 @@
</div>
</div>
<div class="card workspace-editor" data-editor-card>
<h2>Editor</h2>
<p class="workspace-muted">
Your workspace opens a branded DevPlace editor with the
<code>dpc</code> coding agent already running. These preferences are yours and
follow you into every workspace you open.
</p>
{% if restart_required %}
<div class="workspace-editor-restart">
<span>Your editor settings changed. Restart the workspace to apply them.</span>
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace/stop">
<button type="submit" class="btn btn-sm">Stop</button>
</form>
<form method="post" action="/projects/{{ project.slug or project.uid }}/workspace">
<button type="submit" class="btn btn-sm btn-primary">Start</button>
</form>
</div>
{% endif %}
<ul class="workspace-editor-summary">
<li><span>Theme</span><strong>{{ editor.theme }}</strong><em>{{ editor.sources.theme }}</em></li>
<li><span>Layout</span><strong>{{ editor.layout }}</strong><em>{{ editor.sources.layout }}</em></li>
<li><span>Panel</span><strong>{{ editor.panel_preset }}</strong><em>{{ editor.sources.panel_preset }}</em></li>
<li><span>Editor font</span><strong>{{ editor.font_size }} px</strong><em>{{ editor.sources.font_size }}</em></li>
<li><span>Terminal font</span><strong>{{ editor.terminal_font_size }} px</strong><em>{{ editor.sources.terminal_font_size }}</em></li>
<li><span>Zoom</span><strong>{{ editor.zoom_level }}</strong><em>{{ editor.sources.zoom_level }}</em></li>
<li><span>Agent on boot</span><strong>{{ editor.boot_agent }}</strong><em>{{ editor.sources.boot_agent }}</em></li>
<li><span>Shell on boot</span><strong>{{ "yes" if editor.boot_shell else "no" }}</strong><em>{{ editor.sources.boot_shell }}</em></li>
<li><span>Opens in</span><strong>{{ editor.window_mode }}</strong><em>{{ editor.sources.window_mode }}</em></li>
<li><span>Trusts every folder</span><strong>{{ "yes" if editor.trust_all else "no" }}</strong><em>site</em></li>
</ul>
<h3 class="workspace-editor-heading">Container size</h3>
<p class="workspace-muted">Set by an administrator through your workspace quota.</p>
<ul class="workspace-editor-summary workspace-editor-resources">
<li><span>CPU</span><strong>{{ editor.cpu_cores }} cores</strong><em>quota</em></li>
<li><span>Memory</span><strong>{{ editor.memory_mb }} MB</strong><em>quota</em></li>
<li><span>Disk</span><strong>{{ editor.disk_quota_mb }} MB</strong><em>quota</em></li>
</ul>
<form class="workspace-editor-form" method="post" data-native
action="/projects/{{ project.slug or project.uid }}/workspace/editor">
<label>Theme
<select name="theme">
<option value="">Site default</option>
<option value="devplace-dark" {{ "selected" if editor.sources.theme == "user" and editor.theme == "devplace-dark" }}>DevPlace Dark</option>
<option value="devplace-light" {{ "selected" if editor.sources.theme == "user" and editor.theme == "devplace-light" }}>DevPlace Light</option>
<option value="system" {{ "selected" if editor.sources.theme == "user" and editor.theme == "system" }}>Leave to me</option>
</select>
</label>
<label>Layout
<select name="layout">
<option value="">Site default</option>
<option value="standard" {{ "selected" if editor.sources.layout == "user" and editor.layout == "standard" }}>Standard</option>
<option value="terminal-focus" {{ "selected" if editor.sources.layout == "user" and editor.layout == "terminal-focus" }}>Terminal focus</option>
<option value="zen" {{ "selected" if editor.sources.layout == "user" and editor.layout == "zen" }}>Zen</option>
</select>
</label>
<label>Terminal panel
<select name="panel_preset">
<option value="">Site default</option>
<option value="short" {{ "selected" if editor.sources.panel_preset == "user" and editor.panel_preset == "short" }}>Short</option>
<option value="normal" {{ "selected" if editor.sources.panel_preset == "user" and editor.panel_preset == "normal" }}>Normal</option>
<option value="tall" {{ "selected" if editor.sources.panel_preset == "user" and editor.panel_preset == "tall" }}>Tall</option>
<option value="maximized" {{ "selected" if editor.sources.panel_preset == "user" and editor.panel_preset == "maximized" }}>Maximized</option>
</select>
</label>
<label>Editor font size
<input type="number" name="font_size" min="0" max="48"
value="{{ editor.font_size if editor.sources.font_size == 'user' else 0 }}">
</label>
<label>Terminal font size
<input type="number" name="terminal_font_size" min="0" max="48"
value="{{ editor.terminal_font_size if editor.sources.terminal_font_size == 'user' else 0 }}">
</label>
<label>Zoom level
<input type="number" name="zoom_level" min="-99" max="5"
value="{{ editor.zoom_level if editor.sources.zoom_level == 'user' else -99 }}">
</label>
<label>Agent on boot
<select name="boot_agent">
<option value="">Site default</option>
<option value="dpc" {{ "selected" if editor.sources.boot_agent == "user" and editor.boot_agent == "dpc" }}>DevPlace Code (dpc)</option>
<option value="none" {{ "selected" if editor.sources.boot_agent == "user" and editor.boot_agent == "none" }}>None</option>
</select>
</label>
<label>Shell on boot
<select name="boot_shell">
<option value="-1">Site default</option>
<option value="1" {{ "selected" if editor.sources.boot_shell == "user" and editor.boot_shell }}>Yes</option>
<option value="0" {{ "selected" if editor.sources.boot_shell == "user" and not editor.boot_shell }}>No</option>
</select>
</label>
<label>Open editor in
<select name="window_mode">
<option value="">Site default</option>
<option value="tab" {{ "selected" if editor.sources.window_mode == "user" and editor.window_mode == "tab" }}>A new tab</option>
<option value="window" {{ "selected" if editor.sources.window_mode == "user" and editor.window_mode == "window" }}>A sized window</option>
<option value="fullscreen" {{ "selected" if editor.sources.window_mode == "user" and editor.window_mode == "fullscreen" }}>A fullscreen window</option>
</select>
</label>
<label>Window width
<input type="number" name="window_width" min="0" max="7680"
value="{{ editor.window_width if editor.sources.window_width == 'user' else 0 }}">
</label>
<label>Window height
<input type="number" name="window_height" min="0" max="4320"
value="{{ editor.window_height if editor.sources.window_height == 'user' else 0 }}">
</label>
<div class="workspace-editor-actions">
<button type="submit" class="btn btn-primary">Save preferences</button>
</div>
</form>
<form class="workspace-editor-reset" method="post" data-native
action="/projects/{{ project.slug or project.uid }}/workspace/editor">
<input type="hidden" name="reset" value="true">
<button type="submit" class="btn btn-sm">Reset to site defaults</button>
</form>
</div>
<div class="card workspace-tunnels">
<h2>Public tunnels</h2>
<p class="workspace-muted">
@ -112,6 +242,8 @@
<div class="card workspace-help">
<h2>Inside the container</h2>
<ul>
<li>A <strong>DevPlace Code</strong> terminal running <code>dpc</code> opens for you on boot.</li>
<li>Every folder is trusted, so nothing opens in Restricted Mode and project tasks run.</li>
<li><code>sudo</code> and <code>apt install</code> work with no extra setup.</li>
<li>Python, Rust, Nim and Swift toolchains are preinstalled.</li>
<li>Ports below 1024 cannot bind. Use a high port and a tunnel.</li>

View File

@ -213,6 +213,7 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `container.tunnel.suspend` | `services/containers/workspace_service.py` |
| `container.workspace.create` | `routers/projects/containers/workspace.py` |
| `container.workspace.delete` | `routers/projects/containers/workspace.py` |
| `container.workspace.editor.update` | `routers/projects/containers/workspace.py`, `routers/admin/workspaces.py` |
| `container.workspace.flag.dismiss` | `routers/admin/workspaces.py` |
| `container.workspace.flag.raise` | `services/containers/workspace_service.py` |
| `container.workspace.flag.resolve` | `routers/admin/workspaces.py` |

View File

@ -87,6 +87,20 @@ RUN set -eu; \
tar -xzf /tmp/code-server.tar.gz -C /usr/local/lib/code-server --strip-components=1; \
rm -f /tmp/code-server.tar.gz; \
ln -sf /usr/local/lib/code-server/bin/code-server /usr/local/bin/code-server
COPY vscode/devplace-workspace /usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace
COPY vscode/branding/favicon.ico /usr/local/lib/code-server/src/browser/media/favicon.ico
COPY vscode/branding/favicon.svg /usr/local/lib/code-server/src/browser/media/favicon.svg
COPY vscode/branding/favicon-dark-support.svg /usr/local/lib/code-server/src/browser/media/favicon-dark-support.svg
COPY vscode/branding/pwa-icon-192.png /usr/local/lib/code-server/src/browser/media/pwa-icon-192.png
COPY vscode/branding/pwa-icon-512.png /usr/local/lib/code-server/src/browser/media/pwa-icon-512.png
COPY vscode/branding/pwa-icon-maskable-192.png /usr/local/lib/code-server/src/browser/media/pwa-icon-maskable-192.png
COPY vscode/branding/pwa-icon-maskable-512.png /usr/local/lib/code-server/src/browser/media/pwa-icon-maskable-512.png
COPY vscode/branding/devplace-login.css /tmp/devplace-login.css
COPY vscode/product.patch.json /tmp/product.patch.json
RUN set -eu; \
cat /tmp/devplace-login.css >> /usr/local/lib/code-server/src/browser/pages/login.css; \
python3 -c "import json,pathlib; p=pathlib.Path('/usr/local/lib/code-server/lib/vscode/product.json'); d=json.loads(p.read_text()); d.update(json.loads(pathlib.Path('/tmp/product.patch.json').read_text())); p.write_text(json.dumps(d, indent=2))"; \
rm -f /tmp/devplace-login.css /tmp/product.patch.json
COPY sudo /usr/local/bin/sudo
COPY aptroot /usr/local/bin/aptroot
COPY pagent /usr/bin/pagent.py
@ -142,4 +156,23 @@ RUN set -eu; \
done; \
[ -f /home/pravda/.vimrc ] || { echo "missing /home/pravda/.vimrc"; exit 1; }
RUN set -eu; \
ext=/usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace; \
[ -f "$ext/package.json" ] || { echo "missing the DevPlace extension"; exit 1; }; \
[ -f "$ext/extension.js" ] || { echo "missing the DevPlace extension entry point"; exit 1; }; \
for theme in devplace-dark devplace-light; do \
python3 -c "import json,sys; json.load(open('$ext/themes/$theme.json'))" \
|| { echo "invalid theme: $theme"; exit 1; }; \
done; \
python3 -c "import json; d=json.load(open('$ext/package.json')); assert d['contributes']['configurationDefaults']['security.workspace.trust.enabled'] is False, 'trust default lost'"; \
[ -f /usr/local/lib/code-server/src/browser/media/favicon.svg ] || { echo "missing the DevPlace favicon"; exit 1; }; \
python3 -c "import json; d=json.load(open('/usr/local/lib/code-server/lib/vscode/product.json')); assert d['nameShort']=='DevPlace', d['nameShort']; assert d['nameLong']=='DevPlace Workspace', d['nameLong']"; \
grep -q 'devplace-login-theme' /usr/local/lib/code-server/src/browser/pages/login.css \
|| { echo "the DevPlace login stylesheet was not applied"; exit 1; }; \
for flag in --app-name --disable-workspace-trust --disable-getting-started-override --welcome-text; do \
code-server --help 2>&1 | grep -q -- "$flag" \
|| { echo "code-server no longer supports $flag"; exit 1; }; \
done; \
echo "DevPlace branding verified"
CMD ["sleep", "infinity"]

View File

@ -8,6 +8,7 @@ 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, api, store
from devplacepy.services.containers.workspace import (
editor,
flags,
naming,
provision,
@ -28,7 +29,13 @@ def _workspace_db():
init_db()
set_setting("workspace_enabled", "1")
yield
for table in ("instances", "tunnels", "workspace_flags", "workspace_quota_rules"):
for table in (
"instances",
"tunnels",
"workspace_flags",
"workspace_quota_rules",
"workspace_editor_prefs",
):
get_table(table).delete()
@ -391,7 +398,8 @@ def test_editor_runs_with_password_auth_and_an_eight_char_secret():
assert len(password) == api.EDITOR_PASSWORD_LENGTH == 8
assert password.isalnum()
command = api.editor_command(store.get_instance(instance["uid"]))
row = store.get_instance(instance["uid"])
command = editor.argv(row, editor.resolve(OWNER, row))
assert "--auth" in command
assert command[command.index("--auth") + 1] == "password"
assert "none" not in command
@ -806,3 +814,177 @@ def test_no_certificate_is_ordered_when_molohttp_is_unconfigured(monkeypatch):
row = tunnels.list_for_instance(instance["uid"])[0]
assert row["status"] == tunnels.STATUS_PENDING
assert [r["uid"] for r in tunnels.awaiting_certificate()] == [row["uid"]]
def _editor_workspace(owner: dict, title: str):
project = _http_project(owner["uid"], title)
instance = store.create_instance(
{
"project_uid": project["uid"],
"name": "ws-editor-http",
"status": "running",
"desired_state": "running",
"is_workspace": 1,
"workspace_owner_uid": owner["uid"],
"tunnel_name": naming.generate(),
"ports_json": '[{"host": 20911, "container": 8080, "proto": "tcp"}]',
}
)
return project, instance
def _get(path: str, key: str):
import requests
return requests.get(
f"{BASE_URL}{path}",
headers={"Accept": "application/json", "X-API-KEY": key},
timeout=HTTP_TIMEOUT,
)
def test_editor_profile_reads_back_over_http(app_server, seeded_db):
owner = _seeded_user("bob_test")
project, _ = _editor_workspace(owner, "WS Editor Read")
slug = project["slug"]
assert _await_workspaces_enabled(slug, owner["api_key"])
body = _get(f"/projects/{slug}/workspace/editor", owner["api_key"]).json()
assert body["editor"]["theme"] == editor.DEFAULTS["theme"]
assert body["editor"]["sources"]["theme"] == editor.SOURCE_SITE
assert body["editor"]["cpu_millicores"] == quota.resolve(owner["uid"]).cpu_millicores
assert body["restart_required"] is False
def test_editor_preferences_save_and_reset_over_http(app_server, seeded_db):
owner = _seeded_user("bob_test")
project, _ = _editor_workspace(owner, "WS Editor Write")
slug = project["slug"]
assert _await_workspaces_enabled(slug, owner["api_key"])
try:
saved = _post(
f"/projects/{slug}/workspace/editor",
owner["api_key"],
data={"theme": "devplace-light", "font_size": "21"},
)
assert saved.status_code == 200, saved.text
profile = saved.json()["data"]["editor"]
assert profile["theme"] == "devplace-light"
assert profile["font_size"] == 21
assert profile["sources"]["font_size"] == editor.SOURCE_USER
reset = _post(
f"/projects/{slug}/workspace/editor",
owner["api_key"],
data={"reset": "true"},
)
assert reset.status_code == 200, reset.text
after = reset.json()["data"]["editor"]
assert after["font_size"] == editor.DEFAULTS["font_size"]
assert after["sources"]["font_size"] == editor.SOURCE_SITE
finally:
get_table("workspace_editor_prefs").delete(owner_id=owner["uid"])
def test_an_out_of_range_editor_value_is_refused_not_clamped(app_server, seeded_db):
owner = _seeded_user("bob_test")
project, _ = _editor_workspace(owner, "WS Editor Range")
slug = project["slug"]
assert _await_workspaces_enabled(slug, owner["api_key"])
refused = _post(
f"/projects/{slug}/workspace/editor",
owner["api_key"],
data={"font_size": "500"},
)
assert refused.status_code == 422, refused.text
def test_editor_preferences_are_refused_to_a_non_owner(app_server, seeded_db):
owner = _seeded_user("bob_test")
intruder = _fresh_member()
project, _ = _editor_workspace(owner, "WS Editor Foreign")
slug = project["slug"]
denied = _get(f"/projects/{slug}/workspace/editor", intruder["api_key"])
assert denied.status_code in (403, 404), denied.text
def test_editor_preferences_reject_a_guest_with_401_not_422(app_server, seeded_db):
import requests
owner = _seeded_user("bob_test")
project, _ = _editor_workspace(owner, "WS Editor Guest")
guest = requests.post(
f"{BASE_URL}/projects/{project['slug']}/workspace/editor",
headers={"Accept": "application/json"},
data={},
timeout=HTTP_TIMEOUT,
)
assert guest.status_code == 401, guest.status_code
def test_a_saved_preference_asks_for_a_restart_while_running(app_server, seeded_db):
owner = _seeded_user("bob_test")
project, instance = _editor_workspace(owner, "WS Editor Restart")
slug = project["slug"]
assert _await_workspaces_enabled(slug, owner["api_key"])
try:
editor.seed_state(instance, editor.resolve(owner["uid"], instance))
body = _post(
f"/projects/{slug}/workspace/editor",
owner["api_key"],
data={"font_size": "29"},
).json()
assert body["data"]["restart_required"] is True
finally:
get_table("workspace_editor_prefs").delete(owner_id=owner["uid"])
def test_the_run_spec_applies_the_quota_cpu_and_memory_to_a_workspace():
from devplacepy.services.containers import api
instance = _instance(editor_port=api.EDITOR_DEFAULT_PORT)
limits = quota.resolve(OWNER, store.get_instance(instance["uid"]))
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
assert spec.cpu_limit == limits.cpu_limit()
assert spec.mem_limit == limits.mem_limit()
assert spec.command[0] == "code-server"
assert "--app-name" in spec.command
assert "--disable-workspace-trust" in spec.command
def test_the_run_spec_seeds_the_editor_state_and_stamps_a_boot_marker(monkeypatch, tmp_path):
from devplacepy import config
from devplacepy.services.containers import api
monkeypatch.setattr(config, "WORKSPACE_STATE_DIR", tmp_path / "state")
instance = _instance()
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
marker = store.get_instance(instance["uid"])["boot_marker"]
assert marker
assert spec.env["DEVPLACE_CONTAINER_BOOT"] == marker
assert spec.env["DEVPLACE_EDITOR_APP_NAME"] == editor.APP_NAME
seeded = tmp_path / "state" / instance["uid"] / "data" / "User" / "settings.json"
assert seeded.exists()
def test_a_non_workspace_instance_keeps_its_own_limits():
from devplacepy.services.containers import api
instance = _instance(is_workspace=0, cpu_limit="0.5", mem_limit="256m")
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
assert spec.cpu_limit == "0.5"
assert spec.mem_limit == "256m"
assert not spec.env.get("DEVPLACE_EDITOR_APP_NAME")
def test_a_workspace_without_an_editor_port_still_gets_its_size():
from devplacepy.services.containers import api
instance = _instance()
limits = quota.resolve(OWNER, store.get_instance(instance["uid"]))
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
assert spec.cpu_limit == limits.cpu_limit()
assert spec.command == ["sleep", "infinity"]

View File

@ -32,6 +32,7 @@ def _workspaces_on():
get_table("tunnels").delete(instance_uid=uid)
get_table("workspace_flags").delete(instance_uid=uid)
instances.delete(uid=uid)
get_table("workspace_editor_prefs").delete()
def _row_for(user: dict) -> dict:
@ -111,7 +112,7 @@ def test_project_page_shows_a_direct_vscode_button_for_a_running_workspace(alice
page.goto(
f"{BASE_URL}/projects/{project['slug']}", wait_until="domcontentloaded"
)
button = page.locator(".project-detail-actions a:has-text('VS Code')")
button = page.locator(".project-detail-actions a[data-editor-open]")
button.wait_for(state="visible")
expect(button).to_have_attribute("target", "_blank")
expect(button).to_have_attribute(
@ -129,7 +130,7 @@ def test_direct_vscode_button_is_absent_while_the_workspace_is_stopped(alice):
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()
assert not page.locator(".project-detail-actions a[data-editor-open]").count()
def _await_workspace_flag(slug: str, key: str, expected: bool) -> bool:
@ -310,3 +311,107 @@ def test_workspaces_sidebar_link_is_present_for_admin(alice):
link = page.locator(".sidebar-link:has-text('Workspaces')")
link.wait_for(state="visible")
expect(link).to_have_class(re.compile("active"))
def test_the_editor_card_shows_the_resolved_profile_and_its_sources(alice):
page, user = alice
row = _row_for(user)
project = _project_for(row["uid"], "WS Editor Card")
_workspace_for(project, row["uid"])
page.goto(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
card = page.locator(".workspace-editor")
card.wait_for(state="visible")
expect(card.locator(".workspace-editor-summary").first).to_contain_text("devplace-dark")
expect(card.locator(".workspace-editor-resources")).to_contain_text("cores")
expect(card.locator(".workspace-editor-form select[name='theme']")).to_be_visible()
def test_saving_an_editor_preference_changes_its_source_to_the_member(alice):
page, user = alice
row = _row_for(user)
project = _project_for(row["uid"], "WS Editor Save")
_workspace_for(project, row["uid"])
try:
page.goto(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
page.locator(".workspace-editor-form").wait_for(state="visible")
page.select_option(".workspace-editor-form select[name='theme']", "devplace-light")
page.locator(".workspace-editor-actions button[type='submit']").click()
page.wait_for_url(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
summary = page.locator(".workspace-editor-summary").first
summary.wait_for(state="visible")
expect(summary).to_contain_text("devplace-light")
expect(summary).to_contain_text("user")
finally:
get_table("workspace_editor_prefs").delete(owner_id=row["uid"])
def test_resetting_editor_preferences_restores_the_site_default(alice):
page, user = alice
row = _row_for(user)
project = _project_for(row["uid"], "WS Editor Reset")
_workspace_for(project, row["uid"])
try:
page.goto(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
page.locator(".workspace-editor-form").wait_for(state="visible")
page.select_option(".workspace-editor-form select[name='theme']", "devplace-light")
page.locator(".workspace-editor-actions button[type='submit']").click()
page.wait_for_url(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
page.locator(".workspace-editor-reset button").click()
page.wait_for_url(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
summary = page.locator(".workspace-editor-summary").first
summary.wait_for(state="visible")
expect(summary).to_contain_text("devplace-dark")
finally:
get_table("workspace_editor_prefs").delete(owner_id=row["uid"])
def test_the_editor_launch_control_carries_the_window_profile(alice):
page, user = alice
row = _row_for(user)
project = _project_for(row["uid"], "WS Editor Launch")
instance = _workspace_for(project, row["uid"])
page.goto(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
launch = page.locator(".workspace-actions a[data-editor-open]")
launch.wait_for(state="visible")
expect(launch).to_have_attribute("data-editor-mode", "tab")
expect(launch).to_have_attribute("target", "_blank")
expect(launch).to_have_attribute(
"href",
f"/projects/{project['slug']}/containers/instances/{instance['uid']}/code/",
)
def test_the_workspace_help_states_that_every_folder_is_trusted(alice):
page, user = alice
row = _row_for(user)
project = _project_for(row["uid"], "WS Editor Trust")
_workspace_for(project, row["uid"])
page.goto(
f"{BASE_URL}/projects/{project['slug']}/workspace",
wait_until="domcontentloaded",
)
help_card = page.locator(".workspace-help")
help_card.wait_for(state="visible")
expect(help_card).to_contain_text("Restricted Mode")
expect(help_card).to_contain_text("dpc")

View File

@ -0,0 +1,47 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_table, init_db
FILTERED_COLUMNS = {
"messages": ("uid", "sender_uid", "receiver_uid", "content", "read", "created_at"),
"workspace_quota_rules": ("uid", "owner_kind", "owner_id", "deleted_at"),
"workspace_editor_prefs": ("uid", "owner_kind", "owner_id", "deleted_at"),
}
def test_init_db_ensures_every_filtered_column(local_db):
for table, columns in FILTERED_COLUMNS.items():
target = get_table(table)
missing = [column for column in columns if not target.has_column(column)]
assert not missing, f"{table} is missing {missing}"
def test_a_partial_insert_cannot_reduce_the_messages_schema(local_db):
table = get_table("messages")
table.insert(
{
"uid": "schema-msg-1",
"sender_uid": "schema-user-1",
"receiver_uid": "schema-user-2",
"content": "partial row, no created_at",
}
)
try:
init_db()
assert get_table("messages").has_column("created_at")
rows = list(
local_db.query(
"SELECT uid FROM messages WHERE created_at IS NULL AND uid = :u",
u="schema-msg-1",
)
)
assert len(rows) == 1
finally:
get_table("messages").delete(uid="schema-msg-1")
def test_the_workspace_rule_tables_filter_on_a_column_that_exists(local_db):
from devplacepy.services.containers.workspace import editor, quota
assert quota._rule_for("user", "nobody-at-all") is None
assert editor.prefs_for("nobody-at-all") is None

View File

@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>

View File

@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>

View File

@ -0,0 +1,362 @@
# retoor <retoor@molodetz.nl>
import json
import pytest
from devplacepy import config
from devplacepy.database import get_table, init_db, set_setting
from devplacepy.services.containers.backend.base import (
WORKSPACE_MOUNT,
WORKSPACE_STATE_MOUNT,
)
from devplacepy.services.containers.workspace import editor, quota
OWNER = "editor-owner"
OTHER = "editor-other"
@pytest.fixture(autouse=True)
def _editor_db(tmp_path, monkeypatch):
init_db()
monkeypatch.setattr(config, "WORKSPACE_STATE_DIR", tmp_path / "state")
yield
get_table(editor.PREFS_TABLE).delete()
get_table(quota.RULES_TABLE).delete()
for key, default in editor.DEFAULTS.items():
setting = editor.SETTING_KEYS[key]
if isinstance(default, bool):
set_setting(setting, "1" if default else "0")
else:
set_setting(setting, str(default))
def _instance(uid: str = "ws-editor") -> dict:
return {"uid": uid, "editor_port": editor.EDITOR_DEFAULT_PORT, "is_workspace": 1}
def _prefs(**values) -> dict:
return editor.save_prefs(OWNER, values)
def test_resolve_uses_site_defaults():
profile = editor.resolve(OWNER)
assert profile.theme == editor.DEFAULTS["theme"]
assert profile.font_size == editor.DEFAULTS["font_size"]
assert profile.zoom_level == editor.DEFAULTS["zoom_level"]
assert profile.boot_agent == editor.DEFAULTS["boot_agent"]
assert profile.trust_all is True
def test_resolve_reads_a_changed_site_setting():
set_setting("workspace_editor_font_size", "22")
assert editor.resolve(OWNER).font_size == 22
def test_resolve_prefers_the_user_row():
_prefs(font_size=20, theme="devplace-light")
profile = editor.resolve(OWNER)
assert profile.font_size == 20
assert profile.theme == "devplace-light"
def test_a_user_row_does_not_leak_to_another_user():
_prefs(font_size=20)
assert editor.resolve(OTHER).font_size == editor.DEFAULTS["font_size"]
def test_zero_and_empty_inherit_but_zoom_zero_does_not():
set_setting("workspace_editor_font_size", "22")
set_setting("workspace_editor_zoom_level", "3")
_prefs(font_size=0, theme="", zoom_level=0)
profile = editor.resolve(OWNER)
assert profile.font_size == 22
assert profile.theme == editor.DEFAULTS["theme"]
assert profile.zoom_level == 0
def test_the_zoom_sentinel_inherits():
set_setting("workspace_editor_zoom_level", "3")
_prefs(zoom_level=editor.INHERIT_ZOOM)
assert editor.resolve(OWNER).zoom_level == 3
def test_the_boot_shell_sentinel_inherits_but_zero_does_not():
_prefs(boot_shell=editor.INHERIT_FLAG)
assert editor.resolve(OWNER).boot_shell is True
_prefs(boot_shell=0)
assert editor.resolve(OWNER).boot_shell is False
def test_out_of_range_stored_values_are_clamped():
_prefs(font_size=999, zoom_level=99, window_width=1)
profile = editor.resolve(OWNER)
assert profile.font_size == editor.BOUNDS["font_size"][1]
assert profile.zoom_level == editor.BOUNDS["zoom_level"][1]
assert profile.window_width == editor.BOUNDS["window_width"][0]
def test_an_unknown_choice_falls_back_to_the_default():
_prefs(theme="hot-pink", layout="chaos", window_mode="teleport")
profile = editor.resolve(OWNER)
assert profile.theme == editor.DEFAULTS["theme"]
assert profile.layout == editor.DEFAULTS["layout"]
assert profile.window_mode == editor.DEFAULTS["window_mode"]
def test_the_container_size_comes_from_the_quota_resolver():
profile = editor.resolve(OWNER, {"workspace_cpu_millicores": 3000})
assert profile.cpu_millicores == 3000
assert profile.cpu_cores() == 3.0
assert profile.cpu_limit() == "3"
assert profile.mem_limit() == quota.format_memory(profile.memory_mb)
def test_source_map_reports_where_each_value_came_from():
_prefs(font_size=20)
sources = editor.source_map(OWNER)
assert sources["font_size"] == editor.SOURCE_USER
assert sources["theme"] == editor.SOURCE_SITE
assert sources["trust_all"] == editor.SOURCE_SITE
def test_reset_removes_the_row_and_restores_the_defaults():
_prefs(font_size=20)
assert editor.reset_prefs(OWNER, OWNER) is True
assert editor.resolve(OWNER).font_size == editor.DEFAULTS["font_size"]
assert editor.source_map(OWNER)["font_size"] == editor.SOURCE_SITE
def test_reset_on_a_user_with_no_row_is_a_no_op():
assert editor.reset_prefs(OWNER, OWNER) is False
def test_saving_twice_updates_one_row():
_prefs(font_size=20)
_prefs(font_size=21)
rows = list(get_table(editor.PREFS_TABLE).find(owner_id=OWNER, deleted_at=None))
assert len(rows) == 1
assert rows[0]["font_size"] == 21
def test_merge_managed_seeds_an_absent_key():
merged, managed = editor.merge_managed({}, {}, {"editor.fontSize": 14})
assert merged["editor.fontSize"] == 14
assert managed == {"editor.fontSize": 14}
def test_merge_managed_updates_a_value_we_still_own():
merged, _ = editor.merge_managed(
{"editor.fontSize": 14}, {"editor.fontSize": 14}, {"editor.fontSize": 18}
)
assert merged["editor.fontSize"] == 18
def test_merge_managed_respects_a_member_edit_forever():
current = {"editor.fontSize": 30}
managed = {"editor.fontSize": 14}
for desired in (16, 18, 20):
current, managed = editor.merge_managed(
current, managed, {"editor.fontSize": desired}
)
assert current["editor.fontSize"] == 30
def test_merge_managed_keeps_unrelated_member_keys():
merged, _ = editor.merge_managed(
{"files.autoSave": "on"}, {}, {"editor.fontSize": 14}
)
assert merged["files.autoSave"] == "on"
def test_merge_managed_is_idempotent():
desired = editor.settings_for(editor.resolve(OWNER))
first, managed = editor.merge_managed({}, {}, desired)
second, _ = editor.merge_managed(first, managed, desired)
assert first == second
def test_settings_disable_workspace_trust_when_trust_all():
settings = editor.settings_for(editor.resolve(OWNER))
assert settings["security.workspace.trust.enabled"] is False
assert settings["security.workspace.trust.startupPrompt"] == "never"
assert settings["task.allowAutomaticTasks"] == "on"
def test_settings_leave_trust_alone_when_the_kill_switch_is_off():
set_setting("workspace_editor_trust_all", "0")
settings = editor.settings_for(editor.resolve(OWNER))
assert "security.workspace.trust.enabled" not in settings
def test_settings_carry_the_devplace_terminal_profile():
settings = editor.settings_for(editor.resolve(OWNER))
profiles = settings["terminal.integrated.profiles.linux"]
assert profiles["DevPlace Code"]["path"] == "/usr/bin/dpc"
assert settings["terminal.integrated.defaultProfile.linux"] == "bash"
def test_settings_apply_the_layout_preset():
_prefs(layout="zen")
settings = editor.settings_for(editor.resolve(OWNER))
assert settings["workbench.activityBar.location"] == "hidden"
assert settings["editor.minimap.enabled"] is False
def test_the_system_theme_is_left_to_the_member():
_prefs(theme="system")
assert "workbench.colorTheme" not in editor.settings_for(editor.resolve(OWNER))
def test_seed_state_writes_the_whole_tree():
instance = _instance()
profile = editor.resolve(OWNER)
assert editor.seed_state(instance, profile) is True
root = editor.state_dir(instance)
settings = json.loads((root / "data" / "User" / "settings.json").read_text())
managed = json.loads((root / "data" / "User" / editor.MANAGED_FILE).read_text())
payload = json.loads((root / editor.PROFILE_FILE).read_text())
assert settings["editor.fontSize"] == profile.font_size
assert managed["editor.fontSize"] == profile.font_size
assert payload["editor"]["theme"] == profile.theme
assert payload["app_name"] == editor.APP_NAME
def test_seed_state_never_clobbers_a_member_edit():
instance = _instance()
editor.seed_state(instance, editor.resolve(OWNER))
settings_path = editor.state_dir(instance) / "data" / "User" / "settings.json"
stored = json.loads(settings_path.read_text())
stored["editor.fontSize"] = 30
stored["files.autoSave"] = "on"
settings_path.write_text(json.dumps(stored))
set_setting("workspace_editor_font_size", "18")
set_setting("workspace_editor_terminal_font_size", "20")
editor.seed_state(instance, editor.resolve(OWNER))
written = json.loads(settings_path.read_text())
assert written["editor.fontSize"] == 30
assert written["files.autoSave"] == "on"
assert written["terminal.integrated.fontSize"] == 20
def test_seed_state_replaces_an_unparseable_settings_file():
instance = _instance()
user_dir = editor.state_dir(instance) / "data" / "User"
user_dir.mkdir(parents=True, exist_ok=True)
(user_dir / "settings.json").write_text("{not json at all")
assert editor.seed_state(instance, editor.resolve(OWNER)) is True
assert json.loads((user_dir / "settings.json").read_text())["editor.fontSize"]
def test_seed_state_fails_soft_on_an_unwritable_directory(monkeypatch):
def explode(*args, **kwargs):
raise OSError("read-only file system")
monkeypatch.setattr("pathlib.Path.mkdir", explode)
assert editor.seed_state(_instance(), editor.resolve(OWNER)) is False
def test_argv_carries_the_devplace_brand():
instance = _instance()
command = editor.argv(instance, editor.resolve(OWNER))
assert command[0] == "code-server"
assert command[command.index("--app-name") + 1] == editor.APP_NAME
assert "--disable-getting-started-override" in command
assert "--disable-telemetry" in command
def test_argv_disables_workspace_trust_when_trust_all():
command = editor.argv(_instance(), editor.resolve(OWNER))
assert "--disable-workspace-trust" in command
def test_argv_keeps_workspace_trust_when_the_kill_switch_is_off():
set_setting("workspace_editor_trust_all", "0")
command = editor.argv(_instance(), editor.resolve(OWNER))
assert "--disable-workspace-trust" not in command
def test_argv_keeps_password_auth():
command = editor.argv(_instance(), editor.resolve(OWNER))
assert command[command.index("--auth") + 1] == "password"
assert "none" not in command
def test_argv_paths_sit_under_the_state_mount():
command = editor.argv(_instance(), editor.resolve(OWNER))
assert command[command.index("--user-data-dir") + 1].startswith(
WORKSPACE_STATE_MOUNT
)
assert command[command.index("--extensions-dir") + 1].startswith(
WORKSPACE_STATE_MOUNT
)
assert command[-1] == WORKSPACE_MOUNT
def test_argv_binds_the_instance_editor_port():
instance = _instance()
instance["editor_port"] = 9443
command = editor.argv(instance, editor.resolve(OWNER))
assert command[command.index("--bind-addr") + 1] == "0.0.0.0:9443"
def test_every_optional_flag_is_declared():
command = editor.argv(_instance(), editor.resolve(OWNER))
for flag in editor.OPTIONAL_FLAGS:
assert flag in command
def test_env_for_exports_the_profile_to_the_container():
env = editor.env_for(editor.resolve(OWNER))
assert env["DEVPLACE_EDITOR_APP_NAME"] == editor.APP_NAME
assert env["DEVPLACE_EDITOR_PROFILE"].endswith(editor.PROFILE_FILE)
assert env["DEVPLACE_EDITOR_BOOT_AGENT"] == editor.DEFAULTS["boot_agent"]
assert env["DEVPLACE_EDITOR_TRUST_ALL"] == "1"
assert all(isinstance(value, str) for value in env.values())
def test_restart_is_not_required_before_the_first_boot():
instance = _instance()
assert editor.restart_required(instance, editor.resolve(OWNER)) is False
def test_restart_is_not_required_when_nothing_changed():
instance = _instance()
profile = editor.resolve(OWNER)
editor.seed_state(instance, profile)
assert editor.restart_required(instance, profile) is False
def test_restart_is_required_after_a_preference_change():
instance = _instance()
editor.seed_state(instance, editor.resolve(OWNER))
_prefs(font_size=27)
assert editor.restart_required(instance, editor.resolve(OWNER)) is True
def test_view_carries_the_profile_and_its_sources():
_prefs(theme="devplace-light")
payload = editor.view(OWNER)
assert payload["theme"] == "devplace-light"
assert payload["sources"]["theme"] == editor.SOURCE_USER
assert payload["cpu_cores"] == editor.resolve(OWNER).cpu_cores()
def test_settings_suppress_a_foreign_ai_assistant():
settings = editor.settings_for(editor.resolve(OWNER))
assert settings["chat.disableAIFeatures"] is True
assert settings["workbench.secondarySideBar.defaultVisibility"] == "hidden"
def test_settings_open_straight_into_the_workspace():
assert editor.settings_for(editor.resolve(OWNER))["workbench.startupEditor"] == "none"
def test_the_agent_working_files_never_sync_into_the_project():
from devplacepy.project_files import SYNC_SKIP_NAMES
assert ".dpc" in SYNC_SKIP_NAMES
assert "dpc.log" in SYNC_SKIP_NAMES
assert ".devplace" in SYNC_SKIP_NAMES

View File

@ -0,0 +1,112 @@
# retoor <retoor@molodetz.nl>
import pytest
from devplacepy.database import get_table, init_db, set_setting
from devplacepy.services.containers.workspace import quota
from devplacepy.utils import generate_uid
OWNER = "quota-owner"
@pytest.fixture(autouse=True)
def _quota_db():
init_db()
yield
get_table(quota.RULES_TABLE).delete()
for key in quota.SETTING_KEYS.values():
set_setting(key, str(quota.DEFAULTS[_key_for(key)]))
def _key_for(setting: str) -> str:
for key, value in quota.SETTING_KEYS.items():
if value == setting:
return key
raise KeyError(setting)
def _rule(**values) -> dict:
row = {
"uid": generate_uid(),
"owner_kind": "user",
"owner_id": OWNER,
"label": "test",
"created_at": "",
"updated_at": "",
"deleted_at": None,
"deleted_by": None,
**{column: 0 for column in quota.RULE_COLUMNS},
}
row.update(values)
get_table(quota.RULES_TABLE).insert(row)
return row
def test_format_cpu_renders_docker_values():
assert quota.format_cpu(2000) == "2"
assert quota.format_cpu(1500) == "1.5"
assert quota.format_cpu(250) == "0.25"
assert quota.format_cpu(0) == ""
assert quota.format_cpu(-1) == ""
def test_format_memory_renders_docker_values():
assert quota.format_memory(2048) == "2048m"
assert quota.format_memory(0) == ""
def test_limits_expose_the_docker_strings():
limits = quota.resolve()
assert limits.cpu_limit() == quota.format_cpu(limits.cpu_millicores)
assert limits.mem_limit() == quota.format_memory(limits.memory_mb)
def test_defaults_resolve_without_a_rule():
limits = quota.resolve(OWNER)
assert limits.cpu_millicores == quota.DEFAULTS["cpu_millicores"]
assert limits.memory_mb == quota.DEFAULTS["memory_mb"]
def test_a_user_rule_resolves():
_rule(cpu_millicores=4000, memory_mb=8192, disk_quota_mb=512)
limits = quota.resolve(OWNER)
assert limits.cpu_millicores == 4000
assert limits.memory_mb == 8192
assert limits.disk_quota_mb == 512
def test_a_user_rule_does_not_leak_to_another_user():
_rule(cpu_millicores=4000)
assert quota.resolve("someone-else").cpu_millicores == (
quota.DEFAULTS["cpu_millicores"]
)
def test_a_soft_deleted_rule_is_ignored():
row = _rule(cpu_millicores=4000)
get_table(quota.RULES_TABLE).update(
{"uid": row["uid"], "deleted_at": "2026-01-01T00:00:00+00:00"}, ["uid"]
)
assert quota.resolve(OWNER).cpu_millicores == quota.DEFAULTS["cpu_millicores"]
def test_an_instance_override_beats_the_rule():
_rule(cpu_millicores=4000, memory_mb=8192)
limits = quota.resolve(
OWNER, {"workspace_cpu_millicores": 1000, "workspace_memory_mb": 512}
)
assert limits.cpu_millicores == 1000
assert limits.memory_mb == 512
def test_only_declared_instance_overrides_are_read():
limits = quota.resolve(OWNER, {"workspace_max_tunnels": 99})
assert limits.max_tunnels == quota.DEFAULTS["max_tunnels"]
assert "max_tunnels" not in quota.INSTANCE_OVERRIDE_COLUMNS
def test_the_idle_warning_stays_below_the_idle_stop():
set_setting("workspace_idle_stop_minutes", "10")
set_setting("workspace_idle_warn_minutes", "30")
limits = quota.resolve()
assert limits.idle_warn_minutes < limits.idle_stop_minutes