my harness for niri
1import * as pty from "node-pty"
2import { randomBytes } from "crypto"
3import {
4 CONTAINER_NAME,
5 CONTAINER_USER,
6 DEFAULT_COMMAND_TIMEOUT_MS,
7 normalizeTimeoutMs,
8} from "./config.js"
9import type { RunRawOptions } from "./types.js"
10
11/**
12 * Strip ANSI/VT escape sequences and normalize line endings from PTY output.
13 * PTYs emit CRLF and control sequences that we don't want in command results.
14 */
15function cleanOutput(str: string): string {
16 return str
17 .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") // CSI sequences (colors, cursor, etc.)
18 .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC sequences (title, etc.)
19 .replace(/\x1b[^[\]]/g, "") // other 2-char ESC sequences
20 .replace(/\^C\s*/g, "") // Ctrl+C echo after interrupt
21 .replace(/\r\n/g, "\n") // CRLF -> LF
22 .replace(/\r/g, "\n") // stray CR -> LF
23 .replace(/\x00/g, "") // null bytes
24}
25
26let bash: pty.IPty | null = null
27
28/**
29 * Opens and initializes the persistent PTY bash session inside the configured container.
30 *
31 * This performs one-time terminal setup (disable echo, normalize prompt, source bashrc)
32 * so subsequent `runRaw` calls can rely on sentinel-based output capture.
33 *
34 * @returns A promise that resolves when the shell is ready for commands.
35 * @throws If the container shell cannot be started or initialized.
36 */
37export async function openBash(): Promise<void> {
38 // docker exec -it allocates a PTY inside the container so bash runs
39 // interactively with job control. Combined with node-pty on the host
40 // this gives us a proper interactive shell where Ctrl+C interrupts
41 // the running command rather than killing bash itself.
42 const proc = pty.spawn("docker", ["exec", "-it", "-u", CONTAINER_USER, CONTAINER_NAME, "bash"], {
43 name: "xterm-256color",
44 cols: 220, // wide enough to avoid line-wrapping sentinels
45 rows: 50,
46 env: process.env as Record<string, string>,
47 })
48
49 proc.onExit(({ exitCode }) => {
50 console.log(`[bash] exited with code ${exitCode}`)
51 if (bash === proc) bash = null
52 })
53
54 bash = proc
55
56 // Liveness check: if bash exits within 500ms the container is probably down.
57 await new Promise<void>((resolve, reject) => {
58 const timer = setTimeout(resolve, 500)
59 const d = proc.onExit(() => {
60 clearTimeout(timer)
61 d.dispose()
62 reject(new Error(`bash exited immediately — is the '${CONTAINER_NAME}' container running?`))
63 })
64 })
65
66 // We CANNOT use sentinel-based detection yet because bash echo is still on:
67 // if sending `echo SENTINEL` bash will echo the line back before running it
68 // so the sentinel appears in the output immediately as a false positive.
69 //
70 // Strategy: disable echo via a PROMPT-BASED signal.
71 // 1. Send `stty -echo; export PS1='<token>' PS2=''`
72 // 2. Wait for <token> to appear in the PTY output
73 // 3. Now echo is off meaning sentinel detection is safe for all subsequent calls.
74 // 4. Use runRaw to source .bashrc and clear the prompt.
75
76 const initToken = `NIRI_INIT_${randomBytes(4).toString("hex")}_`
77
78 await new Promise<void>((resolve, reject) => {
79 let buf = ""
80 const d = proc.onData((chunk: string) => {
81 buf += chunk
82 const clean = cleanOutput(buf)
83 if (clean.includes(initToken)) {
84 d.dispose()
85 resolve()
86 }
87 })
88 // stty runs directly on the PTY (no stdin redirect); this is intentional.
89 proc.write(`stty -echo; export PS1='${initToken}' PS2=''\n`)
90 setTimeout(() => {
91 d.dispose()
92 reject(new Error("bash init timed out"))
93 }, 10_000)
94 })
95
96 // Echo is now off. Clear the token prompt, source .bashrc, done.
97 await runRaw("export PS1='' PS2=''")
98 await runRaw("source ~/.bashrc 2>/dev/null || true; export PS1='' PS2=''")
99 console.log("[bash] session ready")
100}
101
102/**
103 * Closes the active PTY shell session if one exists.
104 */
105export function closeBash(): void {
106 if (bash) {
107 bash.kill()
108 bash = null
109 }
110}
111
112/**
113 * Runs a command in the persistent PTY session and returns cleaned output.
114 *
115 * This is the low-level primitive used by higher-level container tools.
116 *
117 * @param command - Raw shell command to execute.
118 * @param options - Timeout and stdin-redirection behavior.
119 * @returns Combined stdout/stderr output with PTY control noise removed.
120 * @throws If command execution times out.
121 */
122export async function runRaw(command: string, options: RunRawOptions = {}): Promise<string> {
123 const timeoutMs = normalizeTimeoutMs(options.timeoutMs, DEFAULT_COMMAND_TIMEOUT_MS)
124 const redirectStdinToDevNull = options.redirectStdinToDevNull ?? true
125
126 // Reconnect lazily if the session was lost.
127 if (!bash) {
128 console.log("[bash] no session — attempting to reconnect...")
129 await openBash()
130 }
131
132 // Capture a stable local reference — the module-level `bash` may be nulled
133 // by the exit handler while we're waiting for output.
134 const session = bash!
135
136 // Two sentinels: start + end. Any PTY output buffered from a previous
137 // command arrives before the start sentinel and is discarded, preventing it
138 // from being prepended to this command's output.
139 const startSentinel = `__NIRI_START_${randomBytes(4).toString("hex")}__`
140 const endSentinel = `__NIRI_DONE_${randomBytes(4).toString("hex")}__`
141 let raw = ""
142 let settled = false
143
144 return new Promise((resolve, reject) => {
145 const dataDisposable = session.onData((chunk: string) => {
146 raw += chunk
147 const cleaned = cleanOutput(raw)
148 if (cleaned.includes(startSentinel) && cleaned.includes(endSentinel)) {
149 if (settled) return
150 settled = true
151 clearTimeout(timer)
152 dataDisposable.dispose()
153 const start = cleaned.indexOf(startSentinel) + startSentinel.length
154 const end = cleaned.indexOf(endSentinel)
155 // Drop the single newline that echo adds after the start sentinel,
156 // then trim trailing whitespace from the command output.
157 resolve(cleaned.slice(start).replace(/^\n/, "").slice(0, end - start).trimEnd())
158 }
159 })
160
161 // Wrap command in a { } group. By default we redirect stdin from /dev/null
162 // so accidental interactive reads do not hang forever.
163 // The closing } is on its own line so heredocs inside the command still
164 // get their correct delimiter line.
165 const groupedCommand = redirectStdinToDevNull ? `{ ${command}\n} < /dev/null` : `{ ${command}\n}`
166
167 session.write(`echo ${startSentinel}\n${groupedCommand}\necho ${endSentinel}\n`)
168
169 const timer = setTimeout(() => {
170 if (settled) return
171 settled = true
172 dataDisposable.dispose()
173 session.write("\x03\n")
174 reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`))
175 }, timeoutMs)
176 })
177}