openvibers/openvibe.link Commits

HO8 step 2: the skill runner ([email protected]), sandboxed in a function job (#132)

OP OpenVibers committed 5 days ago · 73496da441 · 11 files, +1343 −0

* the skill runner: a declared function that runs a skill's tool in the job sandbox ([email protected])

* the skill runner's tests: portable absolute paths, a root path is any filesystem root

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 5c7f5d8..8618030 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -29,6 +29,14 @@ jobs:
# The job worker's tests skip where jobs cannot run sandboxed: unprivileged, the runner's cgroup is not delegated
# (and Ubuntu's AppArmor restricts unprivileged user namespaces).
- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
++ # The skill runner runs a skill tool with this Node.js. Test the runner itself here; its tests use --permission,
++ # so this must be Node.js 22.13 or newer, not a distro's Node 18.
++ - uses: actions/setup-node@v4
++ with:
++ node-version: "22"
++ - run: node --test internal/skills/skill_runner.test.js
++ # A job's private root holds /usr only: put the runner's Node.js where the root sandbox test below can see it.
++ - run: sudo install -m 0755 "$(command -v node)" /usr/local/bin/node
- run: go test -race -count=1 ./...
# The tray's desktop notifications, against a fake notification server on a private session bus.
- name: tray notifications (private D-Bus)
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex 1556094..bd159d1 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -16,6 +16,7 @@
// read the memory files of the coding tools (read-only, local)
// openvibe-node skills sync [--tools a,b] [--project DIR …] [--dry-run]
// write the skills you chose in the hub into each tool's own folders
++// openvibe-node skills on|off|status declare/remove/check the skill runner (sandboxed skill code)
// openvibe-node version
package main
@@@ -91,6 +92,9 @@ Usage:
openvibe-node skills sync --undo
write the skills and rules you chose at openvibe.codes/skills
into each tool's own folders, inside one marked block
++ openvibe-node skills on|off|status [--dir DIR] [--node PATH] [--json]
++ run a skill's code as a sandboxed job on this machine
++ ([email protected]; off until "on")
openvibe-node exchange serve --model <id> [--public-url URL]
serve a local model as an OpenVibe.Exchange seller offer
openvibe-node stream --probe --whip-url URL [--seconds N] [--size WxH] [--json]
ddiff --git a/cmd/openvibe-node/skills_runner.go b/cmd/openvibe-node/skills_runner.go
nnew file mode 100644
iindex 0000000..d285157
---- /dev/null
++++ b/cmd/openvibe-node/skills_runner.go
@@@ -0,0 +1,137 @@
++package main
++
++import (
++ "encoding/json"
++ "flag"
++ "fmt"
++ "io"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++ "github.com/OpenVibers/OpenVibe.Node/internal/skills"
++)
++
++// skillsRunnerUsage is what "openvibe-node skills on|off|status" prints.
++var skillsRunnerUsage = "openvibe-node skills on [--dir DIR] [--node PATH]\n" +
++ "openvibe-node skills off\n" +
++ "openvibe-node skills status [--json]\n" +
++ "\n" +
++ " Runs one tool of a declared skill as a sandboxed function job on this\n" +
++ " machine (artifact [email protected], docs/worker.md). The job's\n" +
++ " code is the job's own checked input; it runs with this machine's Node.js,\n" +
++ " no environment, no child processes or workers, no addons, no eval, and\n" +
++ " reads nothing outside the job directory. The network and every kernel\n" +
++ " limit are the job worker's.\n" +
++ "\n" +
++ " The runner is OFF until `skills on`: that writes the runner and declares\n" +
++ " it in worker.functions. It does not turn the job worker on by itself (the\n" +
++ " installer's --worker does), and the Node must be restarted to load it.\n" +
++ "\n" +
++ " --dir DIR where the runner lives (default " + skills.DefaultDir() + ")\n" +
++ " --node PATH the Node.js 22.13+ binary the tool runs with (default: /usr/bin/node,\n" +
++ " /usr/local/bin/node, then node on PATH)\n" +
++ " --json print status as JSON\n"
++
++// cmdSkillsRunner runs the skill runner commands; args[0] is on, off or status.
++func cmdSkillsRunner(args []string, stdout, stderr io.Writer) int {
++ if len(args) == 0 {
++ fmt.Fprint(stderr, skillsRunnerUsage)
++ return 2
++ }
++ cmd, rest := args[0], args[1:]
++ switch cmd {
++ case "on", "off", "status":
++ default:
++ fmt.Fprintf(stderr, "unknown skills command %q\n\n", cmd)
++ fmt.Fprint(stderr, skillsSyncUsage)
++ fmt.Fprint(stderr, skillsRunnerUsage)
++ return 2
++ }
++ fs := flag.NewFlagSet("skills "+cmd, flag.ContinueOnError)
++ fs.SetOutput(stderr)
++ fs.Usage = func() { fmt.Fprint(stderr, skillsRunnerUsage) }
++ home := fs.String("home", "", "config and state directory (also OPENVIBE_NODE_HOME)")
++ dir := fs.String("dir", skills.DefaultDir(), "where the skill runner lives")
++ node := fs.String("node", "", "the Node.js binary the skill's code runs with")
++ asJSON := fs.Bool("json", false, "print JSON (status)")
++ if err := fs.Parse(rest); err != nil {
++ return 2
++ }
++ if fs.NArg() > 0 {
++ fmt.Fprintf(stderr, "unknown argument %q\n\n", fs.Arg(0))
++ fmt.Fprint(stderr, skillsRunnerUsage)
++ return 2
++ }
++ paths := config.DefaultPaths(*home)
++ cfg, err := config.Load(paths.ConfigFile())
++ if err != nil {
++ fmt.Fprintln(stderr, "error:", err)
++ return 1
++ }
++ switch cmd {
++ case "on":
++ entry, n, _, err := skills.On(cfg, *dir, *node)
++ if err != nil {
++ fmt.Fprintln(stderr, "error:", err)
++ return 1
++ }
++ if err := config.Save(paths.ConfigFile(), cfg); err != nil {
++ fmt.Fprintln(stderr, "error:", err)
++ return 1
++ }
++ fmt.Fprintf(stdout, "Skill runner on: %s@%s (%s) in %s\n", entry.Name, entry.Version, entry.Class, paths.ConfigFile())
++ fmt.Fprintf(stdout, " command: %s\n", skills.CommandString(entry.Command))
++ fmt.Fprintf(stdout, " node: %s (%s)\n", n.Path, n.Version)
++ if !cfg.Worker.Enabled {
++ fmt.Fprintln(stdout, "The job worker is off: jobs run only once worker.enabled is true (install.sh --worker sets it).")
++ }
++ fmt.Fprintln(stdout, "Restart the Node to load it.")
++ case "off":
++ if !skills.Off(cfg) {
++ fmt.Fprintf(stdout, "Skill runner is already off (%s).\n", paths.ConfigFile())
++ break
++ }
++ if err := config.Save(paths.ConfigFile(), cfg); err != nil {
++ fmt.Fprintln(stderr, "error:", err)
++ return 1
++ }
++ fmt.Fprintf(stdout, "Skill runner off: removed %s from worker.functions in %s\n", skills.Name, paths.ConfigFile())
++ fmt.Fprintln(stdout, "Restart the Node to unload it.")
++ case "status":
++ s := skills.StatusOf(cfg)
++ if *asJSON {
++ b, _ := json.MarshalIndent(s, "", " ")
++ fmt.Fprintln(stdout, string(b))
++ break
++ }
++ if !s.On {
++ fmt.Fprintln(stdout, "skill runner: off")
++ fmt.Fprintf(stdout, " The job worker is %s.\n", onOff(s.WorkerEnabled))
++ fmt.Fprintln(stdout, " Turn it on: openvibe-node skills on (then restart the Node)")
++ break
++ }
++ fmt.Fprintf(stdout, "skill runner: on (%s@%s, %s)\n", s.Name, s.Version, s.Class)
++ fmt.Fprintf(stdout, " artifact: %s (%s)\n", s.Artifact, present(s.ArtifactPresent))
++ fmt.Fprintf(stdout, " command: %s\n", skills.CommandString(s.Command))
++ if s.NodeVersion != "" {
++ fmt.Fprintf(stdout, " node: %s (%s)\n", s.Node, s.NodeVersion)
++ } else {
++ fmt.Fprintf(stdout, " node: %s (unusable: %s)\n", s.Node, s.NodeError)
++ }
++ fmt.Fprintf(stdout, " job worker: %s\n", onOff(s.WorkerEnabled))
++ }
++ return 0
++}
++
++func onOff(v bool) string {
++ if v {
++ return "on"
++ }
++ return "off"
++}
++
++func present(v bool) string {
++ if v {
++ return "present"
++ }
++ return "missing"
++}
ddiff --git a/cmd/openvibe-node/skills_runner_test.go b/cmd/openvibe-node/skills_runner_test.go
nnew file mode 100644
iindex 0000000..d4706a4
---- /dev/null
++++ b/cmd/openvibe-node/skills_runner_test.go
@@@ -0,0 +1,87 @@
++package main
++
++import (
++ "bytes"
++ "encoding/json"
++ "os"
++ "path/filepath"
++ "strings"
++ "testing"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++ "github.com/OpenVibers/OpenVibe.Node/internal/skills"
++)
++
++// TestSkillsRunnerOnStatusOff drives the CLI the way an owner does: on declares the runner in worker.functions and
++// writes the script, status reports it, off removes the declaration and keeps the script.
++func TestSkillsRunnerOnStatusOff(t *testing.T) {
++ n, err := skills.FindNode("")
++ if err != nil {
++ t.Skipf("no usable node on this machine: %v", err)
++ }
++ home := t.TempDir()
++ dir := filepath.Join(home, "runner")
++ var stdout, stderr bytes.Buffer
++ run := func(args ...string) int {
++ stdout.Reset()
++ stderr.Reset()
++ return runWithStdin(append([]string{"skills"}, args...), strings.NewReader(""), &stdout, &stderr)
++ }
++
++ if code := run("on", "--home", home, "--dir", dir, "--node", n.Path); code != 0 {
++ t.Fatalf("skills on: code %d, stderr %s", code, stderr.String())
++ }
++ if !strings.Contains(stdout.String(), "Skill runner on") {
++ t.Fatalf("on printed %q", stdout.String())
++ }
++ cfg, err := config.Load(filepath.Join(home, "config.json"))
++ if err != nil {
++ t.Fatal(err)
++ }
++ var found *config.FunctionConfig
++ for i := range cfg.Worker.Functions {
++ if cfg.Worker.Functions[i].Name == skills.Name {
++ found = &cfg.Worker.Functions[i]
++ }
++ }
++ if found == nil {
++ t.Fatalf("no %s in worker.functions: %+v", skills.Name, cfg.Worker.Functions)
++ }
++ if found.Version != skills.Version || found.EffectiveClass() != config.ClassFunction {
++ t.Fatalf("entry %+v", *found)
++ }
++ if found.ArtifactDir != dir {
++ t.Fatalf("artifact_dir %q, want %q", found.ArtifactDir, dir)
++ }
++
++ stdout.Reset()
++ if code := run("status", "--home", home, "--json"); code != 0 {
++ t.Fatalf("skills status: code %d, stderr %s", code, stderr.String())
++ }
++ var st skills.Status
++ if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &st); err != nil {
++ t.Fatalf("status is not JSON: %v: %s", err, stdout.String())
++ }
++ if !st.On || !st.ArtifactPresent || st.Version != skills.Version || st.Artifact != dir {
++ t.Fatalf("status %+v", st)
++ }
++
++ if code := run("off", "--home", home); code != 0 {
++ t.Fatalf("skills off: code %d, stderr %s", code, stderr.String())
++ }
++ cfg, err = config.Load(filepath.Join(home, "config.json"))
++ if err != nil {
++ t.Fatal(err)
++ }
++ for _, f := range cfg.Worker.Functions {
++ if f.Name == skills.Name {
++ t.Fatalf("off left %+v", f)
++ }
++ }
++ if _, err := os.Stat(filepath.Join(dir, skills.File)); err != nil {
++ t.Fatalf("off removed the script: %v", err)
++ }
++ if st := skills.StatusOf(cfg); st.On {
++ t.Fatalf("still on: %+v", st)
++ }
++}
ddiff --git a/cmd/openvibe-node/skills_sync.go b/cmd/openvibe-node/skills_sync.go
iindex b4238cc..ac6e409 100644
---- a/cmd/openvibe-node/skills_sync.go
++++ b/cmd/openvibe-node/skills_sync.go
@@@ -54,6 +54,8 @@ func cmdSkills(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
switch args[0] {
case "sync":
return cmdSkillsSync(args[1:], stdin, stdout, stderr)
++ case "on", "off", "status":
++ return cmdSkillsRunner(args, stdout, stderr)
default:
fmt.Fprintf(stderr, "unknown skills command %q\n\n", args[0])
fmt.Fprint(stderr, skillsSyncUsage)
ddiff --git a/docs/worker.md b/docs/worker.md
iindex 26eb2f4..7b8a870 100644
---- a/docs/worker.md
++++ b/docs/worker.md
@@@ -42,6 +42,76 @@ with a token from this `env`. Codes CLI 0.6.1 and later sends it to git as an `A
alone, through `GIT_CONFIG_*` variables: it is never on a command line, in the clone's remote URL or in its
`.git/config`, and it never passes through OpenVibe.
++## Skills that run code (`[email protected]`)
++
++A skill that declares `runs: "sandbox"` has tools whose code runs as a **function job** on a Node that declared the
++skill runner. The runner is a declared function like the Codes runner, and it is **off until the owner turns it on**:
++
++```sh
++sudo openvibe-node skills on # write the runner and declare it in worker.functions
++openvibe-node skills status [--json]
++sudo openvibe-node skills off
++```
++
++`skills on` finds a system Node.js (`/usr/bin/node`, `/usr/local/bin/node`, then `node` on PATH; `--node PATH` overrides),
++requires one whose permission model is stable behind `--permission` (Node.js 22.13 or newer; it runs the exact flags
++once before writing anything), writes the runner script into `--dir` (default `/opt/openvibe-skill-runner`) and adds
++this entry to `worker.functions`:
++
++```json
++{"name": "openvibe-skill-runner", "version": "1.0.0", "class": "function",
++ "command": ["/usr/bin/node", "/opt/openvibe-skill-runner/openvibe-skill-runner.js", "job"],
++ "artifact_dir": "/opt/openvibe-skill-runner"}
++```
++
++It does not turn `worker.enabled` on: the installer's `--worker` does. Restart the Node to load the entry. `skills off`
++removes the entry and keeps the script, so `on` is instant again.
++
++### What a job of the runner runs
++
++The job's `args` are `{"tool": "<tool name>", "entry": "<input name>", "input": <JSON>}`. `entry` must be the name of
++one of the job's `inputs` (the contract's file-name pattern), which the worker staged in the job directory before the
++job started; a missing, non-regular or symlinked entry fails before anything runs. A digest mismatch on an input ends
++the job `failed` before the runner starts (the worker's rule), so nothing runs.
++
++The runner runs the entry with **this Node's own node binary** (`process.execPath`, the one in the entry's command), cwd
++the job's directory, `input` as one JSON value on the tool's stdin, and an **environment with nothing in it** (not the
++Node's, not the function's):
++
++```sh
++node --permission --allow-fs-read=<the job directory> --disallow-code-generation-from-strings <entry>
++```
++
++It passes **none** of `--allow-child-process`, `--allow-worker`, `--allow-addons`, `--allow-wasi`,
++`--allow-fs-write` or `--allow-fs-read=*`: the tool cannot spawn a process, start a worker thread, load a native addon,
++write any file (in the job directory included), read anything outside the job directory, or use `eval`/`new Function`.
++The runner adds no network of its own: the tool has the job sandbox's network namespace, so under `net: deny` (the
++default) there is no network at all, and under `public` or `openvibe-only` only what the job's `net` and the owner's
++egress allow. The tool also has the job's uid/gid, private root, seccomp filter, cgroups and rlimits; it sees no Node
++credential, no owner home and no state.
++
++The tool writes **one JSON value to stdout, at most 64 KiB**. More is stopped and the result says `limit`; stderr is
++kept for the person, cut at 16 KiB. The runner writes the job's result to fd 3:
++
++```json
++{"ok": true, "output": <the tool's JSON>, "exit": 0, "stderr": "…", "stderr_truncated": false}
++{"ok": false, "error": "…", "exit": 7, "stderr": "…"}
++```
++
++`exit` is the tool's status, `null` when it never ran or was killed. A failed tool is still a result: the runner exits
++0 whenever it wrote one (the worker keeps a result only from a function that exits 0), so the person sees what
++happened instead of a bare job failure. The job's own wall, CPU, memory and output limits are the worker's (`limits`,
++`worker.caps`).
++
++### What it never does
++
++It never fetches code, installs a dependency, runs `npm`, writes a file back to the person, keeps state between jobs,
++or calls another tool or the network on the skill's behalf: the only bytes it runs are the input files the worker
++fetched and checked against their pinned sha256. It never runs on an OpenVibe server; the job runs on the Node that
++declared the runner, in the same sandbox as every function job. Node's permission model is a seat belt against a
++tool's accidents, not a boundary against deliberately malicious code; the kernel sandbox (namespaces, seccomp,
++cgroups, no network) is that boundary.
++
## Config
`config.json`, section `worker` (defaults shown; a zero cap means the default):
ddiff --git a/internal/skills/skill_runner.js b/internal/skills/skill_runner.js
nnew file mode 100644
iindex 0000000..35b456b
---- /dev/null
++++ b/internal/skills/skill_runner.js
@@@ -0,0 +1,197 @@
++'use strict';
++
++/**
++ * [email protected]: run one tool of a declared skill as an OpenVibe Run job.
++ *
++ * The Node's job worker runs this file as the job's function (declared in worker.functions by
++ * `openvibe-node skills on`). The job's args are one JSON line on stdin:
++ *
++ * { tool, entry, input }
++ *
++ * `tool` is the skill tool's name (for the error it reports), `entry` the name of the job input holding the tool's
++ * code, and `input` the JSON the tool gets on its own stdin. Nothing is fetched and no path comes from the server:
++ * the entry must be a regular file in the job's working directory, where the worker staged the inputs it checked.
++ *
++ * The tool runs with this Node's own node binary, cwd the job directory, an empty environment, and:
++ *
++ * --permission --allow-fs-read=<the job directory> --disallow-code-generation-from-strings
++ *
++ * and with none of --allow-child-process, --allow-worker, --allow-addons or --allow-wasi: the process permission
++ * model therefore denies file writes, reads outside the job directory, child processes, workers and addons, and
++ * V8 refuses eval/new Function. The network and every kernel limit are the job sandbox's (docs/worker.md): this
++ * file adds no network of its own.
++ *
++ * The tool writes exactly one JSON value to stdout, at most 64 KiB; this runner passes it back as `output`. More
++ * than 64 KiB stops the tool and the result says `limit`. stderr is kept, at most 16 KiB, as `stderr`. The result is
++ * written to fd 3, the worker's result channel:
++ *
++ * { ok: true, output, exit: 0 [, stderr, stderr_truncated] }
++ * { ok: false, error, exit: <code or null> [, stderr, stderr_truncated] }
++ *
++ * A failed tool is still a job result: this process exits 0 whenever it wrote the result (the worker keeps a result
++ * only from a function that exited 0), so the person sees what happened instead of a bare job failure.
++ */
++
++const fs = require('fs');
++const path = require('path');
++const { spawn } = require('child_process');
++
++const MAX_STDOUT = 64 * 1024;
++const MAX_STDERR = 16 * 1024;
++const TOOL_NAME = /^[a-z][a-z0-9_]{0,31}$/;
++// A job input's name (platform.job@1's file-name pattern): one path component, no traversal, no absolute path.
++const ENTRY_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
++
++/** The result on fd 3 (the worker's result channel), or one JSON line on stdout outside a job (tests). */
++function writeResult(doc) {
++ const text = JSON.stringify(doc);
++ try {
++ fs.writeSync(3, text);
++ return true;
++ } catch {
++ try {
++ fs.writeSync(1, text + '\n');
++ return true;
++ } catch {
++ return false;
++ }
++ }
++}
++
++/** Ends with a result that is not a tool output: nothing ran, or the tool did not produce one JSON value. */
++function fail(error, extra) {
++ const doc = Object.assign({ ok: false, error: String(error), exit: null }, extra || {});
++ process.exitCode = writeResult(doc) ? 0 : 1;
++ return doc;
++}
++
++/** The job's args as one JSON value: whatever the worker read from its stdin (the contract passes them unchanged). */
++function readStdin() {
++ return new Promise((resolve) => {
++ const chunks = [];
++ process.stdin.on('data', (c) => chunks.push(c));
++ process.stdin.on('end', () => resolve(Buffer.concat(chunks)));
++ process.stdin.on('error', () => resolve(Buffer.concat(chunks)));
++ });
++}
++
++function parseArgs(raw) {
++ let a;
++ try {
++ a = JSON.parse(String(raw).trim() || '{}');
++ } catch {
++ throw new Error('the job args are not JSON');
++ }
++ if (!a || typeof a !== 'object' || Array.isArray(a)) throw new Error('the job args must be an object');
++ if (typeof a.tool !== 'string' || !TOOL_NAME.test(a.tool)) throw new Error('tool must be a tool name');
++ if (typeof a.entry !== 'string' || !ENTRY_NAME.test(a.entry) || a.entry === '.' || a.entry === '..') {
++ throw new Error('entry must be the name of one of the job inputs');
++ }
++ if (!Object.prototype.hasOwnProperty.call(a, 'input')) throw new Error('input is missing');
++ return a;
++}
++
++/** The entry file: an input the worker staged as a regular file here, never a symlink or another kind of file. */
++function entryFile(dir, entry) {
++ const file = path.join(dir, entry);
++ let st;
++ try {
++ st = fs.lstatSync(file);
++ } catch {
++ throw new Error(`entry ${entry} is not one of the job inputs`);
++ }
++ if (!st.isFile()) throw new Error(`entry ${entry} is not a regular file input`);
++ return file;
++}
++
++/** Runs the tool and resolves with its result document (never rejects). */
++function runTool(tool, file, input, dir) {
++ return new Promise((resolve) => {
++ const out = [];
++ let outLen = 0;
++ const err = [];
++ let errLen = 0;
++ let errCut = false;
++ let overLimit = false;
++ let spawnError = null;
++
++ const child = spawn(process.execPath, [
++ '--permission',
++ `--allow-fs-read=${dir}`,
++ '--disallow-code-generation-from-strings',
++ file,
++ ], { cwd: dir, env: {}, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
++
++ child.on('error', (e) => {
++ spawnError = e.message;
++ });
++ child.stdout.on('data', (chunk) => {
++ if (overLimit) return;
++ if (outLen + chunk.length > MAX_STDOUT) {
++ overLimit = true;
++ try { child.kill('SIGKILL'); } catch { /* already gone */ }
++ return;
++ }
++ out.push(chunk);
++ outLen += chunk.length;
++ });
++ child.stderr.on('data', (chunk) => {
++ if (errLen >= MAX_STDERR) {
++ errCut = true;
++ return;
++ }
++ const room = MAX_STDERR - errLen;
++ if (chunk.length > room) {
++ err.push(chunk.subarray(0, room));
++ errLen = MAX_STDERR;
++ errCut = true;
++ return;
++ }
++ err.push(chunk);
++ errLen += chunk.length;
++ });
++ child.stdin.on('error', () => { /* the tool exited without reading its input */ });
++ child.stdin.end(JSON.stringify(input));
++
++ const done = (code, signal) => {
++ const stderr = Buffer.concat(err).toString('utf8');
++ const extra = stderr ? { stderr, stderr_truncated: errCut } : {};
++ if (overLimit) return resolve({ ok: false, error: `limit: the tool wrote more than ${MAX_STDOUT} bytes to stdout`, exit: null, ...extra });
++ if (spawnError) return resolve({ ok: false, error: `could not start the tool: ${spawnError}`, exit: null, ...extra });
++ if (signal) return resolve({ ok: false, error: `the tool was killed by ${signal}`, exit: null, ...extra });
++ if (code !== 0) return resolve({ ok: false, error: `the tool exited with code ${code}`, exit: code, ...extra });
++ const text = Buffer.concat(out).toString('utf8').trim();
++ if (!text) return resolve({ ok: false, error: 'the tool wrote no output', exit: code, ...extra });
++ let output;
++ try {
++ output = JSON.parse(text);
++ } catch {
++ return resolve({ ok: false, error: 'the tool output is not one JSON value', exit: code, ...extra });
++ }
++ return resolve({ ok: true, output, exit: code, ...extra });
++ };
++ child.on('close', done);
++ });
++}
++
++async function main() {
++ let args;
++ try {
++ args = parseArgs(await readStdin());
++ } catch (e) {
++ fail(e.message);
++ return;
++ }
++ let dir, file;
++ try {
++ dir = fs.realpathSync(process.cwd());
++ file = entryFile(dir, args.entry);
++ } catch (e) {
++ fail(e.message);
++ return;
++ }
++ const doc = await runTool(args.tool, file, args.input, dir);
++ process.exitCode = writeResult(doc) ? 0 : 1;
++}
++
++main().catch((e) => fail(`the skill runner failed: ${e && e.message ? e.message : e}`));
ddiff --git a/internal/skills/skill_runner.test.js b/internal/skills/skill_runner.test.js
nnew file mode 100644
iindex 0000000..6d54a1e
---- /dev/null
++++ b/internal/skills/skill_runner.test.js
@@@ -0,0 +1,214 @@
++'use strict';
++
++// Tests for [email protected]: the declared function that runs one skill tool in the Node's job sandbox.
++//
++// Each test runs the runner as the job worker would: the args are one JSON line on stdin, the result is read from fd 3,
++// the entry is a file in the job directory, and the runner is the parent of the tool. The runner is exercised directly,
++// so the tests also prove the flags it gives the tool; the job sandbox's network and kernel limits are the worker's
++// (internal/worker's Linux tests cover the sandbox itself).
++
++const { test } = require('node:test');
++const assert = require('node:assert/strict');
++const { spawnSync } = require('node:child_process');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++
++const RUNNER = path.join(__dirname, 'skill_runner.js');
++const SECRET = 'ov-skill-runner-test-secret';
++
++/**
++ * Runs the runner as a job: cwd dir (the job directory), input the args JSON, fd 3 a file. `setup` may add other files
++ * (a symlink, say) after the named ones; `outside` is a scratch directory outside the job directory.
++ */
++function runJob(t, { files = {}, args, env = { ...process.env, OV_SKILL_SECRET: SECRET }, command, setup } = {}) {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-skill-job-'));
++ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-skill-outside-'));
++ t.after(() => {
++ fs.rmSync(dir, { recursive: true, force: true });
++ fs.rmSync(outside, { recursive: true, force: true });
++ });
++ for (const [name, body] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), body);
++ if (setup) setup(dir, outside);
++ const resultPath = path.join(outside, 'result.json');
++ const fd = fs.openSync(resultPath, 'w+');
++ const cmd = command || [process.execPath, RUNNER];
++ const r = spawnSync(cmd[0], cmd.slice(1), {
++ cwd: dir, input: JSON.stringify(args), env, timeout: 20000,
++ stdio: ['pipe', 'pipe', 'pipe', fd],
++ });
++ fs.closeSync(fd);
++ const raw = fs.readFileSync(resultPath, 'utf8');
++ let result = null;
++ try { result = JSON.parse(raw); } catch { /* asserted by the caller */ }
++ return { dir, outside, status: r.status, signal: r.signal, stdout: r.stdout.toString(), stderr: r.stderr.toString(), result, raw };
++}
++
++// A tool that answers one JSON value whatever comes in, and reports what it could and could not do.
++const REPORT = `
++const fs = require('fs');
++const out = {};
++const t = (name, fn) => { try { fn(); out[name] = 'ALLOWED'; } catch (e) { out[name] = e.code || e.name; } };
++let input = '';
++process.stdin.on('data', (d) => input += d).on('end', () => {
++ out.input = JSON.parse(input);
++ out.env = Object.keys(process.env);
++ out.secret = process.env.OV_SKILL_SECRET === undefined ? 'absent' : 'present';
++ out.cwd = process.cwd();
++ out.argv = process.argv.slice(2);
++ t('spawn', () => require('child_process').spawnSync('id'));
++ t('worker', () => new (require('worker_threads').Worker)('1', { eval: true }));
++ t('write', () => fs.writeFileSync('written.txt', 'x'));
++ t('eval', () => eval('1+1'));
++ t('function', () => Function('return 1')());
++ t('read_entry', () => fs.readFileSync('tool.js', 'utf8'));
++ out.read_outside = (() => { try { fs.readFileSync(out.input.outside, 'utf8'); return 'ALLOWED'; } catch (e) { return e.code || e.name; } })();
++ out.write_outside = (() => { try { fs.writeFileSync(out.input.outside + '.written', 'x'); return 'ALLOWED'; } catch (e) { return e.code || e.name; } })();
++ process.stdout.write(JSON.stringify(out));
++});
++`;
++
++test('a good tool gets the input on stdin and its one JSON value comes back', (t) => {
++ const r = runJob(t, {
++ files: { 'tool.js': REPORT },
++ args: { tool: 'report', entry: 'tool.js', input: { n: 7, s: 'héllo', nested: [true, null], outside: '/nonexistent' } },
++ });
++ assert.equal(r.status, 0, r.stderr);
++ assert.equal(r.result.ok, true, r.raw);
++ assert.deepEqual(r.result.output.input, { n: 7, s: 'héllo', nested: [true, null], outside: '/nonexistent' });
++ assert.equal(r.result.exit, 0);
++ assert.equal(r.result.stderr, undefined);
++ assert.deepEqual(r.result.output.argv, []);
++ assert.equal(r.result.output.cwd, fs.realpathSync(r.dir));
++});
++
++test('the tool gets no environment at all, not even the runner\'s', (t) => {
++ const r = runJob(t, { files: { 'tool.js': REPORT }, args: { tool: 'report', entry: 'tool.js', input: { outside: '/nonexistent' } } });
++ assert.equal(r.result.ok, true, r.raw);
++ assert.deepEqual(r.result.output.env, []);
++ assert.equal(r.result.output.secret, 'absent');
++});
++
++test('the tool cannot spawn a process, start a worker, write a file, read outside its input dir, or use eval', (t) => {
++ const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-skill-')),
++ 'outside.txt');
++ fs.writeFileSync(out, 'not yours');
++ t.after(() => fs.rmSync(path.dirname(out), { recursive: true, force: true }));
++ const r = runJob(t, { files: { 'tool.js': REPORT }, args: { tool: 'report', entry: 'tool.js', input: { outside: out } } });
++ assert.equal(r.result.ok, true, r.raw);
++ const o = r.result.output;
++ assert.equal(o.spawn, 'ERR_ACCESS_DENIED');
++ assert.equal(o.worker, 'ERR_ACCESS_DENIED');
++ assert.equal(o.write, 'ERR_ACCESS_DENIED');
++ assert.equal(o.read_outside, 'ERR_ACCESS_DENIED');
++ assert.equal(o.write_outside, 'ERR_ACCESS_DENIED');
++ assert.equal(o.eval, 'EvalError');
++ assert.equal(o.function, 'EvalError');
++ assert.equal(o.read_entry, 'ALLOWED'); // its own entry is readable: the job directory is the one allowed tree
++ assert.equal(fs.existsSync(path.join(r.dir, 'written.txt')), false);
++ assert.equal(fs.existsSync(out + '.written'), false);
++});
++
++test('more than 64 KiB on stdout is stopped and the result says limit', (t) => {
++ const r = runJob(t, {
++ files: { 'big.js': `process.stdout.write('"' + 'x'.repeat(70000) + '"');` },
++ args: { tool: 'big', entry: 'big.js', input: {} },
++ });
++ assert.equal(r.status, 0, r.stderr);
++ assert.equal(r.result.ok, false);
++ assert.match(r.result.error, /^limit:/);
++ assert.equal(r.result.exit, null);
++});
++
++test('stderr is kept, cut at 16 KiB', (t) => {
++ const r = runJob(t, {
++ files: { 'noisy.js': `process.stderr.write('e'.repeat(20000)); process.stdout.write('{"ok":1}');` },
++ args: { tool: 'noisy', entry: 'noisy.js', input: {} },
++ });
++ assert.equal(r.result.ok, true, r.raw);
++ assert.equal(r.result.output.ok, 1);
++ assert.equal(r.result.stderr.length, 16384);
++ assert.equal(r.result.stderr_truncated, true);
++});
++
++test('a tool that exits non-zero hands back its code and stderr', (t) => {
++ const r = runJob(t, {
++ files: { 'fail.js': `process.stderr.write('it broke'); process.exit(7);` },
++ args: { tool: 'fail', entry: 'fail.js', input: {} },
++ });
++ assert.equal(r.status, 0, r.stderr);
++ assert.equal(r.result.ok, false);
++ assert.equal(r.result.exit, 7);
++ assert.match(r.result.error, /exited with code 7/);
++ assert.equal(r.result.stderr, 'it broke');
++});
++
++test('stdout that is not one JSON value is a failed tool result, not a crash', (t) => {
++ for (const [name, body] of [['two.js', 'process.stdout.write("{}{}")'], ['none.js', '']]) {
++ const r = runJob(t, { files: { [name]: body }, args: { tool: 'x', entry: name, input: {} } });
++ assert.equal(r.status, 0, r.stderr);
++ assert.equal(r.result.ok, false);
++ assert.match(r.result.error, /not one JSON value|wrote no output/);
++ }
++});
++
++test('entry must be a regular file in the job directory, never a path or a symlink', (t) => {
++ let r = runJob(t, {
++ files: { 'tool.js': 'process.stdout.write("{}")' },
++ args: { tool: 'x', entry: '../tool.js', input: {} },
++ });
++ assert.equal(r.result.ok, false);
++ assert.match(r.result.error, /entry must be the name of one of the job inputs/);
++
++ r = runJob(t, {
++ args: { tool: 'x', entry: 'missing.js', input: {} },
++ });
++ assert.equal(r.result.ok, false);
++ assert.match(r.result.error, /not one of the job inputs/);
++
++ r = runJob(t, {
++ args: { tool: 'x', entry: 'link.js', input: {} },
++ setup: (dir, out) => {
++ fs.writeFileSync(path.join(out, 'tool.js'), 'process.stdout.write("{}")');
++ fs.symlinkSync(path.join(out, 'tool.js'), path.join(dir, 'link.js'));
++ },
++ });
++ assert.equal(r.result.ok, false);
++ assert.match(r.result.error, /not a regular file input/);
++});
++
++test('a bad tool name or missing input fails before anything runs', (t) => {
++ for (const args of [
++ { tool: 'Bad Name', entry: 'tool.js', input: {} },
++ { tool: 'x', entry: 'tool.js' },
++ { entry: 'tool.js', input: {} },
++ ]) {
++ const r = runJob(t, { files: { 'tool.js': 'process.stdout.write("{}")' }, args });
++ assert.equal(r.status, 0, r.stderr);
++ assert.equal(r.result.ok, false);
++ assert.equal(r.result.exit, null);
++ }
++});
++
++test('under net deny (its own network namespace) the tool opens no socket to the internet', (t) => {
++ const probe = spawnSync('unshare', ['-rn', 'true'], { timeout: 5000 });
++ if (probe.error || probe.status !== 0) {
++ return t.skip('unshare -rn is not available here, so a network namespace cannot stand');
++ }
++ const tool = `
++const net = require('net');
++const s = net.connect({ host: '1.1.1.1', port: 80 });
++const done = (o) => { process.stdout.write(JSON.stringify(o)); process.exit(0); };
++s.on('connect', () => done({ reached: true }));
++s.on('error', (e) => done({ reached: false, code: e.code }));
++setTimeout(() => done({ reached: true, timeout: true }), 8000);
++`;
++ const r = runJob(t, {
++ files: { 'net.js': tool },
++ args: { tool: 'net', entry: 'net.js', input: {} },
++ command: ['unshare', '-rn', process.execPath, RUNNER],
++ });
++ assert.equal(r.result.ok, true, r.raw);
++ assert.equal(r.result.output.reached, false, r.raw);
++ assert.ok(['ENETUNREACH', 'EHOSTUNREACH', 'ECONNREFUSED'].includes(r.result.output.code), r.raw);
++});
ddiff --git a/internal/skills/skills.go b/internal/skills/skills.go
nnew file mode 100644
iindex 0000000..ecd51ea
---- /dev/null
++++ b/internal/skills/skills.go
@@@ -0,0 +1,299 @@
++// Package skills declares OpenVibe.Node's skill runner: the function that runs one tool of a declared skill as a Run
++// job, with this Node's Node.js in the function sandbox (docs/worker.md, "Skills that run code").
++package skills
++
++import (
++ _ "embed"
++ "errors"
++ "fmt"
++ "os"
++ "os/exec"
++ "path/filepath"
++ "runtime"
++ "strings"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++)
++
++const (
++ // Name and Version are the artifact the job names (platform.job@1 artifact.name/version).
++ Name = "openvibe-skill-runner"
++ Version = "1.0.0"
++ // File is the runner script inside the artifact directory.
++ File = "openvibe-skill-runner.js"
++ // MinNode is the first Node.js whose permission model is stable behind --permission (v22.13.0).
++ MinNode = "22.13.0"
++)
++
++//go:embed skill_runner.js
++var runnerJS []byte
++
++// DefaultDir is where `skills on` puts the runner when no --dir is given. It is outside the Node's config and state
++// directories and outside /tmp, /home and /var, which the worker refuses as an artifact directory.
++func DefaultDir() string {
++ switch runtime.GOOS {
++ case "linux":
++ return "/opt/openvibe-skill-runner"
++ case "darwin":
++ return "/usr/local/lib/openvibe-skill-runner"
++ default:
++ base := os.Getenv("ProgramData")
++ if base == "" {
++ base = `C:\ProgramData`
++ }
++ return filepath.Join(base, "OpenVibeSkillRunner")
++ }
++}
++
++// RunnerPath is the runner script's path in dir.
++func RunnerPath(dir string) string { return filepath.Join(dir, File) }
++
++// Node is the Node.js binary a job runs the skill's code with.
++type Node struct {
++ Path string // absolute
++ Version string // the version node reports, without the leading v
++}
++
++// FindNode finds the Node.js the runner runs tools with: --node when given, else /usr/bin/node and /usr/local/bin/node
++// (the two the installer uses), else node on PATH. It must report a version and accept the exact permission flags the
++// runner passes, so a Node too old for them is refused here rather than at the first job.
++func FindNode(explicit string) (Node, error) {
++ var candidates []string
++ if explicit != "" {
++ candidates = []string{explicit}
++ } else {
++ if runtime.GOOS == "linux" {
++ candidates = append(candidates, "/usr/bin/node", "/usr/local/bin/node")
++ }
++ if p, err := exec.LookPath("node"); err == nil {
++ candidates = append(candidates, p)
++ }
++ }
++ var tried []string
++ for _, c := range candidates {
++ abs, err := filepath.Abs(c)
++ if err != nil {
++ continue
++ }
++ st, err := os.Stat(abs)
++ if err != nil || !st.Mode().IsRegular() || st.Mode().Perm()&0o111 == 0 {
++ tried = append(tried, fmt.Sprintf("%s (not an executable file)", abs))
++ continue
++ }
++ v, err := nodeVersion(abs)
++ if err != nil {
++ tried = append(tried, fmt.Sprintf("%s (%v)", abs, err))
++ continue
++ }
++ if err := probePermission(abs); err != nil {
++ tried = append(tried, fmt.Sprintf("%s %s (%v)", abs, v, err))
++ continue
++ }
++ if err := nodeVisible(abs); err != nil {
++ tried = append(tried, fmt.Sprintf("%s %s (%v)", abs, v, err))
++ continue
++ }
++ return Node{Path: abs, Version: v}, nil
++ }
++ if len(tried) == 0 {
++ tried = append(tried, "no node found; --node PATH or install Node.js "+MinNode+" or newer system-wide")
++ }
++ return Node{}, fmt.Errorf("no usable Node.js (%s or newer, with --permission): %s", MinNode, strings.Join(tried, "; "))
++}
++
++// nodeVersion is the version node reports (its --version, without the leading v).
++func nodeVersion(path string) (string, error) {
++ out, err := exec.Command(path, "--version").Output()
++ if err != nil {
++ return "", fmt.Errorf("--version failed: %w", err)
++ }
++ v := strings.TrimSpace(string(out))
++ v = strings.TrimPrefix(v, "v")
++ if v == "" || strings.ContainsAny(v, " \t\n") {
++ return "", fmt.Errorf("--version printed %q", strings.TrimSpace(string(out)))
++ }
++ return v, nil
++}
++
++// probePermission runs node with the flags the runner gives a tool, so an old Node that does not know --permission is
++// refused when the owner turns the runner on, not silently at the first job.
++func probePermission(path string) error {
++ dir, err := os.MkdirTemp("", "openvibe-skill-node-")
++ if err != nil {
++ return err
++ }
++ defer os.RemoveAll(dir)
++ cmd := exec.Command(path, "--permission", "--allow-fs-read="+dir, "--disallow-code-generation-from-strings", "-e", "")
++ if out, err := cmd.CombinedOutput(); err != nil {
++ msg := strings.TrimSpace(string(out))
++ if len(msg) > 300 {
++ msg = msg[:300]
++ }
++ return fmt.Errorf("does not support the skill runner's permission flags: %v %s", err, msg)
++ }
++ return nil
++}
++
++// nodeVisible refuses a node under a directory a job's private root never holds (/home, a version manager's store): the
++// job would fail to start its tool. Linux only, where the worker runs.
++func nodeVisible(path string) error {
++ if runtime.GOOS != "linux" {
++ return nil
++ }
++ p := filepath.Clean(path)
++ for _, root := range []string{"/usr", "/bin", "/sbin", "/lib", "/lib64"} {
++ if p == root || strings.HasPrefix(p, root+"/") {
++ return nil
++ }
++ }
++ return fmt.Errorf("is outside the system paths a job sees (/usr, /bin, /sbin, /lib*); install Node.js %s system-wide", MinNode)
++}
++
++// Entry is the worker.functions entry for node and dir.
++func Entry(node Node, dir string) config.FunctionConfig {
++ return config.FunctionConfig{
++ Name: Name,
++ Version: Version,
++ Class: config.ClassFunction,
++ Command: []string{node.Path, RunnerPath(dir), "job"},
++ ArtifactDir: dir,
++ }
++}
++
++// On finds Node.js and enables the runner.
++func On(cfg *config.Config, dir string, nodeArg string) (config.FunctionConfig, Node, bool, error) {
++ node, err := FindNode(nodeArg)
++ if err != nil {
++ return config.FunctionConfig{}, Node{}, false, err
++ }
++ entry, changed, err := Enable(cfg, dir, node)
++ return entry, node, changed, err
++}
++
++// Enable writes the runner script into dir and adds (or replaces) the worker.functions entry for it. It leaves
++// worker.enabled alone: the entry alone does not make the worker run jobs.
++func Enable(cfg *config.Config, dir string, node Node) (config.FunctionConfig, bool, error) {
++ var err error
++ dir, err = filepath.Abs(dir)
++ if err != nil {
++ return config.FunctionConfig{}, false, err
++ }
++ dir = filepath.Clean(dir)
++ if filepath.Dir(dir) == dir { // a filesystem root: no artifact_dir, and a job root must never be /
++ return config.FunctionConfig{}, false, errors.New("the skill runner directory must not be a filesystem root")
++ }
++ entry := Entry(node, dir)
++ functions := make([]config.FunctionConfig, 0, len(cfg.Worker.Functions)+1)
++ for _, f := range cfg.Worker.Functions {
++ if f.Name != Name {
++ functions = append(functions, f)
++ }
++ }
++ functions = append(functions, entry)
++ cfg.Worker.Functions = functions
++ if err := cfg.Validate(); err != nil {
++ return config.FunctionConfig{}, false, err
++ }
++ if err := writeRunner(dir); err != nil {
++ return config.FunctionConfig{}, false, err
++ }
++ return entry, true, nil
++}
++
++// Off removes every worker.functions entry named openvibe-skill-runner (of any version or class) and reports whether
++// the config changed. It keeps the script on disk: turning the runner back on rewrites it.
++func Off(cfg *config.Config) bool {
++ functions := make([]config.FunctionConfig, 0, len(cfg.Worker.Functions))
++ for _, f := range cfg.Worker.Functions {
++ if f.Name != Name {
++ functions = append(functions, f)
++ }
++ }
++ if len(functions) == len(cfg.Worker.Functions) {
++ return false
++ }
++ cfg.Worker.Functions = functions
++ return true
++}
++
++// Status is what `skills status` reports.
++type Status struct {
++ On bool `json:"on"`
++ Name string `json:"name"`
++ Version string `json:"version"`
++ Class string `json:"class"`
++ Artifact string `json:"artifact_dir,omitempty"`
++ ArtifactPresent bool `json:"artifact_present"`
++ Command []string `json:"command,omitempty"`
++ Node string `json:"node,omitempty"`
++ NodeVersion string `json:"node_version,omitempty"`
++ NodeError string `json:"node_error,omitempty"`
++ WorkerEnabled bool `json:"worker_enabled"`
++}
++
++// StatusOf reports whether the runner is declared and whether its files and Node.js are still there. It does not run
++// the Node.js permission probe (status stays cheap); `skills on` does.
++func StatusOf(cfg *config.Config) Status {
++ s := Status{On: false, Name: Name, Version: Version, Class: config.ClassFunction, WorkerEnabled: cfg.Worker.Enabled}
++ for _, f := range cfg.Worker.Functions {
++ if f.Name != Name {
++ continue
++ }
++ s.On = true
++ s.Version = f.Version
++ s.Class = f.EffectiveClass()
++ s.Artifact = f.Artifact()
++ s.Command = append([]string(nil), f.Command...)
++ if st, err := os.Stat(RunnerPath(s.Artifact)); err == nil && st.Mode().IsRegular() {
++ s.ArtifactPresent = true
++ }
++ if len(f.Command) > 0 {
++ s.Node = f.Command[0]
++ if v, err := nodeVersion(s.Node); err == nil {
++ s.NodeVersion = v
++ } else {
++ s.NodeError = err.Error()
++ }
++ }
++ break
++ }
++ return s
++}
++
++// writeRunner writes the embedded script atomically, readable by the unprivileged job user, into dir.
++func writeRunner(dir string) error {
++ if err := os.MkdirAll(dir, 0o755); err != nil {
++ return fmt.Errorf("cannot create %s (run with sudo, or give --dir): %w", dir, err)
++ }
++ f, err := os.CreateTemp(dir, ".openvibe-skill-runner-")
++ if err != nil {
++ return fmt.Errorf("cannot write %s (run with sudo, or give --dir): %w", dir, err)
++ }
++ tmp := f.Name()
++ defer os.Remove(tmp)
++ if _, err := f.Write(runnerJS); err != nil {
++ f.Close()
++ return err
++ }
++ if err := f.Chmod(0o644); err != nil {
++ f.Close()
++ return err
++ }
++ if err := f.Close(); err != nil {
++ return err
++ }
++ return os.Rename(tmp, RunnerPath(dir))
++}
++
++// CommandString is the declared command as a person reads it.
++func CommandString(cmd []string) string {
++ quoted := make([]string, len(cmd))
++ for i, c := range cmd {
++ if c == "" || strings.ContainsAny(c, " \t\"'") {
++ quoted[i] = fmt.Sprintf("%q", c)
++ } else {
++ quoted[i] = c
++ }
++ }
++ return strings.Join(quoted, " ")
++}
ddiff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go
nnew file mode 100644
iindex 0000000..a629f3c
---- /dev/null
++++ b/internal/skills/skills_test.go
@@@ -0,0 +1,153 @@
++package skills
++
++import (
++ "bytes"
++ "os"
++ "path/filepath"
++ "runtime"
++ "strings"
++ "testing"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++)
++
++func testNode(t *testing.T) Node {
++ t.Helper()
++ return Node{Path: filepath.Join(t.TempDir(), "node"), Version: "24.14.1"}
++}
++
++func TestEnableOffStatus(t *testing.T) {
++ cfg := config.Default()
++ dir := t.TempDir()
++ node := testNode(t)
++ entry, changed, err := Enable(cfg, dir, node)
++ if err != nil || !changed {
++ t.Fatalf("Enable: %v (changed %v)", err, changed)
++ }
++ if entry.Name != Name || entry.Version != Version || entry.Class != config.ClassFunction {
++ t.Fatalf("entry %+v", entry)
++ }
++ if entry.ArtifactDir != dir {
++ t.Fatalf("artifact_dir %q, want %q", entry.ArtifactDir, dir)
++ }
++ want := []string{node.Path, RunnerPath(dir), "job"}
++ if strings.Join(entry.Command, "\x00") != strings.Join(want, "\x00") {
++ t.Fatalf("command %q, want %q", entry.Command, want)
++ }
++ got, err := os.ReadFile(RunnerPath(dir))
++ if err != nil {
++ t.Fatal(err)
++ }
++ if !bytes.Equal(got, runnerJS) {
++ t.Fatalf("the runner on disk is %d bytes, want the embedded %d", len(got), len(runnerJS))
++ }
++ if st, err := os.Stat(dir); err != nil || st.Mode().Perm()&0o005 == 0 {
++ t.Fatalf("the runner directory is not readable by the job user: %v %v", st, err)
++ }
++
++ st := StatusOf(cfg)
++ if !st.On || st.Name != Name || st.Version != Version || st.Class != config.ClassFunction {
++ t.Fatalf("status %+v", st)
++ }
++ if st.Artifact != dir || !st.ArtifactPresent {
++ t.Fatalf("status artifact %q present %v", st.Artifact, st.ArtifactPresent)
++ }
++ if len(st.Command) != 3 || st.Command[0] != node.Path {
++ t.Fatalf("status command %q", st.Command)
++ }
++
++ if !Off(cfg) {
++ t.Fatal("Off did not report the removal")
++ }
++ if Off(cfg) {
++ t.Fatal("Off removed a runner that was not declared")
++ }
++ if StatusOf(cfg).On {
++ t.Fatal("status still says on")
++ }
++}
++
++func TestEnableKeepsOtherFunctionsAndReplacesItself(t *testing.T) {
++ other := config.FunctionConfig{Name: "compute", Version: "2.0.0", Command: []string{filepath.Join(os.TempDir(), "true")}}
++ cfg := config.Default()
++ cfg.Worker.Functions = []config.FunctionConfig{other}
++ dir := t.TempDir()
++ if _, _, err := Enable(cfg, dir, testNode(t)); err != nil {
++ t.Fatal(err)
++ }
++ if _, _, err := Enable(cfg, dir, testNode(t)); err != nil {
++ t.Fatal(err)
++ }
++ if len(cfg.Worker.Functions) != 2 {
++ t.Fatalf("%d functions, want the other one and the runner: %+v", len(cfg.Worker.Functions), cfg.Worker.Functions)
++ }
++ n := 0
++ for _, f := range cfg.Worker.Functions {
++ if f.Name == Name {
++ n++
++ }
++ }
++ if n != 1 {
++ t.Fatalf("%d skill runner entries", n)
++ }
++}
++
++func TestEnableRefusesRoot(t *testing.T) {
++ cfg := config.Default()
++ if _, _, err := Enable(cfg, "/", testNode(t)); err == nil {
++ t.Fatal("Enable wrote the runner at /")
++ }
++ if len(cfg.Worker.Functions) != 0 {
++ t.Fatal("a refused Enable changed the config")
++ }
++}
++
++func TestFindNode(t *testing.T) {
++ n, err := FindNode("")
++ if err != nil {
++ t.Skipf("no usable node on this machine: %v", err)
++ }
++ if n.Path == "" || n.Version == "" {
++ t.Fatalf("%+v", n)
++ }
++ if runtime.GOOS == "linux" {
++ if err := nodeVisible(n.Path); err != nil {
++ t.Fatalf("FindNode returned a node a job cannot see: %v", err)
++ }
++ }
++}
++
++func TestNodeVisibleRefusesHome(t *testing.T) {
++ if runtime.GOOS != "linux" {
++ t.Skip("the worker runs on Linux only")
++ }
++ if err := nodeVisible("/home/user/.local/share/fnm/node"); err == nil {
++ t.Fatal("a node under /home was accepted: a job's root never holds /home")
++ }
++ if err := nodeVisible("/usr/local/bin/node"); err != nil {
++ t.Fatalf("a system node was refused: %v", err)
++ }
++}
++
++func TestCommandString(t *testing.T) {
++ if got := CommandString([]string{"/usr/bin/node", "/opt/x/my runner.js", "job"}); got != "/usr/bin/node \"/opt/x/my runner.js\" job" {
++ t.Fatalf("%q", got)
++ }
++}
++
++// TestDefaultDirIsNotAWorkerRefusedPath: the default never lies in /tmp, /home or the Node's own directories, which the
++// worker's artifact probe refuses (docs/worker.md).
++func TestDefaultDirIsNotAWorkerRefusedPath(t *testing.T) {
++ d := filepath.Clean(DefaultDir())
++ if d == "/" || !filepath.IsAbs(d) {
++ t.Fatalf("default dir %q", d)
++ }
++ if runtime.GOOS != "linux" {
++ return
++ }
++ for _, bad := range []string{"/tmp", "/home", "/var/tmp", "/etc", "/root", "/proc", "/sys", "/dev", "/run"} {
++ if d == bad || strings.HasPrefix(d, bad+"/") {
++ t.Fatalf("default dir %q is under %s", d, bad)
++ }
++ }
++}
ddiff --git a/internal/worker/skill_runner_linux_test.go b/internal/worker/skill_runner_linux_test.go
nnew file mode 100644
iindex 0000000..68c8ef8
---- /dev/null
++++ b/internal/worker/skill_runner_linux_test.go
@@@ -0,0 +1,172 @@
++//go:build linux
++
++package worker
++
++import (
++ "encoding/json"
++ "os"
++ "strings"
++ "testing"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++ "github.com/OpenVibers/OpenVibe.Node/internal/skills"
++)
++
++// skillRunnerWorker declares the real [email protected] (skills.Enable writes the script it embedded) and a
++// fake Media API the job's code input comes from. It skips where the sandbox cannot stand: the skill runner's own tests
++// cover its flags, this covers the job it runs in.
++func skillRunnerWorker(t *testing.T, m *mediaFake) (*Worker, *sink) {
++ t.Helper()
++ node, err := skills.FindNode("")
++ if err != nil {
++ if os.Getenv("OPENVIBE_WORKER_TESTS") == "require" {
++ t.Fatalf("the skill runner needs a Node.js a job can see: %v", err)
++ }
++ t.Skipf("no Node.js a job can see: %v", err)
++ }
++ cfg := config.Default()
++ // Directly under /tmp and open to every user: as root the job runs as nobody, which must search every directory
++ // above the artifact (the worker copies it as the job's user), and Go's t.TempDir parents are 0700.
++ dir, err := os.MkdirTemp("", "openvibe-skill-runner-")
++ if err != nil {
++ t.Fatal(err)
++ }
++ t.Cleanup(func() { os.RemoveAll(dir) })
++ if err := os.Chmod(dir, 0o755); err != nil {
++ t.Fatal(err)
++ }
++ if _, _, err := skills.Enable(cfg, dir, node); err != nil {
++ t.Fatal(err)
++ }
++ wc := cfg.Worker
++ wc.Enabled = true
++ wc.Caps = config.WorkerCaps{MaxMemBytes: 8 << 30, MaxDiskBytes: 256 << 20, MaxVMBytes: 1 << 46}
++ if os.Geteuid() == 0 {
++ wc.AllowSameUser, wc.RunAs = false, &config.RunAs{UID: 65534, GID: 65534}
++ } else {
++ wc.AllowSameUser = true
++ }
++ m.config(&wc)
++ w, s := newRunWorkerConfig(t, wc)
++ w.mediaRT = m.srv.Client().Transport
++ return w, s
++}
++
++// skillJob is one job of the declared runner: code.js is the tool's code, pinned by digest, and args names it.
++func skillJob(n int, tool []byte, digest string, args string) protocol.Job {
++ j := testJob(n, skills.Name)
++ j.Artifact = &protocol.Artifact{Name: skills.Name, Version: skills.Version}
++ j.Args = json.RawMessage(args)
++ j.Inputs = []protocol.JobInput{{Name: "code.js", MediaID: inputID, SHA256: digest, SizeBytes: int64(len(tool))}}
++ return j
++}
++
++// TestSkillRunnerJob: the real runner runs the job's code input as a real function job: the tool gets the args' input
++// as JSON, no environment, and the permission model's denials (no spawn, no write); the job's result is { ok, output }.
++func TestSkillRunnerJob(t *testing.T) {
++ tool := []byte(`
++const fs = require('fs');
++let input = '';
++process.stdin.on('data', (d) => input += d).on('end', () => {
++ const out = { input: JSON.parse(input), env: Object.keys(process.env), cwd: process.cwd() };
++ try { require('child_process').spawnSync('id'); out.spawn = 'ALLOWED'; } catch (e) { out.spawn = e.code; }
++ try { fs.writeFileSync('written.txt', 'x'); out.write = 'ALLOWED'; } catch (e) { out.write = e.code; }
++ process.stdout.write(JSON.stringify(out));
++});
++`)
++ m := newMediaFake(t)
++ m.objects[inputID] = tool
++ t.Setenv(testMediaEnv, testMediaToken)
++ w, s := skillRunnerWorker(t, m)
++ j := skillJob(1, tool, digestOf(tool), `{"tool":"report","entry":"code.js","input":{"n":7}}`)
++ run(t, w, j)
++ ex := s.exit(t, j.ID)
++ if ex.Reason != protocol.ExitExited || ex.Code == nil || *ex.Code != 0 {
++ t.Fatalf("%+v result %s", ex, ex.Result)
++ }
++ var got struct {
++ OK bool `json:"ok"`
++ Exit *int `json:"exit"`
++ Output struct {
++ Input map[string]any `json:"input"`
++ Env []string `json:"env"`
++ Cwd string `json:"cwd"`
++ Spawn string `json:"spawn"`
++ Write string `json:"write"`
++ } `json:"output"`
++ }
++ if err := json.Unmarshal(ex.Result, &got); err != nil {
++ t.Fatalf("result %s: %v", ex.Result, err)
++ }
++ if !got.OK || got.Exit == nil || *got.Exit != 0 {
++ t.Fatalf("result %s", ex.Result)
++ }
++ if got.Output.Input["n"] != float64(7) {
++ t.Fatalf("the tool got %v for its input", got.Output.Input)
++ }
++ if len(got.Output.Env) != 0 {
++ t.Fatalf("the tool's environment is not empty: %q", got.Output.Env)
++ }
++ if got.Output.Spawn != "ERR_ACCESS_DENIED" || got.Output.Write != "ERR_ACCESS_DENIED" {
++ t.Fatalf("spawn %q write %q", got.Output.Spawn, got.Output.Write)
++ }
++ if !strings.HasPrefix(got.Output.Cwd, "/tmp/openvibe-job-") {
++ t.Fatalf("the tool's cwd is %q, not the job directory", got.Output.Cwd)
++ }
++}
++
++// TestSkillRunnerJobNetDeny: the job names no net, so the worker's network namespace holds no network: the tool's socket
++// to the internet fails, not because Node's permission model stopped it but because the sandbox has nowhere to send it.
++func TestSkillRunnerJobNetDeny(t *testing.T) {
++ tool := []byte(`
++const net = require('net');
++const s = net.connect({ host: '1.1.1.1', port: 80 });
++const done = (o) => { process.stdout.write(JSON.stringify(o)); process.exit(0); };
++s.on('connect', () => done({ reached: true }));
++s.on('error', (e) => done({ reached: false, code: e.code }));
++setTimeout(() => done({ reached: true, timeout: true }), 8000);
++`)
++ m := newMediaFake(t)
++ m.objects[inputID] = tool
++ t.Setenv(testMediaEnv, testMediaToken)
++ w, s := skillRunnerWorker(t, m)
++ j := skillJob(2, tool, digestOf(tool), `{"tool":"net","entry":"code.js","input":{}}`)
++ run(t, w, j)
++ ex := s.exit(t, j.ID)
++ var got struct {
++ OK bool `json:"ok"`
++ Output struct {
++ Reached bool `json:"reached"`
++ Code string `json:"code"`
++ } `json:"output"`
++ }
++ if err := json.Unmarshal(ex.Result, &got); err != nil || !got.OK {
++ t.Fatalf("%+v result %s: %v", ex, ex.Result, err)
++ }
++ if got.Output.Reached {
++ t.Fatalf("the tool reached the internet under net deny: %s", ex.Result)
++ }
++ if got.Output.Code != "ENETUNREACH" && got.Output.Code != "EHOSTUNREACH" {
++ t.Fatalf("the socket failed with %q, not a missing route: %s", got.Output.Code, ex.Result)
++ }
++}
++
++// TestSkillRunnerDigestMismatch: a code input whose bytes do not match the pinned sha256 ends the job failed before
++// anything runs: no sandbox, no job_started, no result.
++func TestSkillRunnerDigestMismatch(t *testing.T) {
++ tool := []byte(`process.stdout.write('{"ok":true}');`)
++ m := newMediaFake(t)
++ m.objects[inputID] = tool
++ t.Setenv(testMediaEnv, testMediaToken)
++ w, s := skillRunnerWorker(t, m)
++ j := skillJob(3, tool, strings.Repeat("0", 64), `{"tool":"x","entry":"code.js","input":{}}`)
++ run(t, w, j)
++ ex := s.exit(t, j.ID)
++ if ex.Reason != protocol.ExitFailed || ex.Code != nil || string(ex.Result) != "null" {
++ t.Fatalf("%+v result %s", ex, ex.Result)
++ }
++ if n := s.count(protocol.TypeJobStarted, j.ID); n != 0 {
++ t.Fatalf("%d job_started: the runner was started", n)
++ }
++}