openvibers/openvibe.link Commits
HO8 step 2: the skill runner ([email protected]), sandboxed in a function job (#132)
Changes
@@@ -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)@@@ -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 versionpackage main@@@ -91,6 +92,9 @@ Usage:openvibe-node skills sync --undowrite the skills and rules you chose at openvibe.codes/skillsinto 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 offeropenvibe-node stream --probe --whip-url URL [--seconds N] [--size WxH] [--json]nnew file mode 100644@@@ -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"++}nnew file mode 100644@@@ -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)++ }++}@@@ -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)@@@ -42,6 +42,76 @@ with a token from this `env`. Codes CLI 0.6.1 and later sends it to git as an `Aalone, 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):nnew file mode 100644@@@ -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}`));nnew file mode 100644@@@ -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);++});nnew file mode 100644@@@ -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, " ")++}nnew file mode 100644@@@ -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)++ }++ }++}nnew file mode 100644@@@ -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)++ }++}
@@ -29,6 +29,14 @@ jobs:
@@ -29,6 +29,14 @@ jobs:
# The job worker's tests skip where jobs cannot run sandboxed: unprivileged, the runner's cgroup is not delegated
# 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).
# (and Ubuntu's AppArmor restricts unprivileged user namespaces).
- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
- 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 ./...
- run: go test -race -count=1 ./...
# The tray's desktop notifications, against a fake notification server on a private session bus.
# The tray's desktop notifications, against a fake notification server on a private session bus.
- name: tray notifications (private D-Bus)
- name: tray notifications (private D-Bus)
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
// read the memory files of the coding tools (read-only, local)
// read the memory files of the coding tools (read-only, local)
// openvibe-node skills sync [--tools a,b] [--project DIR …] [--dry-run]
// 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
// 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
// openvibe-node version
package main
package main
@@ -91,6 +92,9 @@ Usage:
@@ -91,6 +92,9 @@ Usage:
openvibe-node skills sync --undo
openvibe-node skills sync --undo
write the skills and rules you chose at openvibe.codes/skills
write the skills and rules you chose at openvibe.codes/skills
into each tool's own folders, inside one marked block
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]
openvibe-node exchange serve --model <id> [--public-url URL]
serve a local model as an OpenVibe.Exchange seller offer
serve a local model as an OpenVibe.Exchange seller offer
openvibe-node stream --probe --whip-url URL [--seconds N] [--size WxH] [--json]
openvibe-node stream --probe --whip-url URL [--seconds N] [--size WxH] [--json]
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -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"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -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)
+ }
+}
@@ -54,6 +54,8 @@ func cmdSkills(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
@@ -54,6 +54,8 @@ func cmdSkills(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
switch args[0] {
switch args[0] {
case "sync":
case "sync":
return cmdSkillsSync(args[1:], stdin, stdout, stderr)
return cmdSkillsSync(args[1:], stdin, stdout, stderr)
+ case "on", "off", "status":
+ return cmdSkillsRunner(args, stdout, stderr)
default:
default:
fmt.Fprintf(stderr, "unknown skills command %q\n\n", args[0])
fmt.Fprintf(stderr, "unknown skills command %q\n\n", args[0])
fmt.Fprint(stderr, skillsSyncUsage)
fmt.Fprint(stderr, skillsSyncUsage)
@@ -42,6 +42,76 @@ with a token from this `env`. Codes CLI 0.6.1 and later sends it to git as an `A
@@ -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
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.
`.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
`config.json`, section `worker` (defaults shown; a zero cap means the default):
`config.json`, section `worker` (defaults shown; a zero cap means the default):
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -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}`));
new file mode 100644
new file mode 100644
@@ -0,0 +1,214 @@
@@ -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);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,299 @@
@@ -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, " ")
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -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)
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -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)
+ }
+}