Repository: puppetlabs/wash
Pull request #692 (2020-01-18 00:12:40)

External volume and transport features for external plugins
Add features that external plugins can request Wash provide them from its core capabilities. Implement `volume.FS` and `transport.ExecSSH` as features.

MikaelSmith: Demonstrated with https://github.com/puppetlabs/boltwash/pull/1 using https://github.com/puppetlabs/wash-ruby/pull/27. Still need to write lots of testing in various places, but this should be useful to get feedback on how you set it up.
ekinanp: I think exporting `volume.FS` and `transport` makes sense, I'm just not sure if a separate `features` key is the best way to do it. Here's why I say that:

* `features` needs to be specified in the entry schema JSON. However, `features` doesn't contribute to understanding an entry's high-level nature, which is what the entry schema's for. For example, why would a Wash user care about what core plugin features an entry's using? But I also get why you're including it (so that `stree <ancestor>` shows `volume::fs`). That reason just feels hacky, and hacky means that it may not be the right way to think about this.

* `transport` as-it-currently-is just seems to be a Wash user's way of saying "Hey, I'd like to implement my `exec` using your transport package". It seems like a natural way of extending the method tuple concept to `[<method>, <method_specific_info>]` where for `list` it's a prefetched result, for `read` it's a prefetched result or signature, and for `exec`, it's an implementation, in this case using stuff in the `transport` package. So I don't see why we can't stick the `transport` JSON there.

* `volume.fs` is "I want to re-use this core-plugin entry". Could we achieve that via something like:
    * Check if `list`'s return contains a core plugin entry JSON. A core plugin entry JSON is an entry JSON that needs (all other keys are ignored):
        * `type_id` (this is what tells Wash that we're re-using a core-plugin entry). For `volume.fs`, this could be `__volume::fs__` (or w/e type ID that's likely to minimize conflict).
        * `name` -- For the entry's name
        * `state` -- Representing the entry-specific config. This is where `maxdepth` and all that could go.
For entry schema, we can just check if they provide the core plugin entry type ID in the `children` key. That does make it harder to override specific schema keys (e.g. if you want to change the description or if you want to change the `name` [makes singleton entries easier to document if you change `fs` entry's name for example]), but there's probably a clean way to support that too (have some ideas on that).




MikaelSmith: Those make sense to me. I'll work on the details a bit today.

Patch:
diff --git a/api/client/client.go b/api/client/client.go
index 851c309..ec56c14 100644
--- a/api/client/client.go
+++ b/api/client/client.go
@@ -125,7 +125,7 @@ func (c *domainSocketClient) doRequestAndParseJSONBody(method, endpoint string,
 	}
 
 	if err := json.Unmarshal(respBodyBytes, result); err != nil {
-		return fmt.Errorf("Non-JSON body at %v: %v", endpoint, string(respBodyBytes))
+		return fmt.Errorf("Non-JSON body at %v: %v\n%v", endpoint, err, string(respBodyBytes))
 	}
 
 	return nil
@@ -270,7 +270,7 @@ func (c *domainSocketClient) Clear(path string) ([]string, error) {
 
 	var result []string
 	if err := json.Unmarshal(body, &result); err != nil {
-		return nil, fmt.Errorf("Non-JSON body at %v: %v", "/cache", string(body))
+		return nil, fmt.Errorf("Non-JSON body at %v: %v\n%v", "/cache", err, string(body))
 	}
 
 	return result, nil
diff --git a/datastore/cache.go b/datastore/cache.go
index 25f51b3..b273d9a 100644
--- a/datastore/cache.go
+++ b/datastore/cache.go
@@ -36,6 +36,8 @@ type MemCache struct {
 	limit       int
 }
 
+var _ = Cache(&MemCache{})
+
 // NewMemCache creates a new MemCache object
 func NewMemCache() *MemCache {
 	// The TTLs will be passed-in individually in the GetOrUpdate
diff --git a/go.mod b/go.mod
index 0097efb..3e81077 100644
--- a/go.mod
+++ b/go.mod
@@ -17,6 +17,7 @@ require (
 	github.com/InVisionApp/tabular v0.3.0
 	github.com/Microsoft/go-winio v0.4.14 // indirect
 	github.com/StackExchange/wmi v0.0.0-20181212234831-e0a55b97c705 // indirect
+	github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 // indirect
 	github.com/araddon/dateparse v0.0.0-20190329160016-74dc0e29b01f
 	github.com/avast/retry-go v2.4.1+incompatible
 	github.com/aws/aws-sdk-go v1.25.0
@@ -36,6 +37,7 @@ require (
 	github.com/gammazero/workerpool v0.0.0-20190608213748-0ed5e40ec55e
 	github.com/getlantern/deepcopy v0.0.0-20160317154340-7f45deb8130a
 	github.com/ghodss/yaml v1.0.0
+	github.com/gliderlabs/ssh v0.2.2
 	github.com/go-ole/go-ole v1.2.4 // indirect
 	github.com/go-openapi/strfmt v0.19.3 // indirect
 	github.com/gobwas/glob v0.2.3
@@ -51,6 +53,7 @@ require (
 	github.com/hpcloud/tail v1.0.0
 	github.com/imdario/mergo v0.3.7 // indirect
 	github.com/jedib0t/go-pretty v4.2.1+incompatible
+	github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a
 	github.com/json-iterator/go v1.1.7 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
 	github.com/kevinburke/ssh_config v0.0.0-20190724205821-6cfae18c12b8
diff --git a/go.sum b/go.sum
index 2bbf401..fba3456 100644
--- a/go.sum
+++ b/go.sum
@@ -33,6 +33,8 @@ github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+q
 github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
 github.com/StackExchange/wmi v0.0.0-20181212234831-e0a55b97c705 h1:UUppSQnhf4Yc6xGxSkoQpPhb7RVzuv5Nb1mwJ5VId9s=
 github.com/StackExchange/wmi v0.0.0-20181212234831-e0a55b97c705/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
+github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
+github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
 github.com/araddon/dateparse v0.0.0-20190329160016-74dc0e29b01f h1:wK+B1a8vR5Ew04VA3qxBfXC8E8NghEREybZfotsfHp8=
 github.com/araddon/dateparse v0.0.0-20190329160016-74dc0e29b01f/go.mod h1:SLqhdZcd+dF3TEVL2RMoob5bBP5R1P1qkox+HtCBgGI=
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
@@ -86,6 +88,8 @@ github.com/getlantern/deepcopy v0.0.0-20160317154340-7f45deb8130a h1:yU/FENpkHYI
 github.com/getlantern/deepcopy v0.0.0-20160317154340-7f45deb8130a/go.mod h1:AEugkNu3BjBxyz958nJ5holD9PRjta6iprcoUauDbU4=
 github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
+github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI=
 github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
@@ -158,6 +162,8 @@ github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NH
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/jedib0t/go-pretty v4.2.1+incompatible h1:YgpgCwastS5GrAK9z7OEv6rsgolGjU6h1WT3MmM5Hp8=
 github.com/jedib0t/go-pretty v4.2.1+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag=
+github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a h1:zPPuIq2jAWWPTrGt70eK/BSch+gFAGrNzecsoENgu2o=
+github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s=
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo=
diff --git a/plugin/external/coreEntries.go b/plugin/external/coreEntries.go
new file mode 100644
index 0000000..29c0add
--- /dev/null
+++ b/plugin/external/coreEntries.go
@@ -0,0 +1,36 @@
+package external
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/puppetlabs/wash/plugin"
+	"github.com/puppetlabs/wash/volume"
+)
+
+type coreEntry interface {
+	createInstance(parent *pluginEntry, decodedEntry decodedExternalPluginEntry) (plugin.Entry, error)
+	schema() *plugin.EntrySchema
+}
+
+var coreEntries = map[string]coreEntry{
+	"__volume::fs__": volumeFS{},
+}
+
+type volumeFS struct{}
+
+func (volumeFS) createInstance(parent *pluginEntry, e decodedExternalPluginEntry) (plugin.Entry, error) {
+	var opts struct{ Maxdepth uint }
+	// Use a default of 3 if unspecified.
+	opts.Maxdepth = 3
+
+	if err := json.Unmarshal([]byte(e.State), &opts); err != nil {
+		return nil, fmt.Errorf("volume filesystem options invalid: %v", err)
+	}
+
+	return volume.NewFS(e.Name, parent, int(opts.Maxdepth)), nil
+}
+
+func (volumeFS) schema() *plugin.EntrySchema {
+	return (&volume.FS{}).Schema()
+}
diff --git a/plugin/external/pluginEntry.go b/plugin/external/pluginEntry.go
index d58c57e..3aa1c8e 100644
--- a/plugin/external/pluginEntry.go
+++ b/plugin/external/pluginEntry.go
@@ -13,10 +13,12 @@ import (
 	"time"
 
 	"github.com/getlantern/deepcopy"
+	"github.com/jinzhu/copier"
 
 	"github.com/emirpasic/gods/maps/linkedhashmap"
 	"github.com/puppetlabs/wash/activity"
 	"github.com/puppetlabs/wash/plugin"
+	"github.com/puppetlabs/wash/transport"
 )
 
 type decodedCacheTTLs struct {
@@ -26,10 +28,13 @@ type decodedCacheTTLs struct {
 }
 
 // decodedExternalPluginEntry describes a decoded serialized entry.
+// Methods can be a string, or a tuple of [<method>, <value>]. <value> can take different forms
+// depending on the method. json.RawMessage lets us delay parsing these so we can parse them into
+// method-specific structs, which greatly simplifies validation.
 type decodedExternalPluginEntry struct {
 	TypeID             string                 `json:"type_id"`
 	Name               string                 `json:"name"`
-	Methods            []interface{}          `json:"methods"`
+	Methods            []json.RawMessage      `json:"methods"`
 	SlashReplacer      string                 `json:"slash_replacer"`
 	CacheTTLs          decodedCacheTTLs       `json:"cache_ttls"`
 	InaccessibleReason string                 `json:"inaccessible_reason"`
@@ -38,45 +43,97 @@ type decodedExternalPluginEntry struct {
 	State              string                 `json:"state"`
 }
 
-const entryMethodTypeError = "each method must be a string or tuple [<method>, <result_or_signature>], not %v"
+type methodTuple struct {
+	Method string
+	Value  json.RawMessage
+}
+
+func (m *methodTuple) UnmarshalJSON(data []byte) error {
+	var list []json.RawMessage
+	if err := json.Unmarshal(data, &list); err != nil {
+		return err
+	}
+
+	if len(list) != 2 {
+		return fmt.Errorf("each method must be a string or tuple [<method>, <value>], not %v", string(data))
+	}
+
+	if err := json.Unmarshal(list[0], &m.Method); err != nil {
+		return err
+	}
+	m.Value = list[1]
+	return nil
+}
+
+type execImpl struct {
+	Transport string             `json:"transport"`
+	Options   transport.Identity `json:"options"`
+}
 
-func mungeToMethods(input []interface{}) (map[string]methodInfo, error) {
+func (e decodedExternalPluginEntry) getMungedMethods() (map[string]methodInfo, error) {
 	methods := make(map[string]methodInfo)
-	for _, val := range input {
-		switch data := val.(type) {
-		case string:
-			methods[data] = methodInfo{
-				signature: plugin.DefaultSignature,
+	for _, raw := range e.Methods {
+		// Try to unmarshal to a string. If that doesn't work, unmarshal to a method-specific tuple.
+		var name string
+		if err := json.Unmarshal(raw, &name); err == nil {
+			methods[name] = methodInfo{signature: plugin.DefaultSignature}
+			continue
+		}
+
+		var tuple methodTuple
+		if err := json.Unmarshal(raw, &tuple); err != nil {
+			return nil, err
+		}
+
+		// Assume a default signature. Then validate prefetched values for specific methods and update
+		// methodInfo as needed.
+		info := methodInfo{signature: plugin.DefaultSignature}
+		switch tuple.Method {
+		case "read":
+			// Check if we have ["read", <block_readable?>] or ["read", <content>].
+			// The latter implies <block_readable> == false.
+			var blockReadable bool
+			if err := json.Unmarshal(tuple.Value, &blockReadable); err == nil {
+				if blockReadable {
+					info.signature = plugin.BlockReadableSignature
+				}
+				break
 			}
-		case []interface{}:
-			if len(data) != 2 {
-				return nil, fmt.Errorf(entryMethodTypeError, data)
+
+			var content string
+			if err := json.Unmarshal(tuple.Value, &content); err != nil {
+				return nil, fmt.Errorf("Read method must provide a string, not %v", string(tuple.Value))
 			}
-			name, ok := data[0].(string)
-			if !ok {
-				return nil, fmt.Errorf(entryMethodTypeError, data)
+			info.tupleValue = []byte(content)
+		case "list":
+			var decodedEntries []decodedExternalPluginEntry
+			if err := json.Unmarshal(tuple.Value, &decodedEntries); err != nil {
+				return nil, fmt.Errorf("implementation of list must conform to %v, not %v", listFormat, string(tuple.Value))
 			}
-			info := methodInfo{
-				signature: plugin.DefaultSignature,
+			info.tupleValue = decodedEntries
+		case "schema":
+			graph, err := unmarshalSchemaGraph(e.Name, e.TypeID, tuple.Value)
+			if err != nil {
+				return nil, fmt.Errorf(
+					"could not decode schema from stdout: %v\nreceived:\n%v\nexpected something like:\n%v",
+					err,
+					string(tuple.Value),
+					schemaFormat,
+				)
 			}
-			switch name {
-			default:
-				info.result = data[1]
-			case "read":
-				// Check if we have ["read", <block_readable?>] or ["read", <result>].
-				// The latter implies <block_readable> == false.
-				if blockReadable, ok := data[1].(bool); ok {
-					if blockReadable {
-						info.signature = plugin.BlockReadableSignature
-					}
-				} else {
-					info.result = data[1]
-				}
+			info.tupleValue = graph
+		case "exec":
+			// Check if we have ["exec", <exec_implementation>].
+			var impl execImpl
+			if err := json.Unmarshal(tuple.Value, &impl); err != nil {
+				return nil, fmt.Errorf("result for exec must specify an implementation transport and options")
+			} else if impl.Transport != "ssh" {
+				return nil, fmt.Errorf("unsupported transport %v requested, only ssh is supported", impl.Transport)
 			}
-			methods[name] = info
-		default:
-			return nil, fmt.Errorf(entryMethodTypeError, data)
+			info.tupleValue = impl
 		}
+
+		methods[tuple.Method] = info
 	}
 	return methods, nil
 }
@@ -89,7 +146,7 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry(ctx context.Context, s
 		return nil, fmt.Errorf("the entry's methods must be provided")
 	}
 
-	methods, err := mungeToMethods(e.Methods)
+	methods, err := e.getMungedMethods()
 	if err != nil {
 		return nil, err
 	}
@@ -108,7 +165,7 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry(ctx context.Context, s
 			return nil, fmt.Errorf("entry %v (%v) implements schema, but the plugin root doesn't", e.Name, e.TypeID)
 		}
 		// schemaKnown || isRoot
-		if !isRoot && info.result != nil {
+		if !isRoot && info.tupleValue != nil {
 			return nil, fmt.Errorf(
 				"entry %v (%v) prefetched its schema. Only plugin roots support schema prefetching",
 				e.Name,
@@ -125,8 +182,8 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry(ctx context.Context, s
 
 	// If read content is static, it's likely it's not coming from a source that separately provides
 	// the size of that data. If not provided, update it since we know what it is.
-	if content, ok := methods["read"].result.(string); ok && !e.Attributes.HasSize() {
-		e.Attributes.SetSize(uint64(len(content)))
+	if val := methods["read"].tupleValue; val != nil && !e.Attributes.HasSize() {
+		e.Attributes.SetSize(uint64(len(val.([]byte))))
 	}
 
 	entry := &pluginEntry{
@@ -152,16 +209,18 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry(ctx context.Context, s
 	}
 
 	// If some data originated from the parent via list, mark as prefetched.
-	if entry.methods["list"].result != nil || entry.methods["read"].result != nil {
+	if entry.methods["list"].tupleValue != nil || entry.methods["read"].tupleValue != nil {
 		entry.Prefetched()
 	}
 
 	return entry, nil
 }
 
+// Adds a tupleValue to the signature. tupleValue is either a method-specific type, or
+// json.RawMessage (so we can easily unmarshal it later).
 type methodInfo struct {
-	signature plugin.MethodSignature
-	result    interface{}
+	signature  plugin.MethodSignature
+	tupleValue interface{}
 }
 
 // pluginEntry represents an external plugin entry
@@ -199,10 +258,6 @@ func (e *pluginEntry) implements(method string) bool {
 	return ok
 }
 
-func (e *pluginEntry) supportedMethods() map[string]methodInfo {
-	return e.methods
-}
-
 func (e *pluginEntry) MethodSignature(name string) plugin.MethodSignature {
 	if info, ok := e.methods[name]; ok {
 		return info.signature
@@ -280,7 +335,7 @@ func (e *pluginEntry) SchemaGraph() (*linkedhashmap.Map, error) {
 		es, _ := graph.Get(plugin.TypeID(e))
 		schemaMethods := es.(plugin.EntrySchema).Actions
 		instanceMethods := []string{}
-		for method := range e.supportedMethods() {
+		for method := range e.methods {
 			instanceMethods = append(instanceMethods, method)
 		}
 		sort.Strings(schemaMethods)
@@ -319,7 +374,7 @@ func (e *pluginEntry) SchemaGraph() (*linkedhashmap.Map, error) {
 			)
 			return nil, err
 		}
-		graph, err = unmarshalSchemaGraph(e, inv.Stdout().Bytes())
+		graph, err = unmarshalSchemaGraph(pluginName(e), rawTypeID(e), inv.Stdout().Bytes())
 		if err != nil {
 			err := fmt.Errorf(
 				"%v (%v): could not decode schema from stdout: %v\nreceived:\n%v\nexpected something like:\n%v",
@@ -339,16 +394,8 @@ const listFormat = "[{\"name\":\"entry1\",\"methods\":[\"list\"]},{\"name\":\"en
 
 func (e *pluginEntry) List(ctx context.Context) ([]plugin.Entry, error) {
 	var decodedEntries []decodedExternalPluginEntry
-	if impl := e.methods["list"].result; impl != nil {
-		// Entry statically implements list. Construct new entries based on that rather than invoking the script.
-		bits, err := json.Marshal(impl)
-		if err != nil {
-			panic(fmt.Sprintf("Error remarshaling previously unmarshaled data: %v", err))
-		}
-
-		if err := json.Unmarshal(bits, &decodedEntries); err != nil {
-			return nil, fmt.Errorf("implementation of list must conform to %v, not %v", listFormat, impl)
-		}
+	if impl := e.methods["list"].tupleValue; impl != nil {
+		decodedEntries = impl.([]decodedExternalPluginEntry)
 	} else {
 		inv, err := e.script.InvokeAndWait(ctx, "list", e)
 		if err != nil {
@@ -361,6 +408,15 @@ func (e *pluginEntry) List(ctx context.Context) ([]plugin.Entry, error) {
 
 	entries := make([]plugin.Entry, len(decodedEntries))
 	for i, decodedExternalPluginEntry := range decodedEntries {
+		if coreEnt, ok := coreEntries[decodedExternalPluginEntry.TypeID]; ok {
+			entry, err := coreEnt.createInstance(e, decodedExternalPluginEntry)
+			if err != nil {
+				return nil, err
+			}
+			entries[i] = entry
+			continue
+		}
+
 		entry, err := decodedExternalPluginEntry.toExternalPluginEntry(ctx, e.schemaKnown, false)
 		if err != nil {
 			return nil, err
@@ -370,15 +426,13 @@ func (e *pluginEntry) List(ctx context.Context) ([]plugin.Entry, error) {
 		entry.schemaGraphs = e.schemaGraphs
 		entries[i] = entry
 	}
+
 	return entries, nil
 }
 
 func (e *pluginEntry) Read(ctx context.Context) ([]byte, error) {
-	if impl := e.methods["read"].result; impl != nil {
-		if content, ok := impl.(string); ok {
-			return []byte(content), nil
-		}
-		return nil, fmt.Errorf("Read method must provide a string, not %v", impl)
+	if impl := e.methods["read"].tupleValue; impl != nil {
+		return impl.([]byte), nil
 	}
 
 	inv, err := e.script.InvokeAndWait(ctx, "read", e)
@@ -534,7 +588,20 @@ func (e *pluginEntry) Stream(ctx context.Context) (io.ReadCloser, error) {
 	}
 }
 
+// Used for mocking tests.
+var execSSHFn = transport.ExecSSH
+
 func (e *pluginEntry) Exec(ctx context.Context, cmd string, args []string, opts plugin.ExecOptions) (plugin.ExecCommand, error) {
+	if result := e.methods["exec"].tupleValue; result != nil {
+		impl := result.(execImpl)
+		if impl.Transport != "ssh" {
+			panic("transport must be ssh")
+		}
+
+		args = append([]string{cmd}, args...)
+		return execSSHFn(ctx, impl.Options, args, opts)
+	}
+
 	// Serialize opts to JSON
 	type serializedOptions struct {
 		plugin.ExecOptions
@@ -607,9 +674,7 @@ func newStdoutDecodeErr(ctx context.Context, decodedThing string, reason error,
 	return newInvokeError(fmt.Sprintf("could not decode %v from stdout: %v", decodedThing, reason), inv)
 }
 
-func unmarshalSchemaGraph(e *pluginEntry, stdout []byte) (*linkedhashmap.Map, error) {
-	pluginName, rawTypeID := pluginName(e), rawTypeID(e)
-
+func unmarshalSchemaGraph(pluginName, rawTypeID string, stdout []byte) (*linkedhashmap.Map, error) {
 	// Since we know e's type ID, it is OK if the serialized schema's keys are
 	// out of order. However, the entry schema returned by the Wash API always
 	// ensures that the first key is the entry's type ID. Thus, we unmarshal the
@@ -637,12 +702,29 @@ func unmarshalSchemaGraph(e *pluginEntry, stdout []byte) (*linkedhashmap.Map, er
 	requiredTypeIDs := map[string]bool{
 		rawTypeID: true,
 	}
+
 	type decodedEntrySchema struct {
 		plugin.EntrySchema
 		Methods []string `json:"methods"`
 	}
+
 	graph := linkedhashmap.New()
 	putNode := func(rawTypeID string, rawSchema interface{}) error {
+		if coreEnt, ok := coreEntries[rawTypeID]; ok {
+			pluginSchema := coreEnt.schema()
+
+			// Copy only the public fields so we serialize it as just data. Uses copier because it uses
+			// reflect to copy public fields, rather than Marshal/UnmarshalJSON which we've overridden.
+			var schema plugin.EntrySchema
+			err := copier.Copy(&schema, pluginSchema)
+			if err != nil {
+				panic(fmt.Sprintf("should always be able to copy from EntrySchema to EntrySchema: %v", err))
+			}
+			populatedTypeIDs[rawTypeID] = true
+			graph.Put(namespace(pluginName, rawTypeID), schema)
+			return nil
+		}
+
 		// Deep-copy the value into the decodedEntrySchema object
 		schema, ok := rawSchema.(map[string]interface{})
 		if !ok {
diff --git a/plugin/external/pluginRoot.go b/plugin/external/pluginRoot.go
index 5434440..7e8ff58 100644
--- a/plugin/external/pluginRoot.go
+++ b/plugin/external/pluginRoot.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
-	"strings"
 	"time"
 
 	"github.com/emirpasic/gods/maps/linkedhashmap"
@@ -58,7 +57,7 @@ func (r *pluginRoot) Init(cfg map[string]interface{}) error {
 it's safe to omit name from the response to 'init'`, r.script.Path()))
 	}
 	if decodedRoot.Methods == nil {
-		decodedRoot.Methods = []interface{}{"list"}
+		decodedRoot.Methods = rawMethods(`"list"`)
 	}
 	entry, err := decodedRoot.toExternalPluginEntry(context.Background(), false, true)
 	if err != nil {
@@ -72,21 +71,8 @@ it's safe to omit name from the response to 'init'`, r.script.Path()))
 	r.pluginEntry.script = script
 
 	// Fill in the schema graph if provided
-	if rawSchema := r.methods["schema"].result; rawSchema != nil {
-		marshalledSchema, err := json.Marshal(rawSchema)
-		if err != nil {
-			panic(fmt.Sprintf("Error remarshaling previously unmarshaled data: %v", err))
-		}
-		graph, err := unmarshalSchemaGraph(&r.pluginEntry, marshalledSchema)
-		if err != nil {
-			return fmt.Errorf(
-				"could not decode schema from stdout: %v\nreceived:\n%v\nexpected something like:\n%v",
-				err,
-				strings.TrimSpace(string(marshalledSchema)),
-				schemaFormat,
-			)
-		}
-		r.schemaGraphs = r.partitionSchemaGraph(graph)
+	if val := r.methods["schema"].tupleValue; val != nil {
+		r.schemaGraphs = r.partitionSchemaGraph(val.(*linkedhashmap.Map))
 	}
 
 	return nil
@@ -126,3 +112,11 @@ func (r *pluginRoot) partitionSchemaGraph(graph *linkedhashmap.Map) map[string]*
 
 	return schemaGraphs
 }
+
+func rawMethods(strings ...string) []json.RawMessage {
+	raw := make([]json.RawMessage, len(strings))
+	for i, s := range strings {
+		raw[i] = []byte(s)
+	}
+	return raw
+}
diff --git a/transport/ssh.go b/transport/ssh.go
index f452225..4be60b7 100644
--- a/transport/ssh.go
+++ b/transport/ssh.go
@@ -7,6 +7,7 @@ import (
 	"net"
 	"os"
 	"path/filepath"
+	"strconv"
 	"time"
 
 	"github.com/avast/retry-go"
@@ -31,8 +32,8 @@ func closeConnection(id string, obj interface{}) {
 	}
 }
 
-func newAgent() (ssh.AuthMethod, error) {
-	sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
+func newAgent(path string) (ssh.AuthMethod, error) {
+	sshAgent, err := net.Dial("unix", path)
 
 	if err != nil {
 		return nil, err
@@ -41,9 +42,9 @@ func newAgent() (ssh.AuthMethod, error) {
 }
 
 type sshConfig struct {
-	host, port, user string
-	identityFiles    []string
-	hostKeyCallback  ssh.HostKeyCallback
+	host, port, user, password string
+	identityFiles              []string
+	hostKeyCallback            ssh.HostKeyCallback
 }
 
 func getConnInfo(ctx context.Context, id Identity) (conf sshConfig, err error) {
@@ -55,7 +56,9 @@ func getConnInfo(ctx context.Context, id Identity) (conf sshConfig, err error) {
 	}
 
 	// ssh_config provides a default of port 22.
-	if conf.port, err = ssh_config.GetStrict(id.Host, "Port"); err != nil {
+	if id.Port != 0 {
+		conf.port = strconv.Itoa(int(id.Port))
+	} else if conf.port, err = ssh_config.GetStrict(id.Host, "Port"); err != nil {
 		return
 	}
 
@@ -72,6 +75,8 @@ func getConnInfo(ctx context.Context, id Identity) (conf sshConfig, err error) {
 		conf.user = "root"
 	}
 
+	conf.password = id.Password
+
 	// Try the requested identity file first. Include any in SSH config as well just-in-case.
 	conf.identityFiles = make([]string, 0)
 	if id.IdentityFile != "" {
@@ -136,25 +141,40 @@ func sshConnect(ctx context.Context, conf sshConfig, retries uint) (*ssh.Client,
 	connID := conf.user + "@" + conf.host + ":" + conf.port
 	// This is a single-use cache, so pass in an empty category.
 	obj, err := connectionCache.GetOrUpdate("", connID, expires, true, func() (interface{}, error) {
-		agent, err := newAgent()
-		if err != nil {
-			return nil, fmt.Errorf("Failed to find config from SSH_AUTH_SOCK: %v", err)
+		agentPath := os.Getenv("SSH_AUTH_SOCK")
+		var agent ssh.AuthMethod
+		if agentPath == "" {
+			// If an SSH agent isn't configured, we should still try to connect without it
+			activity.Record(ctx, "SSH_AUTH_SOCK unconfigured, skipping SSH agent configuration")
+		} else {
+			var err error
+			if agent, err = newAgent(agentPath); err != nil {
+				activity.Warnf(ctx, "Failed to connect to SSH agent at %v: %v", agentPath, err)
+			}
 		}
 
 		var authmethod []ssh.AuthMethod
+		if conf.password != "" {
+			authmethod = append(authmethod, ssh.Password(conf.password))
+		}
 		for _, identityFile := range conf.identityFiles {
 			if key, err := ioutil.ReadFile(identityFile); err != nil {
-				activity.Record(ctx, "Unable to read private key, falling back to SSH agent: %v", err)
+				activity.Record(ctx, "Unable to read private key: %v", err)
 			} else {
 				if signer, err := ssh.ParsePrivateKey(key); err != nil {
-					activity.Record(ctx, "Unable to parse private key, falling back to SSH agent: %v", err)
+					activity.Record(ctx, "Unable to parse private key: %v", err)
 				} else {
 					authmethod = append(authmethod, ssh.PublicKeys(signer))
 				}
 			}
 		}
+
 		// Append agent now so that it comes last in case we find another method to try.
-		authmethod = append(authmethod, agent)
+		if agent != nil {
+			activity.Record(ctx, "Adding SSH agent at %v", os.Getenv("SSH_AUTH_SOCK"))
+			authmethod = append(authmethod, agent)
+		}
+
 		sshConfig := &ssh.ClientConfig{
 			User:            conf.user,
 			Auth:            authmethod,
@@ -163,10 +183,10 @@ func sshConnect(ctx context.Context, conf sshConfig, retries uint) (*ssh.Client,
 
 		// Try until we've retried desired number of times or connection is established.
 		var cli *ssh.Client
-		err = retry.Do(
-			func() error {
+		err := retry.Do(
+			func() (err error) {
 				cli, err = ssh.Dial("tcp", conf.host+":"+conf.port, sshConfig)
-				return err
+				return
 			},
 			retry.Attempts(retries+1),
 			retry.Delay(500*time.Millisecond),
@@ -182,9 +202,16 @@ func sshConnect(ctx context.Context, conf sshConfig, retries uint) (*ssh.Client,
 
 // Identity identifies how to connect to a target.
 type Identity struct {
-	Host, User, FallbackUser, IdentityFile, KnownHosts, HostKeyAlias string
+	Host         string `json:"host"`
+	User         string `json:"user"`
+	FallbackUser string `json:"fallback_user"`
+	Password     string `json:"password"`
+	IdentityFile string `json:"identity_file"`
+	KnownHosts   string `json:"known_hosts"`
+	HostKeyAlias string `json:"host_key_alias"`
 	// Retries can be set to a non-zero value to retry every 500ms for that many times.
-	Retries uint
+	Retries uint `json:"retries"`
+	Port    uint `json:"port"`
 }
 
 // ExecSSH executes against a target via SSH. It will look up port, user, and other configuration


Test patch:
diff --git a/plugin/external/pluginEntry_test.go b/plugin/external/pluginEntry_test.go
index b028ae3..aa6d165 100644
--- a/plugin/external/pluginEntry_test.go
+++ b/plugin/external/pluginEntry_test.go
@@ -3,6 +3,7 @@ package external
 import (
 	"bytes"
 	"context"
+	"encoding/json"
 	"fmt"
 	"io/ioutil"
 	"path"
@@ -13,6 +14,8 @@ import (
 
 	"github.com/emirpasic/gods/maps/linkedhashmap"
 	"github.com/puppetlabs/wash/plugin"
+	"github.com/puppetlabs/wash/transport"
+	"github.com/puppetlabs/wash/volume"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/suite"
 )
@@ -71,21 +74,21 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryRequired
 
 	_, err = decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	suite.Regexp("methods", err)
-	decodedEntry.Methods = []interface{}{"list"}
+	decodedEntry.Methods = rawMethods(`"list"`)
 
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	if suite.NoError(err) {
 		suite.Equal(decodedEntry.Name, plugin.Name(entry))
 		suite.Equal(1, len(entry.methods))
 		suite.Contains(entry.methods, "list")
-		suite.Nil(entry.methods["list"].result)
+		suite.Nil(entry.methods["list"].tupleValue)
 	}
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryExtraFields() {
 	decodedEntry := decodedExternalPluginEntry{
 		Name:    "decodedEntry",
-		Methods: []interface{}{"list", "stream"},
+		Methods: rawMethods(`"list"`, `"stream"`),
 	}
 
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
@@ -93,23 +96,22 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryExtraFie
 		suite.Equal(decodedEntry.Name, entry.Name())
 		suite.Contains(entry.methods, "list")
 		suite.Equal(plugin.DefaultSignature, entry.methods["list"].signature)
-		suite.Nil(entry.methods["list"].result)
+		suite.Nil(entry.methods["list"].tupleValue)
 		suite.Contains(entry.methods, "stream")
 		suite.Equal(plugin.DefaultSignature, entry.methods["stream"].signature)
-		suite.Nil(entry.methods["stream"].result)
+		suite.Nil(entry.methods["stream"].tupleValue)
 		suite.False(plugin.IsPrefetched(entry))
 
-		methods := entry.supportedMethods()
-		suite.Equal(2, len(methods))
-		suite.Contains(methods, "list")
-		suite.Contains(methods, "stream")
+		suite.Equal(2, len(entry.methods))
+		suite.Contains(entry.methods, "list")
+		suite.Contains(entry.methods, "stream")
 	}
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntry_SupportsEmptyMethodsArray() {
 	decodedEntry := decodedExternalPluginEntry{
 		Name:    "decodedEntry",
-		Methods: []interface{}{},
+		Methods: []json.RawMessage{},
 	}
 
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
@@ -119,10 +121,9 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntry_Support
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithMethodResults() {
-	childEntry := map[string]interface{}{"name": "foo", "methods": []string{"read"}}
 	decodedEntry := decodedExternalPluginEntry{
 		Name:    "decodedEntry",
-		Methods: []interface{}{[]interface{}{"list", []interface{}{childEntry}}, "read"},
+		Methods: rawMethods(`["list", [{"name": "foo", "methods": ["read"]}]]`, `"read"`),
 	}
 
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
@@ -130,9 +131,9 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithMeth
 		suite.Equal(decodedEntry.Name, entry.Name())
 		suite.Contains(entry.methods, "list")
 		suite.Equal(plugin.DefaultSignature, entry.methods["list"].signature)
-		suite.NotNil(entry.methods["list"].result)
+		suite.NotNil(entry.methods["list"].tupleValue)
 		suite.Contains(entry.methods, "read")
-		suite.Nil(entry.methods["read"].result)
+		suite.Nil(entry.methods["read"].tupleValue)
 		suite.True(plugin.IsPrefetched(entry))
 	}
 }
@@ -140,7 +141,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithMeth
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryMethodTuple_Read() {
 	decodedEntry := decodedExternalPluginEntry{
 		Name:    "decodedEntry",
-		Methods: []interface{}{[]interface{}{"read", true}},
+		Methods: rawMethods(`["read", true]`),
 	}
 
 	type testCase struct {
@@ -151,15 +152,19 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryMethodTu
 	testCases := []testCase{
 		testCase{true, plugin.BlockReadableSignature, nil},
 		testCase{false, plugin.DefaultSignature, nil},
-		testCase{"foo", plugin.DefaultSignature, "foo"},
+		testCase{"foo", plugin.DefaultSignature, []byte("foo")},
 	}
 	for _, testCase := range testCases {
-		decodedEntry.Methods = []interface{}{[]interface{}{"read", testCase.data}}
+		data, err := json.Marshal(testCase.data)
+		if err != nil {
+			panic(err)
+		}
+		decodedEntry.Methods = rawMethods(`["read", ` + string(data) + `]`)
 		entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 		if suite.NoError(err) {
 			suite.NotNil(entry.methods["read"])
 			suite.Equal(testCase.expectedSignature, entry.methods["read"].signature)
-			suite.Equal(testCase.expectedResult, entry.methods["read"].result)
+			suite.Equal(testCase.expectedResult, entry.methods["read"].tupleValue)
 		}
 	}
 }
@@ -167,7 +172,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryMethodTu
 func newMockDecodedEntry(name string) decodedExternalPluginEntry {
 	return decodedExternalPluginEntry{
 		Name:    name,
-		Methods: []interface{}{"list"},
+		Methods: rawMethods(`"list"`),
 	}
 }
 
@@ -227,7 +232,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithPart
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSchema_SchemaUnknown_DoesNotImplementSchema() {
 	decodedEntry := decodedExternalPluginEntry{
 		Name:    "decodedEntry",
-		Methods: []interface{}{"list"},
+		Methods: rawMethods(`"list"`),
 	}
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	if suite.NoError(err) {
@@ -238,7 +243,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSche
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSchema_SchemaUnknown_ImplementsSchema_TypeIDNotIncluded() {
 	decodedEntry := decodedExternalPluginEntry{
 		Name:    "decodedEntry",
-		Methods: []interface{}{"list", "schema"},
+		Methods: rawMethods(`"list"`, `"schema"`),
 	}
 	_, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
 	suite.Regexp("decodedEntry.*implements.*schema.*no.*type.*ID", err)
@@ -247,7 +252,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSche
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSchema_SchemaUnknown_ImplementsSchema() {
 	decodedEntry := decodedExternalPluginEntry{
 		Name:    "decodedEntry",
-		Methods: []interface{}{"list", "schema"},
+		Methods: rawMethods(`"list"`, `"schema"`),
 		TypeID:  "foo",
 	}
 	_, err := decodedEntry.toExternalPluginEntry(context.Background(), false, false)
@@ -257,7 +262,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSche
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSchema_SchemaKnown_DoesNotImplementSchema() {
 	decodedEntry := decodedExternalPluginEntry{
 		Name:    "decodedEntry",
-		Methods: []interface{}{"list"},
+		Methods: rawMethods(`"list"`),
 		TypeID:  "foo",
 	}
 	_, err := decodedEntry.toExternalPluginEntry(context.Background(), true, false)
@@ -267,7 +272,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSche
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSchema_SchemaKnown_ImplementsSchema_TypeIDNotIncluded() {
 	decodedEntry := decodedExternalPluginEntry{
 		Name:    "decodedEntry",
-		Methods: []interface{}{"list", "schema"},
+		Methods: rawMethods(`"list"`, `"schema"`),
 	}
 	_, err := decodedEntry.toExternalPluginEntry(context.Background(), true, false)
 	suite.Regexp("decodedEntry.*implements.*schema.*no.*type.*ID", err)
@@ -275,12 +280,9 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSche
 
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSchema_SchemaKnown_PrefetchesSchema() {
 	decodedEntry := decodedExternalPluginEntry{
-		Name: "decodedEntry",
-		Methods: []interface{}{
-			"list",
-			[]interface{}{"schema", "schema_result"},
-		},
-		TypeID: "foo",
+		Name:    "decodedEntry",
+		Methods: rawMethods(`"list"`, `["schema", {"foo": {"label": "foo", "methods": []}}]`),
+		TypeID:  "foo",
 	}
 	_, err := decodedEntry.toExternalPluginEntry(context.Background(), true, false)
 	suite.Regexp("decodedEntry.*foo.*plugin.*roots.*support.*prefetching", err)
@@ -289,7 +291,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSche
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSchema_SchemaKnown_ImplementsSchema() {
 	decodedEntry := decodedExternalPluginEntry{
 		Name:    "decodedEntry",
-		Methods: []interface{}{"list", "schema"},
+		Methods: rawMethods(`"list"`, `"schema"`),
 		TypeID:  "foo",
 	}
 	entry, err := decodedEntry.toExternalPluginEntry(context.Background(), true, false)
@@ -302,7 +304,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSche
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithInaccessibleReason() {
 	decodedEntry := decodedExternalPluginEntry{
 		Name:               "decodedEntry",
-		Methods:            []interface{}{"list", "stream"},
+		Methods:            rawMethods(`"list"`, `"stream"`),
 		InaccessibleReason: "permission denied",
 	}
 
@@ -526,7 +528,7 @@ func (suite *ExternalPluginEntryTestSuite) TestList() {
 	mockErr := fmt.Errorf("execution error")
 	mockInvokeAndWait([]byte{}, mockErr)
 	_, err := entry.List(ctx)
-	suite.EqualError(mockErr, err.Error())
+	suite.EqualError(err, mockErr.Error())
 
 	// Test that List returns an error if stdout does not have the right
 	// output format
@@ -577,7 +579,7 @@ func (suite *ExternalPluginEntryTestSuite) TestRead() {
 	mockErr := fmt.Errorf("execution error")
 	mockInvokeAndWait([]byte{}, mockErr)
 	_, err := entry.Read(ctx)
-	suite.EqualError(mockErr, err.Error())
+	suite.EqualError(err, mockErr.Error())
 
 	// Test that Read returns the invocation's stdout
 	stdout := "foo"
@@ -606,7 +608,7 @@ func (suite *ExternalPluginEntryTestSuite) TestBlockRead() {
 	mockErr := fmt.Errorf("execution error")
 	mockInvokeAndWait([]byte{}, mockErr)
 	_, err := entry.BlockRead(ctx, 10, 0)
-	suite.EqualError(mockErr, err.Error())
+	suite.EqualError(err, mockErr.Error())
 
 	// Test that BlockRead returns the invocation's stdout
 	stdout := "foo"
@@ -664,6 +666,121 @@ func (suite *ExternalPluginEntryTestSuite) TestListReadWithMethodResults() {
 	}
 }
 
+func (suite *ExternalPluginEntryTestSuite) TestListWithCoreEntry() {
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
+		script:    mockScript,
+	}
+	entry.SetTestID("/foo")
+
+	ctx := context.Background()
+	stdout := []byte(`
+[
+	{"name": "bar", "methods": ["list"]},
+	{"type_id": "__volume::fs__", "name": "fs1", "state": "{\"maxdepth\": 2}"}
+]`)
+	mockScript.OnInvokeAndWait(ctx, "list", entry).Return(mockInvocation(stdout), nil).Once()
+
+	entries, err := entry.List(ctx)
+	if suite.NoError(err) {
+		suite.Equal(2, len(entries))
+
+		suite.Equal([]string{"list"}, plugin.SupportedActionsOf(entries[0]))
+		suite.Equal("bar", plugin.Name(entries[0]))
+
+		suite.Equal([]string{"list"}, plugin.SupportedActionsOf(entries[1]))
+		suite.Equal("fs1", plugin.Name(entries[1]))
+		suite.IsType(&volume.FS{}, entries[1])
+	}
+}
+
+func (suite *ExternalPluginEntryTestSuite) TestListWithUnknownCoreEntry() {
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
+		script:    mockScript,
+	}
+	entry.SetTestID("/foo")
+
+	ctx := context.Background()
+	stdout := []byte(`[{"type_id": "__wash::other__", "name": "bar", "state": "{}"}]`)
+	mockScript.OnInvokeAndWait(ctx, "list", entry).Return(mockInvocation(stdout), nil).Once()
+
+	_, err := entry.List(ctx)
+	suite.EqualError(err, "the entry's methods must be provided")
+}
+
+func (suite *ExternalPluginEntryTestSuite) TestListWithExec_Transport() {
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
+		script:    mockScript,
+	}
+	entry.SetTestID("/foo")
+
+	ctx := context.Background()
+	stdout := []byte(`[
+	{"name": "bar", "methods": [
+		["exec", {"transport": "ssh", "options": {"host": "example.com", "user": "ubuntu"}}]
+	]}
+]`)
+	mockScript.OnInvokeAndWait(ctx, "list", entry).Return(mockInvocation(stdout), nil).Once()
+
+	entries, err := entry.List(ctx)
+	if suite.NoError(err) {
+		suite.Equal(1, len(entries))
+		suite.Equal([]string{"exec"}, plugin.SupportedActionsOf(entries[0]))
+
+		if suite.IsType(&pluginEntry{}, entries[0]) {
+			entry := entries[0].(*pluginEntry)
+			if suite.Contains(entry.methods, "exec") {
+				info := entry.methods["exec"]
+				if suite.IsType(execImpl{}, info.tupleValue) {
+					exec := info.tupleValue.(execImpl)
+					suite.Equal("ssh", exec.Transport)
+					suite.Equal("example.com", exec.Options.Host)
+					suite.Equal("ubuntu", exec.Options.User)
+				}
+			}
+		}
+	}
+}
+
+func (suite *ExternalPluginEntryTestSuite) TestListWithExec_TransportUnknown() {
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
+		script:    mockScript,
+	}
+	entry.SetTestID("/foo")
+
+	ctx := context.Background()
+	stdout := []byte(`[
+	{"name": "bar", "methods": [["exec", {"transport": "foo", "options": {}}]]}
+]`)
+	mockScript.OnInvokeAndWait(ctx, "list", entry).Return(mockInvocation(stdout), nil).Once()
+
+	_, err := entry.List(ctx)
+	suite.EqualError(err, "unsupported transport foo requested, only ssh is supported")
+}
+
+func (suite *ExternalPluginEntryTestSuite) TestListWithExec_Unknown() {
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
+		script:    mockScript,
+	}
+	entry.SetTestID("/foo")
+
+	ctx := context.Background()
+	stdout := []byte(`[{"name": "bar", "methods": [["exec", true]]}]`)
+	mockScript.OnInvokeAndWait(ctx, "list", entry).Return(mockInvocation(stdout), nil).Once()
+
+	_, err := entry.List(ctx)
+	suite.EqualError(err, "result for exec must specify an implementation transport and options")
+}
+
 type mockedInvocation struct {
 	Command
 	mock.Mock
@@ -673,6 +790,18 @@ func (m *mockedInvocation) RunAndWait(ctx context.Context) error {
 	return m.Called(ctx).Error(0)
 }
 
+func (m *mockedInvocation) Start() error {
+	return m.Called().Error(0)
+}
+
+func (m *mockedInvocation) Wait() error {
+	return m.Called().Error(0)
+}
+
+func (m *mockedInvocation) ExitCode() int {
+	return m.Called().Int(0)
+}
+
 func (m *mockedInvocation) Stdout() *bytes.Buffer {
 	return m.Called().Get(0).(*bytes.Buffer)
 }
@@ -703,7 +832,7 @@ func (suite *ExternalPluginEntryTestSuite) TestWrite() {
 	mockErr := fmt.Errorf("execution error")
 	mockRunAndWait(mockErr)
 	err := entry.Write(ctx, data)
-	suite.EqualError(mockErr, err.Error())
+	suite.EqualError(err, mockErr.Error())
 
 	// Test that invocation succeeds
 	mockRunAndWait(nil)
@@ -724,27 +853,24 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeWithErrors() {
 		mockScript.OnInvokeAndWait(ctx, "list", entry).Return(mockInvocation(stdout), err).Once()
 	}
 
-	// Test that List is invoked when
-	stdout := `[{"name": "foo", "methods": [
-								["list", {"name": "bar"}],
-								["read", [1, 2]]
-							]}]`
+	// Test that List validates prefetched read
+	stdout := `[{"name": "foo", "methods": [["read", [1, 2]]]}]`
 	mockInvokeAndWait([]byte(stdout), nil)
-	entries, err := entry.List(ctx)
-	if suite.NoError(err) {
-		suite.Equal(1, len(entries))
-		supported := plugin.SupportedActionsOf(entries[0])
-		suite.Contains(supported, "list")
-		suite.Contains(supported, "read")
+	_, err := entry.List(ctx)
+	suite.EqualError(err, "Read method must provide a string, not [1, 2]")
 
-		_, err = entries[0].(plugin.Parent).List(ctx)
-		suite.EqualError(err, `implementation of list must conform to `+
-			`[{"name":"entry1","methods":["list"]},{"name":"entry2","methods":["list"]}], not map[name:bar]`)
+	// Test that List validates prefetched list
+	stdout = `[{"name": "foo", "methods": [["list", {"name": "bar"}]]}]`
+	mockInvokeAndWait([]byte(stdout), nil)
+	_, err = entry.List(ctx)
+	suite.EqualError(err, `implementation of list must conform to `+
+		`[{"name":"entry1","methods":["list"]},{"name":"entry2","methods":["list"]}], not {"name": "bar"}`)
 
-		_, err = entries[0].(plugin.Readable).Read(ctx)
-		suite.EqualError(err, "Read method must provide a string, not [1 2]")
-		// TODO: Add a test for block readable here
-	}
+	// Test that List validates block readable tag
+	stdout = `[{"name": "foo", "methods": [["read", true]]}]`
+	mockInvokeAndWait([]byte(stdout), nil)
+	_, err = entry.List(ctx)
+	suite.NoError(err)
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestMetadata_NotImplemented() {
@@ -784,7 +910,7 @@ func (suite *ExternalPluginEntryTestSuite) TestMetadata_Implemented() {
 	mockErr := fmt.Errorf("execution error")
 	mockInvokeAndWait([]byte{}, mockErr)
 	_, err := entry.Metadata(ctx)
-	suite.EqualError(mockErr, err.Error())
+	suite.EqualError(err, mockErr.Error())
 
 	// Test that Metadata returns an error if stdout does not have the right
 	// output format
@@ -802,6 +928,99 @@ func (suite *ExternalPluginEntryTestSuite) TestMetadata_Implemented() {
 	}
 }
 
+func (suite *ExternalPluginEntryTestSuite) TestExec() {
+	mockScript := &mockPluginScript{path: "plugin_script"}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
+		methods:   map[string]methodInfo{"exec": methodInfo{}},
+		script:    mockScript,
+	}
+	entry.SetTestID("/foo")
+
+	ctx := context.Background()
+	mockRunAndWait := func(cmd []string, startErr, waitErr error, exitCode int) {
+		mockInv := &mockedInvocation{Command: NewCommand(ctx, "")}
+		args := []interface{}{ctx, "exec", entry, append([]string{`{"tty":false,"elevate":false,"stdin":false}`}, cmd...)}
+		mockScript.On("NewInvocation", args...).Return(mockInv).Once()
+		mockInv.On("Start").Return(startErr).Once()
+		mockInv.On("Wait").Return(waitErr).Once()
+		mockInv.On("ExitCode").Return(exitCode).Once()
+	}
+
+	// Test that if Start errors, then Exec returns its error
+	mockErr := fmt.Errorf("execution error")
+	mockRunAndWait([]string{"echo", "hello"}, mockErr, nil, 0)
+	_, err := entry.Exec(ctx, "echo", []string{"hello"}, plugin.ExecOptions{})
+	suite.EqualError(err, mockErr.Error())
+
+	// Test that if Wait errors, then ExecCommand returns the error
+	mockRunAndWait([]string{"echo", "hello"}, nil, mockErr, -1)
+	cmd, err := entry.Exec(ctx, "echo", []string{"hello"}, plugin.ExecOptions{})
+	if suite.NoError(err) {
+		var chunks []plugin.ExecOutputChunk
+		for chunk := range cmd.OutputCh() {
+			chunks = append(chunks, chunk)
+		}
+		suite.Empty(chunks)
+		_, err := cmd.ExitCode()
+		suite.EqualError(err, mockErr.Error())
+	}
+
+	// Test that if Wait has a non-zero exit code, then ExecCommand returns it
+	mockRunAndWait([]string{"echo", "hello"}, nil, nil, 1)
+	cmd, err = entry.Exec(ctx, "echo", []string{"hello"}, plugin.ExecOptions{})
+	if suite.NoError(err) {
+		<-cmd.OutputCh()
+		exit, err := cmd.ExitCode()
+		suite.NoError(err)
+		suite.Equal(1, exit)
+	}
+
+	// Test that Exec properly executes
+	mockRunAndWait([]string{"echo", "hello"}, nil, nil, 0)
+	cmd, err = entry.Exec(ctx, "echo", []string{"hello"}, plugin.ExecOptions{})
+	if suite.NoError(err) {
+		<-cmd.OutputCh()
+		exit, err := cmd.ExitCode()
+		suite.NoError(err)
+		suite.Zero(exit)
+		mockScript.AssertExpectations(suite.T())
+	}
+}
+
+func (suite *ExternalPluginEntryTestSuite) TestExec_Transport() {
+	// Mock transport.ExecSSH
+	savedFn := execSSHFn
+	var execMock mock.Mock
+	execSSHFn = func(ctx context.Context, id transport.Identity, cmd []string, opts plugin.ExecOptions) (plugin.ExecCommand, error) {
+		args := execMock.Called(ctx, id, cmd, opts)
+		return args.Get(0).(plugin.ExecCommand), args.Error(1)
+	}
+	defer func() { execSSHFn = savedFn }()
+
+	execVals := execImpl{Transport: "ssh", Options: transport.Identity{Host: "example.com"}}
+	entry := &pluginEntry{
+		EntryBase: plugin.NewEntry("foo"),
+		methods:   map[string]methodInfo{"exec": methodInfo{tupleValue: execVals}},
+	}
+	entry.SetTestID("/foo")
+
+	var opts plugin.ExecOptions
+	ctx, mockErr := context.Background(), fmt.Errorf("execution error")
+	args, result := []string{"echo", "hello"}, plugin.NewExecCommand(ctx)
+
+	// Test that if ExecSSH errors then Exec returns the error
+	execMock.On("func1", ctx, execVals.Options, args, opts).Return(result, mockErr).Once()
+	_, err := entry.Exec(ctx, "echo", []string{"hello"}, opts)
+	suite.EqualError(err, mockErr.Error())
+
+	// Test that if ExecSSH runs then Exec returns the result
+	execMock.On("func1", ctx, execVals.Options, args, opts).Return(result, nil).Once()
+	cmd, err := entry.Exec(ctx, "echo", []string{"hello"}, opts)
+	suite.NoError(err)
+	suite.Equal(cmd, result)
+}
+
 func (suite *ExternalPluginEntryTestSuite) TestSignal() {
 	mockScript := &mockPluginScript{path: "plugin_script"}
 	entry := &pluginEntry{
@@ -820,7 +1039,7 @@ func (suite *ExternalPluginEntryTestSuite) TestSignal() {
 	mockErr := fmt.Errorf("execution error")
 	mockInvokeAndWait("start", []byte{}, mockErr)
 	err := entry.Signal(ctx, "start")
-	suite.EqualError(mockErr, err.Error())
+	suite.EqualError(err, mockErr.Error())
 
 	// Test that Signal properly signals the entry
 	mockInvokeAndWait("start", []byte{}, nil)
@@ -848,7 +1067,7 @@ func (suite *ExternalPluginEntryTestSuite) TestDelete() {
 	mockErr := fmt.Errorf("execution error")
 	mockInvokeAndWait([]byte{}, mockErr)
 	_, err := entry.Delete(ctx)
-	suite.EqualError(mockErr, err.Error())
+	suite.EqualError(err, mockErr.Error())
 
 	// Test that Delete returns an error if stdout does not have the right
 	// output format
@@ -872,14 +1091,14 @@ func (suite *ExternalPluginEntryTestSuite) TestDelete() {
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfNotAJSONObject() {
 	entry := &pluginEntry{}
 	entry.SetTestID("/fooPlugin")
-	_, err := unmarshalSchemaGraph(entry, []byte("[]"))
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), []byte("[]"))
 	suite.Regexp("non-empty.*object", err)
 }
 
 func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfAnEmptyJSONObject() {
 	entry := &pluginEntry{}
 	entry.SetTestID("/fooPlugin")
-	_, err := unmarshalSchemaGraph(entry, []byte("{}"))
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), []byte("{}"))
 	suite.Regexp("non-empty.*object", err)
 }
 
@@ -892,7 +1111,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfType
 {
 	"bar": "baz"
 }`)
-	_, err := unmarshalSchemaGraph(entry, stdout)
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp("foo.*missing", err)
 }
 
@@ -908,7 +1127,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsOnMalf
 	"foo": "fooSchema",
 	"bar": {}
 }`)
-	_, err := unmarshalSchemaGraph(entry, stdout)
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp("object.*foo.*fooSchema", err)
 
 	// Error should indicate that "foo"'s label is malformed.
@@ -919,7 +1138,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsOnMalf
 	},
 	"bar": {}
 }`)
-	_, err = unmarshalSchemaGraph(entry, stdout)
+	_, err = unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp("number", err)
 
 	// Error should indicate that "bar"'s children are malformed.
@@ -933,7 +1152,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsOnMalf
 		"children": true
 	}
 }`)
-	_, err = unmarshalSchemaGraph(entry, stdout)
+	_, err = unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp(`\[\]string`, err)
 }
 
@@ -948,7 +1167,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfLabe
 	"foo":{}
 }
 `)
-	_, err := unmarshalSchemaGraph(entry, stdout)
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp("label.*provided", err)
 }
 
@@ -965,7 +1184,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfMeth
 	}
 }
 `)
-	_, err := unmarshalSchemaGraph(entry, stdout)
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp("methods.*provided", err)
 }
 
@@ -984,7 +1203,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfNotP
 	}
 }
 `)
-	_, err := unmarshalSchemaGraph(entry, stdout)
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp("entry.*children.*not.*parent", err)
 }
 
@@ -1003,7 +1222,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfPare
 	}
 }
 `)
-	_, err := unmarshalSchemaGraph(entry, stdout)
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp("parent.*entries.*children", err)
 }
 
@@ -1024,7 +1243,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfNotS
 	}
 }
 `)
-	_, err := unmarshalSchemaGraph(entry, stdout)
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp("entry.*signals.*not.*signalable", err)
 }
 
@@ -1043,7 +1262,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfSign
 	}
 }
 `)
-	_, err := unmarshalSchemaGraph(entry, stdout)
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp("signalable.*entries.*signal", err)
 }
 
@@ -1062,7 +1281,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfMiss
 	}
 }
 `)
-	_, err := unmarshalSchemaGraph(entry, stdout)
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp("bar.*schema.*missing", err)
 }
 
@@ -1088,7 +1307,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsIfSche
 	}
 }
 `)
-	_, err := unmarshalSchemaGraph(entry, stdout)
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	// Need to do several regexp checks here b/c this error
 	// message is generated by iterating over a map's keys.
 	// Map keys are iterated in random order.
@@ -1114,7 +1333,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsOnInva
 	}
 }
 `)
-	_, err := unmarshalSchemaGraph(entry, stdout)
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp("invalid.*partial.*metadata.*object.*schema.*array", err)
 }
 
@@ -1135,7 +1354,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ErrorsOnInva
 	}
 }
 `)
-	_, err := unmarshalSchemaGraph(entry, stdout)
+	_, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	suite.Regexp("invalid.*metadata.*object.*schema.*array", err)
 }
 
@@ -1149,7 +1368,7 @@ func (suite *ExternalPluginEntryTestSuite) TestUnmarshalSchemaGraph_ValidInput()
 		rawTypeID: "aws.Root",
 	}
 	entry.SetTestID("fooPlugin")
-	actualGraph, err := unmarshalSchemaGraph(entry, stdout)
+	actualGraph, err := unmarshalSchemaGraph(pluginName(entry), rawTypeID(entry), stdout)
 	if suite.NoError(err) {
 		// Check that the first key is aws.Root
 		it := actualGraph.Iterator()
diff --git a/transport/ssh_test.go b/transport/ssh_test.go
new file mode 100644
index 0000000..2582975
--- /dev/null
+++ b/transport/ssh_test.go
@@ -0,0 +1,220 @@
+package transport
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"io/ioutil"
+	"os"
+	"strconv"
+	"testing"
+
+	gssh "github.com/gliderlabs/ssh"
+	"github.com/puppetlabs/wash/plugin"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/suite"
+	"golang.org/x/crypto/ssh"
+	"golang.org/x/crypto/ssh/knownhosts"
+)
+
+type SSHTestSuite struct {
+	suite.Suite
+	s                        gssh.Server
+	m                        mock.Mock
+	knownHosts, identityFile string
+}
+
+var _ = suite.SetupAllSuite(&SSHTestSuite{})
+var _ = suite.SetupTestSuite(&SSHTestSuite{})
+var _ = suite.TearDownAllSuite(&SSHTestSuite{})
+var _ = suite.TearDownTestSuite(&SSHTestSuite{})
+
+const (
+	host = "localhost"
+	port = 2222
+)
+
+// Must be mocked for successful connections.
+func (suite *SSHTestSuite) Handler(s gssh.Session) {
+	suite.m.Called(s)
+}
+
+// Must be mocked if Identity.Password is set.
+func (suite *SSHTestSuite) PasswordHandler(ctx gssh.Context, password string) bool {
+	return suite.m.Called(ctx, password).Bool(0)
+}
+
+// Must be mocked.
+func (suite *SSHTestSuite) PublicKeyHandler(ctx gssh.Context, key gssh.PublicKey) bool {
+	return suite.m.Called(ctx, key).Bool(0)
+}
+
+func generateSigner() (ssh.Signer, error) {
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		return nil, err
+	}
+	return ssh.NewSignerFromKey(key)
+}
+
+func createKnownHosts(address string, key ssh.PublicKey) (string, error) {
+	tmpfile, err := ioutil.TempFile("", "wash_ssh_knownhosts")
+	if err != nil {
+		return "", err
+	}
+	if _, err = tmpfile.Write([]byte(knownhosts.Line([]string{address}, key))); err != nil {
+		return "", err
+	}
+	return tmpfile.Name(), tmpfile.Close()
+}
+
+func (suite *SSHTestSuite) SetupSuite() {
+	// Generate a host key and knownhosts file
+	signer, err := generateSigner()
+	if err != nil {
+		suite.T().Fatal(err)
+	}
+
+	addr := host + ":" + strconv.Itoa(port)
+	knownHosts, err := createKnownHosts(addr, signer.PublicKey())
+	if err != nil {
+		suite.T().Fatal(err)
+	}
+	suite.knownHosts = knownHosts
+
+	// Generate an identity file
+	tmpfile, err := ioutil.TempFile("", "wash_ssh_key")
+	if err != nil {
+		suite.T().Fatal(err)
+	}
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		suite.T().Fatal(err)
+	}
+	privBytes := x509.MarshalPKCS1PrivateKey(key)
+	if err := pem.Encode(tmpfile, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: privBytes}); err != nil {
+		suite.T().Fatal(err)
+	}
+	if err := tmpfile.Close(); err != nil {
+		suite.T().Fatal(err)
+	}
+	suite.identityFile = tmpfile.Name()
+
+	// Setup SSH server
+	suite.s.Addr = addr
+	suite.s.Handle(suite.Handler)
+	suite.s.AddHostKey(signer)
+	suite.s.PasswordHandler = suite.PasswordHandler
+	suite.s.PublicKeyHandler = suite.PublicKeyHandler
+	go func() { suite.T().Log(suite.s.ListenAndServe()) }()
+}
+
+func (suite *SSHTestSuite) SetupTest() {
+	// Reset mocks before every test
+	suite.m = mock.Mock{}
+}
+
+func (suite *SSHTestSuite) TearDownTest() {
+	connectionCache.Flush()
+}
+
+func (suite *SSHTestSuite) TearDownSuite() {
+	// Teardown SSH server
+	if err := suite.s.Close(); err != nil {
+		suite.T().Log(err)
+	}
+	if err := os.Remove(suite.knownHosts); err != nil {
+		suite.T().Log(err)
+	}
+	if err := os.Remove(suite.identityFile); err != nil {
+		suite.T().Log(err)
+	}
+}
+
+func (suite *SSHTestSuite) Identity() Identity {
+	return Identity{Host: host, Port: port, IdentityFile: suite.identityFile, KnownHosts: suite.knownHosts}
+}
+
+func (suite *SSHTestSuite) TestExec() {
+	var user, command string
+	suite.m.On("Handler", mock.Anything).Run(func(args mock.Arguments) {
+		sess := args.Get(0).(gssh.Session)
+		user = sess.User()
+		command = sess.RawCommand()
+		_, err := sess.Write([]byte("hello\n"))
+		suite.NoError(err)
+		suite.NoError(sess.CloseWrite())
+	})
+	suite.m.On("PublicKeyHandler", mock.Anything, mock.Anything).Return(true)
+
+	cmd, err := ExecSSH(context.Background(), suite.Identity(), []string{"echo", "hello"}, plugin.ExecOptions{})
+	if suite.NoError(err) {
+		var resp []plugin.ExecOutputChunk
+		for chunk := range cmd.OutputCh() {
+			resp = append(resp, chunk)
+		}
+		exit, err := cmd.ExitCode()
+		suite.NoError(err)
+		suite.Zero(exit)
+		suite.Len(resp, 1)
+
+		suite.Equal("root", user)
+		suite.Equal("echo hello", command)
+	}
+}
+
+func (suite *SSHTestSuite) TestExec_WithUser() {
+	var user string
+	suite.m.On("Handler", mock.Anything).Run(func(args mock.Arguments) {
+		user = args.Get(0).(gssh.Session).User()
+	})
+	suite.m.On("PublicKeyHandler", mock.Anything, mock.Anything).Return(true)
+
+	identity := suite.Identity()
+	identity.User = "other"
+	cmd, err := ExecSSH(context.Background(), identity, []string{"echo", "hello"}, plugin.ExecOptions{})
+	if suite.NoError(err) {
+		<-cmd.OutputCh()
+		exit, err := cmd.ExitCode()
+		suite.NoError(err)
+		suite.Zero(exit)
+		suite.Equal("other", user)
+	}
+}
+
+func (suite *SSHTestSuite) TestExec_WithPassword() {
+	password := "password"
+	suite.m.On("Handler", mock.Anything).Run(func(args mock.Arguments) {})
+	suite.m.On("PublicKeyHandler", mock.Anything, mock.Anything).Return(false)
+	suite.m.On("PasswordHandler", mock.Anything, password).Return(true)
+
+	identity := suite.Identity()
+	identity.Password = password
+	cmd, err := ExecSSH(context.Background(), identity, []string{}, plugin.ExecOptions{})
+	if suite.NoError(err) {
+		<-cmd.OutputCh()
+		exit, err := cmd.ExitCode()
+		suite.NoError(err)
+		suite.Zero(exit)
+	}
+}
+
+func (suite *SSHTestSuite) TestExec_ContextCancelled() {
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel()
+	suite.m.On("Handler", mock.Anything).Run(func(args mock.Arguments) {})
+	suite.m.On("PublicKeyHandler", mock.Anything, mock.Anything).Return(true)
+
+	cmd, err := ExecSSH(ctx, suite.Identity(), []string{}, plugin.ExecOptions{})
+	if suite.NoError(err) {
+		<-cmd.OutputCh()
+		_, err := cmd.ExitCode()
+		suite.EqualError(err, "failed to fetch the command's exit code: context canceled")
+	}
+}
+
+func TestClient(t *testing.T) {
+	suite.Run(t, new(SSHTestSuite))
+}