my harness for niri
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at master 177 lines 6.7 kB view raw
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}