6.2 KiB
| name | description | tags | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| bricks-wterm-terminal | Use when adding Wterm/.xterm terminal to a Bricks app. |
|
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.
6. Cursor not visible → cursorBlink
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=…"returnsNo 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.xtermprocessor 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 barewebsocket closed: 1000without 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
.dspyby returningaiohttp.web.FileResponse