This repository has no description
0

Configure Feed

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

niri / src / container / shell.ts
11 kB 329 lines
1import * as pty from "node-pty" 2import { randomBytes } from "crypto" 3import { spawn } from "child_process" 4import { 5 CONTAINER_NAME, 6 CONTAINER_USER, 7 DEFAULT_COMMAND_TIMEOUT_MS, 8 USE_DOCKER_SHELL, 9 normalizeTimeoutMs, 10} from "./config" 11import type { RunRawOptions } from "./types" 12 13function applyTerminalControls(str: string): string { 14 let out = "" 15 16 for (let i = 0; i < str.length; i += 1) { 17 const ch = str[i] 18 19 if (ch === "\r") { 20 if (str[i + 1] === "\n") { 21 out += "\n" 22 i += 1 23 } else { 24 const lineStart = out.lastIndexOf("\n") + 1 25 out = out.slice(0, lineStart) 26 } 27 continue 28 } 29 30 if (ch === "\b" || ch === "\x7f") { 31 if (out.length > 0 && out[out.length - 1] !== "\n") out = out.slice(0, -1) 32 continue 33 } 34 35 if (ch === "\x00") continue 36 out += ch 37 } 38 39 return out 40} 41 42/** 43 * Strip ANSI/VT escape sequences and apply simple terminal line controls. 44 * PTYs emit redraw-oriented output (CR, backspace, ANSI cursor commands) that 45 * should not be preserved as literal command result text. 46 */ 47export function cleanOutput(str: string): string { 48 const withoutEscapes = str 49 .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") // CSI sequences (colors, cursor, erase, etc.) 50 .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC sequences (title, etc.) 51 .replace(/\x1b[^[\]]/g, "") // other 2-char ESC sequences 52 .replace(/(?:\x03|\^C)\s*/g, "") // Ctrl+C echo after interrupt 53 54 return applyTerminalControls(withoutEscapes) 55} 56 57function removeInternalPtyControlEchoes(str: string): string { 58 return str 59 .split("\n") 60 .filter((line) => line !== "stty -echo 2>/dev/null") 61 .join("\n") 62} 63 64let bash: pty.IPty | null = null 65 66function spawnBash(): { proc: pty.IPty; backend: string } { 67 const env = { 68 ...(process.env as Record<string, string>), 69 // Commands run in a PTY, so Git otherwise assumes an interactive terminal 70 // and may launch a pager for `log`, `show`, `diff`, etc. The pager blocks 71 // the sentinel that marks command completion, causing tool-level timeouts. 72 GIT_PAGER: "cat", 73 GIT_TERMINAL_PROMPT: "0", 74 PAGER: "cat", 75 LESS: "FRX", 76 } 77 const options = { 78 name: "xterm-256color", 79 cols: 220, // wide enough to avoid line-wrapping sentinels 80 rows: 50, 81 env, 82 } 83 84 if (USE_DOCKER_SHELL) { 85 // docker exec -it allocates a PTY inside the container so bash runs 86 // interactively with job control. Combined with node-pty on the host 87 // this gives us a proper interactive shell where Ctrl+C interrupts 88 // the running command rather than killing bash itself. 89 return { 90 proc: pty.spawn("docker", ["exec", "-it", "-u", CONTAINER_USER, CONTAINER_NAME, "bash"], options), 91 backend: `docker:${CONTAINER_NAME}`, 92 } 93 } 94 95 return { 96 proc: pty.spawn("bash", ["--noprofile", "--norc", "-i"], { 97 ...options, 98 cwd: process.cwd(), 99 }), 100 backend: "local", 101 } 102} 103 104/** 105 * Opens and initializes the persistent PTY bash session inside the configured container. 106 * 107 * This performs one-time terminal setup (disable echo, normalize prompt, source bashrc) 108 * so subsequent `runRaw` calls can rely on sentinel-based output capture. 109 * 110 * @returns A promise that resolves when the shell is ready for commands. 111 * @throws If the container shell cannot be started or initialized. 112 */ 113export async function openBash(): Promise<void> { 114 if (bash) return 115 116 const { proc, backend } = spawnBash() 117 118 proc.onExit(({ exitCode }) => { 119 console.log(`[bash:${backend}] exited with code ${exitCode}`) 120 if (bash === proc) bash = null 121 }) 122 123 bash = proc 124 125 // Liveness check: if bash exits within 500ms the container is probably down. 126 await new Promise<void>((resolve, reject) => { 127 const timer = setTimeout(resolve, 500) 128 const d = proc.onExit(() => { 129 clearTimeout(timer) 130 d.dispose() 131 const detail = USE_DOCKER_SHELL ? ` — is the '${CONTAINER_NAME}' container running?` : "" 132 reject(new Error(`bash exited immediately${detail}`)) 133 }) 134 }) 135 136 // We CANNOT use sentinel-based detection yet because bash echo is still on: 137 // if sending `echo SENTINEL` bash will echo the line back before running it 138 // so the sentinel appears in the output immediately as a false positive. 139 // 140 // Strategy: disable echo via a PROMPT-BASED signal. 141 // 1. Send `stty -echo` without any unique token on that input line. 142 // 2. Send `export PS1='<token>' PS2=''` and wait for the prompt token. 143 // 3. Now echo is off meaning sentinel detection is safe for all subsequent calls. 144 // 4. Use runRaw to source .bashrc and clear the prompt. 145 146 const initToken = `NIRI_INIT_${randomBytes(4).toString("hex")}_` 147 148 await new Promise<void>((resolve, reject) => { 149 let buf = "" 150 let dataDisposable: { dispose(): void } | null = null 151 const timer = setTimeout(() => { 152 dataDisposable?.dispose() 153 reject(new Error("bash init timed out")) 154 }, 10_000) 155 dataDisposable = proc.onData((chunk: string) => { 156 buf += chunk 157 const clean = cleanOutput(buf) 158 if (clean.includes(initToken)) { 159 clearTimeout(timer) 160 dataDisposable?.dispose() 161 resolve() 162 } 163 }) 164 // Keep the token off the stty line. Otherwise the terminal echo can make 165 // readiness detection fire before echo has actually been disabled. 166 proc.write("stty -echo\n") 167 setTimeout(() => { 168 proc.write(`export PS1='${initToken}' PS2=''\n`) 169 }, 25) 170 }) 171 172 // Echo is now off. Clear the token prompt, source .bashrc, done. 173 await runRaw("export PS1='' PS2=''") 174 await runRaw("source ~/.bashrc 2>/dev/null || true; export PS1='' PS2=''") 175 console.log(`[bash:${backend}] session ready`) 176} 177 178/** 179 * Closes the active PTY shell session if one exists. 180 */ 181export function closeBash(): void { 182 if (bash) { 183 bash.kill() 184 bash = null 185 } 186} 187 188export async function currentWorkingDirectory(timeoutMs?: number): Promise<string> { 189 return (await runRaw("pwd -P", { timeoutMs, redirectStdinToDevNull: true })).trim() 190} 191 192/** 193 * Runs a command in the persistent PTY session and returns cleaned output. 194 * 195 * This is the low-level primitive used by higher-level container tools. 196 * 197 * @param command - Raw shell command to execute. 198 * @param options - Timeout and stdin-redirection behavior. 199 * @returns Combined stdout/stderr output with PTY control noise removed. 200 * @throws If command execution times out. 201 */ 202export async function runRaw(command: string, options: RunRawOptions = {}): Promise<string> { 203 const timeoutMs = normalizeTimeoutMs(options.timeoutMs, DEFAULT_COMMAND_TIMEOUT_MS) 204 // Default: keep stdin attached to the PTY for more natural command behavior. 205 // Higher-level helpers (e.g. runCommand) can opt into /dev/null for commands 206 // that are likely to block waiting for stdin. 207 const redirectStdinToDevNull = options.redirectStdinToDevNull ?? false 208 209 // Reconnect lazily if the session was lost. 210 if (!bash) { 211 console.log("[bash] no session — attempting to reconnect...") 212 await openBash() 213 } 214 215 // Capture a stable local reference — the module-level `bash` may be nulled 216 // by the exit handler while we're waiting for output. 217 const session = bash! 218 219 // Two sentinels: start + end. Any PTY output buffered from a previous 220 // command arrives before the start sentinel and is discarded, preventing it 221 // from being prepended to this command's output. 222 const startSentinel = `__NIRI_START_${randomBytes(4).toString("hex")}__` 223 const endSentinel = `__NIRI_DONE_${randomBytes(4).toString("hex")}__` 224 let raw = "" 225 let settled = false 226 227 return new Promise((resolve, reject) => { 228 const dataDisposable = session.onData((chunk: string) => { 229 raw += chunk 230 const cleaned = cleanOutput(raw) 231 if (cleaned.includes(startSentinel) && cleaned.includes(endSentinel)) { 232 if (settled) return 233 settled = true 234 clearTimeout(timer) 235 dataDisposable.dispose() 236 const start = cleaned.indexOf(startSentinel) + startSentinel.length 237 const end = cleaned.indexOf(endSentinel) 238 // Drop the single newline that echo adds after the start sentinel, 239 // then trim trailing whitespace from the command output. 240 resolve(removeInternalPtyControlEchoes(cleaned.slice(start, end).replace(/^\n/, "")).trimEnd()) 241 } 242 }) 243 244 // Wrap command in a { } group. When requested, redirect stdin from 245 // /dev/null so accidental interactive reads do not hang forever. 246 // The closing } is on its own line so heredocs inside the command still 247 // get their correct delimiter line. 248 // 249 // Re-assert `stty -echo` before both sentinels. Full-screen tools, 250 // prompts, and some spinners can leave terminal echo enabled; if that 251 // happens, bash echoes the sentinel input lines before executing them and 252 // completion detection fires on our own command text. 253 const groupedCommand = redirectStdinToDevNull ? `{ ${command}\n} < /dev/null` : `{ ${command}\n}` 254 255 session.write( 256 [ 257 "stty -echo 2>/dev/null", 258 `echo ${startSentinel}`, 259 groupedCommand, 260 "stty -echo 2>/dev/null", 261 `echo ${endSentinel}`, 262 "", 263 ].join("\n"), 264 ) 265 266 const timer = setTimeout(() => { 267 if (settled) return 268 settled = true 269 dataDisposable.dispose() 270 session.write("\x03\n") 271 // After a timeout we no longer know whether bash consumed Ctrl+C, 272 // returned to a prompt, or still has a foreground process attached. 273 // Reuse would interleave the next command with a potentially poisoned 274 // PTY, so force a fresh docker exec session next time. 275 session.kill() 276 if (bash === session) bash = null 277 reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`)) 278 }, timeoutMs) 279 }) 280} 281 282/** 283 * Runs a command as a one-off child process instead of through the persistent 284 * PTY shell. This follows Codex's shell-tool execution model for commands 285 * that should not inherit interactive stdin, notably sudo. 286 */ 287export async function runOneOff(command: string, cwd: string, options: RunRawOptions = {}): Promise<string> { 288 const timeoutMs = normalizeTimeoutMs(options.timeoutMs, DEFAULT_COMMAND_TIMEOUT_MS) 289 const args = USE_DOCKER_SHELL 290 ? ["exec", "-i", "-u", CONTAINER_USER, "-w", cwd, CONTAINER_NAME, "bash", "-c", command] 291 : ["-c", command] 292 const program = USE_DOCKER_SHELL ? "docker" : "bash" 293 294 return new Promise((resolve, reject) => { 295 let raw = "" 296 let settled = false 297 const child = spawn(program, args, { 298 cwd: USE_DOCKER_SHELL ? undefined : cwd, 299 env: process.env, 300 stdio: ["ignore", "pipe", "pipe"], 301 }) 302 303 child.stdout.on("data", (chunk) => { 304 raw += chunk.toString("utf8") 305 }) 306 child.stderr.on("data", (chunk) => { 307 raw += chunk.toString("utf8") 308 }) 309 child.on("error", (err) => { 310 if (settled) return 311 settled = true 312 clearTimeout(timer) 313 reject(err) 314 }) 315 child.on("close", () => { 316 if (settled) return 317 settled = true 318 clearTimeout(timer) 319 resolve(cleanOutput(raw).trimEnd()) 320 }) 321 322 const timer = setTimeout(() => { 323 if (settled) return 324 settled = true 325 child.kill("SIGKILL") 326 reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`)) 327 }, timeoutMs) 328 }) 329}