6.2 KiB

name description tags
bricks-wterm-terminal Use when adding Wterm/.xterm terminal to a Bricks app.
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:

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

{"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/<module>/<file>.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.

xterm.js rendererType defaults to canvas, so the cursor is painted on <canvas> — 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 /<module>/<file>.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/<module>/<file>.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).

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