This repository has no description
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}