agentworkspace

package module
v0.0.1 Latest Latest
Warning

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

Go to latest
Published: Oct 9, 2026 License: MIT Imports: 15 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.

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, Root) is what a session records about where its tools ran, as agentsession's env entry keeps it.

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 kills what it started too. Local runs on Linux and macOS.

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 SIGTERM, then SIGKILL; the context's end, Command.Timeout and the workspace's Close end it too; 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.

Planned

  • remote: Handler(ws), an http.Handler serving any workspace, and Dial(url), a workspace over it, with Start's pipes on the wire and scoped credentials (read, write, exec).
  • 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 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 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.

Types

type Command

type Command struct {
	// Args is argv; Args[0] is resolved in the workspace.
	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 added to Workspace.Env.
	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
}

Command is one process to run.

type Descriptor

type Descriptor struct {
	// Kind is KindLocal, KindContainer or KindRemote.
	Kind string
	// Ref is what the harness resolves to it: an image digest, a host,
	// an instance ID; empty for the local machine.
	Ref string
	// Root is Workspace.Root.
	Root string
}

Descriptor is what the session records about a workspace, in its env entry: agentsession's workspace kind and ref, and the root, which is the entry's cwd.

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.

func NewLocal

func NewLocal(dir string, env []string) (*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) Descriptor

func (l *Local) Descriptor() Descriptor

Descriptor names the local machine.

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, so a timeout or a cancel kills what it started too, and waits at most two seconds for output a killed child's children still hold open.

func (*Local) FS

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

FS reads through the root, opening without blocking.

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; when it exits by itself, anything left in its group is killed.

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 Output

type Output struct {
	Stdout, Stderr []byte
	// ExitCode is the process's status; -1 when it was killed.
	ExitCode int
	// TimedOut reports that Command.Timeout ended it.
	TimedOut bool
}

Output is how a process ended.

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. 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 terminates it, then kills it. 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 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 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.
	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, the provider's key and other credentials already removed.
	Env() []string
	// Exec runs one command to completion in a fresh process.
	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.

Jump to

Keyboard shortcuts

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