runnernode

package
v0.7.5 Latest Latest
Warning

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

Go to latest
Published: Oct 13, 2026 License: AGPL-3.0 Imports: 35 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.

Transferred files are a versioned snapshot, not a shared POSIX filesystem. 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.

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. It never interprets Files.Source on this host.

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 {
	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.

Jump to

Keyboard shortcuts

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