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

runnernode

package
v0.15.1 Latest Latest
Warning

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

Go to latest
Published: Oct 27, 2026 License: AGPL-3.0 Imports: 36 Imported by: 0

README

Standalone execution node

artex-runner runs on a Linux Docker host or manages native Kubernetes Pods. The controller keeps agent reasoning, model credentials, permissions and product state. Each leased agent attempt gets a fresh container or Pod for its execution; a physical node or managed pool can host multiple attempts according to its advertised capacity. The node only makes outbound requests to the controller.

See the deployment guide for the optional Compose manager and Kubernetes pool templates. One manager creates execution environments on demand. The UI can persistently adjust concurrency, CPU and memory budgets within the node's hard capacity; reductions affect subsequent allocations while existing executions retain their resources. The Kubernetes backend uses shared RWX storage, scoped Pod permissions, no host socket and no execution service account token. GPU execution currently uses the Docker backend.

Optional warm_idle on a controller environment prewarms concrete queued Kubernetes layouts, within the same physical Pod/CPU/memory budgets. Image, environment, resources, working directory and file mounts must match exactly. Inputs and MCP initialization start only after exclusive assignment. Every Pod is used once and destroyed. When no matching Pod is ready, the unchanged cold Pod can start alongside input transfer. Idle cleanup never removes possible recovery artifacts; the Docker backend is unchanged. See the deployment guide for configuration and retention details.

Build with go build -o artex-runner ./cmd/artex-runner. A minimal node configuration is:

{
  "server_url": "https://artex.example.org",
  "name": "worker-01",
  "data_dir": "/var/lib/artex-runner",
  "capabilities": {
    "slots": 4,
    "cpu_millis": 4000,
    "memory_mib": 8192,
    "environments": ["default"]
  }
}

Run artex-runner --config runner.json --token-file enrollment-token. The enrollment token is only needed once. ARTEX_RUNNER_ENROLLMENT_TOKEN is also supported and is removed from the runner process environment before execution starts. The persistent credential is saved atomically as credentials.json with mode 0600; the data directory is 0700. Only one node process can own that directory. Restarting creates a new boot identity and removes containers from the preceding boot before accepting work.

The controller specifies an image by immutable repository@sha256:digest; the node pulls it automatically. The image must contain /usr/local/bin/artex-runner, the requested interpreters, MCP launchers and their locked dependencies. allowed_images can restrict accepted digests; image_override can select a node-specific pinned image. Changing these does not edit central MCP configuration. Build/publish a new environment image when dependencies change, then select that version centrally. Arbitrary command strings cannot reliably reveal which OS packages, binaries, browser assets or local files they require; missing dependencies fail explicitly inside the container.

MCP connections are initialized once per session and reused by its calls. mcp_overrides, keyed by MCP name or ID, can replace command, args, env, url, headers and working_dir. Controller loopback addresses and untransferred absolute paths require a deliberate reachable override or an updated runtime image. Custom command/Python/HTTP tools execute inside the same sandbox. The process receives only explicit tool/MCP configuration and image environment; controller database and model credentials are not inherited.

Docker uses a read-only root filesystem, independent HOME/tmp, a separate process namespace, dropped capabilities, no-new-privileges, PID limits, CPU/memory limits and explicitly assigned GPU devices. No Docker socket, host PID namespace or host network is mounted. Default per-session limits are one CPU, 1 GiB RAM and 256 PIDs. GPU assignments are exclusive whole devices; GPU memory is a scheduling constraint, not an enforced memory partition. The daemon runs containers under the node process's UID/GID so private file caches remain writable.

The default snapshot file mode transfers a versioned snapshot. Preparation is capped at 10,000 files and 1 GiB across all mounts; each traversal also caps directory entries and depth. Exceeding a limit fails explicitly before any blob download. The node downloads into its private cache and mounts the declared logical target paths; controller Source paths are never interpreted as node host paths. Shared inputs are read-only and refreshed before calls. Only the session's declared writable paths have writable mounts. New/changed writable files publish after each call, periodically during background activity, and on normal close, using compare-and-swap digests. Concurrent changes fail explicitly without overwriting the other version. On conflict, local files remain under the private session cache for recovery, the execution fails, and side-effecting calls must not be replayed automatically. Deletions are not propagated. Symlinks and special files are rejected. Large files stream without the protocol polling timeout, but currently do not have chunk-level resume.

Each tool call has a stable ID. Duplicate commands/results are replayed from their recorded outcome, not executed again; conflicting reuse of an ID is rejected. A local watchdog stops the container when the controller stops renewing its lease, even while an image pull, tool call or file transfer is in progress. Cancellation stops immediately without publishing more files. A normal close first stops sandbox processes and then publishes the final snapshot. Failure to prove process termination is reported as an unknown outcome; capacity is not silently released.

unsafe_process: true is only for explicit development, with no OS isolation, no transferred mount support and no GPU allocation. It must not be used for untrusted agents. HTTP also requires explicit allow_insecure_http: true; production HTTPS certificate verification is enabled by default.

Tests: go test -race ./runnernode ./mcphttp, plus (cd norma && go test -race ./mcp). The repository integration test exercises real Docker containers, file publication and MCP execution.

Kubernetes environments can set file_mode: "shared" to directly mount administrator-provisioned kubernetes.shared_mounts mappings. Session startup checks only the declared path components and filesystem capacity; it does not enumerate, hash, copy or periodically publish task history. workspace and the read-only published alias point to the same task subtree. Other tasks and controller credentials remain outside the sandbox. HOME/tmp/scratch stay disposable, while declared task outputs persist in place. Shared writes are live POSIX writes, without snapshot compare-and-swap: use unique output names and same-directory temporary-file + rename publication. Concurrent overlapping snapshot/shared execution is refused. Switching file mode requires an idle broker.

Shared mappings specify source (controller prefix), path (manager mount), pvc, sub_path, and optional read_only. Unmapped sources, symlinked mount paths and writable requests under read-only mappings fail closed. No snapshot fallback is allowed. Shared execution Pods omit fsGroup to avoid recursive ownership work at every startup; provision compatible ownership once. A default free-space reserve of 1 GiB and 10,000 inodes is checked at admission and every background checkpoint, without walking files; node settings shared_min_free_bytes and shared_min_free_inodes can increase it. Filesystems reporting no inode capacity skip the inode check. This is a stop/admission guard, not a hard per-task filesystem quota. Configure durable byte/inode quotas at the storage backend when required; an NFS PVC capacity request alone does not enforce them.

Documentation

Overview

Package runnernode runs leased execution sandboxes on an independently enrolled Linux host. Agent reasoning and all controller credentials stay on the server.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ServeSandbox

func ServeSandbox(ctx context.Context, in io.Reader, out io.Writer) error

ServeSandbox runs a single session until EOF or close. stdout must be reserved for JSON lines; all diagnostics belong on stderr.

func ServeSandboxWithHeartbeat

func ServeSandboxWithHeartbeat(ctx context.Context, in io.Reader, out io.Writer, timeout time.Duration) error

A Pod must stop its tools even if the manager or Kubernetes API disappears. The manager sends heartbeats independently of long-running calls/transfers.

Types

type Config

type Config struct {
	ServerURL         string                 `json:"server_url"`
	Name              string                 `json:"name"`
	DataDir           string                 `json:"data_dir"`
	WorkspaceDir      string                 `json:"workspace_dir,omitempty"`
	Capabilities      runner.Capabilities    `json:"capabilities"`
	AllowedImages     []string               `json:"allowed_images,omitempty"`
	ImageOverride     string                 `json:"image_override,omitempty"`
	MCPOverrides      map[string]MCPOverride `json:"mcp_overrides,omitempty"`
	Env               map[string]string      `json:"env,omitempty"`
	DockerBinary      string                 `json:"docker_binary,omitempty"`
	Executor          string                 `json:"executor,omitempty"`
	DockerHostDataDir string                 `json:"docker_host_data_dir,omitempty"`
	DockerNetwork     string                 `json:"docker_network,omitempty"`
	Kubernetes        KubernetesConfig       `json:"kubernetes,omitempty"`
	// UnsafeProcess executes on the host without OS isolation. Development only.
	UnsafeProcess     bool   `json:"unsafe_process,omitempty"`
	AllowInsecureHTTP bool   `json:"allow_insecure_http,omitempty"`
	SandboxBinary     string `json:"sandbox_binary,omitempty"`
}

func LoadConfig

func LoadConfig(path string) (Config, error)

func (Config) Prepare

func (c Config) Prepare(spec runner.RunSpec) (runner.RunSpec, runnerfiles.Plan, error)

Prepare applies only explicit node overrides and rejects known controller-local references. Shared sources require separately provisioned Kubernetes mappings.

func (*Config) Validate

func (c *Config) Validate() error

type DockerExecutor

type DockerExecutor struct {
	Binary        string
	SandboxBinary string
	NodeID        string
	DataDir       string
	HostDataDir   string
	Network       string
	Log           io.Writer
}

func (*DockerExecutor) CleanupOrphans

func (d *DockerExecutor) CleanupOrphans(ctx context.Context) error

CleanupOrphans is called before polling with a new boot ID. A process restart cannot inherit old leases, even if Docker kept its containers running.

func (*DockerExecutor) DockerArgs

func (d *DockerExecutor) DockerArgs(id string, spec runner.RunSpec, plan runnerfiles.Plan, roots map[string]string, gpus []string) ([]string, error)

DockerArgs keeps all host-side authority in the daemon. The session receives no socket, credentials, host PID namespace, privileged mode or host network.

func (*DockerExecutor) Start

func (d *DockerExecutor) Start(ctx context.Context, id string, spec runner.RunSpec, plan runnerfiles.Plan, roots map[string]string, gpus []string) (Sandbox, error)

type Executor

type Executor interface {
	Start(context.Context, string, runner.RunSpec, runnerfiles.Plan, map[string]string, []string) (Sandbox, error)
}

type KubernetesConfig

type KubernetesConfig struct {
	SharedMinFreeBytes  uint64            `json:"shared_min_free_bytes,omitempty"`
	SharedMinFreeInodes uint64            `json:"shared_min_free_inodes,omitempty"`
	SharedMounts        []SharedMount     `json:"shared_mounts,omitempty"`
	Binary              string            `json:"binary,omitempty"`
	Namespace           string            `json:"namespace"`
	WorkspacePVC        string            `json:"workspace_pvc"`
	WorkspaceSubPath    string            `json:"workspace_sub_path,omitempty"`
	ServiceAccount      string            `json:"service_account,omitempty"`
	ImagePullSecrets    []string          `json:"image_pull_secrets,omitempty"`
	NodeSelector        map[string]string `json:"node_selector,omitempty"`
}

type KubernetesExecutor

type KubernetesExecutor struct {
	Config                              KubernetesConfig
	WorkspaceDir, NodeID, SandboxBinary string
	MaxPods                             int
	Log                                 io.Writer
	// contains filtered or unexported fields
}

The manager alone has API credentials. Every execution receives only its PVC subdirectories and scratch volumes, with no service account token or hostPath.

func (*KubernetesExecutor) ClaimWarm added in v0.7.0

func (k *KubernetesExecutor) ClaimWarm(ctx context.Context, id string, spec runner.RunSpec, plan runnerfiles.Plan) (*warmPod, error)

Claim removes a clean Pod from the idle set exactly once. Task files are supplied only after ownership is assigned. A used Pod never returns to idle.

func (*KubernetesExecutor) CleanupOrphans

func (k *KubernetesExecutor) CleanupOrphans(ctx context.Context) error

func (*KubernetesExecutor) Pod

func (k *KubernetesExecutor) Pod(id string, spec runner.RunSpec, plan runnerfiles.Plan, roots map[string]string) (map[string]any, error)

func (*KubernetesExecutor) PoolStatus added in v0.7.0

func (k *KubernetesExecutor) PoolStatus() runner.PoolStatus

func (*KubernetesExecutor) PrepareExecution added in v0.7.0

func (k *KubernetesExecutor) PrepareExecution(ctx context.Context, id string, spec runner.RunSpec, plan runnerfiles.Plan, roots map[string]string) Sandbox

func (*KubernetesExecutor) RunPool added in v0.7.0

func (k *KubernetesExecutor) RunPool(ctx context.Context)

func (*KubernetesExecutor) Start

func (k *KubernetesExecutor) Start(ctx context.Context, id string, spec runner.RunSpec, plan runnerfiles.Plan, roots map[string]string, gpus []string) (Sandbox, error)

func (*KubernetesExecutor) UpdatePool added in v0.7.0

func (k *KubernetesExecutor) UpdatePool(policy runner.PoolPolicy)

type LocalSession

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

LocalSession is only constructed inside the sandbox subprocess. Using it in the host daemon would execute untrusted tools on the host and is prohibited.

func OpenLocalSession

func OpenLocalSession(ctx context.Context, id string, spec runner.RunSpec) (*LocalSession, error)

func (*LocalSession) Call

func (*LocalSession) Close

func (s *LocalSession) Close() error

type MCPOverride

type MCPOverride struct {
	Command    *string           `json:"command,omitempty"`
	Args       *[]string         `json:"args,omitempty"`
	Env        map[string]string `json:"env,omitempty"`
	URL        *string           `json:"url,omitempty"`
	Headers    map[string]string `json:"headers,omitempty"`
	WorkingDir *string           `json:"working_dir,omitempty"`
}

MCPOverride is an explicit per-node replacement for controller-local settings. It never mounts arbitrary host paths. Dependencies belong in the runtime image.

type Node

type Node struct {
	Config   Config
	HTTP     *http.Client
	Executor Executor
	Log      *log.Logger
	// contains filtered or unexported fields
}

Node holds only transport/lifecycle state. Tool code runs in Executor's isolated subprocess. HTTP is injectable for TLS-pinned deployments and protocol tests.

func New

func New(c Config) (*Node, error)

func (*Node) Run

func (n *Node) Run(ctx context.Context, enrollmentToken string) error

Run polls independently of calls/image preparation, so long tools cannot starve heartbeat renewal. Lease expiry still stops containers when HTTPS is down.

type ProcessExecutor

type ProcessExecutor struct {
	Binary string
	Args   []string
	Env    []string
	Log    io.Writer
}

ProcessExecutor is explicitly unsafe: it isolates process lifetimes and env, but has the host user's complete filesystem/network authority. Tests/dev only.

func (*ProcessExecutor) Start

func (p *ProcessExecutor) Start(ctx context.Context, id string, spec runner.RunSpec, plan runnerfiles.Plan, roots map[string]string, gpus []string) (Sandbox, error)

type Sandbox

type Sandbox interface {
	Request(context.Context, SandboxRequest) (SandboxReply, error)
	Stop() error
}

type SandboxReply

type SandboxReply struct {
	EnvironmentTools toolenv.Inventory       `json:"environment_tools,omitempty"`
	ID               string                  `json:"id"`
	Result           tool.Result             `json:"result"`
	MCPTools         []runner.ToolDefinition `json:"mcp_tools,omitempty"`
	Notifications    []tool.Notification     `json:"notifications,omitempty"`
	Error            string                  `json:"error,omitempty"`
	Executed         bool                    `json:"executed,omitempty"`
}

type SandboxRequest

type SandboxRequest struct {
	ID        string           `json:"id"`
	Method    string           `json:"method"`
	SessionID string           `json:"session_id,omitempty"`
	Spec      *runner.RunSpec  `json:"spec,omitempty"`
	Call      *runner.ToolCall `json:"call,omitempty"`
	CancelID  string           `json:"cancel_id,omitempty"`
}

SandboxRequest is the private, attached stdin/stdout protocol, not a public listener. Only the host runner sends requests; credentials are never included.

type SharedMount added in v0.8.0

type SharedMount struct {
	Source   string `json:"source"`
	Path     string `json:"path"`
	PVC      string `json:"pvc"`
	SubPath  string `json:"sub_path,omitempty"`
	ReadOnly bool   `json:"read_only,omitempty"`
}

SharedMount is an administrator provisioned mapping, never a host path taken from a session. Path is the manager's mount of PVC/SubPath. Keep mappings narrow (tasks, skills, drafts), excluding controller databases and credentials.

Jump to

Keyboard shortcuts

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