@spunto/build 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -148,6 +148,29 @@ time a vendor reshuffles a field.
148
148
  Here for the reason that inverts the rest of the package: **nothing has forked this yet.** Putting
149
149
  it in the shared package now costs nothing and means the second product never writes its own.
150
150
 
151
+ ### `@spunto/build/terminal` — the half of a persistent terminal that is a string
152
+
153
+ A worker's terminal is an exec attached to a session that outlives it, so closing a browser tab
154
+ doesn't kill a build. The attaching is I/O and stays with whoever owns the Docker socket; the shell
155
+ that gets attached, the layout it writes into, and the parsing of what it prints back are here.
156
+
157
+ ```ts
158
+ import { sanitizeSessionName, buildDtachCommand, parseSessionList } from "@spunto/build/terminal"
159
+
160
+ const name = sanitizeSessionName(fromQuery) // every name MUST come through this
161
+ await exec(container, ["/bin/sh", "-c", buildDtachCommand()], { env: { MP_TERM_SESSION: name } })
162
+ ```
163
+
164
+ **dtach, not tmux.** tmux's only knob for "the wheel scrolls history" is `mouse on`, which also
165
+ makes it swallow every drag — the frontend then has to forge Shift-drags to get a native selection
166
+ back. dtach does only persistence, so the emulator keeps the mouse, its scrollback and its search.
167
+ What dtach doesn't do is remember the screen, so the session is recorded inside the container with
168
+ `script(1)` and the tail replayed on attach.
169
+
170
+ `sanitizeSessionName` is a security boundary, not tidiness: names are interpolated into the shell
171
+ scripts this module builds, so anything that doesn't come through it is a command injection into
172
+ someone's container.
173
+
151
174
  ## The rule
152
175
 
153
176
  A module belongs in this package only if it imports **no** ORM schema, **no** HTTP framework, **no**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spunto/build",
3
- "version": "0.4.1",
4
- "description": "Spunto's shared Build engine \u2014 the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
3
+ "version": "0.5.1",
4
+ "description": "Spunto's shared Build engine — the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "homepage": "https://spunto.net",
@@ -55,6 +55,10 @@
55
55
  "types": "./src/script/index.ts",
56
56
  "import": "./src/script/index.ts"
57
57
  },
58
+ "./terminal": {
59
+ "types": "./src/terminal/index.ts",
60
+ "import": "./src/terminal/index.ts"
61
+ },
58
62
  "./agent-stream": {
59
63
  "types": "./src/agent-stream/index.ts",
60
64
  "import": "./src/agent-stream/index.ts"
package/src/index.ts CHANGED
@@ -12,4 +12,5 @@ export * from "./spec/index"
12
12
  export * from "./types/index"
13
13
  export * from "./catalogs/index"
14
14
  export * from "./script/index"
15
+ export * from "./terminal/index"
15
16
  export * from "./agent-stream/index"
@@ -35,6 +35,37 @@ function shQuote(v: string): string {
35
35
  return `'${v.replace(/'/g, `'\\''`)}'`
36
36
  }
37
37
 
38
+ /**
39
+ * Runs a command as the workspace user instead of root.
40
+ *
41
+ * The setup script itself is the container's CMD, so it runs as root — it has to, to chown
42
+ * /workspace and write under /etc. Everything it *clones*, though, belongs to the user, and
43
+ * cloning as root has cost us twice: once as `npm install` failing with EACCES on a root-owned
44
+ * node_modules (hence the re-chown after the repo clones), and once as a private dotfiles repo
45
+ * that authenticated with no key at all, because root has no `~/.ssh` and the identity was only
46
+ * named on the repo clones.
47
+ *
48
+ * `su` gives the command `HOME=/home/vscode`, which is what makes ssh pick up the `~/.ssh/config`
49
+ * written in the credentials step — so a clone over SSH needs nothing else to find the user's key.
50
+ */
51
+ function asUser(username: string, cmd: string): string {
52
+ return `su ${username} -c ${shQuote(cmd)}`
53
+ }
54
+
55
+ /**
56
+ * A dotfiles setting, as a clonable URL.
57
+ *
58
+ * Anything already naming a transport is left alone; only a bare `owner/repo` shorthand is
59
+ * expanded against GitHub. The previous test — `startsWith("http") || startsWith("git@")` — turned
60
+ * `ssh://git@host/me/dotfiles.git` into `https://github.com/ssh://git@host/me/dotfiles.git`.
61
+ */
62
+ function normalizeDotfilesUrl(raw: string): string {
63
+ const v = raw.trim()
64
+ return /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/|[^/\s]+@[^/\s]+:)/.test(v)
65
+ ? v
66
+ : `https://github.com/${v.replace(/^\/+|\/+$/g, "")}`
67
+ }
68
+
38
69
  /**
39
70
  * `export EXTENSIONS_GALLERY=…`, or nothing when the org is on the default (Open VSX) registry.
40
71
  *
@@ -105,11 +136,13 @@ function cloneRepoBlock(params: {
105
136
  index: number
106
137
  total: number
107
138
  homeDir: string
139
+ /** Who the checkout belongs to — the clone runs as them. */
140
+ username: string
108
141
  branch?: string
109
142
  githubInstallationTokens?: Record<string, string>
110
143
  userSshPrivateKey?: string
111
144
  }): { header: string; lines: string[] } {
112
- const { repo: r, index, total, homeDir, branch, githubInstallationTokens, userSshPrivateKey } = params
145
+ const { repo: r, index, total, homeDir, username, branch, githubInstallationTokens, userSshPrivateKey } = params
113
146
  // Pick the installation token for this repo's owner. A GitHub App installation only grants
114
147
  // access to repos owned by its account, so the owner login (== installation accountLogin)
115
148
  // uniquely selects the right token among the org's connected installations. Covers legacy
@@ -117,13 +150,16 @@ function cloneRepoBlock(params: {
117
150
  const owner = (r.project.split("/")[0] ?? "").toLowerCase()
118
151
  const repoToken = githubInstallationTokens?.[owner]
119
152
  const b = branch ? ` --branch ${shQuote(branch)}` : ""
120
- const cloneCmd = r.provider === "git" && r.cloneUrl
153
+ const rawCloneCmd = r.provider === "git" && r.cloneUrl
121
154
  ? `GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ${homeDir}/.ssh/mp_deploy_key" git clone${b} ${shQuote(r.cloneUrl)} /workspace/${r.workspacePath}`
122
155
  : r.provider === "github" && repoToken
123
156
  ? `git clone${b} https://x-access-token:${repoToken}@github.com/${r.project}.git /workspace/${r.workspacePath} && git -C /workspace/${r.workspacePath} remote set-url origin git@github.com:${r.project}.git`
124
157
  : r.provider === "github" && userSshPrivateKey
125
158
  ? `GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ${homeDir}/.ssh/mp_user_key" git clone${b} git@github.com:${r.project}.git /workspace/${r.workspacePath}`
126
159
  : `git clone${b} https://github.com/${r.project} /workspace/${r.workspacePath}`
160
+ // Cloned as the user, not as root: /workspace is already theirs (the ownership step runs
161
+ // first), so the checkout lands with the right owner and needs no chown afterwards.
162
+ const cloneCmd = asUser(username, rawCloneCmd)
127
163
 
128
164
  // Single-quoted only when a (user-provided) branch is interpolated; the plain form is kept
129
165
  // verbatim for the no-branch case so the generated script is unchanged there.
@@ -851,23 +887,23 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
851
887
 
852
888
  // ── 4. Dotfiles ───────────────────────────────────────────────────────────
853
889
  if (dotfilesRepo) {
854
- const dotfilesUrl = dotfilesRepo.startsWith("http") || dotfilesRepo.startsWith("git@")
855
- ? dotfilesRepo
856
- : `https://github.com/${dotfilesRepo}`
890
+ const dotfilesUrl = normalizeDotfilesUrl(dotfilesRepo)
857
891
  push(...banner("SETUP: DOTFILES"))
858
892
  mpAt(mkStatus("dotfiles", allReposPending, pc0, null), "dotfiles")
859
893
  push(
860
894
  `echo "Cloning dotfiles from ${dotfilesUrl}..."`,
861
895
  `set +e`,
862
- `git clone ${JSON.stringify(dotfilesUrl)} ${homeDir}/dotfiles 2>&1`,
896
+ // As the user, like every other clone: root has no `~/.ssh`, so a private dotfiles repo
897
+ // over SSH would authenticate with no key at all.
898
+ `${asUser(username, `git clone ${shQuote(dotfilesUrl)} ${homeDir}/dotfiles`)} 2>&1`,
863
899
  `_DOTS_EXIT=$?`,
864
900
  `set -e`,
865
901
  `if [ $_DOTS_EXIT -ne 0 ]; then`,
866
902
  ` echo "Dotfiles clone failed (exit $_DOTS_EXIT) — continuing without dotfiles"`,
867
903
  `else`,
868
904
  ` echo "Dotfiles cloned"`,
869
- // Cloned as root, but the install script below runs as the user (and so does whoever edits
870
- // these files later) — hand the clone over before touching it.
905
+ // The clone above already lands as the user. Kept for a worker whose ~/dotfiles was left
906
+ // root-owned by an earlier release, where the install script below would fail on it.
871
907
  ` chown -R ${username}:${username} ${homeDir}/dotfiles`,
872
908
  ` _INSTALL_SCRIPT=""`,
873
909
  ` for _candidate in install.sh bootstrap.sh setup.sh script/setup; do`,
@@ -911,6 +947,7 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
911
947
  index: i,
912
948
  total: project.repositories.length,
913
949
  homeDir,
950
+ username,
914
951
  branch: resolveRepoBranch(r, branch),
915
952
  githubInstallationTokens,
916
953
  userSshPrivateKey,
@@ -924,11 +961,10 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
924
961
  })
925
962
 
926
963
  // ── 5b. Re-own /workspace after cloning ───────────────────────────────────
927
- // Repos are cloned as root (the setup script runs as root), so the cloned
928
- // directories end up root-owned. The initial chown (step 1) ran *before* the
929
- // clone, so it didn't cover them. postCreateCommand runs as vscode, so without
930
- // this the user can't write into the repo (e.g. `npm install` → EACCES on
931
- // node_modules). Must run before postCreate, not just in the final step.
964
+ // The clones above run as the user, so their checkouts already belong to them. This stays as a
965
+ // net: a repo left root-owned by an earlier release (when cloning *was* done as root) would
966
+ // otherwise keep failing postCreate with EACCES — `npm install` on a root-owned node_modules —
967
+ // and the final chown in step 8 comes too late for that.
932
968
  if (project.repositories.length > 0) {
933
969
  push("", `chown -R ${username}:${username} /workspace`)
934
970
  }
@@ -1774,21 +1810,21 @@ export function buildSetupPlan(params: SetupScriptParams): {
1774
1810
 
1775
1811
  // ── dotfiles ──────────────────────────────────────────────────────────────
1776
1812
  if (dotfilesRepo) {
1777
- const dotfilesUrl = dotfilesRepo.startsWith("http") || dotfilesRepo.startsWith("git@")
1778
- ? dotfilesRepo
1779
- : `https://github.com/${dotfilesRepo}`
1813
+ const dotfilesUrl = normalizeDotfilesUrl(dotfilesRepo)
1780
1814
  const l: string[] = ["set -e", ...banner("SETUP: DOTFILES"),
1781
1815
  `echo "Cloning dotfiles from ${dotfilesUrl}..."`,
1782
1816
  `set +e`,
1783
- `git clone ${JSON.stringify(dotfilesUrl)} ${homeDir}/dotfiles 2>&1`,
1817
+ // As the user, like every other clone: root has no `~/.ssh`, so a private dotfiles repo
1818
+ // over SSH would authenticate with no key at all.
1819
+ `${asUser(username, `git clone ${shQuote(dotfilesUrl)} ${homeDir}/dotfiles`)} 2>&1`,
1784
1820
  `_DOTS_EXIT=$?`,
1785
1821
  `set -e`,
1786
1822
  `if [ $_DOTS_EXIT -ne 0 ]; then`,
1787
1823
  ` echo "Dotfiles clone failed (exit $_DOTS_EXIT) — continuing without dotfiles"`,
1788
1824
  `else`,
1789
1825
  ` echo "Dotfiles cloned"`,
1790
- // Cloned as root, but the install script below runs as the user (and so does whoever edits
1791
- // these files later) — hand the clone over before touching it.
1826
+ // The clone above already lands as the user. Kept for a worker whose ~/dotfiles was left
1827
+ // root-owned by an earlier release, where the install script below would fail on it.
1792
1828
  ` chown -R ${username}:${username} ${homeDir}/dotfiles`,
1793
1829
  ` _INSTALL_SCRIPT=""`,
1794
1830
  ` for _candidate in install.sh bootstrap.sh setup.sh script/setup; do`,
@@ -1833,6 +1869,7 @@ export function buildSetupPlan(params: SetupScriptParams): {
1833
1869
  index: i,
1834
1870
  total: project.repositories.length,
1835
1871
  homeDir,
1872
+ username,
1836
1873
  branch: resolveRepoBranch(r, branch),
1837
1874
  githubInstallationTokens,
1838
1875
  userSshPrivateKey,
@@ -0,0 +1,302 @@
1
+ // The half of a persistent terminal that is a string, not a socket.
2
+ //
3
+ // A worker's terminal is a `docker exec` attached to a session that outlives it — so that closing a
4
+ // browser tab does not kill a build. The *attaching* is I/O and stays with whoever owns the Docker
5
+ // socket; everything here is the shell that gets attached, the layout it writes into, and the
6
+ // parsing of what it prints back.
7
+ //
8
+ // **dtach, not tmux** (RFC 0019). tmux is a full multiplexer, and its only knob for "the wheel
9
+ // scrolls history" is `mouse on` — which also makes tmux swallow every drag, so the frontend has to
10
+ // forge Shift-drags to get a native selection back. dtach does only persistence: it owns a pty,
11
+ // lets clients attach and detach, and forwards every byte, mouse included. The terminal emulator
12
+ // keeps the mouse, its own scrollback and its own search.
13
+ //
14
+ // What dtach does not do is remember what was on screen. `-r winch` makes the running program
15
+ // redraw on attach, but the history is gone. So the session is recorded inside the container with
16
+ // script(1) and the tail of that recording is replayed just before attaching:
17
+ //
18
+ // docker exec ──> sh ──> [replay: tail of the log] ──> exec dtach -A ──> script -f log ──> $SHELL
19
+ // │
20
+ // persistent master
21
+ //
22
+ // Recording inside the container rather than in an agent-side ring buffer is what makes the buffer
23
+ // survive a control-plane restart with nothing permanently attached.
24
+
25
+ /**
26
+ * Where a session's socket and recording live. One directory, two files per session.
27
+ *
28
+ * The name deliberately avoids the substring "dtach": the listing and kill scripts match processes
29
+ * by the socket path, and a path containing "dtach" would make every helper's own shell look like a
30
+ * dtach process to its own grep.
31
+ */
32
+ export const TERMINAL_DIR = "/tmp/spunto-term"
33
+
34
+ /** Bytes of recording replayed on attach. ~64 lines of 120 cols is 8 KB; 256 KB is a few screens of a chatty build. */
35
+ export const REPLAY_BYTES = 262144
36
+
37
+ /**
38
+ * Below this there is nothing worth replaying: a session created from a "+" button has already
39
+ * recorded its prompt, and replaying that would greet the user with a "replay" banner on a terminal
40
+ * they just opened. The attach redraw paints the prompt anyway.
41
+ */
42
+ export const REPLAY_MIN_BYTES = 512
43
+
44
+ /** Above this, the recording is trimmed to `TRIM_TO` bytes on the next attach. */
45
+ export const MAX_LOG_BYTES = 4194304
46
+ export const TRIM_TO = 1048576
47
+
48
+ /**
49
+ * Session name a control plane may safely interpolate into the shell scripts below.
50
+ *
51
+ * Restricted to `[A-Za-z0-9_-]` — which is both what a socket filename can hold without quoting and
52
+ * what the listing script's `awk` can match on. **Every name reaching the builders below must come
53
+ * through here**: they interpolate it into shell, so anything else is an injection.
54
+ */
55
+ export function sanitizeSessionName(name: string | undefined): string {
56
+ const cleaned = (name ?? "main").replace(/[^A-Za-z0-9_-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 40)
57
+ return cleaned || "main"
58
+ }
59
+
60
+ export function buildDtachCommand(): string {
61
+ return [
62
+ `D=${TERMINAL_DIR}`,
63
+ 'N="$MP_TERM_SESSION"',
64
+ 'SOCK="$D/$N.sock"',
65
+ 'LOG="$D/$N.log"',
66
+ 'mkdir -p "$D" 2>/dev/null; chmod 700 "$D" 2>/dev/null',
67
+ '_SH=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7); _SH=${_SH:-/bin/bash}',
68
+ // No dtach → say so plainly and degrade to a non-persistent shell.
69
+ "if ! command -v dtach >/dev/null 2>&1; then",
70
+ ` printf '\\033[33m[spunto] dtach absent de ce conteneur — shell simple, sans persistance.\\033[0m\\r\\n'`,
71
+ ' exec "$_SH" -l',
72
+ "fi",
73
+ // A session is live only if some process still holds its socket: a leftover
74
+ // socket file (container restarted, program exited) means the recorded log
75
+ // belongs to a session that no longer exists, so it is dropped rather than
76
+ // replayed into a brand new shell.
77
+ 'LIVE=0',
78
+ 'if [ -S "$SOCK" ] && ps -eo args= 2>/dev/null | grep -qF "$SOCK"; then LIVE=1; fi',
79
+ 'if [ "$LIVE" = 0 ]; then rm -f "$SOCK" 2>/dev/null; : > "$LOG" 2>/dev/null; fi',
80
+ // ── replay buffer ────────────────────────────────────────────────────────
81
+ // Trim first (the log is append-only and would otherwise grow forever), then
82
+ // dump its tail. The dump is preceded by a reset because the tail can start
83
+ // mid-escape-sequence or while the alternate screen / mouse tracking was on;
84
+ // `tail -n +2` drops the (possibly truncated) first line.
85
+ 'if [ "$LIVE" = 1 ] && [ -f "$LOG" ]; then',
86
+ ' SZ=$(wc -c < "$LOG" 2>/dev/null || echo 0)',
87
+ ` if [ "$SZ" -gt ${MAX_LOG_BYTES} ] 2>/dev/null; then`,
88
+ ` tail -c ${TRIM_TO} "$LOG" > "$LOG.trim" 2>/dev/null && cat "$LOG.trim" > "$LOG" 2>/dev/null; rm -f "$LOG.trim"`,
89
+ " fi",
90
+ ` if [ "$SZ" -gt ${REPLAY_MIN_BYTES} ] 2>/dev/null; then`,
91
+ ` printf '\\033[?1049l\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1006l\\033c'`,
92
+ ` printf '\\033[2m── replay ──\\033[0m\\r\\n'`,
93
+ ` tail -c ${REPLAY_BYTES} "$LOG" 2>/dev/null | tail -n +2`,
94
+ " fi",
95
+ "fi",
96
+ // ── attach ───────────────────────────────────────────────────────────────
97
+ // script(1) records the session's raw output for the replay above. Without it
98
+ // the session still works, it just comes back blank.
99
+ "if command -v script >/dev/null 2>&1; then",
100
+ ' exec dtach -A "$SOCK" -r winch -E -z script -q -a -f "$LOG" -c "$_SH -l"',
101
+ "else",
102
+ ' exec dtach -A "$SOCK" -r winch -E -z "$_SH" -l',
103
+ "fi",
104
+ ].join("\n")
105
+ }
106
+
107
+ // A dtach session is a socket file, nothing more — no server to ask. Name and
108
+ // creation date come from the socket itself; the foreground command has to be
109
+ // recovered from the container's process table (this is the metadata tmux gives
110
+ // away for free, see the RFC): walk the process tree down from the dtach master
111
+ // and keep the deepest descendant that sits in its pty's foreground process
112
+ // group (the `+` in ps's STAT), which is exactly what `pane_current_command` means.
113
+ export const LIST_SESSIONS_SCRIPT = [
114
+ `D=${TERMINAL_DIR}`,
115
+ '[ -d "$D" ] || exit 0',
116
+ 'PSOUT=$(ps -eo pid=,ppid=,stat=,args= 2>/dev/null || true)',
117
+ 'ESC=$(printf "\\033"); BEL=$(printf "\\007")',
118
+ 'for s in "$D"/*.sock; do',
119
+ ' [ -S "$s" ] || continue',
120
+ ' n=$(basename "$s" .sock)',
121
+ ' created=$(stat -c %Y "$s" 2>/dev/null || echo 0)',
122
+ ' cmd=$(printf "%s\\n" "$PSOUT" | awk -v sock="$s" \'',
123
+ " {",
124
+ " pid=$1; ppid=$2; st=$3;",
125
+ ' args=""; for (i=4; i<=NF; i++) args = args (i>4 ? " " : "") $i;',
126
+ " n++; order[n]=pid; P[pid]=ppid; S[pid]=st; A[pid]=args;",
127
+ ' if (index(args, sock) > 0) d[pid]=0;',
128
+ " }",
129
+ " END {",
130
+ " for (iter=0; iter<12; iter++)",
131
+ " for (i=1; i<=n; i++) { p=order[i]; if (!(p in d) && (P[p] in d)) d[p]=d[P[p]]+1 }",
132
+ ' best=""; bestd=0;',
133
+ " for (i=1; i<=n; i++) {",
134
+ " p=order[i];",
135
+ " if (!(p in d) || d[p]==0) continue;",
136
+ ' if (S[p] !~ /\\+/) continue;',
137
+ ' c=A[p]; sub(/ .*/, "", c); sub(/.*\\//, "", c); sub(/^-/, "", c);',
138
+ ' if (c=="script" || c=="dtach") continue;',
139
+ " if (d[p] >= bestd) { bestd=d[p]; best=c }",
140
+ " }",
141
+ " print best",
142
+ " }')",
143
+ // Reported title (OSC 0/2, see terminal-title.ts). tmux keeps it in
144
+ // `pane_title`; dtach forwards every byte to the terminal and remembers
145
+ // nothing, so it is dug out of the replay log: the last `ESC ] 0|2 ; … BEL`
146
+ // written is the title currently on screen. Best effort — a container whose
147
+ // grep lacks `-a` simply yields no title and the UI falls back to the name.
148
+ ' t=$(tail -c 65536 "$D/$n.log" 2>/dev/null | LC_ALL=C grep -ao "$ESC][02];[^$BEL$ESC]*" 2>/dev/null | tail -n 1 | LC_ALL=C cut -c5-)',
149
+ ' echo "$n|1|0|$created|$cmd|$t"',
150
+ "done",
151
+ ].join("\n")
152
+
153
+ /** Longest title kept — anything past that is a program dumping data, not naming itself. */
154
+ const MAX_TITLE_LENGTH = 120
155
+
156
+ /**
157
+ * Normalize a reported title, or return "" when there is nothing worth showing.
158
+ *
159
+ * `placeholders` are the values that mean "no one reported anything": tmux seeds
160
+ * `pane_title` with the container hostname, so a title still equal to it says
161
+ * only that the session never set one.
162
+ */
163
+ export function sanitizeReportedTitle(raw: string | undefined, placeholders: string[] = []): string {
164
+ const title = (raw ?? "")
165
+ // Control characters (a stray BEL/ESC from a truncated read, a newline) would
166
+ // land as-is in a JSON payload and then in a React tree.
167
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
168
+ .replace(/\s+/g, " ")
169
+ .trim()
170
+ .slice(0, MAX_TITLE_LENGTH)
171
+ if (!title) return ""
172
+ if (placeholders.some((p) => p && p.trim() === title)) return ""
173
+ return title
174
+ }
175
+
176
+ /**
177
+ * dtach clears the screen as soon as a client attaches — a bare `ESC[H ESC[J` ("Clear the screen.
178
+ * This assumes VT100." in dtach's own source). That write lands *after* the replay the attach
179
+ * script just printed, and wipes exactly what the replay exists to show.
180
+ *
181
+ * So it is dropped: once, and only in the first seconds of an attach — long enough to cover
182
+ * dtach's write, short enough that a program legitimately clearing its screen later is never
183
+ * touched. (`clear` emits `ESC[H ESC[2J ESC[3J`, different bytes, so it would not match anyway.)
184
+ *
185
+ * `Uint8Array` in and out rather than Node's Buffer: a Buffer is one, so a Node caller passes its
186
+ * chunks straight through, and the package stays runnable anywhere.
187
+ */
188
+ export function makeAttachClearStripper(
189
+ windowMs = 5000,
190
+ now: () => number = Date.now,
191
+ ): (chunk: Uint8Array) => Uint8Array {
192
+ const CLEAR = new TextEncoder().encode("\x1b[H\x1b[J")
193
+ const deadline = now() + windowMs
194
+ let done = false
195
+ return (chunk) => {
196
+ if (done) return chunk
197
+ if (now() > deadline) {
198
+ done = true
199
+ return chunk
200
+ }
201
+ const i = indexOfBytes(chunk, CLEAR)
202
+ if (i === -1) return chunk
203
+ done = true
204
+ const out = new Uint8Array(chunk.length - CLEAR.length)
205
+ out.set(chunk.subarray(0, i), 0)
206
+ out.set(chunk.subarray(i + CLEAR.length), i)
207
+ return out
208
+ }
209
+ }
210
+
211
+ /** `Buffer.indexOf` for a plain Uint8Array — the needle is 6 bytes, so the naive scan is the right one. */
212
+ function indexOfBytes(haystack: Uint8Array, needle: Uint8Array): number {
213
+ outer: for (let i = 0; i + needle.length <= haystack.length; i++) {
214
+ for (let j = 0; j < needle.length; j++) if (haystack[i + j] !== needle[j]) continue outer
215
+ return i
216
+ }
217
+ return -1
218
+ }
219
+
220
+ /** One persistent session of a worker, as a control plane reports it. */
221
+ export type TerminalSession = {
222
+ name: string
223
+ /** Always 1 — dtach has no windows. Kept so a UI written against a multiplexer still renders. */
224
+ windows: number
225
+ /**
226
+ * Decided by the caller, not by this parse: a socket says nothing about who is connected, and the
227
+ * process that attaches is the one that knows.
228
+ */
229
+ attached: boolean
230
+ createdAt: number
231
+ command: string
232
+ /** Title the running program reported over OSC 0/2 ("" when it reported none). */
233
+ title: string
234
+ }
235
+
236
+ /**
237
+ * Parse what `LIST_SESSIONS_SCRIPT` printed into sessions.
238
+ *
239
+ * Separate from running it: executing a command inside a container is the caller's business, and
240
+ * keeping the parse here is what lets it be tested without a container.
241
+ */
242
+ export function parseSessionList(output: string, isAttached: (session: string) => boolean): TerminalSession[] {
243
+ return output
244
+ .trim()
245
+ .split("\n")
246
+ .filter(Boolean)
247
+ .map((line) => {
248
+ // The title is free-form and last, so it keeps any "|" it contains.
249
+ const parts = line.split("|")
250
+ const [name = "", , , created = "0", command = ""] = parts
251
+ return {
252
+ name: name.trim(),
253
+ windows: 1,
254
+ attached: isAttached(name.trim()),
255
+ createdAt: (parseInt(created) || 0) * 1000,
256
+ command: command.trim(),
257
+ title: sanitizeReportedTitle(parts.slice(5).join("|")),
258
+ }
259
+ })
260
+ .filter((s) => s.name)
261
+ }
262
+
263
+ /**
264
+ * Shell that creates a session without attaching to it — `dtach -n` daemonizes the master.
265
+ *
266
+ * `session` must already have been through `sanitizeSessionName`: it is interpolated into the
267
+ * script.
268
+ */
269
+ export function buildCreateSessionCommand(session: string): string {
270
+ return [
271
+ `D=${TERMINAL_DIR}`,
272
+ `N="${session}"`,
273
+ 'mkdir -p "$D" 2>/dev/null; chmod 700 "$D" 2>/dev/null',
274
+ "command -v dtach >/dev/null 2>&1 || exit 0",
275
+ '[ -S "$D/$N.sock" ] && exit 0',
276
+ '_SH=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7); _SH=${_SH:-/bin/bash}',
277
+ // stdio redirected: the daemonized master inherits the exec's fds and would otherwise hold its
278
+ // stdout open long after the session exists.
279
+ "if command -v script >/dev/null 2>&1; then",
280
+ ' dtach -n "$D/$N.sock" -r winch -E -z script -q -a -f "$D/$N.log" -c "$_SH -l" </dev/null >/dev/null 2>&1',
281
+ "else",
282
+ ' dtach -n "$D/$N.sock" -r winch -E -z "$_SH" -l </dev/null >/dev/null 2>&1',
283
+ "fi",
284
+ ].join("\n")
285
+ }
286
+
287
+ /**
288
+ * Shell that kills a session: SIGTERM every process holding its socket (master and any client),
289
+ * then drop the socket and the recording.
290
+ *
291
+ * `session` must already have been through `sanitizeSessionName`.
292
+ */
293
+ export function buildKillSessionCommand(session: string): string {
294
+ return [
295
+ `SOCK=${TERMINAL_DIR}/${session}.sock`,
296
+ 'for p in $(ps -eo pid=,args= 2>/dev/null | awk -v sock="$SOCK" -v me=$$ \'$1 != me && index($0, sock) > 0 && index($0, "dtach") > 0 { print $1 }\'); do',
297
+ ' kill "$p" 2>/dev/null',
298
+ "done",
299
+ `rm -f "$SOCK" ${TERMINAL_DIR}/${session}.log 2>/dev/null`,
300
+ "true",
301
+ ].join("\n")
302
+ }
@@ -0,0 +1,22 @@
1
+ // The half of a persistent terminal that is a string, not a socket: the shell that gets attached,
2
+ // the layout it writes into, and the parsing of what it prints back. Attaching is I/O and stays
3
+ // with whoever owns the Docker socket.
4
+ //
5
+ // See `./dtach` for why dtach and not tmux (RFC 0019).
6
+
7
+ export {
8
+ buildCreateSessionCommand,
9
+ buildDtachCommand,
10
+ buildKillSessionCommand,
11
+ LIST_SESSIONS_SCRIPT,
12
+ makeAttachClearStripper,
13
+ MAX_LOG_BYTES,
14
+ parseSessionList,
15
+ REPLAY_BYTES,
16
+ REPLAY_MIN_BYTES,
17
+ sanitizeReportedTitle,
18
+ sanitizeSessionName,
19
+ TERMINAL_DIR,
20
+ TRIM_TO,
21
+ } from "./dtach"
22
+ export type { TerminalSession } from "./dtach"