SpuntoSpunto/@spunto/build

Terminal

The half of a persistent worker terminal that is a string — the dtach scripts, the session layout, and the parsing of what they print back.

Why it exists

A worker's terminal is a docker exec attached to a session that outlives it, so that closing a browser tab does not kill a build. The attaching is I/O and stays with whoever owns the Docker socket. Everything else — the shell that gets attached, the layout it writes into, and the parsing of what it prints back — is a string, and lives here.

dtach, not tmux. tmux's only knob for "the wheel scrolls history" is mouse on, which also makes it swallow every drag — the frontend then has to forge Shift-drags to get a native selection back. dtach does only persistence: it owns a pty, lets clients attach and detach, and forwards every byte, mouse included. The terminal emulator keeps the mouse, its own scrollback and its own search.

What dtach does not do is remember the screen. So the session is recorded inside the container with script(1), and the tail of that recording is replayed just before attaching:

docker exec ──> sh ──> [replay: tail of the log] ──> exec dtach -A ──> script -f log ──> $SHELL
                                                          │
                                                 persistent master

Recording inside the container rather than in a control-plane buffer is what makes the history survive a control-plane restart with nothing permanently attached.

import { sanitizeSessionName, buildDtachCommand, parseSessionList } from "@spunto/build/terminal"

const name = sanitizeSessionName(fromQuery) // every name MUST come through this
await exec(container, ["/bin/sh", "-c", buildDtachCommand()], { env: { MP_TERM_SESSION: name } })

sanitizeSessionName is a security boundary

Session names are interpolated into the shell scripts this module builds. A name that has not been through sanitizeSessionName is a command injection into someone's container. This is not tidiness — sanitize at the boundary, every time, before calling any builder below.

Layout and limits

TERMINAL_DIR

const TERMINAL_DIR = "/tmp/spunto-term"

Where every session's socket (<name>.sock) and recording (<name>.log) live — one directory, two files per session, created with mode 700. The name deliberately avoids the substring dtach: the listing and kill scripts match processes by socket path, and a path containing dtach would make every helper's own shell look like a dtach process to its own grep.

Replay constants

const REPLAY_BYTES = 262144      // 256 KB of recording replayed on attach
const REPLAY_MIN_BYTES = 512     // below this, nothing is replayed
const MAX_LOG_BYTES = 4194304    // above this (4 MB), the recording is trimmed on the next attach…
const TRIM_TO = 1048576          // …down to its last 1 MB

REPLAY_MIN_BYTES exists because a session created from a "+" button has already recorded its prompt, and replaying that would greet the user with a replay banner on a terminal they just opened; the attach redraw paints the prompt anyway.

Building the scripts

sanitizeSessionName(name)

function sanitizeSessionName(name: string | undefined): string

Restricts a name to [A-Za-z0-9_-] — both what a socket filename can hold without quoting and what the listing script's awk can match on. Every other character becomes -, leading and trailing dashes are trimmed, the result is capped at 40 characters, and an empty result (or undefined) becomes main.

sanitizeSessionName("my-build_2")     // → "my-build_2"
sanitizeSessionName(undefined)        // → "main"
sanitizeSessionName("x; rm -rf /")    // → "x--rm--rf"

buildDtachCommand()

function buildDtachCommand(): string

The shell to run in the attaching docker exec (with a TTY). It reads the session name from the MP_TERM_SESSION environment variable — not from an argument — and then:

  1. picks the user's login shell (getent passwd, falling back to /bin/bash);
  2. if dtach is missing, prints a yellow notice and degrades to a plain, non-persistent login shell;
  3. treats a socket no process holds any more as dead, and drops it and its recording, so an old log is never replayed into a brand-new shell;
  4. for a live session, trims the recording above MAX_LOG_BYTES, and when it is over REPLAY_MIN_BYTES, resets the terminal modes and prints a dim ── replay ── banner followed by the last REPLAY_BYTES;
  5. exec dtach -A on the socket with -r winch -E -z, wrapping the shell in script -q -a -f <log> when script is available.
const session = sanitizeSessionName(req.query.session)
const execInstance = await container.exec({
  Cmd: ["/bin/sh", "-c", buildDtachCommand()],
  Env: [`MP_TERM_SESSION=${session}`],
  AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true,
  User: "vscode",
})

makeAttachClearStripper(windowMs?, now?)

function makeAttachClearStripper(windowMs?: number, now?: () => number): (chunk: Uint8Array) => Uint8Array

dtach clears the screen as soon as a client attaches — a bare ESC[H ESC[J. That write lands after the replay the attach script just printed, and wipes exactly what the replay exists to show. The returned filter drops that sequence once, and only within windowMs (default 5000) of creation — long enough to cover dtach's write, short enough that a program legitimately clearing its screen later is never touched. (clear emits different bytes, so it would not match anyway.)

Create one per attach and pass every output chunk through it. Uint8Array in and out, so Node Buffer chunks pass straight through.

const strip = makeAttachClearStripper()
stream.on("data", (chunk: Buffer) => ws.send(strip(chunk)))

buildCreateSessionCommand(session)

function buildCreateSessionCommand(session: string): string

Shell that creates a session without attaching (dtach -n, daemonized, stdio redirected so the master does not hold the exec's stdout open). No-op if dtach is missing or the socket already exists. session is interpolated: it must already have been through sanitizeSessionName.

buildKillSessionCommand(session)

function buildKillSessionCommand(session: string): string

Shell that kills a session: SIGTERM to every dtach process holding its socket (master and any client), then removes the socket and the recording. Always exits 0. session must already have been through sanitizeSessionName.

const session = sanitizeSessionName(params.name)
await execCapture(container, ["/bin/sh", "-c", buildKillSessionCommand(session)])

Listing sessions

LIST_SESSIONS_SCRIPT

const LIST_SESSIONS_SCRIPT: string

A shell script that prints one line per session socket in TERMINAL_DIR:

<name>|1|0|<created, epoch seconds>|<foreground command>|<title>

A dtach session is a socket file, nothing more — there is no server to ask. The name and creation date come from the socket; the foreground command is recovered from the process table by walking down from the dtach master to the deepest descendant in its pty's foreground process group (what tmux calls pane_current_command). The title is the last OSC 0/2 sequence found in the tail of the recording — best effort: a container whose grep lacks -a simply yields no title.

TerminalSession

Prop

Type

parseSessionList(output, isAttached)

function parseSessionList(output: string, isAttached: (session: string) => boolean): TerminalSession[]

Parses what LIST_SESSIONS_SCRIPT printed. Kept separate from running it — executing a command in a container is the caller's business — which is what lets the parse be tested without a container. The title is the last field and keeps any | it contains; lines with an empty name are dropped.

const output = await execCapture(container, ["/bin/sh", "-c", LIST_SESSIONS_SCRIPT])
const sessions = parseSessionList(output, (name) => openSockets.has(`${workerId}/${name}`))

parseSessionList("build|1|0|1700000000|npm|Building the thing", () => false)
// → [{ name: "build", windows: 1, attached: false, createdAt: 1700000000000,
//      command: "npm", title: "Building the thing" }]

sanitizeReportedTitle(raw, placeholders?)

function sanitizeReportedTitle(raw: string | undefined, placeholders?: string[]): string

Normalises a title a program reported: control characters replaced (a stray BEL or ESC would otherwise land in a JSON payload and then a React tree), whitespace collapsed, trimmed, capped at 120 characters. Returns "" when nothing is left, or when the title equals one of placeholders — values that mean "nobody reported anything", such as the container hostname tmux seeds its pane title with. parseSessionList already applies it (with no placeholders).

sanitizeReportedTitle("vim\u0007 main.ts")            // → "vim main.ts"
sanitizeReportedTitle("a1b2c3d4e5f6", [hostname])     // → "" when hostname === "a1b2c3d4e5f6"
Edit on GitHub

On this page