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 ¶
- func BuildEnv() expand.Environ
- func BuildEnvWith(overrides map[string]string) expand.Environ
- func CoreutilsExec(next interp.ExecHandlerFunc) interp.ExecHandlerFunc
- func DefaultJobsDir() (string, error)
- func RunLocal(ctx context.Context) (int, error)
- func RunLocalCommand(ctx context.Context, command string, stdin io.Reader, stdout, stderr io.Writer) (int, error)
- type JobRecord
- type JobRegistry
- type Session
- func (s *Session) Close() error
- func (s *Session) CloseSlave() error
- func (s *Session) Done() <-chan struct{}
- func (s *Session) Master() io.ReadWriteCloser
- func (s *Session) Resize(cols, rows uint16) error
- func (s *Session) Run(ctx context.Context) error
- func (s *Session) RunOnce(ctx context.Context, command string) uint32
- type SessionOptions
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func BuildEnv ¶
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 ¶
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 ¶
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
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.
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) CloseSlave ¶
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 ¶
Resize updates the PTY's window size — equivalent to a SIGWINCH inside the runner. cols/rows in characters.
func (*Session) Run ¶
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 ¶
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.