openvibers/openvibe.tools Commits
Install and update outbound Tools workers on Ubuntu (#294)
Changes
@@@ -89,6 +89,18 @@ server {proxy_buffering off;}++ location = /api/v1/workers/dry-run {++ limit_req zone=ovtools_api burst=10 nodelay;++ proxy_pass http://127.0.0.1:4001;++ proxy_http_version 1.1;++ proxy_set_header Upgrade $http_upgrade;++ proxy_set_header Connection "upgrade";++ proxy_set_header Host $host;++ proxy_read_timeout 60s;++ proxy_send_timeout 60s;++ proxy_buffering off;++ }++location /api/ {limit_req zone=ovtools_api burst=30 nodelay;proxy_pass http://127.0.0.1:4001;@@@ -79,6 +79,7 @@ app.post('/api/internal/workers/dispatch', (req, res, next) => {catch { if (!res.headersSent) res.status(503).json({ error: 'Worker placement unavailable' }); }});++app.get('/worker/install.sh', (_req, res) => { res.type('text/plain'); res.sendFile(require('path').resolve(__dirname, '../../worker/install.sh')); });app.get('/api/v1/workers/status', async (_req, res) => {try { res.json({ protocol: 1, dry_run: true, transport: 'outbound-websocket', accepts: ['img.process', 'docs.process', 'audio.process'], routing_enabled: !!(await workerStore.routing())?.enabled, strategy: 'owner-order/free-slots/recent-latency', fallback: 'host' }); }catch { res.status(503).json({ error: 'Worker registry unavailable' }); }@@@ -687,6 +687,17 @@ function send(req, res, key, render, type, extra) {res.send(e.html);}++function pageWorkers() {++ return shell({ families: registry.get().families, head: head({ title: 'Run Tools on your own machine', description: 'Install an outbound Tools worker on Ubuntu.', canonical: SITE + '/workers' }), body: `<main><h1>Run Tools on your own machine</h1>++<p>A worker transforms uploaded image, PDF and audio files. It opens an outbound encrypted connection; no inbound port or public home address is needed. URL-fetching jobs stay on the host. File transfers are limited to 16 MiB per input set and output set.</p>++<p>On Ubuntu, use the <a href="/worker/install.sh">installer</a> and the <a href="https://github.com/OpenVibers/OpenVibe.Tools/blob/main/apps/worker/README.md">setup instructions</a>. The owner creates a worker credential once, then enables routing separately in the Tools owner console. Keep the credential in a private file, never a command argument.</p>++<p>The service defaults to two slots, two CPU cores and a 2 GiB memory limit. A daily timer checks the deployed release and installs updates. Draining a worker stops new assignments; running jobs finish.</p>++<h2>Check without accepting jobs</h2>++<pre><code>bash install.sh --dry-run --prefix /tmp/tools-worker-check-new</code></pre>++<p>Dry mode checks HTTPS and the outbound WebSocket without a credential, registration or real jobs. It requires Node 22 or newer and creates no system service.</p>++<p><a href="/developers#worker-routing">API routing and receipts</a></p></main>` });++}++function sitemapEntries() {const { tools, families } = registry.get();const today = new Date().toISOString().slice(0, 10);@@@ -770,6 +781,7 @@ function createSiteRouter({ opens = null, discovery = null } = {}) {const apexOnly = (req, res, next) => (!req.ovHost || req.ovHost.kind === 'apex' || (req.ovHost.kind === 'unknown' && !req.ovHost.inZone) ? next() : next('router'));router.use(apexOnly);router.get('/api/v1/openapi.json', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); send(req, res, 'openapi', () => JSON.stringify(require('../openapi').openapi(require('../registry/descriptors').snapshot(), SITE)), 'application/json; charset=utf-8'); });++ router.get('/workers', (req, res) => send(req, res, 'workers', pageWorkers));router.get('/developers', (req, res) => send(req, res, 'developers', pageDevelopers));// What shipped on OpenVibe.Tools: the shared update log every OpenVibe site has.router.get('/updates', (req, res) => send(req, res, 'updates', pageUpdates));@@@ -815,4 +827,4 @@ function createSiteRouter({ opens = null, discovery = null } = {}) {const renderNotFound = () => pageSearch('').replace('<h1>Search tools</h1>', '<h1>That page is not here</h1>').replace(/<title>[^<]*<\/title>/, '<title>Page not found | OpenVibe.Tools</title>');--module.exports = { headlineClause, createSiteRouter, pageIndex, pageFamily, pageTool, pageAll, pageDevelopers, search, sitemapEntries, renderNotFound, SITE, NAME };++module.exports = { headlineClause, createSiteRouter, pageIndex, pageFamily, pageTool, pageAll, pageDevelopers, pageWorkers, search, sitemapEntries, renderNotFound, SITE, NAME };@@@ -1,10 +1,19 @@'use strict';const crypto = require('node:crypto');const { MAX_FRAME, TYPES, verify } = require('../../../worker/server/protocol');--function createHub({ WebSocketServer, store, now = Date.now }) {++function createHub({ WebSocketServer, store, now = Date.now, heartbeatMs = 15000, staleMs = 45000 }) {const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME, perMessageDeflate: false });const connections = new Map(), nonces = new Map();async function upgrade(req, socket, head) {++ if (req.url === '/api/v1/workers/dry-run') {++ wss.handleUpgrade(req, socket, head, ws => {++ ws.on('error', () => {});++ ws.send(JSON.stringify({ protocol: 1, dry_run: true, accepts_real_jobs: false }), () => ws.close());++ const deadline = setTimeout(() => ws.terminate(), 3000); deadline.unref();++ ws.once('close', () => clearTimeout(deadline));++ });++ return true; // Never registered, authenticated as a worker, or offered work.++ }if (req.url !== '/api/v1/workers/connect') return false;const deny = () => { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); };try {@@@ -20,6 +29,8 @@ function createHub({ WebSocketServer, store, now = Date.now }) {if (old) old.ws.terminate();const c = { id, location: worker.location, draining: worker.draining, ws, last: now(), pending: new Map(), capabilities: [], tools: [], slots: 0, free: 0, load: 0, latency_ms: null };connections.set(id, c);++ const activity = () => { c.last = now(); };++ socket.on('data', activity); // A slow result may span several heartbeat intervals.ws.on('error', () => {});ws.on('message', raw => {let m;@@@ -38,6 +49,7 @@ function createHub({ WebSocketServer, store, now = Date.now }) {} else ws.close(1008);});ws.on('close', () => {++ socket.removeListener('data', activity);if (connections.get(id) === c) connections.delete(id);for (const p of c.pending.values()) { clearTimeout(p.timer); p.reject(Object.assign(new Error('worker disconnected'), { code: 'worker.disconnected' })); }c.pending.clear();@@@ -50,12 +62,13 @@ function createHub({ WebSocketServer, store, now = Date.now }) {const sweep = setInterval(async () => {for (const c of connections.values()) {try {++ c.ws.send(JSON.stringify({ type: 'heartbeat' }));const row = await store.lookup(c.id);-- if (!row || now() - c.last > 45000) c.ws.terminate();++ if (!row || now() - c.last > staleMs) c.ws.terminate();else c.draining = row.draining;} catch { c.ws.terminate(); }}-- }, 15000);++ }, heartbeatMs);sweep.unref();function dispatch(c, job, signal) {return new Promise((resolve, reject) => {@@@ -1,11 +1,44 @@--# Tools worker protocol 1++# Run Tools on your own machine--A single Node 22 process reuses the image, PDF and audio satellite job handlers and their validation, bounded worker threads and hardened ffmpeg execution. Jobs arrive as bytes over an outbound TLS WebSocket; no HTTP listener is opened. The gateway stores only a SHA-256 credential hash, verifies timestamped HMAC signatures, refuses replayed handshakes and drops stale connections. Worker credentials are independent of person credentials.++The worker is one outbound process reusing the host's image, PDF and audio job handlers, validation, cancellation and time limits. It advertises installed operations, free slots and load. The Tools gateway uses a signed WebSocket because Link's current machine channel does not yet carry this Tools file-job protocol. No port forwarding, public home address, person token or caller-chosen URL reaches the worker.--`node apps/worker/server/index.js --dry-run` checks the production protocol without credentials, registering a worker or accepting any job. Real workers require `TOOLS_WORKER_ID`, `TOOLS_WORKER_TOKEN_FILE` (a private file), and optionally `TOOLS_WORKER_SLOTS` (1–8, default 2). `TOOLS_GATEWAY` defaults to `https://openvibe.tools` and must be an HTTPS origin.++URL-fetching jobs (including YouTube, maps and network tools), SVG inputs, and transfers over 16 MiB stay on the host. ffmpeg and ffprobe accept local file/pipe protocols only. Inputs arrive as bytes and results return over the same channel. The gateway retains job ownership and receipts. Worker routing starts **off**.--Only img.process, docs.process and audio.process are supported. Each job is capped at 16 MiB of input and output; larger jobs remain on the host. Input types are sniffed again on the worker. SVG and URL-fetching jobs are refused. Tool options are validated by the same satellite definitions. The gateway retains job ownership, quotas and result storage. No caller token crosses the worker channel.++## Ubuntu setup--Worker routing is default-off. The owner can configure it with GET/PUT `/api/v1/owner/worker-routing` (`{ enabled, order: ["home", "host"] }`); writes require the same-origin owner session. Order entries can also name a worker id. Within a group the gateway uses free slots, then recent latency. A disconnected job gets one attempt elsewhere, including the host when no second worker is available.++Use Ubuntu 22.04 or newer. The installer installs ffmpeg, qpdf and Poppler, and Node 22 if Node 22+ is missing. On either Dell Precision 3620, start with the default two slots; the systemd unit limits work to two CPU cores and 2 GiB RAM. Home upload is the bottleneck: keep URL-fetching traffic on the host.--Run requests accept `X-Tools-Prefer: auto|home|host`. `host` keeps the work on the host; `home` is advisory and respects the owner's order. Completed jobs include `result.data.execution` with worker id, location, duration and attempts. Failed/cancelled jobs carry the same receipt in `error.execution`. Inputs over 16 MiB, SVG, unavailable tools and all fetch-based jobs stay on the host. Workers advertise individual operations, so missing ffmpeg/qpdf/poppler remove affected capabilities.++1. In the owner's Network console, OpenVibe.Tools settings, create a worker labelled for this machine with location `home`. Copy its id and one-time token. Only the token hash remains in the gateway database. The worker controls are being added in the owner-view part of this rollout; the owner API is `POST /api/v1/owner/workers`.++2. Save the token in a private file on the tower (mode 600). Do not put it in a command, shell history, repository or log.++3. Download and inspect the installer, then run it with the id and the **path** to that file:++++```sh++curl --fail --proto '=https' https://openvibe.tools/worker/install.sh -o install.sh++sudo bash install.sh --worker-id WORKER_ID --token-file /private/path/worker-token --slots 2++```++++The id above is a placeholder, not a credential. The installer copies the credential to `/etc/openvibe-tools-worker/token` (root-only), then systemd supplies it through `LoadCredential`. Remove your temporary token file after installation. The root-only config contains the id, gateway and slots, never the token. A trusted alternative gateway can be selected with `--gateway https://your-gateway.example`.++++4. Check `systemctl status openvibe-tools-worker` and confirm the worker appears with free slots in the owner console. Enable routing there when ready. Default order is home, then host. Drain before planned maintenance; a disconnect retries a job once elsewhere.++++## Updates and maintenance++++`openvibe-tools-worker-update.timer` checks daily, with a randomized delay. It reads the deployed gateway's `/release.json`, resolves that revision to an immutable commit of OpenVibers/OpenVibe.Tools, installs dependencies in a new release directory and checks the dry channel before switching the `current` symlink. The previous release is retained; a failed service restart restores it. The installer serializes updates with a lock. No git checkout or repository credential is needed.++++Run `sudo systemctl start openvibe-tools-worker-update` to check now. Updates can interrupt a running job, which uses the normal retry path. For a quiet maintenance window, drain first. To stop updates: `sudo systemctl disable --now openvibe-tools-worker-update.timer`. To stop work: drain, then `sudo systemctl stop openvibe-tools-worker`. Revoke its credential in the console if retiring the machine. No inbound firewall rule is needed.++++## Safe workstation check++++With Node 22+ already installed, use a new throwaway directory:++++```sh++bash install.sh --dry-run --prefix /tmp/tools-worker-check-new++```++++This downloads the deployed release and dependencies, then checks HTTPS plus a dedicated anonymous WebSocket. It never registers a worker, reads a token, installs a service or accepts jobs. Success includes `service_installed=false; accepts_real_jobs=false`. Remove the throwaway directory when finished.++++For an already installed checkout: `node apps/worker/server/index.js --dry-run`. Real startup requires `TOOLS_WORKER_ID`, `TOOLS_WORKER_TOKEN_FILE`, and optional `TOOLS_WORKER_SLOTS` (1–8, default 2) and `TOOLS_GATEWAY` (an HTTPS origin). Run from a private writable directory; processing files are temporary. These settings can be supervised outside systemd, but the installer is tested on Ubuntu.++++## Routing API++++The owner can GET/PUT `/api/v1/owner/worker-routing` with `{ "enabled": false, "order": ["home", "host"] }`; mutations require the owner and same origin. Order entries may name a worker id. Workers within a group rank by free slots, then recent latency. Run requests accept `X-Tools-Prefer: auto|home|host`; `host` stays on the host and `home` respects owner order. Receipts are at `result.data.execution` on success or `error.execution` on failure/cancellation. See [the API guide](https://openvibe.tools/developers#worker-routing).nnew file mode 100644@@@ -0,0 +1,10 @@++[Unit]++Description=Update the Tools worker from the deployed Tools release++Wants=network-online.target++After=network-online.target++++[Service]++Type=oneshot++ExecStart=/opt/openvibe-tools-worker/current/apps/worker/install.sh --update++TimeoutStartSec=15min++UMask=0022nnew file mode 100644@@@ -0,0 +1,11 @@++[Unit]++Description=Check for a released Tools worker daily++++[Timer]++OnBootSec=15min++OnUnitActiveSec=1d++RandomizedDelaySec=1h++Persistent=yes++++[Install]++WantedBy=timers.targetnnew file mode 100644@@@ -0,0 +1,34 @@++[Unit]++Description=OpenVibe Tools outbound worker++After=network-online.target++Wants=network-online.target++++[Service]++Type=simple++DynamicUser=yes++StateDirectory=openvibe-tools-worker++WorkingDirectory=/var/lib/openvibe-tools-worker++EnvironmentFile=/etc/openvibe-tools-worker/config++Environment=PATH=/usr/local/bin:/usr/bin:/bin++Environment=DATA_DIR=/var/lib/openvibe-tools-worker/data++LoadCredential=token:/etc/openvibe-tools-worker/token++Environment=TOOLS_WORKER_TOKEN_FILE=%d/token++ExecStart=/usr/bin/env node /opt/openvibe-tools-worker/current/apps/worker/server/index.js++Restart=always++RestartSec=5++TimeoutStopSec=30++NoNewPrivileges=yes++PrivateTmp=yes++ProtectSystem=strict++ProtectHome=yes++ProtectKernelTunables=yes++ProtectControlGroups=yes++RestrictSUIDSGID=yes++LockPersonality=yes++UMask=0077++MemoryMax=2G++CPUQuota=200%++TasksMax=128++++[Install]++WantedBy=multi-user.targetnnew file mode 100755@@@ -0,0 +1,118 @@++#!/usr/bin/env bash++# Ubuntu installer. Dry mode is unprivileged and never registers or accepts jobs.++set -euo pipefail++umask 022++prefix=/opt/openvibe-tools-worker++gateway=https://openvibe.tools++worker_id= token_file= slots=2 dry=0 update=0++while (($#)); do++ case "$1" in++ --dry-run) dry=1; shift ;;++ --update) update=1; shift ;;++ --prefix) prefix=$2; shift 2 ;;++ --gateway) gateway=$2; shift 2 ;;++ --worker-id) worker_id=$2; shift 2 ;;++ --token-file) token_file=$2; shift 2 ;;++ --slots) slots=$2; shift 2 ;;++ *) echo 'Unknown installer option' >&2; exit 2 ;;++ esac++done++if ((dry)); then++ [[ "$prefix" == /* && "$prefix" != /opt/openvibe-tools-worker && ! -e "$prefix" ]] || { echo 'Dry mode needs a new absolute --prefix directory' >&2; exit 2; }++ ((update == 0)) || exit 2++else++ [[ $EUID == 0 && "$prefix" == /opt/openvibe-tools-worker ]] || { echo 'Run as root; custom prefixes are dry-mode only' >&2; exit 2; }++ mkdir -p "$prefix"++ exec 9>"$prefix/update.lock"++ flock -n 9 || { echo 'Worker update already running'; exit 0; }++ if ((update)); then++ # This root-owned file has only a gateway, worker id and slot count, never a token.++ while IFS='=' read -r key value; do++ case "$key" in TOOLS_GATEWAY) gateway=$value ;; TOOLS_WORKER_ID) worker_id=$value ;; TOOLS_WORKER_SLOTS) slots=$value ;; esac++ done < /etc/openvibe-tools-worker/config++ [[ "$worker_id" =~ ^wrk_[a-f0-9]{24}$ && "$slots" =~ ^[1-8]$ ]] || exit 2++ else++ [[ "$worker_id" =~ ^wrk_[a-f0-9]{24}$ && "$slots" =~ ^[1-8]$ && -f "$token_file" ]] || { echo 'Supply --worker-id, --token-file and --slots 1..8' >&2; exit 2; }++ apt-get update -qq++ apt-get install -y -qq ca-certificates curl python3 xz-utils ffmpeg qpdf poppler-utils++ fi++fi++python3 - "$gateway" <<'PY'++import sys,urllib.parse++u=urllib.parse.urlsplit(sys.argv[1])++if u.scheme!='https' or not u.hostname or u.username or u.password or u.path not in ('','/') or u.query or u.fragment or any(c.isspace() for c in sys.argv[1]): raise SystemExit('Gateway must be an HTTPS origin')++PY++mkdir -p "$prefix/releases"++stage=$(mktemp -d "$prefix/.stage-XXXXXX")++trap 'rm -rf -- "$stage"' EXIT++get() { curl --fail --silent --show-error --location --max-redirs 3 --connect-timeout 15 --max-time 180 --proto '=https' --proto-redir '=https' "$1" -o "$2"; }++if ! command -v node >/dev/null || ! node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)'; then++ ((dry == 0)) || { echo 'Dry mode requires Node 22 or newer' >&2; exit 2; }++ case "$(uname -m)" in x86_64) arch=x64 ;; aarch64) arch=arm64 ;; *) echo 'Install Node 22+ for this architecture first' >&2; exit 2 ;; esac++ get https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt "$stage/node-sums"++ node_archive=$(awk -v suffix="linux-$arch.tar.xz" '$2 ~ suffix "$" {print $2}' "$stage/node-sums")++ [[ "$node_archive" =~ ^node-v22\.[0-9]+\.[0-9]+-linux-(x64|arm64)\.tar\.xz$ ]] || exit 1++ get "https://nodejs.org/dist/latest-v22.x/$node_archive" "$stage/$node_archive"++ (cd "$stage"; awk -v name="$node_archive" '$2==name' node-sums | sha256sum -c - >/dev/null)++ tar -xJf "$stage/$node_archive" -C /usr/local --strip-components=1 --no-same-owner++fi++# Resolve the production release to its immutable full commit; never follow a development branch.++get "$gateway/release.json" "$stage/release.json"++revision=$(python3 -c 'import json,sys,re; s=json.load(open(sys.argv[1]))["release"]; assert re.fullmatch("[0-9a-f]{12,40}",s); print(s)' "$stage/release.json")++get "https://api.github.com/repos/OpenVibers/OpenVibe.Tools/commits/$revision" "$stage/commit.json"++revision=$(python3 -c 'import json,sys,re; s=json.load(open(sys.argv[1]))["sha"]; assert re.fullmatch("[0-9a-f]{40}",s) and s.startswith(sys.argv[2]); print(s)' "$stage/commit.json" "$revision")++release="$prefix/releases/$revision"++if [[ ! -d "$release" ]]; then++ get "https://codeload.github.com/OpenVibers/OpenVibe.Tools/tar.gz/$revision" "$stage/source.tar.gz"++ mkdir "$stage/source"++ tar -xzf "$stage/source.tar.gz" -C "$stage/source" --strip-components=1 --no-same-owner++ for app in gateway img docs audio; do++ npm_config_userconfig=/dev/null npm_config_cache="$prefix/npm-cache" npm install --prefix "$stage/source/apps/$app" --omit=dev --no-audit --no-fund >"$stage/npm-$app.log" 2>&1 || { echo "Dependency installation failed for $app" >&2; exit 1; }++ done++ chmod +x "$stage/source/apps/worker/install.sh"++ mv "$stage/source" "$release"++fi++mkdir -p "$prefix/check-state"++(cd "$prefix/check-state"; TOOLS_GATEWAY="$gateway" node "$release/apps/worker/server/index.js" --dry-run)++if ((dry)); then++ echo "dry-install: release=$revision; service_installed=false; accepts_real_jobs=false"++ exit 0++fi++if ((!update)); then++ install -d -m 700 /etc/openvibe-tools-worker++ python3 - "$token_file" <<'PY'++import sys,re++with open(sys.argv[1]) as f: token=f.read().strip()++if not re.fullmatch('[A-Za-z0-9_-]{43}',token): raise SystemExit('Invalid worker credential file')++PY++ install -m 600 "$token_file" /etc/openvibe-tools-worker/token++ printf 'TOOLS_GATEWAY=%s\nTOOLS_WORKER_ID=%s\nTOOLS_WORKER_SLOTS=%s\n' "$gateway" "$worker_id" "$slots" > /etc/openvibe-tools-worker/config++ chmod 600 /etc/openvibe-tools-worker/config++fi++previous=$(readlink "$prefix/current" || true)++rm -f "$prefix/current.next"++ln -s "$release" "$prefix/current.next"++mv -Tf "$prefix/current.next" "$prefix/current"++install -m 644 "$release/apps/worker/deploy/"*.service "$release/apps/worker/deploy/"*.timer /etc/systemd/system/++systemctl daemon-reload++systemctl enable --now openvibe-tools-worker-update.timer++if [[ "$previous" != "$release" ]] || ((!update)); then++ if ! systemctl restart openvibe-tools-worker.service || ! systemctl is-active --quiet openvibe-tools-worker.service; then++ if [[ -n "$previous" && -d "$previous" ]]; then++ ln -s "$previous" "$prefix/current.rollback"++ mv -Tf "$prefix/current.rollback" "$prefix/current"++ install -m 644 "$previous/apps/worker/deploy/"*.service "$previous/apps/worker/deploy/"*.timer /etc/systemd/system/++ systemctl daemon-reload++ systemctl restart openvibe-tools-worker.service++ fi++ echo 'Worker update failed; previous release restored when available' >&2++ exit 1++ fi++fi++systemctl enable openvibe-tools-worker.service++systemctl is-active --quiet openvibe-tools-worker.service++# Retain the previous release for rollback, remove only old releases created by this installer.++for old in "$prefix/releases/"*; do++ [[ "$old" == "$release" || "$old" == "$previous" ]] || rm -rf -- "$old"++done++echo "worker-installed: release=$revision; outbound_only=true; enable routing in the owner console when ready"@@@ -10,7 +10,20 @@ async function main() {const r = await fetch(new URL('/api/v1/workers/status', gateway), { signal: AbortSignal.timeout(15000), redirect: 'error' });const body = await r.json();if (!r.ok || body.protocol !== 1 || body.dry_run !== true) throw new Error('worker protocol unavailable');-- console.log('dry-run: production protocol 1 reachable; accepts_real_jobs=false');++ const WebSocket = require('../../gateway/node_modules/ws');++ const endpoint = new URL('/api/v1/workers/dry-run', gateway); endpoint.protocol = 'wss:';++ await new Promise((resolve, reject) => {++ const ws = new WebSocket(endpoint, { handshakeTimeout: 15000, maxPayload: 1024, perMessageDeflate: false });++ const timer = setTimeout(() => { ws.terminate(); reject(new Error('dry handshake timeout')); }, 15000);++ ws.on('error', reject);++ ws.once('message', raw => {++ try { const m = JSON.parse(String(raw)); if (m.protocol !== 1 || m.accepts_real_jobs !== false || m.dry_run !== true) throw new Error('invalid dry handshake'); resolve(); }++ catch (err) { reject(err); }++ finally { clearTimeout(timer); ws.close(); }++ });++ ws.once('close', () => { clearTimeout(timer); reject(new Error('dry handshake closed')); });++ });++ console.log('dry-run: production protocol 1 and outbound WebSocket reachable; accepts_real_jobs=false');return;}const token = fs.readFileSync(process.env.TOOLS_WORKER_TOKEN_FILE, 'utf8').trim();@@@ -6,7 +6,7 @@ const path = require('node:path');const { MAX_BYTES, TYPES, bytes } = require('./protocol');const { detect } = require('../../_shared/guard/sniff');const { poolFromEnv } = require('../../_shared/jobs/pool');--function definitions() {++function definitions({ slots = 2 } = {}) {const defs = new Map(), pools = [], tools = [];const hasAudio = ['ffmpeg', 'ffprobe'].every(bin => require('node:child_process').spawnSync(bin, ['-version'], { stdio: 'ignore', timeout: 3000 }).status === 0);for (const app of ['img', 'docs', 'audio']) {@@@ -15,14 +15,14 @@ function definitions() {const catalog = require(`../../${app}/server/tools`);tools.push(...catalog.listTools().filter(t => t.available !== false).map(t => `${app}.process:${t.id}`));const process = require(`../../${app}/server/process`);-- const pool = app === 'audio' ? null : poolFromEnv(app, path.resolve(__dirname, `../../${app}/server/worker.js`), { size: 1, maxQueue: 0 });++ const pool = app === 'audio' ? null : poolFromEnv(app, path.resolve(__dirname, `../../${app}/server/worker.js`), { size: slots, maxQueue: 0 });if (pool) pools.push(pool);process.defineJobs({ define: def => defs.set(def.type, def), JobError: Error }, pool);} catch { /* A missing dependency removes this capability, rather than accepting work it cannot run. */ }}return { defs, tools, close: () => Promise.all(pools.map(p => p.close())) };}--function createRuntime({ slots = 2, dry = false, registry = definitions(), root = os.tmpdir() } = {}) {++function createRuntime({ slots = 2, dry = false, registry = definitions({ slots }), root = os.tmpdir() } = {}) {if (!Number.isInteger(slots) || slots < 1 || slots > 8) throw new Error('slots must be 1..8');const active = new Map();function status() { return { tools: dry ? [] : (registry.tools || []), capabilities: dry ? [] : [...registry.defs.keys()].filter(k => TYPES.includes(k)), slots, free: dry ? 0 : slots - active.size, load: os.loadavg()[0], dry }; }@@@ -50,7 +50,7 @@ function createRuntime({ slots = 2, dry = false, registry = definitions(), rootif (!detected || !allowed.test(detected.mime) || detected.mime === 'image/svg+xml') throw new Error('unsupported file');const local = path.join(dir, `${files.length}.${detected.ext}`);await fs.writeFile(local, b, { mode: 0o600 });-- files.push({ path: local, name: `${files.length}.${detected.ext}`, mime: detected.mime, size: b.length });++ files.push({ path: local, name: typeof f.name === 'string' ? path.basename(f.name).slice(0,200) : `${files.length}.${detected.ext}`, mime: detected.mime, size: b.length });}const invalid = def.validate(job.input, files);if (invalid) throw new Error('invalid tool options');nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++const assert = require('node:assert/strict');++const fs = require('node:fs/promises');++const path = require('node:path');++const os = require('node:os');++const http = require('node:http');++const crypto = require('node:crypto');++const { once } = require('node:events');++const { execFileSync, spawnSync } = require('node:child_process');++const WebSocket = require('../../gateway/node_modules/ws');++const { createHub } = require('../../gateway/server/workers/hub');++const { sign, hash } = require('../server/protocol');++const { createRuntime } = require('../server/runtime');++const sleep = ms => new Promise(r => setTimeout(r, ms));++(async () => {++ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'tools-worker-test-'));++ let hub, server, runtime;++ try {++ const token = crypto.randomBytes(32).toString('base64url'), id = `wrk_${crypto.randomBytes(12).toString('hex')}`;++ hub = createHub({ WebSocketServer: WebSocket.WebSocketServer, heartbeatMs: 50, staleMs: 400, store: { lookup: async () => ({ token_hash: hash(token), location: 'home', draining: false }) } });++ server = http.createServer(); server.on('upgrade', (req, socket, head) => hub.upgrade(req, socket, head));++ server.listen(0, '127.0.0.1'); await once(server, 'listening');++ const base = `ws://127.0.0.1:${server.address().port}`;++ const dry = new WebSocket(base + '/api/v1/workers/dry-run');++ const [raw] = await once(dry, 'message');++ assert.deepEqual(JSON.parse(String(raw)), { protocol: 1, dry_run: true, accepts_real_jobs: false });++ assert.equal(hub.connections.size, 0, 'dry sockets never enter dispatch candidates');++ const ws = new WebSocket(base + '/api/v1/workers/connect', { headers: sign(token, id) });++ await once(ws, 'open');++ const status = JSON.stringify({ type: 'status', capabilities: ['img.process'], tools: ['img.process:convert'], slots: 2, free: 2, load: 0, padding: 'x'.repeat(1000) });++ for (let n = 0; n < status.length; n += 100) {++ ws.send(status.slice(n, n + 100), { fin: n + 100 >= status.length });++ await sleep(70);++ }++ assert.equal(ws.readyState, WebSocket.OPEN, 'partial frame bytes keep a slow upload alive beyond stale interval');++ assert.equal(hub.stats()[0].free, 2);++ ws.terminate();++ runtime = createRuntime({ slots: 2, root });++ const sharp = require('../../img/node_modules/sharp');++ const bytes = (await sharp({ create: { width: 2, height: 2, channels: 3, background: 'red' } }).png().toBuffer()).toString('base64');++ const results = await Promise.all([1, 2].map(() => runtime.run({ id: crypto.randomBytes(16).toString('hex'), type: 'img.process', input: { tool: 'convert', format: 'png' }, files: [{ name: 'sample.png', bytes }] })));++ assert.equal(results.length, 2, 'advertised slots support simultaneous image work');++ assert(results.every(r => r.files[0].mime === 'image/png'));++ const wav = Buffer.alloc(44 + 1600);++ wav.write('RIFF'); wav.writeUInt32LE(wav.length - 8, 4); wav.write('WAVEfmt ', 8);++ wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);++ wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);++ wav.write('data', 36); wav.writeUInt32LE(1600, 40);++ const audio = await runtime.run({ id: crypto.randomBytes(16).toString('hex'), type: 'audio.process', input: { tool: 'convert', format: 'wav' }, files: [{ name: 'sample.wav', bytes: wav.toString('base64') }] });++ assert.equal(audio.files[0].name, 'sample.wav');++ assert(audio.files[0].bytes.length > 0, 'worker uses the existing hardened audio path');++ const page = require('../../gateway/server/pages/site').pageWorkers();++ assert.match(page, /Run Tools on your own machine/);++ assert.match(page, /--dry-run --prefix/);++ const installer = path.resolve(__dirname, '../install.sh');++ execFileSync('bash', ['-n', installer]);++ assert.notEqual(spawnSync('bash', [installer, '--dry-run', '--prefix', root], { encoding: 'utf8' }).status, 0, 'dry installation cannot overwrite an existing directory');++ console.log('worker setup: dry WebSocket accepts no jobs; slow uploads survive; two slots execute; shared audio guard reused; installer guards passed');++ } finally {++ if (runtime) await runtime.close();++ if (hub) hub.close();++ if (server) await new Promise(r => server.close(r));++ await fs.rm(root, { recursive: true, force: true });++ }++})().catch(e => { console.error(e); process.exitCode = 1; });@@@ -2,7 +2,7 @@"name": "openvibe-tools","version": "1.0.0","private": true,-- "description": "OpenVibe.Tools \u2014 the tools suite on openvibe.tools and *.openvibe.tools",++ "description": "OpenVibe.Tools — the tools suite on openvibe.tools and *.openvibe.tools","scripts": {"test": "node test/run.js","test:pg": "DATABASE_URL=\"$OV_TEST_PG_URL\" DATABASE_DIRECT_URL=\"$OV_TEST_PG_DIRECT_URL\" VALKEY_URL=\"$OV_TEST_VALKEY_URL\" node test/run.js",@@@ -20,7 +20,8 @@"test:suite": "node --unhandled-rejections=strict test/run.js","test:worker-security": "node apps/gateway/test/security-ssrf.test.js","test:routing": "node apps/worker/test/routing.test.js && node apps/worker/test/worker.test.js && node apps/_shared/test/jobs.test.js && node apps/gateway/test/security-ssrf.test.js",-- "test:worker-docs": "node apps/gateway/test/openapi.test.js"++ "test:worker-docs": "node apps/gateway/test/openapi.test.js",++ "test:worker-setup": "node apps/worker/test/deploy.test.js && node apps/worker/test/worker.test.js && node apps/gateway/test/security-ssrf.test.js"},"license": "MIT","engines": {
@@ -89,6 +89,18 @@ server {
@@ -89,6 +89,18 @@ server {
proxy_buffering off;
proxy_buffering off;
}
}
+ location = /api/v1/workers/dry-run {
+ limit_req zone=ovtools_api burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4001;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_read_timeout 60s;
+ proxy_send_timeout 60s;
+ proxy_buffering off;
+ }
+
location /api/ {
location /api/ {
limit_req zone=ovtools_api burst=30 nodelay;
limit_req zone=ovtools_api burst=30 nodelay;
proxy_pass http://127.0.0.1:4001;
proxy_pass http://127.0.0.1:4001;
@@ -79,6 +79,7 @@ app.post('/api/internal/workers/dispatch', (req, res, next) => {
@@ -79,6 +79,7 @@ app.post('/api/internal/workers/dispatch', (req, res, next) => {
catch { if (!res.headersSent) res.status(503).json({ error: 'Worker placement unavailable' }); }
catch { if (!res.headersSent) res.status(503).json({ error: 'Worker placement unavailable' }); }
});
});
+app.get('/worker/install.sh', (_req, res) => { res.type('text/plain'); res.sendFile(require('path').resolve(__dirname, '../../worker/install.sh')); });
app.get('/api/v1/workers/status', async (_req, res) => {
app.get('/api/v1/workers/status', async (_req, res) => {
try { res.json({ protocol: 1, dry_run: true, transport: 'outbound-websocket', accepts: ['img.process', 'docs.process', 'audio.process'], routing_enabled: !!(await workerStore.routing())?.enabled, strategy: 'owner-order/free-slots/recent-latency', fallback: 'host' }); }
try { res.json({ protocol: 1, dry_run: true, transport: 'outbound-websocket', accepts: ['img.process', 'docs.process', 'audio.process'], routing_enabled: !!(await workerStore.routing())?.enabled, strategy: 'owner-order/free-slots/recent-latency', fallback: 'host' }); }
catch { res.status(503).json({ error: 'Worker registry unavailable' }); }
catch { res.status(503).json({ error: 'Worker registry unavailable' }); }
@@ -687,6 +687,17 @@ function send(req, res, key, render, type, extra) {
@@ -687,6 +687,17 @@ function send(req, res, key, render, type, extra) {
res.send(e.html);
res.send(e.html);
}
}
+function pageWorkers() {
+ return shell({ families: registry.get().families, head: head({ title: 'Run Tools on your own machine', description: 'Install an outbound Tools worker on Ubuntu.', canonical: SITE + '/workers' }), body: `<main><h1>Run Tools on your own machine</h1>
+<p>A worker transforms uploaded image, PDF and audio files. It opens an outbound encrypted connection; no inbound port or public home address is needed. URL-fetching jobs stay on the host. File transfers are limited to 16 MiB per input set and output set.</p>
+<p>On Ubuntu, use the <a href="/worker/install.sh">installer</a> and the <a href="https://github.com/OpenVibers/OpenVibe.Tools/blob/main/apps/worker/README.md">setup instructions</a>. The owner creates a worker credential once, then enables routing separately in the Tools owner console. Keep the credential in a private file, never a command argument.</p>
+<p>The service defaults to two slots, two CPU cores and a 2 GiB memory limit. A daily timer checks the deployed release and installs updates. Draining a worker stops new assignments; running jobs finish.</p>
+<h2>Check without accepting jobs</h2>
+<pre><code>bash install.sh --dry-run --prefix /tmp/tools-worker-check-new</code></pre>
+<p>Dry mode checks HTTPS and the outbound WebSocket without a credential, registration or real jobs. It requires Node 22 or newer and creates no system service.</p>
+<p><a href="/developers#worker-routing">API routing and receipts</a></p></main>` });
+}
+
function sitemapEntries() {
function sitemapEntries() {
const { tools, families } = registry.get();
const { tools, families } = registry.get();
const today = new Date().toISOString().slice(0, 10);
const today = new Date().toISOString().slice(0, 10);
@@ -770,6 +781,7 @@ function createSiteRouter({ opens = null, discovery = null } = {}) {
@@ -770,6 +781,7 @@ function createSiteRouter({ opens = null, discovery = null } = {}) {
const apexOnly = (req, res, next) => (!req.ovHost || req.ovHost.kind === 'apex' || (req.ovHost.kind === 'unknown' && !req.ovHost.inZone) ? next() : next('router'));
const apexOnly = (req, res, next) => (!req.ovHost || req.ovHost.kind === 'apex' || (req.ovHost.kind === 'unknown' && !req.ovHost.inZone) ? next() : next('router'));
router.use(apexOnly);
router.use(apexOnly);
router.get('/api/v1/openapi.json', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); send(req, res, 'openapi', () => JSON.stringify(require('../openapi').openapi(require('../registry/descriptors').snapshot(), SITE)), 'application/json; charset=utf-8'); });
router.get('/api/v1/openapi.json', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); send(req, res, 'openapi', () => JSON.stringify(require('../openapi').openapi(require('../registry/descriptors').snapshot(), SITE)), 'application/json; charset=utf-8'); });
+ router.get('/workers', (req, res) => send(req, res, 'workers', pageWorkers));
router.get('/developers', (req, res) => send(req, res, 'developers', pageDevelopers));
router.get('/developers', (req, res) => send(req, res, 'developers', pageDevelopers));
// What shipped on OpenVibe.Tools: the shared update log every OpenVibe site has.
// What shipped on OpenVibe.Tools: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => send(req, res, 'updates', pageUpdates));
router.get('/updates', (req, res) => send(req, res, 'updates', pageUpdates));
@@ -815,4 +827,4 @@ function createSiteRouter({ opens = null, discovery = null } = {}) {
@@ -815,4 +827,4 @@ function createSiteRouter({ opens = null, discovery = null } = {}) {
const renderNotFound = () => pageSearch('').replace('<h1>Search tools</h1>', '<h1>That page is not here</h1>').replace(/<title>[^<]*<\/title>/, '<title>Page not found | OpenVibe.Tools</title>');
const renderNotFound = () => pageSearch('').replace('<h1>Search tools</h1>', '<h1>That page is not here</h1>').replace(/<title>[^<]*<\/title>/, '<title>Page not found | OpenVibe.Tools</title>');
-module.exports = { headlineClause, createSiteRouter, pageIndex, pageFamily, pageTool, pageAll, pageDevelopers, search, sitemapEntries, renderNotFound, SITE, NAME };
+module.exports = { headlineClause, createSiteRouter, pageIndex, pageFamily, pageTool, pageAll, pageDevelopers, pageWorkers, search, sitemapEntries, renderNotFound, SITE, NAME };
@@ -1,10 +1,19 @@
@@ -1,10 +1,19 @@
'use strict';
'use strict';
const crypto = require('node:crypto');
const crypto = require('node:crypto');
const { MAX_FRAME, TYPES, verify } = require('../../../worker/server/protocol');
const { MAX_FRAME, TYPES, verify } = require('../../../worker/server/protocol');
-function createHub({ WebSocketServer, store, now = Date.now }) {
+function createHub({ WebSocketServer, store, now = Date.now, heartbeatMs = 15000, staleMs = 45000 }) {
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME, perMessageDeflate: false });
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME, perMessageDeflate: false });
const connections = new Map(), nonces = new Map();
const connections = new Map(), nonces = new Map();
async function upgrade(req, socket, head) {
async function upgrade(req, socket, head) {
+ if (req.url === '/api/v1/workers/dry-run') {
+ wss.handleUpgrade(req, socket, head, ws => {
+ ws.on('error', () => {});
+ ws.send(JSON.stringify({ protocol: 1, dry_run: true, accepts_real_jobs: false }), () => ws.close());
+ const deadline = setTimeout(() => ws.terminate(), 3000); deadline.unref();
+ ws.once('close', () => clearTimeout(deadline));
+ });
+ return true; // Never registered, authenticated as a worker, or offered work.
+ }
if (req.url !== '/api/v1/workers/connect') return false;
if (req.url !== '/api/v1/workers/connect') return false;
const deny = () => { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); };
const deny = () => { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); };
try {
try {
@@ -20,6 +29,8 @@ function createHub({ WebSocketServer, store, now = Date.now }) {
@@ -20,6 +29,8 @@ function createHub({ WebSocketServer, store, now = Date.now }) {
if (old) old.ws.terminate();
if (old) old.ws.terminate();
const c = { id, location: worker.location, draining: worker.draining, ws, last: now(), pending: new Map(), capabilities: [], tools: [], slots: 0, free: 0, load: 0, latency_ms: null };
const c = { id, location: worker.location, draining: worker.draining, ws, last: now(), pending: new Map(), capabilities: [], tools: [], slots: 0, free: 0, load: 0, latency_ms: null };
connections.set(id, c);
connections.set(id, c);
+ const activity = () => { c.last = now(); };
+ socket.on('data', activity); // A slow result may span several heartbeat intervals.
ws.on('error', () => {});
ws.on('error', () => {});
ws.on('message', raw => {
ws.on('message', raw => {
let m;
let m;
@@ -38,6 +49,7 @@ function createHub({ WebSocketServer, store, now = Date.now }) {
@@ -38,6 +49,7 @@ function createHub({ WebSocketServer, store, now = Date.now }) {
} else ws.close(1008);
} else ws.close(1008);
});
});
ws.on('close', () => {
ws.on('close', () => {
+ socket.removeListener('data', activity);
if (connections.get(id) === c) connections.delete(id);
if (connections.get(id) === c) connections.delete(id);
for (const p of c.pending.values()) { clearTimeout(p.timer); p.reject(Object.assign(new Error('worker disconnected'), { code: 'worker.disconnected' })); }
for (const p of c.pending.values()) { clearTimeout(p.timer); p.reject(Object.assign(new Error('worker disconnected'), { code: 'worker.disconnected' })); }
c.pending.clear();
c.pending.clear();
@@ -50,12 +62,13 @@ function createHub({ WebSocketServer, store, now = Date.now }) {
@@ -50,12 +62,13 @@ function createHub({ WebSocketServer, store, now = Date.now }) {
const sweep = setInterval(async () => {
const sweep = setInterval(async () => {
for (const c of connections.values()) {
for (const c of connections.values()) {
try {
try {
+ c.ws.send(JSON.stringify({ type: 'heartbeat' }));
const row = await store.lookup(c.id);
const row = await store.lookup(c.id);
- if (!row || now() - c.last > 45000) c.ws.terminate();
+ if (!row || now() - c.last > staleMs) c.ws.terminate();
else c.draining = row.draining;
else c.draining = row.draining;
} catch { c.ws.terminate(); }
} catch { c.ws.terminate(); }
}
}
- }, 15000);
+ }, heartbeatMs);
sweep.unref();
sweep.unref();
function dispatch(c, job, signal) {
function dispatch(c, job, signal) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
@@ -1,11 +1,44 @@
@@ -1,11 +1,44 @@
-# Tools worker protocol 1
+# Run Tools on your own machine
-A single Node 22 process reuses the image, PDF and audio satellite job handlers and their validation, bounded worker threads and hardened ffmpeg execution. Jobs arrive as bytes over an outbound TLS WebSocket; no HTTP listener is opened. The gateway stores only a SHA-256 credential hash, verifies timestamped HMAC signatures, refuses replayed handshakes and drops stale connections. Worker credentials are independent of person credentials.
+The worker is one outbound process reusing the host's image, PDF and audio job handlers, validation, cancellation and time limits. It advertises installed operations, free slots and load. The Tools gateway uses a signed WebSocket because Link's current machine channel does not yet carry this Tools file-job protocol. No port forwarding, public home address, person token or caller-chosen URL reaches the worker.
-`node apps/worker/server/index.js --dry-run` checks the production protocol without credentials, registering a worker or accepting any job. Real workers require `TOOLS_WORKER_ID`, `TOOLS_WORKER_TOKEN_FILE` (a private file), and optionally `TOOLS_WORKER_SLOTS` (1–8, default 2). `TOOLS_GATEWAY` defaults to `https://openvibe.tools` and must be an HTTPS origin.
+URL-fetching jobs (including YouTube, maps and network tools), SVG inputs, and transfers over 16 MiB stay on the host. ffmpeg and ffprobe accept local file/pipe protocols only. Inputs arrive as bytes and results return over the same channel. The gateway retains job ownership and receipts. Worker routing starts **off**.
-Only img.process, docs.process and audio.process are supported. Each job is capped at 16 MiB of input and output; larger jobs remain on the host. Input types are sniffed again on the worker. SVG and URL-fetching jobs are refused. Tool options are validated by the same satellite definitions. The gateway retains job ownership, quotas and result storage. No caller token crosses the worker channel.
+## Ubuntu setup
-Worker routing is default-off. The owner can configure it with GET/PUT `/api/v1/owner/worker-routing` (`{ enabled, order: ["home", "host"] }`); writes require the same-origin owner session. Order entries can also name a worker id. Within a group the gateway uses free slots, then recent latency. A disconnected job gets one attempt elsewhere, including the host when no second worker is available.
+Use Ubuntu 22.04 or newer. The installer installs ffmpeg, qpdf and Poppler, and Node 22 if Node 22+ is missing. On either Dell Precision 3620, start with the default two slots; the systemd unit limits work to two CPU cores and 2 GiB RAM. Home upload is the bottleneck: keep URL-fetching traffic on the host.
-Run requests accept `X-Tools-Prefer: auto|home|host`. `host` keeps the work on the host; `home` is advisory and respects the owner's order. Completed jobs include `result.data.execution` with worker id, location, duration and attempts. Failed/cancelled jobs carry the same receipt in `error.execution`. Inputs over 16 MiB, SVG, unavailable tools and all fetch-based jobs stay on the host. Workers advertise individual operations, so missing ffmpeg/qpdf/poppler remove affected capabilities.
+1. In the owner's Network console, OpenVibe.Tools settings, create a worker labelled for this machine with location `home`. Copy its id and one-time token. Only the token hash remains in the gateway database. The worker controls are being added in the owner-view part of this rollout; the owner API is `POST /api/v1/owner/workers`.
+2. Save the token in a private file on the tower (mode 600). Do not put it in a command, shell history, repository or log.
+3. Download and inspect the installer, then run it with the id and the **path** to that file:
+
+```sh
+curl --fail --proto '=https' https://openvibe.tools/worker/install.sh -o install.sh
+sudo bash install.sh --worker-id WORKER_ID --token-file /private/path/worker-token --slots 2
+```
+
+The id above is a placeholder, not a credential. The installer copies the credential to `/etc/openvibe-tools-worker/token` (root-only), then systemd supplies it through `LoadCredential`. Remove your temporary token file after installation. The root-only config contains the id, gateway and slots, never the token. A trusted alternative gateway can be selected with `--gateway https://your-gateway.example`.
+
+4. Check `systemctl status openvibe-tools-worker` and confirm the worker appears with free slots in the owner console. Enable routing there when ready. Default order is home, then host. Drain before planned maintenance; a disconnect retries a job once elsewhere.
+
+## Updates and maintenance
+
+`openvibe-tools-worker-update.timer` checks daily, with a randomized delay. It reads the deployed gateway's `/release.json`, resolves that revision to an immutable commit of OpenVibers/OpenVibe.Tools, installs dependencies in a new release directory and checks the dry channel before switching the `current` symlink. The previous release is retained; a failed service restart restores it. The installer serializes updates with a lock. No git checkout or repository credential is needed.
+
+Run `sudo systemctl start openvibe-tools-worker-update` to check now. Updates can interrupt a running job, which uses the normal retry path. For a quiet maintenance window, drain first. To stop updates: `sudo systemctl disable --now openvibe-tools-worker-update.timer`. To stop work: drain, then `sudo systemctl stop openvibe-tools-worker`. Revoke its credential in the console if retiring the machine. No inbound firewall rule is needed.
+
+## Safe workstation check
+
+With Node 22+ already installed, use a new throwaway directory:
+
+```sh
+bash install.sh --dry-run --prefix /tmp/tools-worker-check-new
+```
+
+This downloads the deployed release and dependencies, then checks HTTPS plus a dedicated anonymous WebSocket. It never registers a worker, reads a token, installs a service or accepts jobs. Success includes `service_installed=false; accepts_real_jobs=false`. Remove the throwaway directory when finished.
+
+For an already installed checkout: `node apps/worker/server/index.js --dry-run`. Real startup requires `TOOLS_WORKER_ID`, `TOOLS_WORKER_TOKEN_FILE`, and optional `TOOLS_WORKER_SLOTS` (1–8, default 2) and `TOOLS_GATEWAY` (an HTTPS origin). Run from a private writable directory; processing files are temporary. These settings can be supervised outside systemd, but the installer is tested on Ubuntu.
+
+## Routing API
+
+The owner can GET/PUT `/api/v1/owner/worker-routing` with `{ "enabled": false, "order": ["home", "host"] }`; mutations require the owner and same origin. Order entries may name a worker id. Workers within a group rank by free slots, then recent latency. Run requests accept `X-Tools-Prefer: auto|home|host`; `host` stays on the host and `home` respects owner order. Receipts are at `result.data.execution` on success or `error.execution` on failure/cancellation. See [the API guide](https://openvibe.tools/developers#worker-routing).
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+[Unit]
+Description=Update the Tools worker from the deployed Tools release
+Wants=network-online.target
+After=network-online.target
+
+[Service]
+Type=oneshot
+ExecStart=/opt/openvibe-tools-worker/current/apps/worker/install.sh --update
+TimeoutStartSec=15min
+UMask=0022
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+[Unit]
+Description=Check for a released Tools worker daily
+
+[Timer]
+OnBootSec=15min
+OnUnitActiveSec=1d
+RandomizedDelaySec=1h
+Persistent=yes
+
+[Install]
+WantedBy=timers.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+[Unit]
+Description=OpenVibe Tools outbound worker
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+DynamicUser=yes
+StateDirectory=openvibe-tools-worker
+WorkingDirectory=/var/lib/openvibe-tools-worker
+EnvironmentFile=/etc/openvibe-tools-worker/config
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+Environment=DATA_DIR=/var/lib/openvibe-tools-worker/data
+LoadCredential=token:/etc/openvibe-tools-worker/token
+Environment=TOOLS_WORKER_TOKEN_FILE=%d/token
+ExecStart=/usr/bin/env node /opt/openvibe-tools-worker/current/apps/worker/server/index.js
+Restart=always
+RestartSec=5
+TimeoutStopSec=30
+NoNewPrivileges=yes
+PrivateTmp=yes
+ProtectSystem=strict
+ProtectHome=yes
+ProtectKernelTunables=yes
+ProtectControlGroups=yes
+RestrictSUIDSGID=yes
+LockPersonality=yes
+UMask=0077
+MemoryMax=2G
+CPUQuota=200%
+TasksMax=128
+
+[Install]
+WantedBy=multi-user.target
new file mode 100755
new file mode 100755
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+# Ubuntu installer. Dry mode is unprivileged and never registers or accepts jobs.
+set -euo pipefail
+umask 022
+prefix=/opt/openvibe-tools-worker
+gateway=https://openvibe.tools
+worker_id= token_file= slots=2 dry=0 update=0
+while (($#)); do
+ case "$1" in
+ --dry-run) dry=1; shift ;;
+ --update) update=1; shift ;;
+ --prefix) prefix=$2; shift 2 ;;
+ --gateway) gateway=$2; shift 2 ;;
+ --worker-id) worker_id=$2; shift 2 ;;
+ --token-file) token_file=$2; shift 2 ;;
+ --slots) slots=$2; shift 2 ;;
+ *) echo 'Unknown installer option' >&2; exit 2 ;;
+ esac
+done
+if ((dry)); then
+ [[ "$prefix" == /* && "$prefix" != /opt/openvibe-tools-worker && ! -e "$prefix" ]] || { echo 'Dry mode needs a new absolute --prefix directory' >&2; exit 2; }
+ ((update == 0)) || exit 2
+else
+ [[ $EUID == 0 && "$prefix" == /opt/openvibe-tools-worker ]] || { echo 'Run as root; custom prefixes are dry-mode only' >&2; exit 2; }
+ mkdir -p "$prefix"
+ exec 9>"$prefix/update.lock"
+ flock -n 9 || { echo 'Worker update already running'; exit 0; }
+ if ((update)); then
+ # This root-owned file has only a gateway, worker id and slot count, never a token.
+ while IFS='=' read -r key value; do
+ case "$key" in TOOLS_GATEWAY) gateway=$value ;; TOOLS_WORKER_ID) worker_id=$value ;; TOOLS_WORKER_SLOTS) slots=$value ;; esac
+ done < /etc/openvibe-tools-worker/config
+ [[ "$worker_id" =~ ^wrk_[a-f0-9]{24}$ && "$slots" =~ ^[1-8]$ ]] || exit 2
+ else
+ [[ "$worker_id" =~ ^wrk_[a-f0-9]{24}$ && "$slots" =~ ^[1-8]$ && -f "$token_file" ]] || { echo 'Supply --worker-id, --token-file and --slots 1..8' >&2; exit 2; }
+ apt-get update -qq
+ apt-get install -y -qq ca-certificates curl python3 xz-utils ffmpeg qpdf poppler-utils
+ fi
+fi
+python3 - "$gateway" <<'PY'
+import sys,urllib.parse
+u=urllib.parse.urlsplit(sys.argv[1])
+if u.scheme!='https' or not u.hostname or u.username or u.password or u.path not in ('','/') or u.query or u.fragment or any(c.isspace() for c in sys.argv[1]): raise SystemExit('Gateway must be an HTTPS origin')
+PY
+mkdir -p "$prefix/releases"
+stage=$(mktemp -d "$prefix/.stage-XXXXXX")
+trap 'rm -rf -- "$stage"' EXIT
+get() { curl --fail --silent --show-error --location --max-redirs 3 --connect-timeout 15 --max-time 180 --proto '=https' --proto-redir '=https' "$1" -o "$2"; }
+if ! command -v node >/dev/null || ! node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)'; then
+ ((dry == 0)) || { echo 'Dry mode requires Node 22 or newer' >&2; exit 2; }
+ case "$(uname -m)" in x86_64) arch=x64 ;; aarch64) arch=arm64 ;; *) echo 'Install Node 22+ for this architecture first' >&2; exit 2 ;; esac
+ get https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt "$stage/node-sums"
+ node_archive=$(awk -v suffix="linux-$arch.tar.xz" '$2 ~ suffix "$" {print $2}' "$stage/node-sums")
+ [[ "$node_archive" =~ ^node-v22\.[0-9]+\.[0-9]+-linux-(x64|arm64)\.tar\.xz$ ]] || exit 1
+ get "https://nodejs.org/dist/latest-v22.x/$node_archive" "$stage/$node_archive"
+ (cd "$stage"; awk -v name="$node_archive" '$2==name' node-sums | sha256sum -c - >/dev/null)
+ tar -xJf "$stage/$node_archive" -C /usr/local --strip-components=1 --no-same-owner
+fi
+# Resolve the production release to its immutable full commit; never follow a development branch.
+get "$gateway/release.json" "$stage/release.json"
+revision=$(python3 -c 'import json,sys,re; s=json.load(open(sys.argv[1]))["release"]; assert re.fullmatch("[0-9a-f]{12,40}",s); print(s)' "$stage/release.json")
+get "https://api.github.com/repos/OpenVibers/OpenVibe.Tools/commits/$revision" "$stage/commit.json"
+revision=$(python3 -c 'import json,sys,re; s=json.load(open(sys.argv[1]))["sha"]; assert re.fullmatch("[0-9a-f]{40}",s) and s.startswith(sys.argv[2]); print(s)' "$stage/commit.json" "$revision")
+release="$prefix/releases/$revision"
+if [[ ! -d "$release" ]]; then
+ get "https://codeload.github.com/OpenVibers/OpenVibe.Tools/tar.gz/$revision" "$stage/source.tar.gz"
+ mkdir "$stage/source"
+ tar -xzf "$stage/source.tar.gz" -C "$stage/source" --strip-components=1 --no-same-owner
+ for app in gateway img docs audio; do
+ npm_config_userconfig=/dev/null npm_config_cache="$prefix/npm-cache" npm install --prefix "$stage/source/apps/$app" --omit=dev --no-audit --no-fund >"$stage/npm-$app.log" 2>&1 || { echo "Dependency installation failed for $app" >&2; exit 1; }
+ done
+ chmod +x "$stage/source/apps/worker/install.sh"
+ mv "$stage/source" "$release"
+fi
+mkdir -p "$prefix/check-state"
+(cd "$prefix/check-state"; TOOLS_GATEWAY="$gateway" node "$release/apps/worker/server/index.js" --dry-run)
+if ((dry)); then
+ echo "dry-install: release=$revision; service_installed=false; accepts_real_jobs=false"
+ exit 0
+fi
+if ((!update)); then
+ install -d -m 700 /etc/openvibe-tools-worker
+ python3 - "$token_file" <<'PY'
+import sys,re
+with open(sys.argv[1]) as f: token=f.read().strip()
+if not re.fullmatch('[A-Za-z0-9_-]{43}',token): raise SystemExit('Invalid worker credential file')
+PY
+ install -m 600 "$token_file" /etc/openvibe-tools-worker/token
+ printf 'TOOLS_GATEWAY=%s\nTOOLS_WORKER_ID=%s\nTOOLS_WORKER_SLOTS=%s\n' "$gateway" "$worker_id" "$slots" > /etc/openvibe-tools-worker/config
+ chmod 600 /etc/openvibe-tools-worker/config
+fi
+previous=$(readlink "$prefix/current" || true)
+rm -f "$prefix/current.next"
+ln -s "$release" "$prefix/current.next"
+mv -Tf "$prefix/current.next" "$prefix/current"
+install -m 644 "$release/apps/worker/deploy/"*.service "$release/apps/worker/deploy/"*.timer /etc/systemd/system/
+systemctl daemon-reload
+systemctl enable --now openvibe-tools-worker-update.timer
+if [[ "$previous" != "$release" ]] || ((!update)); then
+ if ! systemctl restart openvibe-tools-worker.service || ! systemctl is-active --quiet openvibe-tools-worker.service; then
+ if [[ -n "$previous" && -d "$previous" ]]; then
+ ln -s "$previous" "$prefix/current.rollback"
+ mv -Tf "$prefix/current.rollback" "$prefix/current"
+ install -m 644 "$previous/apps/worker/deploy/"*.service "$previous/apps/worker/deploy/"*.timer /etc/systemd/system/
+ systemctl daemon-reload
+ systemctl restart openvibe-tools-worker.service
+ fi
+ echo 'Worker update failed; previous release restored when available' >&2
+ exit 1
+ fi
+fi
+systemctl enable openvibe-tools-worker.service
+systemctl is-active --quiet openvibe-tools-worker.service
+# Retain the previous release for rollback, remove only old releases created by this installer.
+for old in "$prefix/releases/"*; do
+ [[ "$old" == "$release" || "$old" == "$previous" ]] || rm -rf -- "$old"
+done
+echo "worker-installed: release=$revision; outbound_only=true; enable routing in the owner console when ready"
@@ -10,7 +10,20 @@ async function main() {
@@ -10,7 +10,20 @@ async function main() {
const r = await fetch(new URL('/api/v1/workers/status', gateway), { signal: AbortSignal.timeout(15000), redirect: 'error' });
const r = await fetch(new URL('/api/v1/workers/status', gateway), { signal: AbortSignal.timeout(15000), redirect: 'error' });
const body = await r.json();
const body = await r.json();
if (!r.ok || body.protocol !== 1 || body.dry_run !== true) throw new Error('worker protocol unavailable');
if (!r.ok || body.protocol !== 1 || body.dry_run !== true) throw new Error('worker protocol unavailable');
- console.log('dry-run: production protocol 1 reachable; accepts_real_jobs=false');
+ const WebSocket = require('../../gateway/node_modules/ws');
+ const endpoint = new URL('/api/v1/workers/dry-run', gateway); endpoint.protocol = 'wss:';
+ await new Promise((resolve, reject) => {
+ const ws = new WebSocket(endpoint, { handshakeTimeout: 15000, maxPayload: 1024, perMessageDeflate: false });
+ const timer = setTimeout(() => { ws.terminate(); reject(new Error('dry handshake timeout')); }, 15000);
+ ws.on('error', reject);
+ ws.once('message', raw => {
+ try { const m = JSON.parse(String(raw)); if (m.protocol !== 1 || m.accepts_real_jobs !== false || m.dry_run !== true) throw new Error('invalid dry handshake'); resolve(); }
+ catch (err) { reject(err); }
+ finally { clearTimeout(timer); ws.close(); }
+ });
+ ws.once('close', () => { clearTimeout(timer); reject(new Error('dry handshake closed')); });
+ });
+ console.log('dry-run: production protocol 1 and outbound WebSocket reachable; accepts_real_jobs=false');
return;
return;
}
}
const token = fs.readFileSync(process.env.TOOLS_WORKER_TOKEN_FILE, 'utf8').trim();
const token = fs.readFileSync(process.env.TOOLS_WORKER_TOKEN_FILE, 'utf8').trim();
@@ -6,7 +6,7 @@ const path = require('node:path');
@@ -6,7 +6,7 @@ const path = require('node:path');
const { MAX_BYTES, TYPES, bytes } = require('./protocol');
const { MAX_BYTES, TYPES, bytes } = require('./protocol');
const { detect } = require('../../_shared/guard/sniff');
const { detect } = require('../../_shared/guard/sniff');
const { poolFromEnv } = require('../../_shared/jobs/pool');
const { poolFromEnv } = require('../../_shared/jobs/pool');
-function definitions() {
+function definitions({ slots = 2 } = {}) {
const defs = new Map(), pools = [], tools = [];
const defs = new Map(), pools = [], tools = [];
const hasAudio = ['ffmpeg', 'ffprobe'].every(bin => require('node:child_process').spawnSync(bin, ['-version'], { stdio: 'ignore', timeout: 3000 }).status === 0);
const hasAudio = ['ffmpeg', 'ffprobe'].every(bin => require('node:child_process').spawnSync(bin, ['-version'], { stdio: 'ignore', timeout: 3000 }).status === 0);
for (const app of ['img', 'docs', 'audio']) {
for (const app of ['img', 'docs', 'audio']) {
@@ -15,14 +15,14 @@ function definitions() {
@@ -15,14 +15,14 @@ function definitions() {
const catalog = require(`../../${app}/server/tools`);
const catalog = require(`../../${app}/server/tools`);
tools.push(...catalog.listTools().filter(t => t.available !== false).map(t => `${app}.process:${t.id}`));
tools.push(...catalog.listTools().filter(t => t.available !== false).map(t => `${app}.process:${t.id}`));
const process = require(`../../${app}/server/process`);
const process = require(`../../${app}/server/process`);
- const pool = app === 'audio' ? null : poolFromEnv(app, path.resolve(__dirname, `../../${app}/server/worker.js`), { size: 1, maxQueue: 0 });
+ const pool = app === 'audio' ? null : poolFromEnv(app, path.resolve(__dirname, `../../${app}/server/worker.js`), { size: slots, maxQueue: 0 });
if (pool) pools.push(pool);
if (pool) pools.push(pool);
process.defineJobs({ define: def => defs.set(def.type, def), JobError: Error }, pool);
process.defineJobs({ define: def => defs.set(def.type, def), JobError: Error }, pool);
} catch { /* A missing dependency removes this capability, rather than accepting work it cannot run. */ }
} catch { /* A missing dependency removes this capability, rather than accepting work it cannot run. */ }
}
}
return { defs, tools, close: () => Promise.all(pools.map(p => p.close())) };
return { defs, tools, close: () => Promise.all(pools.map(p => p.close())) };
}
}
-function createRuntime({ slots = 2, dry = false, registry = definitions(), root = os.tmpdir() } = {}) {
+function createRuntime({ slots = 2, dry = false, registry = definitions({ slots }), root = os.tmpdir() } = {}) {
if (!Number.isInteger(slots) || slots < 1 || slots > 8) throw new Error('slots must be 1..8');
if (!Number.isInteger(slots) || slots < 1 || slots > 8) throw new Error('slots must be 1..8');
const active = new Map();
const active = new Map();
function status() { return { tools: dry ? [] : (registry.tools || []), capabilities: dry ? [] : [...registry.defs.keys()].filter(k => TYPES.includes(k)), slots, free: dry ? 0 : slots - active.size, load: os.loadavg()[0], dry }; }
function status() { return { tools: dry ? [] : (registry.tools || []), capabilities: dry ? [] : [...registry.defs.keys()].filter(k => TYPES.includes(k)), slots, free: dry ? 0 : slots - active.size, load: os.loadavg()[0], dry }; }
@@ -50,7 +50,7 @@ function createRuntime({ slots = 2, dry = false, registry = definitions(), root
@@ -50,7 +50,7 @@ function createRuntime({ slots = 2, dry = false, registry = definitions(), root
if (!detected || !allowed.test(detected.mime) || detected.mime === 'image/svg+xml') throw new Error('unsupported file');
if (!detected || !allowed.test(detected.mime) || detected.mime === 'image/svg+xml') throw new Error('unsupported file');
const local = path.join(dir, `${files.length}.${detected.ext}`);
const local = path.join(dir, `${files.length}.${detected.ext}`);
await fs.writeFile(local, b, { mode: 0o600 });
await fs.writeFile(local, b, { mode: 0o600 });
- files.push({ path: local, name: `${files.length}.${detected.ext}`, mime: detected.mime, size: b.length });
+ files.push({ path: local, name: typeof f.name === 'string' ? path.basename(f.name).slice(0,200) : `${files.length}.${detected.ext}`, mime: detected.mime, size: b.length });
}
}
const invalid = def.validate(job.input, files);
const invalid = def.validate(job.input, files);
if (invalid) throw new Error('invalid tool options');
if (invalid) throw new Error('invalid tool options');
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+const assert = require('node:assert/strict');
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const os = require('node:os');
+const http = require('node:http');
+const crypto = require('node:crypto');
+const { once } = require('node:events');
+const { execFileSync, spawnSync } = require('node:child_process');
+const WebSocket = require('../../gateway/node_modules/ws');
+const { createHub } = require('../../gateway/server/workers/hub');
+const { sign, hash } = require('../server/protocol');
+const { createRuntime } = require('../server/runtime');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+(async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'tools-worker-test-'));
+ let hub, server, runtime;
+ try {
+ const token = crypto.randomBytes(32).toString('base64url'), id = `wrk_${crypto.randomBytes(12).toString('hex')}`;
+ hub = createHub({ WebSocketServer: WebSocket.WebSocketServer, heartbeatMs: 50, staleMs: 400, store: { lookup: async () => ({ token_hash: hash(token), location: 'home', draining: false }) } });
+ server = http.createServer(); server.on('upgrade', (req, socket, head) => hub.upgrade(req, socket, head));
+ server.listen(0, '127.0.0.1'); await once(server, 'listening');
+ const base = `ws://127.0.0.1:${server.address().port}`;
+ const dry = new WebSocket(base + '/api/v1/workers/dry-run');
+ const [raw] = await once(dry, 'message');
+ assert.deepEqual(JSON.parse(String(raw)), { protocol: 1, dry_run: true, accepts_real_jobs: false });
+ assert.equal(hub.connections.size, 0, 'dry sockets never enter dispatch candidates');
+ const ws = new WebSocket(base + '/api/v1/workers/connect', { headers: sign(token, id) });
+ await once(ws, 'open');
+ const status = JSON.stringify({ type: 'status', capabilities: ['img.process'], tools: ['img.process:convert'], slots: 2, free: 2, load: 0, padding: 'x'.repeat(1000) });
+ for (let n = 0; n < status.length; n += 100) {
+ ws.send(status.slice(n, n + 100), { fin: n + 100 >= status.length });
+ await sleep(70);
+ }
+ assert.equal(ws.readyState, WebSocket.OPEN, 'partial frame bytes keep a slow upload alive beyond stale interval');
+ assert.equal(hub.stats()[0].free, 2);
+ ws.terminate();
+ runtime = createRuntime({ slots: 2, root });
+ const sharp = require('../../img/node_modules/sharp');
+ const bytes = (await sharp({ create: { width: 2, height: 2, channels: 3, background: 'red' } }).png().toBuffer()).toString('base64');
+ const results = await Promise.all([1, 2].map(() => runtime.run({ id: crypto.randomBytes(16).toString('hex'), type: 'img.process', input: { tool: 'convert', format: 'png' }, files: [{ name: 'sample.png', bytes }] })));
+ assert.equal(results.length, 2, 'advertised slots support simultaneous image work');
+ assert(results.every(r => r.files[0].mime === 'image/png'));
+ const wav = Buffer.alloc(44 + 1600);
+ wav.write('RIFF'); wav.writeUInt32LE(wav.length - 8, 4); wav.write('WAVEfmt ', 8);
+ wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
+ wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
+ wav.write('data', 36); wav.writeUInt32LE(1600, 40);
+ const audio = await runtime.run({ id: crypto.randomBytes(16).toString('hex'), type: 'audio.process', input: { tool: 'convert', format: 'wav' }, files: [{ name: 'sample.wav', bytes: wav.toString('base64') }] });
+ assert.equal(audio.files[0].name, 'sample.wav');
+ assert(audio.files[0].bytes.length > 0, 'worker uses the existing hardened audio path');
+ const page = require('../../gateway/server/pages/site').pageWorkers();
+ assert.match(page, /Run Tools on your own machine/);
+ assert.match(page, /--dry-run --prefix/);
+ const installer = path.resolve(__dirname, '../install.sh');
+ execFileSync('bash', ['-n', installer]);
+ assert.notEqual(spawnSync('bash', [installer, '--dry-run', '--prefix', root], { encoding: 'utf8' }).status, 0, 'dry installation cannot overwrite an existing directory');
+ console.log('worker setup: dry WebSocket accepts no jobs; slow uploads survive; two slots execute; shared audio guard reused; installer guards passed');
+ } finally {
+ if (runtime) await runtime.close();
+ if (hub) hub.close();
+ if (server) await new Promise(r => server.close(r));
+ await fs.rm(root, { recursive: true, force: true });
+ }
+})().catch(e => { console.error(e); process.exitCode = 1; });
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-tools",
"name": "openvibe-tools",
"version": "1.0.0",
"version": "1.0.0",
"private": true,
"private": true,
- "description": "OpenVibe.Tools \u2014 the tools suite on openvibe.tools and *.openvibe.tools",
+ "description": "OpenVibe.Tools — the tools suite on openvibe.tools and *.openvibe.tools",
"scripts": {
"scripts": {
"test": "node test/run.js",
"test": "node test/run.js",
"test:pg": "DATABASE_URL=\"$OV_TEST_PG_URL\" DATABASE_DIRECT_URL=\"$OV_TEST_PG_DIRECT_URL\" VALKEY_URL=\"$OV_TEST_VALKEY_URL\" node test/run.js",
"test:pg": "DATABASE_URL=\"$OV_TEST_PG_URL\" DATABASE_DIRECT_URL=\"$OV_TEST_PG_DIRECT_URL\" VALKEY_URL=\"$OV_TEST_VALKEY_URL\" node test/run.js",
@@ -20,7 +20,8 @@
@@ -20,7 +20,8 @@
"test:suite": "node --unhandled-rejections=strict test/run.js",
"test:suite": "node --unhandled-rejections=strict test/run.js",
"test:worker-security": "node apps/gateway/test/security-ssrf.test.js",
"test:worker-security": "node apps/gateway/test/security-ssrf.test.js",
"test:routing": "node apps/worker/test/routing.test.js && node apps/worker/test/worker.test.js && node apps/_shared/test/jobs.test.js && node apps/gateway/test/security-ssrf.test.js",
"test:routing": "node apps/worker/test/routing.test.js && node apps/worker/test/worker.test.js && node apps/_shared/test/jobs.test.js && node apps/gateway/test/security-ssrf.test.js",
- "test:worker-docs": "node apps/gateway/test/openapi.test.js"
+ "test:worker-docs": "node apps/gateway/test/openapi.test.js",
+ "test:worker-setup": "node apps/worker/test/deploy.test.js && node apps/worker/test/worker.test.js && node apps/gateway/test/security-ssrf.test.js"
},
},
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {