Help us shape the future of Go! Take the 2026 Go Developer Survey

agentworkspace

package module
v0.0.11 Latest Latest
Warning

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

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 23 Imported by: 4

README

agentworkspace

Where an agent's tools act: a file tree, processes, a working directory and an environment, behind one interface, so the same read, write, edit and bash tools run unchanged against a directory on this machine, a container or a remote runtime. The module imports the standard library alone, its package remote included, which serves a whole workspace over HTTP; mcpworkspace, a module of its own beside it, carries a workspace's processes and files over an MCP connection, and tunnel, another, serves a workspace behind NAT through a connection its host dials out.

ws, err := agentworkspace.NewLocal(dir, env) // env: this process's, credentials removed
if err != nil {
	return err
}
defer ws.Close()

data, err := agentworkspace.Read(ws, "go.mod", 1<<20)
err = ws.WriteFile(ctx, "notes/todo.md", []byte("- ship it\n"), 0o644)
out, err := ws.Exec(ctx, agentworkspace.Command{
	Args:    []string{"go", "test", "./..."},
	Timeout: 2 * time.Minute,
})

Names are fs.ValidPath names relative to Root. A name that leaves the workspace, absolute, climbing out with .. or through a symbolic link, is an error that errors.Is ErrOutside, for reads, writes, removes and a command's Dir alike. Opening never blocks: a FIFO or a device where a file should be opens, or fails, at once, and WriteFile refuses one. An FS that can tell a link from its target implements fs.ReadLinkFS, so a policy can follow a link inside the workspace before it decides what a read of it really reads. Env is what the constructor was given, a copy; nil is an empty environment, never the host's. Descriptor (Kind, Ref, Host, Instance, Root) is what a session records about where its tools ran, as agentsession's env entry keeps it; Local's names this machine's host, as the system does or as WithHost says.

Local is a directory on this machine. Every file operation goes through an os.Root, so a link that leads out is refused rather than followed. Its processes are not confined: a shell reaches whatever the user can, and the product's policy is what stands in front of it. Exec runs each command in a process group of its own, so a timeout or a cancel ends what it started too; a cancel still returns the output so far, marked Interrupted, beside the context's error. Local runs on Linux and macOS. On Linux an end also reaches the descendants that left the group (setsid, a shell's jobs), found through /proc; on macOS it reaches the group alone. ReachOf says which.

RFC 0001 specifies the contract every backend keeps, for an implementation in any language: names and confinement, files, the environment, commands, Exec, processes and how they end, offered servers and the descriptor, with this module as its Go binding, remote and mcpworkspace as its wires, and workspacetest as its conformance suite.

Long-lived processes

A workspace that can run a process for longer than one call is a Starter. Start returns a Process with pipes to its standard input and output, Wait, Signal and Close; its standard error goes to Command.Stream. Its first use is an MCP stdio server running in the workspace rather than beside the agent:

p, err := agentworkspace.Start(ctx, ws, agentworkspace.Command{
	Args:   []string{"my-mcp-server", "--stdio"},
	Stream: serverLog, // its standard error
})
if err != nil {
	return err // errors.ErrUnsupported when ws cannot start processes
}
defer p.Close()
transport := &mcp.IOTransport{Reader: p.Stdout(), Writer: p.Stdin()}

A process lives no longer than its owner. Close closes its input and gives it a moment to leave, as the MCP stdio shutdown asks, then sends SIGINT, SIGTERM and SIGKILL, a moment apart; the context's end, Command.Timeout and the workspace's Close end it too, with the same signals, as an interrupted Exec does; each ends everything it started, and when it exits by itself what it left running goes with it. Starter is an interface of its own, beside Workspace as fs.ReadLinkFS is beside fs.FS, so a workspace that only stores files, or a runtime that runs one command at a time, is still a Workspace.

A command started with Command.Terminal runs on a pseudo-terminal of that size, its standard error merged into its output, and agentworkspace.Resize changes the size; a backend that cannot give one refuses the start with errors.ErrUnsupported rather than run it on pipes. A workspace that is a Connector opens a byte stream to a TCP port on its loopback (agentworkspace.Connect), and Forward exposes one on this machine's loopback alone:

ln, err := agentworkspace.Forward(ctx, ws, 8080) // a dev server in the workspace
// ln.Addr() is 127.0.0.1:<port>; each connection to it is a stream to 8080 there.

Servers a workspace offers

A workspace may hold MCP servers of its own, such as one installed in a container's image or one a host runs beside a sandbox: such a workspace is a ServerProvider. Servers lists them (a Server is a name, as ValidServerName holds it, and a description), and StartServer starts one by name as a Process whose pipes carry the MCP stream, with Start's lifetime. A name it does not offer is ErrNoServer. Which servers a session connects to stays the program's choice. Offer makes one from a Starter and a command per server, for a workspace whose servers are programs installed in it:

ws, err := agentworkspace.Offer(local, []agentworkspace.OfferedServer{
	{Server: agentworkspace.Server{Name: "rag", Description: "the project's index"},
		Command: agentworkspace.Command{Args: []string{"rag-mcp", "--stdio"}}},
})

A server given a credential names it in Secrets, each a variable its Command.Env sets once, and WithOutputFilter has the Offering read that server's standard output and standard error through a filter told the values, so a server that quotes its own token (an env tool, an error message) does not hand it to the session. The root imports no redactor; agentredact's standard-library-only secrets module is the filter it is shaped for:

ws, err := agentworkspace.Offer(local, []agentworkspace.OfferedServer{
	{Server: agentworkspace.Server{Name: "github"},
		Command: agentworkspace.Command{Args: []string{"github-mcp", "stdio"}, Env: []string{"GITHUB_TOKEN=" + tok}},
		Secrets: []string{"GITHUB_TOKEN"}},
}, agentworkspace.WithOutputFilter(func(_ string, values map[string]string, r io.Reader) io.Reader {
	return secrets.New(values).Reader(r, nil) // github.com/ChristopherDavenport/agentredact/secrets
}))

Offer fails closed: a server that names secrets is refused without a filter, and so is a secret whose value is empty. The filter is told its own copy of the values, once for each stream, and the two are read at once; a filter that gives no reader, or panics, fails the start, and one that holds standard error back is given a short grace when the server ends. Standard error is never forwarded in part: when it cannot be filtered whole and in order (the filter more than a megabyte behind, so the lag can reach about twice that; the grace past; the filter failing) it is cut for the rest of the server's life, after one line of this package's own, agentworkspace: <server>: standard error cut (<reason>), and nothing of the server's follows. Standard output is not cut. A secret holding a newline (a PEM key) is not found on its raw spelling by a filter that works a line at a time, as agentredact's secrets does.

It is a backstop: a value the server encodes otherwise (base64, split across messages) passes through, and a very short value ("1", "true") is replaced inside the server's JSON-RPC too, which breaks the conversation rather than leak. It covers the offered servers' own output and nothing else: an Offering embeds its Starter, so its Start and Exec run anything unfiltered, as the same user, and a client of a workspace that runs its commands (one served through mcpworkspace.NewProcesses answers process.start as well as servers.start, and remote with exec runs Exec and Start) can read a running server's environment from /proc/<pid>/environ, and a client that may write the workspace can change a server's code, config or PATH there and have it print its secret in an encoding no filter looks for. A secret-holding server is offered safely only from a workspace the client can neither run commands in nor write, as dax's box host does, each server a unit in its own container, or through remote (or tunnel) to a principal holding the servers scope alone: exec exposes the environment through /proc, write defeats any filter.

Over MCP: mcpworkspace

github.com/ChristopherDavenport/agentworkspace/mcpworkspace is for a program whose tools act in a workspace at the other end of an MCP connection it already has, such as a sandbox's: the server half answers for a Workspace, and the client half is that workspace's Starter and read-only file system on this side. It rides the program's connection rather than dialing one, so a program that relays one stream (stdio through a container's exec) carries the workspace on it too.

// In the sandbox, on each session's server:
mcpworkspace.ServeFiles(srv, ws.FS())
procs := mcpworkspace.NewProcesses(ws) // ws is a Starter
err := procs.Register(srv)

// On this side, before the client connects:
err = mcpworkspace.RegisterClient(client)
// ... then, once it has, with a context that ends with the connection:
done, end := context.WithCancel(context.Background())
c := mcpworkspace.Conn{Session: cs, Bounds: wire.DefaultBounds(), Done: done}
c.Reach, c.Bounds, err = mcpworkspace.ProcessInfo(ctx, cs) // what the server keeps
p, err := mcpworkspace.Start(ctx, c, cmd)
out, err := mcpworkspace.Exec(ctx, c, cmd) // Workspace.Exec's rules, over the process methods
fsys := mcpworkspace.NewFS(mcpworkspace.SessionSource(cs))

// And to hang up: end Done (or close every process) first, since the
// session's close waits for the requests in flight and a process's wait
// is one for as long as it runs. The server ends the processes when the
// session ends.
end()
cs.Close()

Two transports connect a program's MCP client to a stdio server in a workspace, here or at the other end of the connection, each Connect starting a new one, so a client that connects again after a server ended gets a fresh server:

// A server the program names a command for, started in the workspace:
t := mcpworkspace.CommandTransport(ws, agentworkspace.Command{Args: []string{"my-mcp-server"}})
// A server the workspace offers, by name; over a connection, the
// workspace there is mcpworkspace.Servers(conn):
t = mcpworkspace.ServerTransport(mcpworkspace.Servers(conn), "rag", serverLog)

A server the program reaches on its own, on this machine or at a URL, needs neither: it is the SDK's command or streamable HTTP transport. On the server half, NewProcesses answers agentworkspace/servers.list and servers.start when its workspace is a ServerProvider; on the client half, ListServers and StartServer call them, and a server that does not answer them offers none.

The processes are custom JSON-RPC methods (agentworkspace/process.*), never tools, so nothing offers them to a model; the files are one read-only resource template, agentworkspace:///{op}{?path}. Package mcpworkspace/wire names the protocol in the standard library alone, for a program that only relays or answers the methods. What the connection is, how it is authenticated and whether it is made again are the program's.

Over HTTP: remote

Package remote serves any workspace over HTTP and is one at the other end: every file operation, write, Exec and started process crosses the wire on its own, and the tools and their policy stay on the client's side (the primitive cut; a sandbox that should run the tools themselves is cut at the tool calls instead, as dax's executor is).

// The server: a plain http.Handler; TLS, the listener and the
// authentication are the program's. authenticate is the program's own:
// it says who a request is from and what that principal may do.
h, err := remote.NewHandler(ws, remote.WithAuthenticator(func(r *http.Request) (remote.Principal, error) {
	name, err := authenticate(r) // the program's check of its credentials
	if err != nil {
		return remote.Principal{}, err
	}
	return remote.Principal{Name: name, Scopes: []remote.Scope{remote.ScopeRead, remote.ScopeWrite, remote.ScopeExec}}, nil
}), remote.WithChallenge("Bearer")) // a 401's WWW-Authenticate names the program's scheme
defer h.Close() // ends every session's processes
http.Handle("/", h)

// The client: a Workspace, a Starter when the server can start and
// the principal holds exec, and a ServerProvider when the server offers
// MCP servers and the principal holds servers. Its credentials are in
// the program's HTTP client (a Transport that sets the Authorization
// header, or a client certificate).
ws, err := remote.Dial(ctx, "https://host/", remote.WithHTTPClient(hc))
defer ws.Close()

The package ships no authentication: it asks the program's Authenticator who each request is from and grants that principal's scopes, and the client sends whatever the program's http.Client sends. Every request is put to the authenticator, whatever its route; with no authenticator every request is refused, unless the handler was made WithInsecureNoAuth, and such a handler answers only a loopback Host. A request that looks like a browser page's (an Origin, a cross-site Sec-Fetch-Site, a body of another type than its route's) is refused in every mode. read covers the files' reads, write writes and removes, and exec runs anything: Exec, the processes and the environment, which a client without it is not told. exec is never implied by read or write. A served ServerProvider's MCP servers take a scope of their own, servers: the client lists them and starts one by name (Servers, StartServer), each a process of its session, and runs no command; servers and exec imply neither the other, servers needs no read to dial (a client holding it alone reads no file: its FS, WriteFile and Remove are ErrForbidden), and a process's routes take the scope it was started under. A session is its principal's, so its close ends every process in it, whichever scope started it. The server starts it, so an Offering's OutputFilter reads its output before a byte is sent, and its secrets never cross the wire. Dial's client is a Starter exactly when the server can start and the principal holds exec, and a ServerProvider exactly when the server offers servers and the principal holds servers (*Client, *StarterClient, *ServerClient, *StarterServerClient). A client's processes live in a session it opens at its first Start or StartServer and renews on a lease (DefaultLease); when the lease lapses, or the client closes, the server ends every process the session started, and a process is reached only through its own session, by the principal that opened it. Exec streams its output as NDJSON frames, and its process ends with the request. Errors keep their sentinels across the wire (ErrOutside, fs.ErrNotExist and the rest), and every bound (body sizes, sessions, processes per session, a listing) is an exported constant or option. The routes are in the package doc.

Behind NAT: tunnel

github.com/ChristopherDavenport/agentworkspace/tunnel is for a workspace the server cannot dial. Its host dials out over a WebSocket, and serves the workspace to the server through it, on remote's wire unchanged: the host is the HTTP/2 server inside the tunnel (h2c on the WebSocket's bytes), the server the client.

// The workspace's host: dial the hub and serve ws until ctx ends,
// dialling again after a drop. The scopes are this end's to grant; the
// credentials are the application's, in its HTTP client or a header.
err := tunnel.Serve(ctx, "wss://hub.example/tunnel", ws,
	tunnel.WithScopes(remote.ScopeRead, remote.ScopeWrite, remote.ScopeExec),
	tunnel.WithHeader(func(ctx context.Context) (http.Header, error) {
		return http.Header{"Authorization": {"Bearer " + credential()}}, nil
	}))

// The server: a Hub mounted where the hosts dial. The enrollment is the
// application's authentication: it names the workspace an upgrade is
// from, or refuses it, before the upgrade is answered.
hub, err := tunnel.NewHub(tunnel.WithEnrollment(func(r *http.Request) (string, error) {
	return enrolled(r) // "build-1", or an error
}), tunnel.WithChallenge("Bearer"))
defer hub.Close()
http.Handle("/tunnel", hub)

ws, err := hub.Workspace(ctx, "build-1") // waits for it to connect, bounded by ctx
defer ws.Close()

Over wss://, TLS proves the hub to the host, and a bearer credential in the upgrade, as above, is safe. Over a ws:// loopback URL, as behind an ssh -R forward, whoever binds the port first gets the upgrade and everything in it, so there the upgrade carries no bearer token or other reusable credential, only what is good for one dial, and the application proves the hub itself: the Hub adds its headers to the 101 (WithProofHeader), and Serve checks them (WithHubProof) before it serves anything. The package doc, and its HubProof example, show such a proof: an HMAC under a key both ends hold, never sent, over a nonce the hub issues before each upgrade (statelessly, MACed under a secret of its own) and takes once within a short lifetime, both MACs binding the workspace's name and the run's instance id. It holds against an impostor that cannot reach the hub (the ssh -R case, with ExitOnForwardFailure): an upgrade it captured is over a nonce no hub issued. It binds no connection, so one that can reach the hub relays to it, and where the hub is reachable, use wss://. One that fetched real nonces before taking the port gets a replay within their lifetime for each upgrade it collects, so the workspace end stops, rather than dial again, at a hub that fails the proof (ending Serve from the hook, which otherwise tries again within the lease after a connection that served) and after a few upgrades in a row left unanswered.

One remote.Handler serves every connection, so a session and its processes outlive a drop shorter than the lease, and the server's client renews through the next connection; what was in flight, a process's open pipes included, fails and is not sent again. A drop past the lease ends the processes, as over HTTP. A second connection for a connected name replaces the first. HTTP/2 pings notice a dead tunnel at both ends; a WebSocket message, the workspaces a hub holds and the streams a tunnel carries are bounded. A workspace's offered MCP servers cross as they do over HTTP: WithScopes(remote.ScopeServers) grants the server the servers alone, no file and no command, its Workspace a ServerProvider and no Starter. The package doc has the details.

Planned

  • Container: a remote workspace whose server runs in a container.
  • The built-in tools over a Workspace (tools/), when a second product wants them, and a persistent shell built on Start.

The design is dax's proposal (docs/plans/proposals/agentworkspace-module.md) and the openhands-workspace study it came from.

Development

make check    # gofmt, tidy, vet, deps, staticcheck, govulncheck, race tests, guard tests

Documentation

Overview

Package agentworkspace is where an agent's tools act: a file tree, processes, a working directory and an environment. A session runs over one Workspace, whichever machine it is on, and every tool that touches files or runs a command goes through it, so a tool written for one runs unchanged on another. Local is the working directory on this machine; a container or a remote runtime is another implementation of the same interface.

The interface is the one the openhands-workspace study settled on: a small value with a file system, one-shot execution and a descriptor the session records. A workspace that can run a process for longer than one call, with pipes to it, is also a Starter.

The package is pre-1.0: its API may change in a minor version.

Index

Constants

View Source
const (
	KindLocal     = "local"
	KindContainer = "container"
	KindRemote    = "remote"
)

Kinds a Descriptor names, as agentsession's env entry records them.

Variables

View Source
var ErrNoServer = errors.New("no such server in this workspace")

ErrNoServer is a server name a ServerProvider does not offer.

View Source
var ErrOutputFilter = errors.New("the server's output filter failed")

ErrOutputFilter is an OutputFilter that failed: one that gave no reader, or panicked in its call or in a read. The error that wraps it holds the panic's value, which may hold what the filter was given, so a backend that carries a server's start or output across a wire sends this error's words alone, never the wrapping error's.

View Source
var ErrOutside = errors.New("path is outside the workspace")

ErrOutside is what a name that leaves the workspace gets, whether it names another directory outright, climbs out with "..", or goes out through a symbolic link.

Functions

func Forward added in v0.0.11

func Forward(ctx context.Context, ws Workspace, port int) (net.Listener, error)

Forward listens on this machine's loopback alone (127.0.0.1, on a port the system picks) and, for each connection it accepts, opens one stream to port in ws (Connect) and copies both ways until either side ends. It returns the listener, whose Addr is where to connect; closing it, or the end of ctx, stops it and ends every stream it opened. It is a binding helper over Connect: a workspace that is no Connector is errors.ErrUnsupported.

func Read

func Read(w Workspace, name string, max int64) ([]byte, error)

Read is fs.ReadFile on w's files, at most max bytes of a regular file: a FIFO, a device or a directory is an error, and so is a file over max.

func Resize added in v0.0.11

func Resize(p Process, size TerminalSize) error

Resize changes the size of p's terminal, and is errors.ErrUnsupported for a process that was started without one.

func ValidServerName added in v0.0.3

func ValidServerName(name string) bool

ValidServerName reports whether name may name a Server: one to 32 lowercase letters, digits and inner hyphens, beginning and ending with a letter or a digit. It has no underscore, so a program can prefix a server's tools with its name and a separator of underscores (mcp__<name>__<tool>) without two servers' tools running together.

Types

type Command

type Command struct {
	// Args is argv. Args[0] with no slash is looked up as execvp(3)
	// does, on the PATH of the environment the process starts with
	// (Workspace.Env, then Env), as the workspace's own processes see
	// the file system, never on this process's: a relative PATH entry,
	// the empty one included, is skipped, and with no PATH the name is
	// not found. One with a slash is used as it is, relative to Dir.
	// The process gets Args[0] as given.
	Args []string
	// Dir is relative to Root; "" is Root. A Dir that leaves the
	// workspace, by name or through a link, is ErrOutside.
	Dir string
	// Env is over Workspace.Env, name by name: an entry replaces the
	// workspace's of the same name, and a later entry an earlier one,
	// so each name reaches the process once.
	Env []string
	// Stdin is the process's standard input; nil is none. Start refuses
	// it: a started process's input is Process.Stdin.
	Stdin []byte
	// Timeout ends the process, and everything it started, after this
	// long; zero is no limit but the context's.
	Timeout time.Duration
	// Stream, when set, receives the output the caller is not otherwise
	// given, as it arrives. For Exec that is standard output and
	// standard error, interleaved, and Output holds neither. For Start
	// it is standard error, standard output being Process.Stdout; nil
	// discards it.
	Stream io.Writer
	// Terminal, when set, runs the command, Start's alone, with a
	// pseudo-terminal of this size as its controlling terminal, in a
	// session of its own: Process.Stdin is the terminal's input and
	// Process.Stdout its output, standard error merged into it, and
	// what is written is echoed as the terminal echoes it. Closing
	// Stdin sends the terminal's end of file (^D). Resize changes the
	// size. A Stream is refused beside it, since standard error is in
	// the output, and Exec refuses it, since it gives the two apart. A
	// backend that cannot give a terminal refuses the start with
	// errors.ErrUnsupported; it never runs the command on pipes instead.
	Terminal *TerminalSize
}

Command is one process to run.

type Connector added in v0.0.11

type Connector interface {
	Workspace
	// Connect opens one stream to port on the workspace's loopback.
	// ctx bounds the connecting alone; the stream lives until its
	// Close, or the workspace's.
	Connect(ctx context.Context, port int) (PortConn, error)
}

Connector is a Workspace that can open a byte stream to a TCP port on its loopback, as its own processes reach it: a server a command started there, reached from the program's side. Connecting is as much power as running a command, and a wire grants it with exec.

type Descriptor

type Descriptor struct {
	// Kind is KindLocal, KindContainer or KindRemote.
	Kind string
	// Ref is what the workspace was made from or is reached by: a
	// container's image digest, a remote's endpoint. It is empty for
	// KindLocal. The machine and the instance are Host and Instance,
	// never Ref.
	Ref string
	// Host names the machine the file system is on, as the program
	// names it: a KindLocal workspace sets it to a name that tells this
	// machine from the others a session may be resumed on, and a
	// container one when its runtime is not on the agent's machine. A
	// host name lands in session files a program may publish.
	Host string
	// Instance names the file system among those Kind, Ref and Host
	// describe: a container's or a sandbox's ID, set whenever a restart
	// gives a new file system.
	Instance string
	// Root is Workspace.Root: absolute, or "" when it is withheld.
	Root string
}

Descriptor is what the session records about a workspace, in its env entry: agentsession's workspace members (kind, ref, host and instance, every fact that tells this file system from another the session could be resumed against) and the root, which is the entry's cwd. Each is a string a reader compares exactly; one that is empty is not written.

type Local

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

Local is a directory on this machine. Every file operation goes through an os.Root, which the kernel-facing layer of the standard library keeps from following a name out of the directory, so a link inside it that points outside is refused rather than followed. A process it runs is not confined: a shell reaches whatever the user does, and the policy is what stands in front of it.

Local runs on Unix: its processes run in process groups of their own, and its files are opened without blocking. Ending a process ends its group and, on Linux, the descendants that left it (Reach).

func NewLocal

func NewLocal(dir string, env []string, opts ...LocalOption) (*Local, error)

NewLocal opens dir as a workspace whose processes start with env and nothing else: nil is an empty environment, not this process's. A product passes this process's environment without its credentials.

func (*Local) Close

func (l *Local) Close() error

Close ends every process Start started that is not closed yet, as Process.Close does, and releases the directory handle.

func (*Local) Connect added in v0.0.11

func (l *Local) Connect(ctx context.Context, port int) (PortConn, error)

Connect opens a stream to port on this machine's loopback, which is the workspace's: 127.0.0.1, and ::1 when nothing listens there. The workspace's Close ends it.

func (*Local) Descriptor

func (l *Local) Descriptor() Descriptor

Descriptor names the local machine: KindLocal, no Ref, Host the system's host name as NewLocal found it (or WithHost's, or hostOr's fallback when the system gives none), and the root.

func (*Local) Env

func (l *Local) Env() []string

Env is the environment its processes start with, a copy: a caller that changes it changes nothing a later process starts with.

func (*Local) Exec

func (l *Local) Exec(ctx context.Context, c Command) (*Output, error)

Exec runs a command in its own process group. When ctx ends, or Command.Timeout, before the command exits, Exec ends it and what it started with Local's ladder (SIGINT, then SIGTERM, then SIGKILL, a grace apart; see Process) and returns the output so far, with ExitCode -1 and Interrupted or TimedOut; one that ends after the command exited leaves its status as it was. It waits at most two seconds past the command's exit for output its children still hold open.

func (*Local) FS

func (l *Local) FS() fs.FS

FS reads through the root, opening without blocking.

func (*Local) Reach added in v0.0.11

func (l *Local) Reach() Reach

Reach is ReachDescendants on Linux while /proc is this process's, where Local walks it for the descendants that left a process's group before it signals, and ReachGroup otherwise (no /proc, an empty one, another PID namespace's, or macOS), where it signals the group alone. It is asked of /proc at each call. On either, a descendant whose parent exited before the end began is not found.

func (*Local) Real

func (l *Local) Real() string

Real is the directory with its links resolved, in which an absolute path the model wrote may be spelled.

func (*Local) Remove

func (l *Local) Remove(_ context.Context, name string) error

Remove deletes a file or an empty directory under the root.

func (*Local) Root

func (l *Local) Root() string

Root is the directory, absolute and cleaned.

func (*Local) Start

func (l *Local) Start(ctx context.Context, c Command) (Process, error)

Start runs a command in its own process group, with pipes for its standard input and output, and returns at once. Its standard error goes to Command.Stream, or nowhere. The process, and everything it started, is ended by Process.Close, by the end of ctx, by Command.Timeout, or by the workspace's Close, each with Local's ladder (SIGINT, SIGTERM, SIGKILL, a grace apart), which Close begins by closing the process's input; when it exits by itself, anything left in its group is killed.

With Command.Terminal, on Linux, the process runs in a session of its own on a pseudo-terminal; elsewhere the start is errors.ErrUnsupported.

func (*Local) WriteFile

func (l *Local) WriteFile(_ context.Context, name string, data []byte, perm fs.FileMode) error

WriteFile creates or replaces a regular file under the root.

type LocalOption added in v0.0.11

type LocalOption func(*localConfig)

A LocalOption changes how NewLocal opens a workspace.

func WithHost added in v0.0.11

func WithHost(name string) LocalOption

WithHost names this machine in the Descriptor, in place of the system's host name, which two machines a session may move between can share. NewLocal refuses an empty name.

type OfferOption added in v0.0.4

type OfferOption func(*offerConfig)

An OfferOption changes how an Offering starts its servers. The options are this package's own, such as WithOutputFilter.

func WithOutputFilter added in v0.0.4

func WithOutputFilter(f OutputFilter) OfferOption

WithOutputFilter has every server that names Secrets started with its standard output and standard error read through f. A server that names none is started as without it. It covers what the server writes, and nothing else the workspace runs: see Offer.

type OfferedServer added in v0.0.3

type OfferedServer struct {
	Server
	Command Command
	// Secrets name the variables of Command.Env whose values are
	// secrets, such as the server's token: an Offering with an
	// OutputFilter reads the server's output through it, told these
	// values. A client is never told of them.
	Secrets []string
}

OfferedServer is one server Offer offers: the Server a client is told of, and the command that starts it.

type Offering added in v0.0.3

type Offering struct {
	Starter
	// contains filtered or unexported fields
}

Offering is a Starter offering servers that are commands in it, which Offer makes.

func Offer added in v0.0.3

func Offer(ws Starter, servers []OfferedServer, opts ...OfferOption) (*Offering, error)

Offer returns ws offering servers, each started from its command in ws, at its Dir with its Env, as Start starts any command: a ServerProvider for a workspace whose servers are programs installed in it. It is also a Starter, as ws is, so a program that starts commands of its own in the workspace can keep doing so. A server's Command.Stdin is refused when it starts, as Start refuses it, and its Command.Stream is replaced by StartServer's stderr. A name that is not ValidServerName is an error, and so is a name given twice, and so is a nil ws. A server's Secrets must each name a variable its Command.Env sets exactly once, to a value that is not empty, and a server that names any is refused unless WithOutputFilter is given: nothing would keep them out of its output. A nil option is skipped. The commands are copied: changing the slices given changes nothing Offer offers.

The filter covers the offered servers' own output and nothing else. An Offering embeds ws, so its Start and Exec run whatever they are given, unfiltered, as the same user as the servers; a client of a workspace that runs its commands (one served through mcpworkspace's NewProcesses answers both servers.start and process.start, and one served through remote to a principal holding exec runs its Exec and Start) can read a running server's environment, its secrets among it, from /proc/<pid>/environ. And a client that may write the workspace can change a server's code, config or PATH wherever they lie in it, and have the server print its secret in an encoding the filter does not know: no filter survives that. Offer secret-holding servers from a workspace the client can neither run commands in nor write, as dax's box host does with each server a unit in its own container, or serve the Offering through remote to a principal holding the servers scope alone.

func (*Offering) Servers added in v0.0.3

func (o *Offering) Servers(context.Context) ([]Server, error)

Servers is the servers offered, by name.

func (*Offering) StartServer added in v0.0.3

func (o *Offering) StartServer(ctx context.Context, name string, stderr io.Writer) (Process, error)

StartServer starts the server called name from its command. With an OutputFilter, a server that names Secrets has its standard output and its standard error read through the filter.

What the filter gives of standard error is written to stderr by the time Wait or Close returns. Nothing reaches stderr that the filter has not seen whole and in order: when standard error cannot be filtered so, it is cut, for the rest of the server's life, after one line of this package's own saying why ("agentworkspace: <server>: standard error cut (<reason>)"), and nothing of the server's comes after it. It is cut when the filter falls behind by more than stderrBacklog (a megabyte; with what the filter is reading, the lag can reach about twice that), so the workspace's writes never wait on the filter; when the filter still holds some of it a short grace after the process has exited, so a filter that holds on does not hold up the host; and when the filter fails or panics. When it is stderr itself that stalls past the grace, Wait and Close return without it, and the notice is written once stderr takes writes again. Standard error the workspace writes after its process's Wait returns is dropped (io.ErrClosedPipe); Local writes all of it before. Standard output is not cut: a filter's failure there is the read's error.

type Output

type Output struct {
	Stdout, Stderr []byte
	// ExitCode is the process's status; -1 when it was killed, or
	// ended by its timeout or its context.
	ExitCode int
	// TimedOut reports that Command.Timeout ended it.
	TimedOut bool
	// Interrupted reports that the end of Exec's context ended it. The
	// Output is then what the command wrote until its end, returned
	// beside the context's error.
	Interrupted bool
}

Output is how a process ended.

type OutputFilter added in v0.0.4

type OutputFilter func(server string, secrets map[string]string, r io.Reader) io.Reader

An OutputFilter is what a server's output is read through on its way out of an Offering: r is the server's standard output, or its standard error, and secrets are the values of the variables the server names as Secrets, by name, a copy of its own on each call. It is called twice for each start, once for each stream, and the two readers are read at once. The reader it returns must pass a line on as soon as it has it, since the MCP stream on standard output is a line at a time, and give what it holds back at r's end of file. A nil reader is refused: the start fails, and the server is ended.

A secret is replaced wherever its value appears, so a very short one ("1", "true") is replaced inside the server's own JSON-RPC too, which breaks the conversation rather than leak; nothing here holds a value to a length. A value holding a newline, such as a PEM key, is not found on its raw spelling by a filter that works a line at a time, as agentredact's secrets does (its JSON spellings, with \n escaped, are found); such values are taken, not refused.

agentredact's secrets module is the filter this is shaped for, each value replaced in every spelling a JSON encoder writes it in:

agentworkspace.WithOutputFilter(func(_ string, values map[string]string, r io.Reader) io.Reader {
	return secrets.New(values).Reader(r, nil)
})

It is a backstop, not a guarantee: a value the server encodes otherwise (base64, split across messages) passes through.

type PortConn added in v0.0.11

type PortConn interface {
	io.ReadWriteCloser
	// CloseWrite ends what is sent, as a TCP half-close does: the port
	// reads end of file, and this side may still read.
	CloseWrite() error
}

PortConn is one byte stream to a TCP port in a workspace: what is written is sent to the port, and what the port sends is read.

func Connect added in v0.0.11

func Connect(ctx context.Context, ws Workspace, port int) (PortConn, error)

Connect opens a stream to port in ws if ws is a Connector, and otherwise returns an error that errors.Is errors.ErrUnsupported. A port outside 1 to 65535 is fs.ErrInvalid.

type Process

type Process interface {
	// Stdin is the process's standard input. Closing it is end of file
	// for the process.
	Stdin() io.WriteCloser
	// Stdout is the process's standard output. It reaches end of file
	// once the process and everything it started have closed it.
	Stdout() io.ReadCloser
	// Wait waits for the process to exit and returns its status, -1 if
	// a signal ended it. err is the context's error when the context or
	// Command.Timeout ended it, and otherwise nil unless waiting itself
	// failed; an exit status other than zero is not an error. Wait may
	// be called any number of times and returns the same each time.
	Wait() (status int, err error)
	// Signal sends sig to the process and to everything it started, as
	// far as the workspace's Reach goes. It is os.ErrProcessDone once
	// the process has exited.
	Signal(sig os.Signal) error
	// Close ends the process, if it is still running, and everything it
	// started, and releases the pipes. It first closes Stdin and gives
	// the process a moment to exit, as the MCP stdio transport's
	// shutdown asks, then climbs the ladder every end of a process
	// climbs (the context's, Command.Timeout's, Exec's): it interrupts
	// them (SIGINT), then terminates them (SIGTERM), then kills them
	// (SIGKILL), a moment apart. It returns once the process has
	// exited, and may be called more than once.
	Close() error
}

Process is a process started in a workspace. Its methods are safe for concurrent use: one goroutine may read Stdout while another writes Stdin and a third waits.

func Start

func Start(ctx context.Context, ws Workspace, cmd Command) (Process, error)

Start starts cmd in ws if ws is a Starter, and otherwise returns an error that errors.Is errors.ErrUnsupported.

type Reach added in v0.0.11

type Reach string

Reach is how far a Starter's end of a process reaches among what the process started. Every end (Process.Close, the context's, the timeout's, the workspace's Close, and Exec's) is meant to end every process the process started, directly or not, whatever group or session it moved to; a backend that cannot find some of them says which with its Reach, and a caller asks for it with ReachOf.

const (
	// ReachGroup is the process's group alone: a descendant that
	// called setsid or setpgid, or that a shell's job control put in a
	// group of its own, outlives the end.
	ReachGroup Reach = "group"
	// ReachDescendants is the group and every descendant found through
	// parent links when a signal is sent, whatever its group or
	// session. A descendant whose parent exited before any signal was
	// sent has been given to another parent, and is not found.
	ReachDescendants Reach = "descendants"
	// ReachAll is every descendant, orphaned or not, as when the
	// backend ends a PID namespace or a cgroup whole.
	ReachAll Reach = "all"
)

func ReachOf added in v0.0.11

func ReachOf(ws Workspace) Reach

ReachOf says how far ending a process started in ws reaches, as ws says with a Reach method, and "" when ws does not say. "" is not a claim of ReachAll.

type Resizer added in v0.0.11

type Resizer interface {
	Resize(size TerminalSize) error
}

Resizer is a Process that can change its terminal's size, as one started with Command.Terminal can; the process's foreground group is sent SIGWINCH.

type Server added in v0.0.3

type Server struct {
	// Name is what the server is called, as ValidServerName holds it.
	Name string
	// Description says what the server is for, for a person choosing
	// among them; empty when the workspace says nothing.
	Description string
	// Secrets says the workspace gives the server a secret (an
	// OfferedServer that names Secrets); no name or value of one is
	// told. It informs a listing and no more: a listing is the
	// workspace's own word, and a caller must not take a server
	// without the mark to hold no secret.
	Secrets bool
}

Server is an MCP server a workspace offers: one it holds of its own, such as a server installed in a container's image or one a host runs beside a sandbox, rather than one the program names a command for.

type ServerProvider added in v0.0.3

type ServerProvider interface {
	// Servers lists the servers the workspace offers, in a stable order.
	Servers(ctx context.Context) ([]Server, error)
	// StartServer starts the server called name and returns at once, as
	// Starter's Start does, with the same lifetime: Process.Close, the
	// end of ctx and the workspace's Close each end it and everything it
	// started, and a caller that is done with it calls Close. Its
	// standard input and output carry the MCP stream, and its standard
	// error goes to stderr, or nowhere when stderr is nil. A name the
	// workspace does not offer is an error that errors.Is ErrNoServer.
	StartServer(ctx context.Context, name string, stderr io.Writer) (Process, error)
}

ServerProvider is a workspace that offers MCP servers of its own, each a stdio server it starts by name: the program lists them and connects to the ones it wants over the process's pipes, as it would to a server it started from a command. It is an interface of its own, as Starter is, so that a workspace offering none is still a Workspace; a caller asks for it with a type assertion. A client of a workspace elsewhere implements it too: mcpworkspace's, without being a Workspace, which is why it does not embed one, and remote's, when its principal holds the servers scope.

Which of the servers a session uses is the program's to decide: a workspace offering a server does not make the program connect to it.

type Starter

type Starter interface {
	Workspace
	// Start starts cmd and returns at once. The process lives no longer
	// than its owner: Process.Close, the end of ctx, cmd.Timeout and
	// the workspace's Close each end it and everything it started, and
	// when it exits by itself, what it started goes with it. A caller
	// that is done with it calls Close, even after it exited.
	Start(ctx context.Context, cmd Command) (Process, error)
}

Starter is a Workspace that can run a process for longer than one call, with pipes to it: an MCP server over stdio, a persistent shell. It is an interface of its own, as fs.ReadLinkFS is beside fs.FS, so that a workspace that only stores files, or a runtime whose API runs one command at a time, is still a Workspace; a caller asks for it with a type assertion, or calls Start.

type TerminalSize added in v0.0.11

type TerminalSize struct {
	Rows, Cols uint16
}

TerminalSize is a terminal's size in character cells.

type Workspace

type Workspace interface {
	// Root is the absolute path of the working directory as the
	// workspace's own processes see it: the checkout on this machine,
	// /workspace in a container. It is what the model is told and what
	// the session records as its cwd. It is "" when the workspace does
	// not tell this side its root (a remote client whose principal may
	// not read), never a partial path: a caller tests for "" before it
	// joins a name with it or compares it with another.
	Root() string
	// FS reads the workspace's files. Opening a name never blocks: a
	// FIFO or a device opens, or fails, at once, and a tool checks what
	// it opened is a regular file. A name that leaves the workspace is
	// an error that errors.Is ErrOutside. An FS that can tell a link
	// from what it leads to implements fs.ReadLinkFS; one that cannot
	// leaves a check that must follow links unable to, and so asking.
	FS() fs.FS
	// WriteFile creates or replaces a regular file, creating the
	// directories above it. It refuses a FIFO or a device where the file
	// is, and does not block on one.
	WriteFile(ctx context.Context, name string, data []byte, perm fs.FileMode) error
	// Remove deletes a file or an empty directory.
	Remove(ctx context.Context, name string) error
	// Env is the environment every process in the workspace starts
	// with: what the workspace was given, a copy, with nothing added or
	// removed. A workspace keeps no credential out of it; leaving the
	// provider's key and other secrets out of what it is given is the
	// program's.
	Env() []string
	// Exec runs one command to completion in a fresh process. An exit
	// status other than zero is an Output, not an error. When
	// Command.Timeout ends the command, Exec returns its Output with
	// ExitCode -1 and TimedOut, and no error. When ctx ends first, Exec
	// ends the command and everything it started, and returns the
	// output collected so far, with ExitCode -1 and Interrupted, beside
	// ctx.Err(); a ctx that has ended before the command starts runs
	// nothing and gives no Output.
	Exec(ctx context.Context, cmd Command) (*Output, error)
	// Descriptor is what the session records about where the tools ran.
	Descriptor() Descriptor
	// Close releases the workspace. It is used until Close and not after.
	Close() error
}

Workspace is where a session's tools act. Names given to it are fs.ValidPath names relative to Root; turning a path the model wrote into one is the tool's. Its methods are safe for concurrent use, and Root and Descriptor answer the same every time.

Directories

Path Synopsis
internal
lifetime
Package lifetime holds what Local's processes and the conformance tables that check them must agree on, so the two cannot drift.
Package lifetime holds what Local's processes and the conformance tables that check them must agree on, so the two cannot drift.
portproc
Package portproc gives a stream to a port in a workspace the shape of a started process, so that a wire carries it on its process routes and methods (its input, its output, its close) rather than on a carrier of its own: a connect is a start whose process is the stream.
Package portproc gives a stream to a port in a workspace the shape of a started process, so that a wire carries it on its process routes and methods (its input, its output, its close) rather than on a carrier of its own: a connect is a start whose process is the stream.
wireerr
Package wireerr holds what every wire's server sends of a refusal (RFC 0001 §15), so that remote and mcpworkspace cannot drift.
Package wireerr holds what every wire's server sends of a refusal (RFC 0001 §15), so that remote and mcpworkspace cannot drift.
mcpworkspace module
Package remote serves an agentworkspace.Workspace over HTTP, and is one at the other end: NewHandler serves any workspace, and Dial returns a workspace whose files, writes, environment, Exec and, when the served workspace is an agentworkspace.Starter, Start are the served one's, and, when it is an agentworkspace.ServerProvider, so are the MCP servers it offers.
Package remote serves an agentworkspace.Workspace over HTTP, and is one at the other end: NewHandler serves any workspace, and Dial returns a workspace whose files, writes, environment, Exec and, when the served workspace is an agentworkspace.Starter, Start are the served one's, and, when it is an agentworkspace.ServerProvider, so are the MCP servers it offers.
tunnel module
Package workspacetest is the conformance tables every agentworkspace backend runs: Run, the rules every Workspace keeps (confinement, nothing blocking on a FIFO, Env a copy, Exec, names that are not valid paths), RunStarter, the rules every Starter keeps (the pipes, the environment, the lifetimes, the refusals), and RunServerProvider, the rules every ServerProvider keeps (the list, the pipes, ErrNoServer, the lifetimes, standard error, secrets kept out of the output).
Package workspacetest is the conformance tables every agentworkspace backend runs: Run, the rules every Workspace keeps (confinement, nothing blocking on a FIFO, Env a copy, Exec, names that are not valid paths), RunStarter, the rules every Starter keeps (the pipes, the environment, the lifetimes, the refusals), and RunServerProvider, the rules every ServerProvider keeps (the list, the pipes, ErrNoServer, the lifetimes, standard error, secrets kept out of the output).

Jump to

Keyboard shortcuts

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