A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

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

feat(sophia): wire OS pipes between SSH and kefka shell

Previously the SSH session handed the kefka shell a strings.NewReader
for stdin and a *term.Terminal for stdout/stderr. wazero only takes
its fast *os.File path when host stdio is genuinely *os.File; the
term-backed handles fell back to a treat-stdio-as-block-device path
that broke wasi-libc isatty detection inside python.wasm and qjs.wasm,
so REPLs would not start a real interactive loop.

Replace that with three os.Pipe() pairs threaded through interp.StdIO,
plus a goroutine that pumps SSH bytes into the shared stdin pipe with
software line discipline that a kernel PTY would normally do:

- ICRNL: translate Enter (\r on a raw SSH channel) to \n, so
line-buffered WASI readers like Python's fgets recognize line
boundaries. term.Terminal accepts either, so the prompt is
unaffected.
- ECHO: while a foreground command is active (gated by an
atomic.Bool flipped around sh.Run), echo typed bytes back to the
client so REPLs aren't typing blind. term.Terminal already
handles its own echo during the prompt.

stdout/stderr drain into the terminal via io.Copy goroutines so its
writeWithCRLF still does \n -> \r\n on the way back out.

Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso ad8ae546 57b10a8b

+107 -3
+107 -3
cmd/sophia/main.go
··· 8 8 "log" 9 9 "log/slog" 10 10 "os" 11 - "strings" 11 + "sync/atomic" 12 12 "time" 13 13 14 14 "github.com/aws/aws-sdk-go-v2/service/s3" ··· 123 123 return fmt.Errorf("can't setup s3fs: %w", err) 124 124 } 125 125 126 - t := term.NewTerminal(sess, "$ ") 126 + // Wire stdio for the shell through real *os.File pipes. The kefka CLI 127 + // gets *os.File via os.Stdin/Stdout/Stderr; sophia previously handed 128 + // the shell strings.NewReader("") plus a *term.Terminal, which forces 129 + // wazero down its non-*os.File path. That path reports stdio as 130 + // FILETYPE_BLOCK_DEVICE to WASI guests and trips up wasi-libc's 131 + // isatty/buffering detection in python.wasm and qjs.wasm. 132 + stdinR, stdinW, err := os.Pipe() 133 + if err != nil { 134 + return fmt.Errorf("can't open stdin pipe: %w", err) 135 + } 136 + defer stdinR.Close() 137 + defer stdinW.Close() 138 + 139 + stdoutR, stdoutW, err := os.Pipe() 140 + if err != nil { 141 + return fmt.Errorf("can't open stdout pipe: %w", err) 142 + } 143 + defer stdoutR.Close() 144 + defer stdoutW.Close() 145 + 146 + stderrR, stderrW, err := os.Pipe() 147 + if err != nil { 148 + return fmt.Errorf("can't open stderr pipe: %w", err) 149 + } 150 + defer stderrR.Close() 151 + defer stderrW.Close() 152 + 153 + // commandActive is true while the foreground command (sh.Run) is 154 + // executing. It gates the input-side line discipline below: term.Terminal 155 + // already echoes during the prompt, so we only echo during command mode. 156 + var commandActive atomic.Bool 157 + 158 + // Pump SSH client bytes into the shared stdin pipe. Both term.Terminal 159 + // (for the prompt) and running wasm commands read from stdinR; they 160 + // alternate in time, so a single pump is race-free. 161 + // 162 + // Two pieces of line discipline that a kernel PTY would normally do for 163 + // us, done here in software: 164 + // - ICRNL: translate \r (the Enter key on a raw SSH channel) to \n, 165 + // so line-mode WASI readers like Python's fgets recognize Enter. 166 + // term.Terminal accepts either, so the prompt is unaffected. 167 + // - ECHO: while a command is running, echo typed bytes back to the 168 + // SSH client so REPLs (qjs, python -i) aren't typing blind. 169 + go func() { 170 + defer stdinW.Close() 171 + buf := make([]byte, 4096) 172 + for { 173 + n, err := sess.Read(buf) 174 + if n > 0 { 175 + for i := range buf[:n] { 176 + if buf[i] == '\r' { 177 + buf[i] = '\n' 178 + } 179 + } 180 + if commandActive.Load() { 181 + echoLineDiscipline(sess, buf[:n]) 182 + } 183 + if _, werr := stdinW.Write(buf[:n]); werr != nil { 184 + return 185 + } 186 + } 187 + if err != nil { 188 + return 189 + } 190 + } 191 + }() 192 + 193 + // Drain guest stdout/stderr into the terminal, which handles \n→\r\n 194 + // translation in writeWithCRLF. 195 + t := term.NewTerminal(sessRW{r: stdinR, w: sess}, "$ ") 196 + go io.Copy(t, stdoutR) 197 + go io.Copy(t, stderrR) 127 198 128 199 var sh *interp.Runner 129 200 ··· 149 220 sh, err = interp.New( 150 221 interp.Interactive(true), 151 222 interp.Env(env), 152 - interp.StdIO(strings.NewReader(""), t, t), 223 + interp.StdIO(stdinR, stdoutW, stderrW), 153 224 interp.ExecHandlers(middleware), 154 225 interp.CallHandler(billysh.CallHandler(s.reg, fsys, os.Stdout, os.Stderr)), 155 226 interp.StatHandler(billysh.FsysStatHandler(s.reg, fsys)), ··· 179 250 180 251 ctx, cancel := context.WithTimeout(context.Background(), *timeout) 181 252 for _, stmt := range stmts { 253 + commandActive.Store(true) 182 254 runErr := sh.Run(ctx, stmt) 255 + commandActive.Store(false) 183 256 if sh.Exited() { 184 257 cancel() 185 258 return runErr ··· 195 268 196 269 return nil 197 270 } 271 + 272 + // echoLineDiscipline writes buf to w, expanding \n to \r\n so the terminal 273 + // returns to column 0 after a line. Used to echo typed bytes back to the 274 + // SSH client during command mode (where no kernel PTY does it for us). 275 + func echoLineDiscipline(w io.Writer, buf []byte) { 276 + start := 0 277 + for i, b := range buf { 278 + if b == '\n' { 279 + if i > start { 280 + w.Write(buf[start:i]) 281 + } 282 + w.Write([]byte{'\r', '\n'}) 283 + start = i + 1 284 + } 285 + } 286 + if start < len(buf) { 287 + w.Write(buf[start:]) 288 + } 289 + } 290 + 291 + // sessRW wires term.Terminal's input to a separately-fed reader (typically 292 + // a pipe driven by a goroutine copying from the SSH session) while keeping 293 + // writes going to the SSH session directly. This lets us share a single 294 + // stdin source between the prompt and any running command. 295 + type sessRW struct { 296 + r io.Reader 297 + w io.Writer 298 + } 299 + 300 + func (s sessRW) Read(p []byte) (int, error) { return s.r.Read(p) } 301 + func (s sessRW) Write(p []byte) (int, error) { return s.w.Write(p) } 198 302 199 303 // termLineReader adapts term.Terminal.ReadLine into an io.Reader that emits 200 304 // one line (with a trailing '\n') per ReadLine call, so the bash parser can