--- name: bricks-wterm-terminal description: Use when adding Wterm/.xterm terminal to a Bricks app. tags: [bricks, wterm, xterm, terminal, ssh, websocket, sage, ahserver] --- # Wterm / .xterm In-Browser SSH Terminal How to put a real terminal (SSH backend) in a Bricks app — used for `vi`-editing server-side workspace files, `sagelog` tails, remote-host consoles, etc. ## The chain ``` .ui/.dspy → Wterm widget (ws_url) → nginx /wss/ (WebSocket upgrade) → XtermProcessor → .xterm file (Python script) returns DictObject(SSH info) → SSHServer → asyncssh.connect → create_process(cmdargs) ``` ## 1. `.xterm` file — a Python script, same exec context as `.dspy` Placed in module `wwwroot/`. Runs under `XtermProcessor.path_call()` which wraps it as `async def myfunc(request, **ns)` and `exec()`s it (identical to `.dspy`). `params_kw` carries the query string. It MUST `return` a `DictObject` describing the SSH connection: ```python import os, shlex file_id = (params_kw or {}).get('id', '').strip() # ... resolve absolute path (DB query for workspace dir, path-traversal guard via realpath) ... r = DictObject() r.host = 'localhost' # edit files on the app server itself r.username = 'pipeline' # app OS user (needs passwordless SSH key to localhost) r.cmdargs = ['vi ' + shlex.quote(full_path)] # SINGLE command string — see asyncssh note # r.noinput = True # optional: read-only terminal (no keyboard) return r ``` Other fields SSHServer/sshx reads: `port`, `password`, `client_keys`/`client_key`, `passphrase`, `jumperservers` (list of nested host DictObjects for jump hosts). ## 2. asyncssh `create_process` single-command bug (CRITICAL) `ahserver/xtermProcessor.py` calls `conn.create_process(*login_info.cmdargs, term_type=..., term_size=...)`. New asyncssh signature: `create_process(*args, ...)` forwards to `create_session(session_factory, command=(), *, ...)` — **`command` is a single positional arg**. A multi-element `cmdargs = ['vi', '/path']` expands to `create_session(SSHClientProcess, 'vi', '/path')` and raises: ``` TypeError: SSHClientConnection.create_session() takes from 2 to 3 positional arguments but 4 positional arguments (and 2 keyword-only arguments) were given ``` **Fix**: `cmdargs` must be a **one-element list** holding the full joined command: `r.cmdargs = ['vi ' + shlex.quote(full_path)]`. (A bare command with no args, e.g. `['~/bin/sagelog']`, already works.) ## 3. `.xterm` processor must be registered `conf/config.json` → `website.processors` needs `['.xterm', 'xterm']` (and `['.ws', 'ws']`). A fresh/independent app often only ships `['.dspy','dspy'], ['.ui','bui'], ['.tmpl','tmpl']` — without the `.xterm` entry the file is served as static HTML and never reaches XtermProcessor. **Editing config.json from a script — `getConfig()` takes a DIRECTORY, not a file.** `appPublic.jsonConfig.getConfig(path)` internally does `cfname = os.path.join(path, "conf", "config.json")`. Passing the config FILE path (`getConfig(os.path.join(ROOT, 'conf', 'config.json'))`) double-joins to `.../conf/config.json/conf/config.json` → `NotADirectoryError`. Pass the app ROOT_DIR instead: `getConfig(ROOT_DIR, NS={'workdir': ROOT_DIR, ...})`. ## 4. nginx `/wss/` route does the WebSocket upgrade ``` location /wss/ { proxy_pass http://localhost:9090/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header X-Forwarded-Path 'wss'; } ``` ## 5. Wterm widget + URL scheme ```json {"widgettype":"PopupWindow","options":{"auto_open":true,"width":"85%","height":"85%","resizable":true}, "subwidgets":[{"widgettype":"Wterm","options":{ "width":"100%","height":"100%", "term_options":{"fontSize":14,"cursorBlink":true}, "ws_url":"{{entire_url('/wss//.xterm')}}?id={{params_kw.id}}"}}]} ``` **URL scheme — why `entire_url` is correct for `.xterm`:** the browser's `new WebSocket(url)` auto-rewrites the scheme per WHATWG spec (`http→ws`, `https→wss`). So `entire_url('/wss/…')` returning `https://…/wss/…` connects fine. `ahserver`'s `urlWebsocketify()` only converts URLs ending in `.ws`/`.wss` (NOT `.xterm`), so `websocket_url()` is the explicit alternative; either works. ## 6. Cursor not visible → `cursorBlink` xterm.js `rendererType` defaults to `canvas`, so the cursor is painted on `` — there is **no** `.xterm-cursor` DOM element (don't look for one). `cursorBlink` defaults to `false` (static block cursor, easy to miss). Set `"cursorBlink": true` in `term_options` to make it blink. ## 7. RBAC permission for the `.xterm` path Register the `.xterm` so logged-in users can reach it: `set_role_perm.py logined //.xterm` (path is the module-relative wwwroot path, no `/wss/` prefix — same form as the module's `load_path.py`). Independent apps' `set_role_perm.py` is sometimes copied from an OLD Sage deployment and targets a `role_path` table that no longer exists. The current schema is `permission(id, path)` + `rolepermission(id, roleid, permid)` (one `permission` row per path, multiple `rolepermission` rows for roles). Symptom of the stale script: `Table 'X.role_path' doesn't exist`. A working script upserts into `permission` then links via `rolepermission` with `roleid` = `'logined'`/`'any'`/`'owner.superuser'`. ## Verification - Backend: `curl -sk "https://host//.xterm?id=…"` returns `No WebSocket UPGRADE hdr` — routing + RBAC + XtermProcessor all fired (only the handshake is missing). A 401 means the RBAC path isn't registered; HTML/404 means the `.xterm` processor isn't configured. - SSH: confirm the target user reaches the host passwordlessly first (`ssh -o BatchMode=yes localhost 'which vi'`). - Browser: open the terminal, watch console for VIM init sequences (`\u001b[2;2R`, `\u001b[>0;276;0c`, `\u001b]11;rgb:…`) — these prove SSH + the process started. A bare `websocket closed: 1000` without them means create_process threw (see section 2). ## Related Bricks pitfalls (came up together) See `references/popup-resize-and-file-serving.md` for: - PopupWindow resize broken (resizebox z-index vs canvas content; `resizing()` stale e.target check) - Serving/downloading binary files from a `.dspy` by returning `aiohttp.web.FileResponse`