Documentation
¶
Overview ¶
Package sshclient implements the client-side SSH transport for `agnt ssh <host>[:path]`: ssh_config resolution, auth method chaining, host-key verification, ProxyJump multi-hop dialing, a PTY session relay, and a keepalive dead-transport detector.
Scope note: this package deliberately does NOT implement port forwarding, SFTP, or remote binary bootstrap — those belong to other tasks in the remote-ssh epic. It exposes the underlying *ssh.Client publicly (see Client.SSH) so those tasks can add direct-tcpip forwards or an SFTP subsystem channel without restructuring this package.
Index ¶
- Constants
- Variables
- func BuildAuthMethods(identityFiles []string, prompter Prompter) []ssh.AuthMethod
- func ControlSocketPath(host string) (string, error)
- func DefaultConfigPath() string
- func DefaultKnownHostsPath() string
- func DialControl(host string) (net.Conn, error)
- func DiscoverActiveHosts() ([]string, error)
- func FormatClientStatus(status ClientStatus) string
- func FormatSplash(s Splash) string
- func HostKeyCallback(knownHostsPath string, prompter Prompter) (ssh.HostKeyCallback, error)
- func InstallRemoteBinary(client *ssh.Client, opts BootstrapOptions, decision BootstrapDecision) error
- func ListenControl(host string) (net.Listener, error)
- func LocalForwardSocketPath(host string) string
- func NewSFTPClient(client *ssh.Client) (*sftp.Client, error)
- func NotifyFileArrived(daemonSocket, sessionName, projectRoot, remotePath string, size int64) error
- func ParseSSHConfig(r io.Reader) ([]hostBlock, error)
- func PushOneFile(host, fileName, destRelPath string, size int64, src io.Reader) (string, error)
- func PushToInbox(sc *sftp.Client, projectRoot, destRelPath, fileName string, src io.Reader) (string, error)
- func PushToInboxNoClobber(sc *sftp.Client, projectRoot, destRelPath, fileName string, src io.Reader) (string, error)
- func ReleaseDownloadURL(repo, version, goos, goarch string) string
- func RemoteAgntVersion(client *ssh.Client) (string, error)
- func RemoteAttachCommand(name, cwd string) string
- func RemoteDaemonSocketPath(client *ssh.Client) (string, error)
- func RemoteHasGo(client *ssh.Client) bool
- func RemoteReattachCommand(name string) string
- func RemoteUname(client *ssh.Client) (goos, goarch string, err error)
- func ResolveRemoteProjectRoot(client *ssh.Client, remotePath string) (string, error)
- func ServeControl(ln net.Listener, projectRoot string, sc *sftp.Client, ...)
- func ServePushQueue(ln net.Listener, queue *PushQueue)
- func TerminalTitle(host, session string) string
- func UploadFile(client *ssh.Client, src io.Reader, remotePath string, mode os.FileMode) error
- type AttachFunc
- type BackoffConfig
- type BootstrapDecision
- type BootstrapOptions
- type Client
- type ClientStatus
- type ConnectionState
- type DialFunc
- type DropUpload
- type DropWatcher
- type FileArrivalNotifier
- type Forwarder
- type HostConfig
- type InputPump
- type Mapping
- type PTYSession
- type PortForwardManager
- func (m *PortForwardManager) Pause()
- func (m *PortForwardManager) Paused() <-chan struct{}
- func (m *PortForwardManager) Reconciled() <-chan struct{}
- func (m *PortForwardManager) Resume(ctx context.Context, sshClient *Client, dclient *daemon.Client)
- func (m *PortForwardManager) SetOnChange(fn func([]Mapping))
- func (m *PortForwardManager) Start(ctx context.Context)
- func (m *PortForwardManager) Status() []Mapping
- func (m *PortForwardManager) Stop()
- type Prompter
- type PushQueue
- func (q *PushQueue) Close()
- func (q *PushQueue) Connected(open func() (*sftp.Client, error))
- func (q *PushQueue) Depth() int
- func (q *PushQueue) Drained() <-chan struct{}
- func (q *PushQueue) ProjectRoot() string
- func (q *PushQueue) Push(fileName, destRelPath string, src io.Reader) (string, error)
- func (q *PushQueue) Queued() <-chan struct{}
- func (q *PushQueue) Reconnecting()
- func (q *PushQueue) SetSFTP(sc *sftp.Client)
- type Reconnector
- type RemotePullManager
- type SSHDFreezeHarness
- type Source
- type Splash
- type StatusTracker
- type TermSize
Constants ¶
const DefaultInboxDir = ".agnt-inbox"
DefaultInboxDir is the project-relative directory a push lands in when the caller does not specify a destination: <project-root>/.agnt-inbox.
Variables ¶
var ( ErrPushQueueFull = errors.New("sshclient: reconnect push queue full") ErrPushQueueClosed = errors.New("sshclient: reconnect push queue closed") )
var ErrDestinationExists = errors.New("sshclient: push: destination exists")
ErrDestinationExists reports that a no-clobber upload lost the atomic final-name claim. Callers may select another name and retry.
var ErrHostKeyRejected = errors.New("sshclient: host key rejected by user")
ErrHostKeyRejected is returned when the interactive TOFU prompt answers "no" to an unknown host key.
var ErrNoActiveSession = errors.New("sshclient: no active 'agnt ssh' session")
ErrNoActiveSession is wrapped into the error DialControl and the CLI return when no live control socket exists for the requested host — the loud, actionable failure required by the daemon-architecture Silent Failure Prohibition: a caller of 'agnt push' must be told plainly that no 'agnt ssh' session is running, not left to guess from a generic dial error.
var ErrRemoteBinaryMissing = errors.New("sshclient: remote agnt binary not found")
ErrRemoteBinaryMissing wraps a RemoteAgntVersion failure, distinguishing "no agnt on PATH remote-side" (the common case bootstrap exists to fix) from other session/transport errors. Callers use errors.Is against this sentinel rather than string-matching shell output.
var ErrSSHDNotFound = errors.New("sshclient: sshd (or ssh-keygen) not found on PATH")
ErrSSHDNotFound is returned by NewSSHDFreezeHarness when no "sshd" (or its companion "ssh-keygen") binary is on PATH. Callers must t.Skip on this error, per acceptance criterion 1 — the freeze-mode drop simulation is skipped, never failed, when the environment lacks a real sshd.
var ErrSessionMissing = errors.New("sshclient: remote session-host session not found; reconnect will not create a new one (pass --create-if-missing or --new)")
ErrSessionMissing is returned by an AttachFunc when the named remote session-host session no longer exists and the caller has not opted in to creating a new one. The Reconnector treats this as fatal (invariant 24: reconnect never re-creates) — it does not retry past this error, and unwraps with errors.Is/errors.As through any %w wrapping the caller adds.
var ErrWindowsRemoteUnsupported = errors.New("sshclient: remote host does not appear to be Linux or macOS (uname unavailable) — Windows remotes are not supported by agnt ssh bootstrap in v1")
ErrWindowsRemoteUnsupported is returned by RemoteUname when 'uname -sm' fails remote-side — on a real Windows sshd (win32-openssh, PowerShell/ cmd.exe as the default shell) there is no uname, so its absence is this package's only signal. Per the task spec, Windows remotes are an explicit, documented v1 gap (named-pipe transport is task 06a), so this is a loud, specific failure rather than a generic exec error.
Functions ¶
func BuildAuthMethods ¶
func BuildAuthMethods(identityFiles []string, prompter Prompter) []ssh.AuthMethod
BuildAuthMethods assembles the []ssh.AuthMethod chain in OpenSSH client order: (a) ssh-agent (if SSH_AUTH_SOCK is set and connects), offering all its keys; (b) IdentityFile(s) from resolved ssh_config, parsed as PEM, prompting interactively for a passphrase when a key is encrypted; (c) interactive keyboard-interactive/password fallback if the above yield no methods. Methods are composable — the ssh package itself tries each in order and advances only on failure, per its own retry semantics.
func ControlSocketPath ¶
ControlSocketPath returns the platform control endpoint registered by 'agnt ssh': a Unix socket on Unix/WSL or an owner-only pipe on Windows.
func DefaultConfigPath ¶
func DefaultConfigPath() string
DefaultConfigPath returns the standard ~/.ssh/config path.
func DefaultKnownHostsPath ¶
func DefaultKnownHostsPath() string
DefaultKnownHostsPath returns the standard ~/.ssh/known_hosts path.
func DialControl ¶
DialControl connects to host's control socket. Any failure — the socket file absent, or present but refusing connections (a stale file from a crashed process that ListenControl on the OWNING side hasn't had a chance to reclaim yet) — is reported as ErrNoActiveSession, wrapped with the underlying cause and the host name, per the Silent Failure Prohibition: 'agnt push' must never proceed, or fail with an opaque dial error, when there is nothing listening.
func DiscoverActiveHosts ¶
DiscoverActiveHosts lists every host with a live control socket: it globs ~/.agnt/ssh/*.ctl and pings each one, keeping only hosts that answer. A socket file that exists but does not answer (the owning 'agnt ssh' process crashed without cleaning up) is treated as stale and removed — mirroring ListenControl's own reclaim-on-register behavior, so a crashed session does not linger in discovery results indefinitely between one process's exit and another's next 'agnt ssh' to the same host.
func FormatClientStatus ¶
func FormatClientStatus(status ClientStatus) string
func FormatSplash ¶
func HostKeyCallback ¶
func HostKeyCallback(knownHostsPath string, prompter Prompter) (ssh.HostKeyCallback, error)
HostKeyCallback builds an ssh.HostKeyCallback that verifies against the known_hosts file at knownHostsPath.
Behavior (spec §5.1, the single most security-critical invariant in this package):
- Key matches a recorded entry → accept silently.
- Key error with ZERO `Want` entries (truly unknown host) → prompt interactively via prompter; on "yes", append the key to knownHostsPath and accept for this connection; on anything else, hard fail with ErrHostKeyRejected.
- Key error WITH `Want` entries (the host is known but the key changed) → ALWAYS hard fail with a clear error naming the expected vs. actual fingerprint. Never prompt, never fall through, never accept — this is the MITM-detection case.
There is no InsecureIgnoreHostKey path anywhere in this function or package.
func InstallRemoteBinary ¶
func InstallRemoteBinary(client *ssh.Client, opts BootstrapOptions, decision BootstrapDecision) error
InstallRemoteBinary executes decision.Source's install path. Callers must have already gated on consent (--no-bootstrap / --bootstrap=yes / an interactive prompt) — this function performs the install unconditionally.
func ListenControl ¶
ListenControl opens the platform listener. Unix reclaims stale socket files; Windows pipe instances vanish with their owner and live collisions fail.
func LocalForwardSocketPath ¶
func NewSFTPClient ¶
NewSFTPClient opens the SFTP subsystem over an already-established SSH connection. This is the sanctioned way to move arbitrary user files (task 08a's "agnt push") — unlike UploadFile's bare-exec-channel installer (bootstrap_upload.go, task 05), which deliberately avoids an SFTP dependency for the single-binary-install use case, bulk/arbitrary file push is exactly the case pkg/sftp exists for: directory creation, stat/lstat, and rename all need a real protocol rather than hand-rolled shell one-liners.
func NotifyFileArrived ¶
NotifyFileArrived sends actionable file context to the remote daemon's agent surface through SESSION-HOST NOTICE. The daemon owns final PTY delivery.
func ParseSSHConfig ¶
ParseSSHConfig reads an OpenSSH-config-format stream and returns the parsed host blocks in file order. This is a deliberately small parser: it supports "Host <pattern...>" blocks containing HostName, User, Port, IdentityFile, and ProxyJump directives. It does NOT support Match blocks, Include, or wildcard directive values beyond exact-match Host patterns (glob "*" patterns are matched via filepath.Match, which is the one nice-to-have beyond pure exact match) — a full ssh_config grammar is out of scope per the minimal-code-ladder: this codebase needs a handful of directives, not a general-purpose config engine.
func PushOneFile ¶
PushOneFile implements the client half of the push protocol for a single file: dial host's control socket, send a "push" header naming fileName and destRelPath, stream size bytes from src, and decode the response. Returns the absolute remote path the file was written to.
func PushToInbox ¶
func PushToInbox(sc *sftp.Client, projectRoot, destRelPath, fileName string, src io.Reader) (string, error)
PushToInbox uploads src (exactly the bytes of one file) to a path computed from projectRoot, destRelPath, and fileName, enforcing the traversal guard documented on validateDestRelPath/checkNoEscapingSymlink and using the same temp-write -> verify -> rename shape as UploadFile (lessons #5/#6 in .claude/rules/lessons-ssh-transport.md): nothing lands at the final path until the uploaded bytes have been read back and re-hashed, and the destination is re-verified as contained within projectRoot immediately before the rename that activates it.
destRelPath == "" defaults to DefaultInboxDir. On success returns the absolute remote path the file was written to.
func PushToInboxNoClobber ¶
func PushToInboxNoClobber(sc *sftp.Client, projectRoot, destRelPath, fileName string, src io.Reader) (string, error)
PushToInboxNoClobber is PushToInbox with an atomic no-overwrite activation. It uses the SFTP hardlink extension to claim finalPath: unlike PosixRename, hardlink fails when finalPath already exists. The temp and final paths share a directory, so successful linking exposes the already-verified inode in one operation. Servers without hardlink support return an error rather than silently weakening the no-clobber guarantee.
func ReleaseDownloadURL ¶
ReleaseDownloadURL mirrors install.sh's asset-naming convention ("agnt-<platform>-<arch>" under a "vX.Y.Z" release tag) rather than reinventing it, so bootstrap and the curl installer always agree on where a given release's binaries live.
func RemoteAgntVersion ¶
RemoteAgntVersion execs "agnt --version" as a one-shot command on a new session channel and parses its version, mirroring the "agnt vX.Y.Z\n" shape produced by cmd/agnt/main.go's getVersionString. A non-zero exit (command not found, PATH doesn't include it, etc.) is reported as ErrRemoteBinaryMissing so callers can distinguish "needs install" from a genuine transport failure.
func RemoteAttachCommand ¶
RemoteAttachCommand builds the shell command run on the remote session channel: bare `agnt attach <name>`, identical in shape to RemoteReattachCommand. It used to bake `--create-if-missing --cwd <cwd>` into the command line, but cmd/agnt/attach.go's attachCmd defines neither flag — cobra rejects them on a real round trip, breaking every initial connect to a not-yet-existing session (see .claude/rules/lessons-ssh-transport.md #10: a remote-exec command line and the remote subcommand's actual flag set can drift independently). Session creation is now driven through the daemon protocol directly (SESSION-HOST LIST/CREATE, see ensureRemoteSessionCreated) by OpenPTYSession before this command is ever sent, mirroring the reconnect state machine's ensureRemoteSessionAttachable (cmd/agnt/ssh.go). name is shell-escaped defensively (single-quoted, with embedded single quotes escaped) so shell metacharacters cannot inject additional commands.
func RemoteDaemonSocketPath ¶
RemoteDaemonSocketPath execs "agnt daemon socket-path" as a one-shot command on a new session channel over client and returns its trimmed stdout: the remote daemon's default unix socket path. That subcommand's contract (cmd/agnt/daemon.go's runDaemonSocketPath) is exactly one line, no decoration, so trimming trailing whitespace/newline is sufficient.
func RemoteHasGo ¶
RemoteHasGo reports whether "go version" succeeds remote-side. Used by selectSource to decide between SourceGoInstall and SourceReleaseDownload when the local binary can't be copied directly (cross-platform/arch).
func RemoteReattachCommand ¶
RemoteReattachCommand builds the bare `agnt attach <name>` command used by the reconnect state machine (task 09c): NO --create-if-missing and no --cwd. This is deliberate, not an oversight — spec invariant 24 requires reconnect to never re-create the named session; the caller (reconnect.go's AttachFunc, wired in cmd/agnt/ssh.go) is responsible for confirming the session still exists (and creating one only on explicit --create-if-missing / --new opt-in, via the daemon protocol directly) before ever reaching this exec, so this command can safely assume attach-only semantics.
func RemoteUname ¶
RemoteUname execs "uname -sm" and normalizes the result to Go's GOOS/GOARCH vocabulary (linux/darwin, amd64/arm64) — v1 supports only those two platforms remote-side, per the task spec; anything else (including a failed uname, i.e. likely Windows) is ErrWindowsRemoteUnsupported or an explicit unsupported-platform error, never a silent guess.
func ResolveRemoteProjectRoot ¶
ResolveRemoteProjectRoot resolves remotePath (as given to 'agnt ssh host:remotePath', possibly relative or empty) to an absolute path on the remote host by running a one-shot 'cd ... && pwd' over a fresh session channel: empty remotePath 'cd's to the remote user's home directory (POSIX 'cd' with no argument), matching the default project root an unqualified 'agnt ssh host' connects to.
func ServeControl ¶
func ServeControl(ln net.Listener, projectRoot string, sc *sftp.Client, notifier ...FileArrivalNotifier)
ServeControl accepts connections on ln until it is closed, handling each one synchronously in its own goroutine: a "ping" request replies with projectRoot (used by DiscoverActiveHosts' liveness probe and by PushOneFile to confirm the session is alive); a "push" request streams exactly Size bytes and hands them to PushToInbox, replying with the resulting remote path or a loud error. sc is the SFTP subsystem opened over the same live SSH connection the owning 'agnt ssh' process is relaying its PTY through.
func ServePushQueue ¶
ServePushQueue is the reconnect-aware control server. Unlike ServeControl, its listener stays registered while the SSH transport is down; PushQueue decides whether each push executes immediately or waits in its bounded FIFO.
func TerminalTitle ¶
func UploadFile ¶
UploadFile streams src to remotePath on client, avoiding partial writes per the repo-wide "collect modifications, write atomically" standard: bytes land at a temp name first, get sha256-verified against what was actually sent, and only THEN get chmod'd and renamed onto remotePath (a same-filesystem 'mv', atomic on POSIX). If anything fails before the rename step — a read error from src, a transport error, or a sha256 mismatch — remotePath is never created; a best-effort cleanup removes the temp file so it doesn't accumulate, but even if that cleanup itself fails, the caller's invariant (no partial binary at the final path) still holds.
This uses the ssh exec channel (mkdir/cat/sha256sum/chmod/mv over a session) rather than SFTP: internal/sshclient has no SFTP client dependency, and every remote agnt/install.sh target already ships these POSIX coreutils, so adding github.com/pkg/sftp would be new surface for no capability gain (see task brief's minimal-code guidance).
Types ¶
type AttachFunc ¶
type AttachFunc func(ctx context.Context, client *Client) (*PTYSession, error)
AttachFunc re-attaches to the same named session-host session on an already-dialed client. Implementations must return an error satisfying errors.Is(err, ErrSessionMissing) when the named session is gone and the caller has not opted in to creating a replacement — any other error is treated as retryable.
type BackoffConfig ¶
type BackoffConfig struct {
// BaseDelay is the delay before the first retry attempt. Defaults to
// 1s if zero.
BaseDelay time.Duration
// MaxDelay caps the (pre-jitter) computed delay. Defaults to 30s if
// zero.
MaxDelay time.Duration
// Jitter returns a value in [0,1); Delay maps it onto a ±20% factor
// ([0.8,1.2)). A nil Jitter disables jitter (factor is always 1.0) —
// production wiring should pass rand.Float64, tests pass a fixed or
// sequenced stub for deterministic assertions.
Jitter func() float64
}
BackoffConfig parameterizes the exponential-with-jitter backoff schedule (spec invariant 23: 1s, 2s, 4s, ... capped at 30s, ±20% jitter). BaseDelay and Jitter are both injectable specifically so tests can assert multiplicative growth and jitter bounds deterministically at microsecond scale, never via wall-clock sleeps (see .claude/rules — no load-sensitive wall-clock primary invariants).
func (BackoffConfig) Delay ¶
func (c BackoffConfig) Delay(attempt int) time.Duration
Delay returns the backoff delay before reconnect attempt N (1-indexed): min(BaseDelay*2^(N-1), MaxDelay), then scaled by a ±20% jitter factor derived from Jitter(). Pure function — no sleeping, no wall clock reads — so it is trivially unit-testable at any timescale.
type BootstrapDecision ¶
type BootstrapDecision struct {
NeedsInstall bool
Reason string
Source Source
RemoteVersion string // best-effort; empty if the remote had no agnt at all
RemoteGOOS string
RemoteGOARCH string
FinalPath string
}
BootstrapDecision is the outcome of CheckRemoteBinary: whether an install is needed and, if so, how (Source) and where (FinalPath).
func CheckRemoteBinary ¶
func CheckRemoteBinary(client *ssh.Client, opts BootstrapOptions) (BootstrapDecision, error)
CheckRemoteBinary implements the connect-time bootstrap check from the task spec: run "agnt --version" remote-side and classify the result as missing, outside the compatibility window, or fine. When an install is needed it also resolves the remote OS/arch (failing loud on anything but Linux/macOS — Windows sshd is an explicit v1 gap) and applies the source precedence (selectSource) so the caller knows exactly what InstallRemoteBinary will do before committing to it (e.g. to gate on user consent).
type BootstrapOptions ¶
type BootstrapOptions struct {
// LocalVersion is this process's own version (main.appVersion). Required.
LocalVersion string
// LocalBinaryPath is the path to this process's own executable, used
// for SourceLocalCopy. Required.
LocalBinaryPath string
// LocalGOOS / LocalGOARCH default to runtime.GOOS/runtime.GOARCH when
// empty — overridable so tests can force a cross-arch/cross-OS
// decision without cross-compiling a fixture binary.
LocalGOOS, LocalGOARCH string
// GitHubRepo defaults to updater.DefaultGitHubRepo when empty.
GitHubRepo string
}
BootstrapOptions configures CheckRemoteBinary / InstallRemoteBinary.
type Client ¶
type Client struct {
// SSH is the underlying golang.org/x/crypto/ssh client. Exported
// deliberately — do not hide behind an unexported field.
SSH *ssh.Client
// contains filtered or unexported fields
}
Client wraps an established *ssh.Client, exposing it publicly (invariant 7 in the task brief) so later tasks (port forwarding, SFTP) can call SSH.OpenChannel / SSH.Dial directly without this package growing new wrapper methods for every future channel type.
func Dial ¶
func Dial(alias, configPath, knownHostsPath, defaultUser string, prompter Prompter) (*Client, error)
Dial resolves alias against the ssh_config at configPath (DefaultConfigPath if empty), verifies the host key against known_hosts (DefaultKnownHostsPath if empty) via HostKeyCallback, builds the auth method chain, and performs ProxyJump hops (if the resolved config has one) before establishing the final connection. defaultUser is used when neither the alias nor ssh_config supplies a User (e.g. the OS user invoking the command).
func (*Client) Dead ¶
func (c *Client) Dead() <-chan struct{}
Dead returns a channel that is closed when the keepalive detector determines the transport is no longer responsive (3 consecutive unanswered keepalives, ~45s). Reconnect logic itself belongs to a later task; this channel is the hook that task uses to learn the transport died.
type ClientStatus ¶
type ClientStatus struct {
Connection ConnectionState
ReconnectAttempts int
Forwards []Mapping
QueuedPushes int
SessionName string
}
ClientStatus is the user-facing state behind `agnt ssh --status`.
type ConnectionState ¶
type ConnectionState string
const ( ConnectionConnected ConnectionState = "connected" ConnectionReconnecting ConnectionState = "reconnecting" ConnectionDisconnected ConnectionState = "disconnected" )
type DialFunc ¶
DialFunc establishes a fresh transport to the same remote host. It should perform dial + host-key verification + auth (mirroring the initial sshclient.Dial call) but must NOT touch any session-host state.
type DropUpload ¶
DropUpload uploads localPath under remoteName. Implementations must not overwrite an existing remote file.
func NewDropUpload ¶
func NewDropUpload(sc *sftp.Client, projectRoot string) DropUpload
NewDropUpload adapts task 08a's verified SFTP upload for a drop watcher. Existing inbox names receive a timestamp suffix instead of being replaced.
type DropWatcher ¶
type DropWatcher struct {
// contains filtered or unexported fields
}
DropWatcher synchronizes settled regular files from one local directory. It uses fsnotify for prompt delivery and also polls file metadata. The poll is intentional: notifications on WSL's /mnt/c 9P mounts are unreliable.
func NewDropWatcher ¶
func NewDropWatcher(dir string, upload DropUpload, notify func(string)) (*DropWatcher, error)
NewDropWatcher creates and starts a watcher. Existing regular files are treated like newly created files and uploaded once they are quiescent.
func (*DropWatcher) Close ¶
func (w *DropWatcher) Close() error
Close stops the watcher and waits for its sole goroutine to exit.
func (*DropWatcher) SetUpload ¶
func (w *DropWatcher) SetUpload(upload DropUpload)
SetUpload replaces the transport-dependent upload function. A nil function pauses uploads while local observation continues (for SSH reconnects).
type FileArrivalNotifier ¶
FileArrivalNotifier runs after an upload has been atomically installed. An error is returned to the push caller so notification loss is never silent.
type Forwarder ¶
type Forwarder struct {
// contains filtered or unexported fields
}
LocalForwardSocketPath is platform-defined: Unix/WSL use a protected Unix socket and native Windows uses an owner-only named pipe.
Forwarder listens on that local endpoint and, for every accepted connection, opens a NEW direct-streamlocal@openssh.com channel to remoteSocketPath on client and proxies bytes bidirectionally. Each accepted local connection gets its own remote channel — there is no shared/multiplexed channel — so concurrent local clients (e.g. `agnt monitor` and `agnt doctor` running at once) get independent byte streams.
func NewForwarder ¶
NewForwarder binds localSocketPath using the platform listener and returns a Forwarder ready for Serve. Unix reclaims stale socket files; Windows pipe instances disappear with their owner and a live name collision fails loud.
func (*Forwarder) Pause ¶
func (f *Forwarder) Pause()
Pause keeps the local socket bound, rejects newly accepted connections, and closes every transport-dependent relay.
func (*Forwarder) Paused ¶
func (f *Forwarder) Paused() <-chan struct{}
Paused returns a signal closed exactly when Pause has taken effect. Tests and callers can observe this transition without sleep-based races.
func (*Forwarder) Resume ¶
Resume swaps in the freshly connected transport without rebinding the local socket. remoteSocketPath is rediscovered after reconnect.
func (*Forwarder) Serve ¶
Serve accepts local connections until the listener is closed (via Close), opening one new streamlocal channel per connection and proxying bytes both ways with io.Copy; either side erroring or hitting EOF closes both. Returns nil on a clean shutdown (Close called), otherwise the Accept error.
Coexistence policy: a second "agnt ssh" to the same host fails to bind the already-live endpoint rather than silently sharing or clobbering it.
type HostConfig ¶
type HostConfig struct {
// Host is the alias that was resolved (as passed to Resolve).
Host string
// HostName is the actual network name/address to dial. Defaults to
// Host if not set in config.
HostName string
// User is the remote login user. Empty if not resolved from config or
// the host alias itself (caller may fall back to os/user).
User string
// Port defaults to 22 per OpenSSH semantics.
Port int
// IdentityFile lists private key paths to try, in file order.
IdentityFile []string
// ProxyJump is the raw ProxyJump directive value, e.g.
// "bastion,jump2:2222" — comma-separated jump hosts, OpenSSH's own
// multi-hop syntax "[user@]host[:port]" per hop. Empty means no jump.
ProxyJump string
}
HostConfig is the resolved set of ssh_config directives for one host alias, matching the small subset of OpenSSH's ssh_config(5) this package supports: HostName, User, Port, IdentityFile (repeatable), ProxyJump.
func ResolveHost ¶
func ResolveHost(configPath, alias string) (HostConfig, error)
ResolveHost resolves HostConfig for alias by reading the ssh_config file at configPath. A missing file resolves to a HostConfig containing only defaults (HostName == alias, Port == 22) rather than an error, matching OpenSSH behavior of tolerating an absent config file.
func ResolveHostFromReader ¶
func ResolveHostFromReader(r io.Reader, alias string) (HostConfig, error)
ResolveHostFromReader is ResolveHost taking an io.Reader directly, for testability without touching the filesystem.
type InputPump ¶
type InputPump struct {
// contains filtered or unexported fields
}
InputPump owns a single real input source (os.Stdin in production) for the entire process lifetime, so that repeated reconnects never leave more than one goroutine blocked reading it (the generalized lesson in .claude/rules/lessons-liveness-probes.md applies equally to fan-out readers: an indefinite-blocking Read used to feed a channel that gets re-created every reconnect must have exactly one owner, or concurrent stale readers race for the same bytes). It forwards bytes to whatever target is currently set; while no target is set (the RECONNECTING window), it instead watches for a literal Ctrl-C byte (0x03) and invokes the configured interrupt callback: a raw-mode terminal (term.MakeRaw disables ISIG) never delivers a real SIGINT for Ctrl-C — the byte arrives as ordinary stdin data — and during RECONNECTING there is no live Relay() loop forwarding stdin to a remote that doesn't exist yet, so this is the only way Ctrl-C during backoff/reattach is observable.
func NewInputPump ¶
func NewInputPump(interrupt func()) *InputPump
NewInputPump constructs a pump; interrupt is called (from the pump's own goroutine) whenever a Ctrl-C byte arrives while no target is set. interrupt may be nil.
func (*InputPump) SetTarget ¶
SetTarget switches the forwarding destination. Pass nil to enter the "no live session" state, which arms the Ctrl-C watch instead of forwarding bytes (used for the RECONNECTING window).
func (*InputPump) Start ¶
Start begins reading src in a single background goroutine. Call at most once per InputPump. The goroutine runs until src.Read returns an error (EOF or otherwise) — for a real os.Stdin that is effectively "until the process exits," matching the existing fire-and-forget stdin-copy pattern in PTYSession.Relay; tests should pass a closable io.Reader (e.g. an io.Pipe) so the goroutine can be observed to exit for goleak checks.
type Mapping ¶
type Mapping struct {
ProxyID string `json:"proxy_id"`
RemotePort int `json:"remote_port"`
LocalPort int `json:"local_port"`
Remapped bool `json:"remapped"`
}
Mapping is one active remote->local port forward, returned by Status for `agnt ssh --status` and the overlay ports panel.
type PTYSession ¶
type PTYSession struct {
// contains filtered or unexported fields
}
PTYSession is an open "session" channel with a PTY attached, running the remote agnt attach command as a dumb byte relay (spec invariant 16 — all session-host framing happens remote-side; this layer only carries raw PTY bytes, never sessionhost.Frame JSON).
func OpenPTYSession ¶
OpenPTYSession ensures the named session-host session exists on the remote daemon — creating it if this is the first connect (unconditional, matching this function's historical always-create-if-missing behavior; reconnect's opt-in --create-if-missing/--new is a separate, stricter policy handled by the caller via OpenPTYSessionWithCommand + RemoteReattachCommand, see cmd/agnt/ssh.go's reattachRemoteSession) — via the daemon protocol (SESSION-HOST LIST/CREATE), then opens a "session" channel on client, requests a PTY sized per size, and execs the remote attach command for the given session name. The returned *PTYSession's channel is not yet relayed — call Relay to pump bytes.
func OpenPTYSessionWithCommand ¶
OpenPTYSessionWithCommand is the lower-level form of OpenPTYSession that takes an already-built remote command line rather than assembling one via RemoteAttachCommand. The reconnect state machine (task 09c) uses this directly with RemoteReattachCommand so a reconnect attempt never sends --create-if-missing over the wire, regardless of what the initial-connect path does.
func (*PTYSession) Relay ¶
Relay pumps stdin -> remote and remote -> stdout/stderr until the channel closes or ctx is cancelled. It discards incidental channel requests (e.g. exit-status) on s.reqs by draining them via ssh.DiscardRequests in a background goroutine.
Completion is driven by the remote -> stdout direction only: an EOF on local stdin (a closed pipe, a batch script's input running out) does not by itself end an interactive session, so it is copied in a fire-and-forget goroutine. The channel closing (remote -> stdout hits EOF) or ctx cancellation are the two ways Relay returns. Returns the first non-EOF error from the remote -> stdout direction, or nil on clean channel close.
func (*PTYSession) Resize ¶
func (s *PTYSession) Resize(size TermSize) error
Resize sends a "window-change" request for the given new size. Actual OS SIGWINCH handling is the caller's responsibility (cmd/agnt/ssh.go), keeping this package signal-agnostic and testable.
type PortForwardManager ¶
type PortForwardManager struct {
// contains filtered or unexported fields
}
PortForwardManager keeps a set of local TCP listeners in sync with the reverse proxies running on a remote agnt daemon reached over an existing SSH connection, so a local browser can hit http://127.0.0.1:<port> directly without the developer ever running `ssh -L`. Lifecycle:
- reconcileOnce() on Start (and after every event) is the "cache is never trusted alone" doctrine: PROXY LIST is the source of truth, STREAM-EVENTS diagnostics are just a trigger to re-check it sooner than the periodic loop would.
- one local net.Listener per remote proxy ID; every accepted conn opens its own direct-tcpip channel (client.SSH.Dial("tcp", ...)) — same one-channel-per-conn model as Forwarder in forward.go.
func NewPortForwardManager ¶
func NewPortForwardManager(sshClient *Client, dclient *daemon.Client, notify func(string)) *PortForwardManager
NewPortForwardManager builds a manager for the proxies visible on dclient (a daemon.Client already connected to the remote daemon, typically over the forwarded unix socket set up by startDaemonSocketForwarding), dialing new streams through sshClient. notify receives one-line human-readable status messages (collision remaps, forward up/down) for the caller to print or toast — this package never writes to stdout/stderr directly.
func (*PortForwardManager) Pause ¶
func (m *PortForwardManager) Pause()
Pause retains every local listener but rejects new connections and drains relays before the dead SSH transport is closed.
func (*PortForwardManager) Paused ¶
func (m *PortForwardManager) Paused() <-chan struct{}
func (*PortForwardManager) Reconciled ¶
func (m *PortForwardManager) Reconciled() <-chan struct{}
Reconciled closes once the current Start/Resume generation has completed its authoritative PROXY LIST reconciliation.
func (*PortForwardManager) Resume ¶
Resume supplies fresh transport clients, enables the retained listeners, then performs an authoritative PROXY LIST reconciliation before returning.
func (*PortForwardManager) SetOnChange ¶
func (m *PortForwardManager) SetOnChange(fn func([]Mapping))
func (*PortForwardManager) Start ¶
func (m *PortForwardManager) Start(ctx context.Context)
Start reconciles once immediately (reconcile-on-connect: see daemon-architecture.md § Reconciliation Model, "on session connect"), then runs the event-driven + periodic reconcile loop in the background until ctx is cancelled or Stop is called.
func (*PortForwardManager) Status ¶
func (m *PortForwardManager) Status() []Mapping
Status returns a snapshot of every currently-forwarded proxy.
func (*PortForwardManager) Stop ¶
func (m *PortForwardManager) Stop()
Stop halts the reconcile loop and tears down every active local listener (and drains in-flight connections through each) before returning.
type Prompter ¶
Prompter feeds the interactive yes/no host-key prompt from an io.Reader (canned answers in tests) and writes the fingerprint prompt text to an io.Writer, so no code path in this package requires real stdin/stdout to be exercised by tests.
func StdioPrompter ¶
func StdioPrompter() Prompter
StdioPrompter returns a Prompter wired to the process's real stdin/stdout.
type PushQueue ¶
type PushQueue struct {
// contains filtered or unexported fields
}
PushQueue keeps the local control surface alive while SSH reconnects. Calls accepted in that interval wait in a bounded FIFO and complete only after the replacement transport reaches CONNECTED. The replacement SFTP subsystem is represented by a factory and is deliberately opened by the first push that needs it, never by Connected itself.
func NewPushQueue ¶
func NewPushQueue(projectRoot string, capacity int, notify FileArrivalNotifier, report func(string)) *PushQueue
func (*PushQueue) Connected ¶
Connected installs a lazy factory. If pushes arrived during reconnect, their FIFO flush starts now and the first item opens SFTP; with an empty queue this method performs no transport work at all.
func (*PushQueue) Drained ¶
func (q *PushQueue) Drained() <-chan struct{}
Drained closes after every push accepted during the current reconnect has completed. Reconnecting starts a fresh generation before callers enqueue.
func (*PushQueue) ProjectRoot ¶
func (*PushQueue) Queued ¶
func (q *PushQueue) Queued() <-chan struct{}
Queued closes when the current reconnect generation accepts its first push.
func (*PushQueue) Reconnecting ¶
func (q *PushQueue) Reconnecting()
type Reconnector ¶
type Reconnector struct {
Backoff BackoffConfig
MaxAttempts int // 0 = unlimited (interactive default, spec invariant 23)
Dial DialFunc
Attach AttachFunc
// OnStatus, if non-nil, is called with a short human-readable status
// line before each attempt and once more on success. Callers wire this
// to a stderr writer so the line prints before scrollback replay
// begins on stdout (criterion 3) — Run calls OnStatus and returns the
// new session synchronously, so the caller's subsequent Relay() call
// is always ordered after the final OnStatus call.
OnStatus func(string)
}
Reconnector drives the RECONNECTING state: backoff, dial, attach, repeat until success, a fatal ErrSessionMissing, ctx cancellation, or MaxAttempts is exhausted.
func (*Reconnector) Run ¶
func (r *Reconnector) Run(ctx context.Context) (*Client, *PTYSession, error)
Run attempts to reconnect, blocking (subject to ctx and MaxAttempts) across the full backoff schedule. Returns the new Client and PTYSession on success. Returns ctx.Err() on cancellation (callers that derive ctx specifically to represent a local Ctrl-C, e.g. via InputPump's interrupt callback, can treat that as a clean user-requested stop rather than a failure — Run itself has no opinion on why ctx was cancelled), or an error wrapping ErrSessionMissing if the named session is confirmed gone and Attach did not opt into creating one.
type RemotePullManager ¶
type RemotePullManager struct {
// contains filtered or unexported fields
}
RemotePullManager downloads files referenced by remote screenshot events. The event stream is only the notification path; file bytes always travel over SFTP and are activated locally by rename after the copy succeeds.
func NewRemotePullManager ¶
func NewRemotePullManager(events pullEventStream, sc *sftp.Client, host, baseDir string, notify func(string)) *RemotePullManager
NewRemotePullManager creates a reverse-pull consumer. baseDir may be empty, in which case ~/.agnt/drop is used. notify receives both successful local paths and loud stream/download errors; callers normally print these lines.
func (*RemotePullManager) Resume ¶
Resume swaps in the event and SFTP connections established after an SSH reconnect, then starts a fresh subscription.
func (*RemotePullManager) Start ¶
func (m *RemotePullManager) Start(ctx context.Context)
Start begins consuming screenshot and snapshot-related capture events. Repeated Start calls replace and fully drain the previous subscription.
func (*RemotePullManager) Stop ¶
func (m *RemotePullManager) Stop()
Stop cancels the stream and waits until all event/download work is drained.
type SSHDFreezeHarness ¶
type SSHDFreezeHarness struct {
// contains filtered or unexported fields
}
SSHDFreezeHarness manages a real sshd subprocess to simulate drop mode (b): a soft black-hole where the TCP connection stays ESTABLISHED (no RST, no FIN) but the peer answers nothing. A hard TCP close cannot reproduce this — the kernel on the harness side would still ack or reset a genuinely dead process. SIGSTOP freezes the real sshd process (and its already- forked per-connection children) without touching the socket, which is exactly the "frozen but connected" shape a reconnect state machine must detect via a bounded liveness probe rather than a transport-level error.
func NewSSHDFreezeHarness ¶
func NewSSHDFreezeHarness(t *testing.T, clientAuthorizedKey ssh.PublicKey) (*SSHDFreezeHarness, error)
NewSSHDFreezeHarness generates a throwaway host key + authorized_keys pair, starts a real sshd subprocess listening on an ephemeral localhost port with that identity, and returns once it is accepting TCP connections.
Returns ErrSSHDNotFound (not a fatal test failure) when sshd or ssh-keygen is absent from PATH — callers must t.Skip on that per acceptance criterion 1. clientAuthorizedKey is the public key the harness will accept for publickey auth (pass the pub half of generateClientKey's output).
func (*SSHDFreezeHarness) Addr ¶
func (h *SSHDFreezeHarness) Addr() string
Addr returns the "host:port" dial target for the sshd subprocess.
func (*SSHDFreezeHarness) Freeze ¶
func (h *SSHDFreezeHarness) Freeze() error
Freeze sends SIGSTOP to the sshd process AND every descendant it has forked so far (its privilege-separation monitor child, and the per-session child once a connection is authenticated), deterministically producing the "frozen but connected" black-hole: TCP stays established, nothing answers.
Freezing only the main listener process is not sufficient: sshd forks a child per connection immediately after accept, and that child calls setpgid on itself (its pgid differs from the main process's), so it keeps answering an already-established connection's requests even while the parent is stopped. Every descendant must be stopped individually.
sshd's post-auth privilege-drop fork can still be in flight for a brief window right after a client's handshake returns (the client sees auth success before the server has necessarily finished forking its unprivileged worker for the connection). A single /proc snapshot taken immediately after Dial() can therefore miss that worker, stop everything it *did* find, and then watch the late-arriving worker answer requests completely unfrozen. Freeze re-scans and stops any newly-discovered descendant across a short, bounded settle window, converging as soon as two consecutive scans agree — so callers get a deterministic call with no sleep of their own, and the settle window is capped, not open-ended.
func (*SSHDFreezeHarness) Kill ¶
func (h *SSHDFreezeHarness) Kill() error
Kill terminates the sshd subprocess and reaps it. Safe to call multiple times and safe to call while frozen (SIGCONT is delivered first — on Linux SIGKILL alone is sufficient to terminate a stopped process, but continuing first keeps behavior identical on platforms where that isn't guaranteed, and lets forked children exit on their own before the parent disappears).
func (*SSHDFreezeHarness) Resume ¶
func (h *SSHDFreezeHarness) Resume() error
Resume sends SIGCONT to the same process set Freeze() would target, letting a frozen sshd (and its children) answer again.
type Source ¶
type Source int
Source identifies where a remote agnt binary bootstrap install gets its bytes from, in the precedence order selectSource applies.
const ( // SourceLocalCopy uploads this process's own binary — only valid when // the remote is the same GOOS/GOARCH, since a binary built for one // platform/architecture can't run on a mismatched one. SourceLocalCopy Source = iota // SourceGoInstall runs 'go install .../cmd/agnt@latest' on the remote, // when it already has a Go toolchain — avoids a network binary // download and always matches the remote's own arch by construction. SourceGoInstall // SourceReleaseDownload downloads the matching platform/arch binary // from the project's GitHub releases, remote-side. SourceReleaseDownload )
type StatusTracker ¶
type StatusTracker struct {
// contains filtered or unexported fields
}
StatusTracker keeps connection counters independent from transport-owned forward and push snapshots, which callers supply when rendering.
func NewStatusTracker ¶
func NewStatusTracker(sessionName string) *StatusTracker
func (*StatusTracker) Disconnected ¶
func (t *StatusTracker) Disconnected()
func (*StatusTracker) Observe ¶
func (t *StatusTracker) Observe(message string)
Observe consumes the Reconnector's stable status messages.
func (*StatusTracker) Snapshot ¶
func (t *StatusTracker) Snapshot(forwards []Mapping, queuedPushes int) ClientStatus
Source Files
¶
- auth.go
- bootstrap.go
- bootstrap_compat.go
- bootstrap_remote.go
- bootstrap_source.go
- bootstrap_upload.go
- client.go
- config.go
- control.go
- control_unix.go
- dropwatch.go
- forward.go
- forward_unix.go
- hostkey.go
- notify.go
- portforward.go
- pull.go
- pushqueue.go
- reconnect.go
- session.go
- sftp.go
- status.go
- testharness_reconnect.go
- windows_pipe_contract.go