openvibers/openvibe.link Commits

Run linux-class jobs as declared commands in the worker sandbox, like code entries (#32)

OP OpenVibers committed 5 Oct 2026 · d8a2e90ec5 · 7 files, +87 −33

* Run linux-class jobs as declared commands in the worker sandbox, like code entries

**Changes (file:line)**
- `internal/protocol/protocol.go:125` — `JobNoArtifact` message now names `linux`.
- `internal/protocol/protocol.go:390` — `Check` now requires an artifact for `ClassLinux` too, making it a declared-command class like `function`/`code` (browser/desktop/gpu unaffected).
- `internal/config/config.go:112` — `Config.Worker` doc lists `linux`.
- `internal/config/config.go:212` — `FunctionConfig.Class` doc: accepts `function`/`code`/`linux`, says linux is an arbitrary declared Linux command, not a language runtime; browser/desktop/gpu refused at load.
- `internal/config/config.go:227` — added `ClassLinux = "linux"`.
- `internal/config/config.go:444` — `validate` accepts `ClassLinux` (load refusal for other classes kept).
- `internal/worker/worker.go:1` — package doc lists the three classes.
- `internal/worker/worker.go:139` — `Admit` accepts `ClassLinux`; every other class still `JobNotAvailable`.
- `internal/config/config_test.go:90,100,107` — linux entry loads; browser/desktop/gpu still rejected.
- `internal/worker/worker_test.go:215` — new `TestAdmitLinux`: `Classes()` advertises linux, a linux job is admitted, a linux job naming a function entry is `unknown artifact`, browser/desktop/gpu refused.
- `internal/worker/run_linux_test.go:1223` — new `TestRunLinuxClass`: a declared linux entry runs through the same isolate/result path.
- `docs/worker.md:1,50,126,299` — title/intro, `class` bullet, advertised-capabilities paragraph and refusal table list linux and what distinguishes it.
- `docs/protocol.md:256,261,317` — refusal table text/classes and the Fabric capability example, kept consistent with the changed nack message.

**Test results**
- `ov test internal/config internal/worker internal/protocol` → gofmt clean, `go vet` ✓, `go test` ✓, all green (3 packages).
- Verbose targeted run: `TestWorkerEntryClass` PASS, `TestAdmitByClass` PASS, `TestAdmitLinux` PASS (all config/admit paths exercised); `TestRunLinuxClass` SKIP — "job isolation unavailable here … the cgroup v2 controller cpu is not available in … (delegate the Node's cgroup: systemd Delegate=yes)", i.e. this box lacks the delegated cgroup, same skip the other sandbox tests take.

**Where the code differed from the brief**
- The brief said `ov test internal/config/config_test.go …`; in a Go repo `ov test` matches arguments as package words against import paths, so full test-file paths select 0 packages ("0 packages reach it"). I ran the equivalent packages: `ov test internal/config internal/worker internal/protocol` (protocol included because `protocol.go` is part of the change).
- `config.go:213` is the `Class` doc comment; the actual load refusal is at `config.go:442`, now `:444` after the added const/comment lines. `worker.go:139` matched exactly; `protocol.go:85`/`:91` matched (`ClassLinux` already existed and was already in `RuntimeClasses`) — the real gates were `protocol.go:390`, `worker.go:139` and `config.go:442`.

* Keep the protocol check as platform.job@1 states it (artifact required for function and code); document linux as single-shot declared commands and advertise worker:linux in the example (review)

Changes

ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex cc155ee..d2ebc1f 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -258,7 +258,7 @@ checked in this order:
| `invalid inputs` | `bad_value` | an entry of `inputs` outside the contract's patterns, a repeated name, or more than 32 |
| `job inputs not supported` | `unsupported` | the job carries `inputs`: this Node cannot stage and check them yet, so it never runs it with them dropped |
| `missing or invalid args, ttl_ms or limits` | `bad_value` | `args` is not an object, or `ttl_ms` or a limit is missing or below 1 |
--| `class not available` | `unsupported` | the class is valid but this Node does not run it (every class but `function`; every class while the worker is off) |
++| `class not available` | `unsupported` | the class is valid but this Node does not run it (every class but `function`, `code` and `linux`; every class while the worker is off) |
| `the e-stop or local stop is latched` | `estopped` or `local_stop` | the stop latch is set: no job starts until it clears |
| `class not available` | `shutting_down` | the Node is stopping |
| `unknown artifact` | `unsupported` | `artifact` is not a function the owner declared, at that exact version |
@@@ -303,7 +303,7 @@ worker caps in `status.capabilities.worker`:
`capabilities` holds one `worker:<class>` name per entry in `runtime_classes` (the `platform.resource-offer@1`
capabilities namespace OpenVibe.Run routes on; the names are reserved in OpenVibe.Contracts and no schema change
was needed), so only classes this Node actually runs are advertised: `worker:function` when a `function` entry is declared,
--`worker:code` when a `code` entry is (`"runtime_classes":["function","code"]`, in that order, with both). The limits are `worker.caps` with defaults applied; the key is absent while the worker is off or
++`worker:code` when a `code` entry is, and `worker:linux` when a `linux` entry is (`"runtime_classes":["function","code","linux"]`, in that order, with all three). A Node's `linux` class runs one declared Linux command per job, single-shot like `code`: it is not the persistent environment that lives across commands which `platform.runtime-class@1` also lets `linux` mean, so a linux job without an `artifact` naming a declared entry is refused (`unknown artifact`). The limits are `worker.caps` with defaults applied; the key is absent while the worker is off or
declares no `functions` entry (a probe that passes with none advertises nothing, so Fabric places no job this Node would
refuse), and `worker` is reserved as a plugin name.
@@@ -314,7 +314,7 @@ this mapping:
| Node descriptor field | Fabric offer | Example |
| --- | --- | --- |
--| capabilities.worker.runtime_classes | resource-offer.capabilities item `worker:<class>` | worker:function, worker:code |
++| capabilities.worker.runtime_classes | resource-offer.capabilities item `worker:<class>` | worker:function, worker:code, worker:linux |
| capabilities.worker.max_jobs | runtime-offer.limits.concurrency | 1 |
| capabilities.worker.max_ttl_ms | runtime-offer.limits.duration_seconds | 600 |
| device node_id / cell | resource-offer.node_id / resource-offer.cell | node_01H… / wnam-1 |
ddiff --git a/docs/worker.md b/docs/worker.md
iindex 0b68eb9..df062de 100644
---- a/docs/worker.md
++++ b/docs/worker.md
@@@ -1,9 +1,13 @@
--# The job worker (`function` and `code` jobs)
++# The job worker (`function`, `code` and `linux` jobs)
--The Node can run **function and code jobs** (OpenVibe.Contracts `platform.job@1`, class `function` or `code`) sent over
--the control link ([protocol.md](protocol.md#jobs)). A job is untrusted input: it names an artifact, and the Node runs it
--only if the owner declared that artifact, at that exact version and for that class, in the local config. Nothing is downloaded and no path comes from
--the server. The worker is **off by default**; off, the Node refuses every job `class not available`, as before.
++The Node can run **function, code and linux jobs** (OpenVibe.Contracts `platform.job@1`, class `function`, `code` or
++`linux`) sent over the control link ([protocol.md](protocol.md#jobs)). A job is untrusted input: it names an artifact,
++and the Node runs it only if the owner declared that artifact, at that exact version and for that class, in the local
++config. Nothing is downloaded and no path comes from the server. The worker is **off by default**; off, the Node refuses
++every job `class not available`, as before. A `linux` entry is an arbitrary declared Linux command, not a language
++runtime: it runs through the same isolate path as a `code` entry, with the same sandbox, limits and net policy.
++It is single-shot like a `code` job: the Node does not offer the persistent Linux environment that lives across
++commands, which `platform.runtime-class@1` also lets `linux` describe.
## Config
@@@ -46,10 +50,11 @@ the server. The worker is **off by default**; off, the Node refuses every job `c
- `functions`: `name` and `version` match a job's `artifact` exactly (the contract's patterns; listed once per class);
`command[0]` is an absolute path. `env` is added to the job's environment. `class` is the runtime class the entry
-- implements: `function` (the default) or `code`; `browser`, `linux`, `desktop` and `gpu` are refused at load. A job
-- runs only an entry of its own class: a `code` job naming a `function` entry is an `unknown artifact`, and the
-- reverse. A `code` entry is a local declared runtime like a function (nothing is fetched); its job gets exactly the
-- same sandbox, limits and checks as a function job, never weaker. `artifact_dir` (default: the directory
++ implements: `function` (the default), `code` or `linux`; `browser`, `desktop` and `gpu` are refused at load. A job
++ runs only an entry of its own class: a `code` or `linux` job naming a `function` entry is an `unknown artifact`, and
++ the reverse. A `code` or `linux` entry is a local declared command like a function (nothing is fetched), and a `linux`
++ one is an arbitrary Linux command, not a language runtime; its job gets exactly the same sandbox, limits and checks as
++ a function job, never weaker. `artifact_dir` (default: the directory
of `command[0]`) is the one host directory a job gets besides the system paths, as a **copy** made as the job
starts, read-only, at the same path: nothing the host adds to it or changes in it later reaches the job (a Unix
socket or a FIFO that appears there, say, through which a job could reach a host process). The Node opens the
@@@ -120,8 +125,9 @@ the server. The worker is **off by default**; off, the Node refuses every job `c
and no error; it is sent only to `endpoint`'s own host. `max_bytes` caps each input and each uploaded result.
Off, a job naming `inputs` is refused and a result over 256 KiB ends the job `limit`, as before.
--The classes `function` and `code` — advertised as the reserved Fabric capabilities `worker:function` and `worker:code` in
--`status.capabilities.worker` (the `platform.resource-offer@1` namespace OpenVibe.Run routes on) — are each offered only when `enabled` is true, the OS is Linux
++The classes `function`, `code` and `linux` — advertised as the reserved Fabric capabilities `worker:function`,
++`worker:code` and `worker:linux` in `status.capabilities.worker` (the `platform.resource-offer@1` namespace OpenVibe.Run
++routes on) — are each offered only when `enabled` is true, the OS is Linux
(amd64 or arm64: the architectures with a seccomp allowlist), at least one `functions` entry of that class is declared and a **boot-time probe** passes: it starts `/bin/sh`
the way a job is started and checks every control from the host: the sandbox was set up; the process has user,
mount, network, PID, IPC, UTS and cgroup namespaces of its own, `NoNewPrivs` and a seccomp filter; nothing in its
@@@ -292,7 +298,7 @@ Refused with `nack` (keyed by the job id; the first matching row wins, after the
| `message` | `fault_code` | when |
|---------------------------------------|----------------------------|---------------------------------------------------------|
--| `class not available` | `unsupported` | the worker is off (disabled, not Linux, probe failed), or no declared entry has the job's class (`function` or `code`) |
++| `class not available` | `unsupported` | the worker is off (disabled, not Linux, probe failed), or no declared entry has the job's class (`function`, `code` or `linux`) |
| `job inputs not supported` | `unsupported` | the job carries `inputs` and `media` is off: this Node never runs a job with them silently dropped |
| `net policy not supported` | `unsupported` | the job names a `net` this host cannot enforce (`public` or `openvibe-only` while `egress` is `none`) |
| `the e-stop or local stop is latched` | `estopped` or `local_stop` | the stop latch is set |
ddiff --git a/internal/config/config.go b/internal/config/config.go
iindex f3904a6..5255d6a 100644
---- a/internal/config/config.go
++++ b/internal/config/config.go
@@@ -109,7 +109,7 @@ type Config struct {
// Python is the interpreter for plugins without an explicit command (default: <state>/venv/bin/python, then python3).
Python string `json:"python,omitempty"`
LogLevel string `json:"log_level,omitempty"`
-- // Worker runs `function` and `code` jobs from the control link (docs/worker.md). Off unless enabled.
++ // Worker runs `function`, `code` and `linux` jobs from the control link (docs/worker.md). Off unless enabled.
Worker WorkerConfig `json:"worker,omitzero"`
}
@@@ -209,8 +209,9 @@ const (
type FunctionConfig struct {
Name string `json:"name"`
Version string `json:"version"`
-- // Class is the runtime class this entry implements: function (the default) or code. A job runs it only when its
-- // class matches; browser, linux, desktop and gpu are refused at load.
++ // Class is the runtime class this entry implements: function (the default), code or linux. A job runs it only when
++ // its class matches; browser, desktop and gpu are refused at load. A linux entry is an arbitrary declared Linux
++ // command, not a language runtime.
Class string `json:"class,omitempty"`
Command []string `json:"command"` // Command[0] is an absolute path
Env map[string]string `json:"env,omitempty"`
@@@ -223,6 +224,7 @@ type FunctionConfig struct {
const (
ClassFunction = "function"
ClassCode = "code"
++ ClassLinux = "linux"
)
// EffectiveClass is the entry's runtime class, function when none is set.
@@@ -439,8 +441,8 @@ func (w WorkerConfig) validate() error {
if !functionNameRe.MatchString(f.Name) || !functionVersionRe.MatchString(f.Version) {
return fmt.Errorf("config: worker function %q version %q: name or version is not a valid artifact name or exact version", f.Name, f.Version)
}
-- if c := f.EffectiveClass(); c != ClassFunction && c != ClassCode {
-- return fmt.Errorf("config: worker function %s@%s has class %q: only function or code are implemented", f.Name, f.Version, f.Class)
++ if c := f.EffectiveClass(); c != ClassFunction && c != ClassCode && c != ClassLinux {
++ return fmt.Errorf("config: worker function %s@%s has class %q: only function, code or linux are implemented", f.Name, f.Version, f.Class)
}
key := f.Name + "@" + f.Version + "@" + f.EffectiveClass()
if seen[key] {
ddiff --git a/internal/config/config_test.go b/internal/config/config_test.go
iindex 6ed96a5..faf7278 100644
---- a/internal/config/config_test.go
++++ b/internal/config/config_test.go
@@@ -87,8 +87,8 @@ func TestWorkerNameReserved(t *testing.T) {
}
}
--// TestWorkerEntryClass: an entry is a function unless it says code; the other runtime classes are refused at load,
--// and one name@version may be declared once per class.
++// TestWorkerEntryClass: an entry is a function unless it says code or linux; browser, desktop and gpu are refused at
++// load, and one name@version may be declared once per class.
func TestWorkerEntryClass(t *testing.T) {
run := filepath.Join(t.TempDir(), "run") // absolute on every platform: Validate wants an absolute command
entry := func(class string) FunctionConfig {
@@@ -97,14 +97,14 @@ func TestWorkerEntryClass(t *testing.T) {
if c := entry("").EffectiveClass(); c != ClassFunction {
t.Fatalf("no class is %q", c)
}
-- for _, class := range []string{"", "function", "code"} {
++ for _, class := range []string{"", "function", "code", "linux"} {
c := Default()
c.Worker.Functions = []FunctionConfig{entry(class)}
if err := c.Validate(); err != nil {
t.Fatalf("class %q: %v", class, err)
}
}
-- for _, class := range []string{"browser", "linux", "desktop", "gpu", "wasm", "Code"} {
++ for _, class := range []string{"browser", "desktop", "gpu", "wasm", "Code"} {
c := Default()
c.Worker.Functions = []FunctionConfig{entry(class)}
if err := c.Validate(); err == nil {
ddiff --git a/internal/worker/run_linux_test.go b/internal/worker/run_linux_test.go
iindex 93a0fcd..698ef2a 100644
---- a/internal/worker/run_linux_test.go
++++ b/internal/worker/run_linux_test.go
@@@ -1220,6 +1220,22 @@ func TestShortLivedJob(t *testing.T) {
}
}
++// TestRunLinuxClass: a declared linux entry runs an arbitrary command through the same isolate path as a code entry:
++// the process starts once, its result reaches job_exit and its exit code is 0.
++func TestRunLinuxClass(t *testing.T) {
++ cfg := helperConfig(t, config.WorkerCaps{}, "quick")
++ cfg.Functions[0].Class = config.ClassLinux
++ w, s := newRunWorkerConfig(t, cfg)
++ j := testJob(1, "quick")
++ j.Class = protocol.ClassLinux
++ run(t, w, j)
++ ex := s.exit(t, j.ID)
++ if ex.Reason != protocol.ExitExited || ex.Code == nil || *ex.Code != 0 || string(ex.Result) != `{"ok":true}` ||
++ s.count(protocol.TypeJobStarted, j.ID) != 1 {
++ t.Fatalf("%+v %s", ex, ex.Result)
++ }
++}
++
// TestForkBomb: pids.max and RLIMIT_NPROC stop a job that starts processes without end; the Node is untouched and the
// job ends when its first process exits, taking the others with it.
func TestForkBomb(t *testing.T) {
ddiff --git a/internal/worker/worker.go b/internal/worker/worker.go
iindex 0256fe6..4e65abb 100644
---- a/internal/worker/worker.go
++++ b/internal/worker/worker.go
@@@ -1,11 +1,11 @@
--// Package worker runs `function` jobs (OpenVibe.Contracts platform.job@1) from the control link. It runs only the
--// functions the owner declared in the local config, each job as one child process in a sandbox: its own process group,
--// user, mount, network (no network at all), PID, IPC, UTS and cgroup namespaces, a private read-only root holding only
--// the system paths and the function's artifact, its own cgroup v2 (cpu, memory, pids, io), rlimits, no_new_privs and a
--// seccomp allowlist, with a scrubbed environment and a fresh /tmp. It kills a job at its ttl, at its limits, on
--// job_cancel and while the stop latch is set. It never runs a job with less isolation: a job whose sandbox cannot be
--// set up fails without running, and a Node whose boot-time Probe fails does not advertise the class. docs/worker.md
--// has the model and its limits.
++// Package worker runs `function`, `code` and `linux` jobs (OpenVibe.Contracts platform.job@1) from the control link. It
++// runs only the functions the owner declared in the local config, each job as one child process in a sandbox: its own
++// process group, user, mount, network (no network at all), PID, IPC, UTS and cgroup namespaces, a private read-only root
++// holding only the system paths and the function's artifact, its own cgroup v2 (cpu, memory, pids, io), rlimits,
++// no_new_privs and a seccomp allowlist, with a scrubbed environment and a fresh /tmp. It kills a job at its ttl, at its
++// limits, on job_cancel and while the stop latch is set. It never runs a job with less isolation: a job whose sandbox
++// cannot be set up fails without running, and a Node whose boot-time Probe fails does not advertise the class.
++// docs/worker.md has the model and its limits.
package worker
import (
@@@ -136,7 +136,7 @@ func (w *Worker) Admit(j protocol.Job) (fault, reason string) {
if w.closed {
return protocol.FaultShuttingDown, protocol.JobNotAvailable
}
-- if j.Class != protocol.ClassFunction && j.Class != protocol.ClassCode {
++ if j.Class != protocol.ClassFunction && j.Class != protocol.ClassCode && j.Class != protocol.ClassLinux {
return protocol.FaultUnsupported, protocol.JobNotAvailable
}
// inputs are fetched from worker.media and checked against their digests before the job starts (platform.job@1):
ddiff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go
iindex df8f70c..27cd6d9 100644
---- a/internal/worker/worker_test.go
++++ b/internal/worker/worker_test.go
@@@ -212,6 +212,36 @@ func TestAdmitByClass(t *testing.T) {
w.Close()
}
++// TestAdmitLinux: a declared linux entry is a declared-command class like code — its job is admitted and matched by
++// name, version and class — while browser, desktop and gpu jobs stay refused class not available.
++func TestAdmitLinux(t *testing.T) {
++ cfg := helperConfig(t, config.WorkerCaps{MaxJobs: 8}, "sleep")
++ tool := cfg.Functions[0]
++ tool.Name, tool.Class = "tool", config.ClassLinux
++ cfg.Functions = append([]config.FunctionConfig{tool}, cfg.Functions...)
++ w := New(cfg, (&sink{}).send, quiet())
++ if c := w.Classes(); !slices.Equal(c, []string{protocol.ClassFunction, protocol.ClassLinux}) {
++ t.Fatalf("classes %v", c)
++ }
++ job := func(n int, class, name string) protocol.Job {
++ j := testJob(n, name)
++ j.Class = class
++ return j
++ }
++ if f, r := w.Admit(job(1, protocol.ClassLinux, "tool")); f != "" {
++ t.Fatalf("a declared linux entry: %s %s", f, r)
++ }
++ if f, r := w.Admit(job(2, protocol.ClassLinux, "sleep")); f != protocol.FaultUnsupported || r != protocol.JobUnknownArtifact {
++ t.Fatalf("a linux job naming a function entry: %q %q", f, r)
++ }
++ for i, class := range []string{protocol.ClassBrowser, protocol.ClassDesktop, protocol.ClassGPU} {
++ if f, r := w.Admit(job(3+i, class, "tool")); f != protocol.FaultUnsupported || r != protocol.JobNotAvailable {
++ t.Errorf("%s: %q %q, want %q %q", class, f, r, protocol.FaultUnsupported, protocol.JobNotAvailable)
++ }
++ }
++ w.Close()
++}
++
// TestIsolationUnavailableRefused: when the namespaces cannot be created the probe fails (the class is not
// advertised), and a job whose isolation fails ends `failed` without its process ever starting.
func TestIsolationUnavailableRefused(t *testing.T) {