shell

package
v0.7.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 14, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Embedded coreutils fallback for the in-process shell.

The matrix shell / `outpost sshd` exec surface runs on whatever the host OS provides — which on Windows means `ls`, `cat`, `head`, `whoami`, … simply don't exist and every agentic caller gets 127. CoreutilsExec closes that gap: commands missing from PATH are resolved against the pure-Go tool registry in github.com/qiangli/coreutils (the sibling library `outpost git` already embeds), so the shell offers one identical core toolset on every platform.

Precedence is deliberate: a real executable on PATH always wins. On unix hosts with a full userland this middleware is a no-op, so existing behavior is unchanged; the fallback only fires where the platform has nothing to offer.

Package shell is the in-process bash interpreter (qiangli/sh / mvdan.cc/sh) wrapped in a PTY so xterm.js sees a real TTY: line discipline, echo, backspace, resize, and Ctrl-C all flow through the kernel TTY layer just as they would for a child `bash` process — except there is no child process.

The interactive read-edit-execute loop lives in mvdan.cc/sh/v3/interactive (a fork-only package). That layer hosts the ergochat/readline integration — arrow-key history navigation, cursor movement, Ctrl-R reverse search — that the upstream parser.Interactive API does not provide. The PTY slave fd is what readline drives in raw mode while reading a line; for command execution between prompts the slave goes back to whatever termios the running command sets, so curses programs (vim, htop) see a real TTY.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func BuildEnv

func BuildEnv() expand.Environ

BuildEnv returns the env that the in-process matrix shell should run in.

Starts from the outpost daemon's own process env (os.Environ()) and **prepends** to PATH a small fixed set of "user-shell-style" directories that launchd-spawned daemons consistently lack:

  • the directory containing the running outpost binary itself (without this, `$(which outpost)` returns empty inside the shell — hits any agentic flow that does `ls -la $(which outpost)` style introspection)
  • $HOME/bin and $HOME/.local/bin (the standard places a user puts locally-installed binaries)
  • /opt/homebrew/{bin,sbin} (macOS Homebrew on Apple Silicon — usually in launchd's default PATH but only on newer macOS versions)
  • /usr/local/bin and /usr/local/sbin (Intel Homebrew, MacPorts; common deploy target for `make install`)

Entries that don't exist or that PATH already contains are skipped, so running this on a host with a fully-correct PATH is a no-op. Dedup is case-sensitive (paths are case-sensitive on the platforms we target).

Returns an expand.Environ suitable for passing to interp.Env(...).

func BuildEnvWith

func BuildEnvWith(overrides map[string]string) expand.Environ

BuildEnvWith is BuildEnv with caller-supplied overrides applied on top. Each key in overrides replaces any existing entry of that name in the outpost process env; absent keys are appended. Used by NewSession to stamp TERM (from the SSH client's pty-req) so vim/htop/less know what escape sequences the terminal understands. Pass nil for no overrides (equivalent to BuildEnv).

func CoreutilsExec added in v0.7.2

func CoreutilsExec(next interp.ExecHandlerFunc) interp.ExecHandlerFunc

CoreutilsExec is an interp.ExecHandlers middleware: when the command name is not resolvable on PATH but is implemented by the embedded coreutils registry, run the embedded implementation in-process. Everything else (PATH hits, unknown names, path-qualified invocations like ./foo) falls through to the next handler unchanged.

func DefaultJobsDir

func DefaultJobsDir() (string, error)

DefaultJobsDir is the path the default registry writes to. Caller is responsible for MkdirAll — the registry creates it lazily on first Record, so a host that never backgrounds anything has no dir at all.

func RunLocal added in v0.4.2

func RunLocal(ctx context.Context) (int, error)

RunLocal runs an interactive shell against the caller's stdio. No internal PTY is allocated — the caller's terminal is already a real TTY, so `interactive.Run` raws fd 0 directly via its `bindTTY` helper. Intended for `outpost shell`; the WebSocket/SSH paths still go through Session (which owns its own PTY pair).

History file and the detached-job registry are shared with the matrix-shell path: a `nohup foo &` started here surfaces under `outpost jobs` just like one started over the matrix tunnel.

Returns (exitCode, err). exitCode is 0 on clean exit (`exit` with no arg, Ctrl-D on an empty line), or N from `exit N`. err is non-nil only for setup failures or runner errors that aren't an exit-status carrier.

func RunLocalCommand added in v0.4.2

func RunLocalCommand(ctx context.Context, command string, stdin io.Reader, stdout, stderr io.Writer) (int, error)

RunLocalCommand parses and runs `command` once against the supplied stdio, returning the runner's exit code. Same env construction + detached-job registry hook as RunLocal. Used by `outpost shell -c`.

stdout/stderr default to os.Stdout/os.Stderr when nil; stdin defaults to os.Stdin. Returning (127, err) signals a parse failure.

Types

type JobRecord

type JobRecord struct {
	PID       int       `json:"pid"`
	User      string    `json:"user"`
	Cmd       string    `json:"cmd"`
	StartedAt time.Time `json:"started_at"`
}

JobRecord is one row in the persistent background-job registry. The PID is both the kernel PID (so `outpost kill <pid>` is equivalent to plain `kill <pid>`) and the filename key on disk.

type JobRegistry

type JobRegistry struct {
	// contains filtered or unexported fields
}

JobRegistry persists detached background jobs the matrix shell spawned. One JSON file per job at <UserCacheDir>/outpost/jobs/<pid>.json.

Concurrency: inserts use temp-file + rename, which is atomic on POSIX. Two outposts can't both be running on one host (the cmd/outpost/main.go pidfile claim refuses), so cross-process serialization is unnecessary. The external CLI only reads and deletes, never inserts.

func DefaultRegistry

func DefaultRegistry() *JobRegistry

DefaultRegistry is the process-wide registry rooted at DefaultJobsDir. The shell.NewSession callback writes here so the external CLI's JobRegistry (same path) reads what was recorded.

func NewJobRegistry

func NewJobRegistry(dir string) *JobRegistry

NewJobRegistry constructs a registry rooted at dir. Pass "" to disable (Record/List/Delete become no-ops); useful in tests and on hosts where os.UserCacheDir fails.

func (*JobRegistry) Delete

func (r *JobRegistry) Delete(pid int) error

Delete removes the registry entry for pid. No effect on the OS-level process — use syscall.Kill for that.

func (*JobRegistry) Get

func (r *JobRegistry) Get(pid int) (JobRecord, error)

Get returns one record. Returns fs.ErrNotExist if pid is not registered or if its file was pruned.

func (*JobRegistry) List

func (r *JobRegistry) List() ([]JobRecord, error)

List returns all currently-recorded jobs, sorted by PID, with dead records pruned in-place. A "dead" PID is one syscall.Kill(pid, 0) reports as ESRCH — alive-but-not-owned (EPERM) is treated as alive, which is conservative.

func (*JobRegistry) Record

func (r *JobRegistry) Record(pid int, cmd string) error

Record inserts a job for the given kernel PID. The cmd is informational — the fork's WithBgPidCallback callback signature is (pid int) only, so outpost records "(detached)" until a richer callback ships.

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session is one interactive shell sitting between a tty pair and a runner. Caller writes to / reads from the master side of the PTY; the runner is hooked up to the slave side as stdin/stdout/stderr.

func NewSession

func NewSession(opts SessionOptions) (*Session, error)

NewSession allocates a PTY pair and constructs the runner. Caller is responsible for closing the returned Session.

func (*Session) Close

func (s *Session) Close() error

Close releases the PTY pair. Safe to call multiple times.

func (*Session) CloseSlave

func (s *Session) CloseSlave() error

CloseSlave closes the slave (runner-side) PTY fd only, leaving the master open so a reader can drain any kernel-buffered output. Used by the SSH exec-with-pty path: after the runner finishes we close the slave to signal EOF, wait for the PTY→channel goroutine to drain, then Close() the rest. Closing the master prematurely would drop bytes still in the kernel buffer — which is exactly the bug this method was added to fix.

func (*Session) Done

func (s *Session) Done() <-chan struct{}

Done returns a channel closed after Run returns.

func (*Session) Master

func (s *Session) Master() io.ReadWriteCloser

Master returns the master end. The caller pipes WebSocket bytes ↔ this.

func (*Session) Resize

func (s *Session) Resize(cols, rows uint16) error

Resize updates the PTY's window size — equivalent to a SIGWINCH inside the runner. cols/rows in characters.

func (*Session) Run

func (s *Session) Run(ctx context.Context) error

Run starts the interactive read-edit-execute loop, blocking until ctx is canceled, the user exits (the `exit` builtin or Ctrl-D on an empty line), or a fatal interp error.

All line editing — arrow-key history navigation, cursor movement, backspace/Ctrl-W/Ctrl-U editing, Ctrl-R reverse search, history persistence — is delegated to mvdan.cc/sh/v3/interactive (which wraps ergochat/readline). The PTY slave fd is the TTY readline drives in raw mode; the swap back to cooked between prompts is what lets curses programs spawned by a stmt see a real /dev/ttysNN.

Per-stmt cancellation: each parsed statement runs under a child context so a future signal-handling layer (Ctrl-C wiring on the PTY) can cancel just the current command without ending the session.

func (*Session) RunOnce

func (s *Session) RunOnce(ctx context.Context, command string) uint32

RunOnce parses `command` and runs it once through the PTY-backed runner, then returns. Used by the SSH `exec` path when the client asked for a TTY first (`ssh -tt host cmd`) — the command sees a real /dev/ttysNN so `tty`, `screen -dmS`, etc. behave like they do under real openssh. Caller pipes the channel ↔ s.Master() and tears down the session when RunOnce returns.

Returns a POSIX-style exit status (0 = ok, non-zero from the command or from a parse error → 127 / 1).

type SessionOptions

type SessionOptions struct {
	// Term is the TERM env var the runner should see (e.g. "xterm-256color"
	// from an SSH pty-req). Empty = inherit outpost's TERM (usually unset
	// in a daemon context, which makes vim/htop fall back to dumb mode).
	Term string
	// Cols/Rows are the initial PTY window dimensions in characters.
	// Both 0 = skip the initial resize.
	Cols uint16
	Rows uint16
	// Env is an optional set of env-var overrides applied on top of
	// the daemon's env. The SSH server uses this to stamp
	// SSH_AUTH_SOCK from per-session agent forwarding (`ssh -A`).
	// Empty/nil = no overrides.
	Env map[string]string
}

SessionOptions configures a new shell Session. All fields are optional — the zero value is "no PTY hints, inherit outpost's env verbatim", which matches the pre-options behavior used by the xterm.js /shell path.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL