openvibers/openvibe.actor Commits
Actor: run your own — self-hosted mode (owner sign-in, PGlite, Docker) and the runtime on any OpenAI-compatible model (#78)
Changes
nnew file mode 100644@@@ -0,0 +1,8 @@++node_modules++data++.env++.env.*++.git++test++docs++*.md@@@ -42,6 +42,23 @@ ACTOR_OPENAI_API_KEY=# The open model on this server (private mode): an OpenAI-compatible server such as llama.cpp.ACTOR_LOCAL_LLM_URL=ACTOR_LOCAL_LLM_MODEL=++++# The runtime on any OpenAI-compatible model instead of DeepSeek (a self-hosted Actor's usual choice; docs/self-host.md):++# its endpoint, model, key (if any) and price per million tokens (0 for your own hardware).++ACTOR_RUNTIME_URL=++ACTOR_RUNTIME_MODEL=++ACTOR_RUNTIME_API_KEY=++ACTOR_RUNTIME_INPUT_USD_PER_MTOK=0++ACTOR_RUNTIME_OUTPUT_USD_PER_MTOK=0++++# A self-hosted Actor (docs/self-host.md): one owner, no OpenVibe.Network. `npm run selfhost:init` writes these.++# ACTOR_SELF_HOSTED=1++# ACTOR_OWNER_NAME=++# ACTOR_OWNER_SUBJECT=++# ACTOR_OWNER_PASSWORD_HASH=++# ACTOR_OWNER_TOKEN=++# ACTOR_SESSION_SECRET=++# DATA_DIR=# The OpenVibe services the runtime acts through.# ACTOR_SEARCH_URL=https://search.openvibe.network# ACTOR_TOOLS_URL=https://openvibe.toolsnnew file mode 100644@@@ -0,0 +1,19 @@++# A self-hosted OpenVibe.Actor (docs/self-host.md): one process, PGlite in /data unless DATABASE_URL is set.++# npm run selfhost:init -- --name you --url http://localhost:4950 (writes .env.selfhost)++# docker compose -f docker-compose.selfhost.yml up -d++FROM node:22-bookworm-slim++WORKDIR /app++COPY package.json package-lock.json ./++RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force++COPY server ./server++COPY public ./public++COPY migrations ./migrations++COPY scripts ./scripts++COPY STATUS.json ./++RUN mkdir -p /data && chown node:node /data++ENV NODE_ENV=production HOST=0.0.0.0 PORT=4950 DATA_DIR=/data ACTOR_SELF_HOSTED=1++VOLUME /data++EXPOSE 4950++USER node++HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD node -e "fetch('http://127.0.0.1:4950/api/health').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"++CMD ["node", "server/index.js"]@@@ -148,6 +148,17 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu4. Personal agents v1 are in this worktree: named instructions, memory people control, hourly/daily/weekly schedules and ordinary task runs. An approval inbox (a coding task an agent wants to start waits for its owner) and Watch triggers (a watch hands itstask to an agent when its condition fires) are built, and so is asking Actor from chat (`@actor …`). Frame access comes later.++## Run your own++++A self-hosted Actor ([docs/self-host.md](docs/self-host.md)): `npm run selfhost:init`, then++`docker compose -f docker-compose.selfhost.yml --profile ollama up -d`.++- One owner signs in with a password, and the API takes their own token.++- One process, with its database on disk (PGlite) unless `DATABASE_URL` is set.++- The OpenVibe runtime runs on any OpenAI-compatible model (`ACTOR_RUNTIME_URL`), at the operator's price, listed as++ user-owned. With no other model family, it checks its own answers and says so.++- OpenVibe's public Search and Tools are used anonymously, or not at all.++- The parts that need an OpenVibe service identity are off: Bot, Codes, chat asks and Network-signed webhooks.++## ConfigurationSee [.env.example](.env.example). Required in production:nnew file mode 100644@@@ -0,0 +1,23 @@++# A self-hosted OpenVibe.Actor with a local model (docs/self-host.md).++# npm run selfhost:init -- --name you --url http://localhost:4950++# docker compose -f docker-compose.selfhost.yml --profile ollama up -d++# docker compose -f docker-compose.selfhost.yml exec ollama ollama pull qwen2.5:7b-instruct++# Without the ollama profile, point ACTOR_RUNTIME_URL in .env.selfhost at a model server you already run.++services:++ actor:++ build: .++ env_file: .env.selfhost++ ports:++ - "127.0.0.1:4950:4950"++ volumes:++ - actor-data:/data++ restart: unless-stopped++ ollama:++ image: ollama/ollama++ profiles: ["ollama"]++ volumes:++ - ollama-models:/root/.ollama++ restart: unless-stopped++volumes:++ actor-data: {}++ ollama-models: {}nnew file mode 100644@@@ -0,0 +1,73 @@++# Run your own Actor++++OpenVibe.Actor runs on your own server too: your tasks, your model, your data. A self-hosted Actor has one owner++(you), signs you in with a password, keeps everything in one process with its database on disk, and runs the++OpenVibe runtime on any OpenAI-compatible model you point it at: Ollama, llama.cpp, vLLM, or a provider you pay. It can++still use OpenVibe's public Search and Tools anonymously, or stay fully offline.++++## Five minutes with Docker++++```sh++git clone https://github.com/OpenVibers/OpenVibe.Actor && cd OpenVibe.Actor++npm ci --omit=dev++npm run selfhost:init -- --name you --url http://localhost:4950++docker compose -f docker-compose.selfhost.yml --profile ollama up -d++docker compose -f docker-compose.selfhost.yml exec ollama ollama pull qwen2.5:7b-instruct++```++++Open http://localhost:4950/auth/login and sign in with the password `selfhost:init` printed. It is shown only once.++Then give Actor a task on `/console`, or call the API with your token:++++```sh++curl -s http://localhost:4950/api/v1/tasks -H "Authorization: Bearer $ACTOR_OWNER_TOKEN" \++ -H 'Content-Type: application/json' -d '{"task": "Explain what a mutex is in two sentences."}'++```++++Without Docker: `npm ci --omit=dev`, then `node --env-file=.env.selfhost server/index.js`. Change `DATA_DIR` to a folder++you own first.++++## What `selfhost:init` writes++++`.env.selfhost`, mode 600, never overwritten. Keep it private.++++| Setting | What it is |++|---|---|++| `ACTOR_SELF_HOSTED=1` | the self-hosted mode |++| `ACTOR_OWNER_NAME`, `ACTOR_OWNER_SUBJECT` | you; the subject is a `usr_…` id your tasks are filed under |++| `ACTOR_OWNER_PASSWORD_HASH` | your password, scrypt-hashed (set `ACTOR_INIT_PASSWORD` to choose it) |++| `ACTOR_OWNER_TOKEN` | your API token (`Authorization: Bearer …`) |++| `ACTOR_SESSION_SECRET` | signs your sign-in cookie (30 days) |++| `BASE_URL`, `COOKIE_SECURE` | the address you open; secure cookies when it is https |++| `DATA_DIR` | where the database lives (PGlite in `DATA_DIR/pglite`); set `DATABASE_URL` and `DATABASE_DIRECT_URL` for PostgreSQL instead |++| `ACTOR_RUNTIME_URL`, `ACTOR_RUNTIME_MODEL`, `ACTOR_RUNTIME_API_KEY` | the runtime's model: any OpenAI-compatible `/v1` endpoint |++| `ACTOR_RUNTIME_INPUT_USD_PER_MTOK`, `…_OUTPUT_…` | its price per million tokens (0 on your own hardware), used for budgets and costs |++| `ACTOR_SEARCH_URL`, `ACTOR_TOOLS_URL` | OpenVibe's public Search and Tools, used anonymously; empty means the runtime is not offered them |++| `ACTOR_DEEPSEEK_API_KEY`, `ACTOR_OPENAI_API_KEY` | optional: more agents, and another model family to check answers |++| `ACTOR_FREE_*`, `ACTOR_MAX_COST_USD_PER_DAY` | your own daily limits |++++## How it differs from openvibe.actor++++- **Sign-in.** One owner and a password, not OpenVibe.Network. The API takes only your token; OpenVibe account tokens are++ refused.++- **Models.** The OpenVibe runtime runs on your model and is listed as user-owned, at your price.++ - **Checking.** Every answer is checked before you see it. With only your model, it checks its own answers, and the++ task says it was a same-family check. Add an OpenAI or DeepSeek key for a check from another family.++ - **Tools.** The runtime acts through OpenVibe Search and Tools when those URLs are set. A small model may use tools++ less well than DeepSeek; pick one that supports tool calls (Qwen 2.5 7B and up, Llama 3.1 8B and up).++- **What is off.** Your robots and computers on OpenVibe.Bot, coding on OpenVibe.Codes, asking from OpenVibe chat, task++ webhooks signed by Network, and account export through Network all need an OpenVibe service identity.++- **Data.** Tasks, their logs and your daily spend stay in your database. Nothing is sent to OpenVibe except the++ Search and Tools calls the runtime makes, when you leave those URLs set.++++## Updating++++`git pull`, then `docker compose -f docker-compose.selfhost.yml up -d --build`. Migrations apply at start, and your data++stays in the `actor-data` volume.++++## Security notes++++- **Exposure.** It listens on 127.0.0.1:4950 in the compose file. Put it behind your own TLS proxy before exposing it,++ and set `BASE_URL` to the https address so cookies are secure.++- **Secrets.** The password is stored only as a scrypt hash. The session cookie is HMAC-signed and httpOnly. The API++ token is compared in constant time.++- **Limits.** Sign-in attempts are limited (60 per 15 minutes per address).@@@ -8,7 +8,8 @@"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js","test": "node test/run.js",-- "test:pg": "ACTOR_TEST_STORE=pg node test/run.js"++ "test:pg": "ACTOR_TEST_STORE=pg node test/run.js",++ "selfhost:init": "node scripts/selfhost-init.js"},"keywords": ["openvibe",nnew file mode 100644@@@ -0,0 +1,82 @@++#!/usr/bin/env node++'use strict';++/**++ * Set up a self-hosted Actor (docs/self-host.md): writes .env.selfhost (mode 600) with the owner, their password's++ * hash, their API token, the session secret and the model settings, and prints the password and the token once.++ * It never overwrites an existing file.++ *++ * npm run selfhost:init -- [--name alex] [--url http://localhost:4950] [--runtime-url http://ollama:11434/v1]++ * [--model qwen2.5:7b-instruct] [--out .env.selfhost]++ *++ * ACTOR_INIT_PASSWORD in the environment sets the password instead of a generated one (it is then not printed).++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const { ids } = require('openvibe-contracts');++const { hashPassword } = require('../server/auth/local');++++function args(argv) {++ const out = {};++ for (let i = 0; i < argv.length; i++) {++ const m = /^--([a-z-]+)$/.exec(argv[i]);++ if (!m) throw new Error(`unknown argument ${argv[i]}`);++ out[m[1]] = argv[++i];++ if (out[m[1]] === undefined) throw new Error(`--${m[1]} needs a value`);++ }++ return out;++}++++function main() {++ const a = args(process.argv.slice(2));++ const file = path.resolve(a.out || '.env.selfhost');++ if (fs.existsSync(file)) throw new Error(`${file} exists: nothing written (delete it first to start over)`);++ const name = String(a.name || 'owner').replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 64) || 'owner';++ const url = String(a.url || 'http://localhost:4950').replace(/\/+$/, '');++ if (!/^https?:\/\/[^/\s]+$/.test(url)) throw new Error('--url is the address people open, like https://actor.example.org');++ const given = process.env.ACTOR_INIT_PASSWORD || '';++ const password = given || crypto.randomBytes(15).toString('base64url');++ const token = `act_${crypto.randomBytes(32).toString('base64url')}`;++ const lines = [++ '# A self-hosted OpenVibe.Actor (docs/self-host.md). Keep this file private: it holds the owner\'s secrets.',++ 'ACTOR_SELF_HOSTED=1',++ 'NODE_ENV=production',++ 'HOST=0.0.0.0',++ 'PORT=4950',++ `BASE_URL=${url}`,++ `COOKIE_SECURE=${url.startsWith('https://') ? 'true' : 'false'}`,++ 'TRUST_PROXY=1',++ 'DATA_DIR=/data',++ `ACTOR_OWNER_NAME=${name}`,++ `ACTOR_OWNER_SUBJECT=usr_${ids.ulid(Date.now())}`,++ `ACTOR_OWNER_PASSWORD_HASH=${hashPassword(password)}`,++ `ACTOR_OWNER_TOKEN=${token}`,++ `ACTOR_SESSION_SECRET=${crypto.randomBytes(32).toString('base64url')}`,++ '# The runtime\'s model: any OpenAI-compatible endpoint (Ollama, llama.cpp, vLLM, a provider). Prices are per',++ '# million tokens, 0 for your own hardware.',++ `ACTOR_RUNTIME_URL=${String(a['runtime-url'] || 'http://ollama:11434/v1').replace(/\/+$/, '')}`,++ `ACTOR_RUNTIME_MODEL=${a.model || 'qwen2.5:7b-instruct'}`,++ 'ACTOR_RUNTIME_API_KEY=',++ 'ACTOR_RUNTIME_INPUT_USD_PER_MTOK=0',++ 'ACTOR_RUNTIME_OUTPUT_USD_PER_MTOK=0',++ '# OpenVibe\'s public Search and Tools APIs, used anonymously; leave both empty to stay fully offline.',++ 'ACTOR_SEARCH_URL=https://search.openvibe.network',++ 'ACTOR_TOOLS_URL=https://openvibe.tools',++ '# Optional: other agents (their keys are yours; each adds its own family to check answers with).',++ 'ACTOR_DEEPSEEK_API_KEY=',++ 'ACTOR_OPENAI_API_KEY=',++ '# The free tier and the daily ceiling still apply to the owner (USD; 0-cost models never reach them).',++ 'ACTOR_FREE_TASKS_PER_DAY=500',++ 'ACTOR_FREE_PER_TASK_USD=0.5',++ 'ACTOR_FREE_PER_DAY_USD=5',++ 'ACTOR_MAX_COST_USD_PER_DAY=5',++ '',++ ];++ fs.writeFileSync(file, lines.join('\n'), { mode: 0o600, flag: 'wx' });++ console.log(`Wrote ${file} (mode 600).`);++ console.log(`Sign in at ${url}/auth/login as ${name}.`);++ if (!given) console.log(`Password (shown once): ${password}`);++ console.log(`API token (shown once; Authorization: Bearer …): ${token}`);++}++++try { main(); } catch (err) { console.error(err.message); process.exitCode = 1; }@@@ -50,8 +50,14 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = conconst openaiAgent = p.openai.apiKey ? responsesAgent({ baseUrl: p.openai.baseUrl, apiKey: p.openai.apiKey, model: p.openai.model, rates: rates.openai, fetchImpl }) : null;const local = p.local.url && p.local.model ? chatClient({ baseUrl: p.local.url, apiKey: '', model: p.local.model, rates: rates.local, fetchImpl, timeoutMs: 120_000 }) : null;++ // The runtime's model: the operator's own (ACTOR_RUNTIME_URL, catalog.runtimeOverride) when set, else DeepSeek's.++ const own = p.runtime && p.runtime.url && p.runtime.model ? p.runtime : null;++ const ownRates = own ? { input_usd_per_mtok: own.inputUsdPerMtok, cached_usd_per_mtok: own.inputUsdPerMtok, output_usd_per_mtok: own.outputUsdPerMtok } : null;++ const runtimeModel = own ? chatClient({ baseUrl: own.url, apiKey: own.apiKey, model: own.model, rates: ownRates, fetchImpl, timeoutMs: 180_000 }) : deepseek;++ const runtimeModelName = own ? own.model : p.deepseek.model;++async function runtime({ task, requester = null, signal, emit, meter, budgetLeft }) {-- if (!deepseek) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });++ if (!runtimeModel) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });const tools = await ovTools.definitions({ signal, requester });const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');const computers = tools.some((d) => d.function && d.function.name === 'computer_run');@@@ -62,7 +68,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = conif (budgetLeft() <= 0) throw new ProviderError('the task reached its budget before the runtime finished', { code: 'actor.budget.exceeded' });const finalTurn = turn === MAX_TURNS;// The model reasons before it answers, inside the same token budget: room for both.-- const r = await deepseek({ messages, tools: finalTurn ? null : tools, maxTokens: 4000, signal });++ const r = await runtimeModel({ messages, tools: finalTurn ? null : tools, maxTokens: 4000, signal });await meter(r.cost_usd);messages.push(r.message);if (r.text && r.text.trim()) { last = r.text.trim(); }@@@ -81,7 +87,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con}}if (!last) throw new ProviderError('the runtime used all its turns without an answer', { code: 'actor.agent.no_answer' });-- return { text: last, sources: [], model: p.deepseek.model, evidence };++ return { text: last, sources: [], model: runtimeModelName, evidence };}async function openai({ task, mode, signal, emit, meter, budgetLeft }) {@@@ -113,6 +119,9 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con// An answer backed by tool results: compared against them by a model that does it reliably (agents/check.js).openaiGrounded: p.openai.apiKey ? chatClient({ baseUrl: p.openai.baseUrl, apiKey: p.openai.apiKey, model: p.openai.groundedCheckModel || 'gpt-5-mini', rates: rates.openaiGroundedCheck, fetchImpl }) : null,local,++ // The operator's own runtime model checks the runtime's answers when no other family is configured++ // (agents/check.js: same family, so it says so).++ runtime: own ? chatClient({ baseUrl: own.url, apiKey: own.apiKey, model: own.model, rates: ownRates, fetchImpl, timeoutMs: 120_000 }) : null,};return { run, checkers, tools: ovTools };@@@ -82,9 +82,30 @@ const AGENTS = [},];++/** The runtime's own model when ACTOR_RUNTIME_URL names one (config.providers.runtime), else null (DeepSeek's). */++function runtimeOverride(config) {++ const r = config && config.providers && config.providers.runtime;++ return r && r.url && r.model ? r : null;++}++++/**++ * The agent as this server runs it: the OpenVibe runtime on an operator's own model has that model's price (its++ * endpoint is the rate card's source) and is user-owned; every other agent is as listed.++ */++function effective(agent, config) {++ const r = agent.id === 'openvibe-runtime' ? runtimeOverride(config) : null;++ if (!r) return agent;++ return {++ ...agent, trust: 'user-owned',++ rate_card: { input_usd_per_mtok: r.inputUsdPerMtok, output_usd_per_mtok: r.outputUsdPerMtok, source: r.url, verified_at: VERIFIED },++ blurb: `OpenVibe's own agent on this server's model (${r.model}): it acts through OpenVibe services — search across the network and the tools — and the task's text goes only to that model.`,++ };++}++/** Is the agent usable with this configuration? { available, reason }. */function availability(agent, config) {const p = config.providers || {};++ if (agent.id === 'openvibe-runtime' && runtimeOverride(config)) return { available: true };if (agent.providerKey === 'deepseek') return p.deepseek && p.deepseek.apiKey ? { available: true } : { available: false, reason: 'no DeepSeek key is configured on this server' };if (agent.providerKey === 'openai') return p.openai && p.openai.apiKey ? { available: true } : { available: false, reason: 'no OpenAI key is configured on this server' };if (agent.providerKey === 'local') return p.local && p.local.url && p.local.model ? { available: true } : { available: false, reason: 'no open model is running on this server' };@@@ -97,13 +118,15 @@ function availability(agent, config) {/** The model an agent runs with this configuration (for the listing and the task's explanation). */function modelOf(agent, config) {++ if (agent.id === 'openvibe-runtime' && runtimeOverride(config)) return runtimeOverride(config).model;const p = (config.providers || {})[agent.providerKey];return p && p.model ? p.model : undefined;}/** What one task of this agent typically costs, at its rate card (USD). */--function typicalCost(agentId) {-- const a = AGENTS.find((x) => x.id === agentId);++function typicalCost(agentId, config = null) {++ const found = AGENTS.find((x) => x.id === agentId);++ const a = found && config ? effective(found, config) : found;const t = TYPICAL[agentId];if (!a || !t) return 0;const r = a.rate_card;@@@ -112,7 +135,8 @@ function typicalCost(agentId) {/** GET /api/v1/agents: actor.agent-list-result@1. */function list(config) {-- return AGENTS.map((a) => {++ return AGENTS.map((listed) => {++ const a = effective(listed, config);const av = availability(a, config);const row = { id: a.id, name: a.name, kind: a.kind, provider: a.provider, trust: a.trust, can: [...a.can], rate_card: { ...a.rate_card }, typical_latency_ms: TYPICAL[a.id].latencyMs, available: av.available };const model = modelOf(a, config);@@@ -126,7 +150,8 @@ function list(config) {* One platform.resource-offer@1 per agent for openvibe-sdk/placement. `quality:<tier>` is an offer capability so* best mode can require it; an unavailable agent is health down with its reason kept for the explanation.*/--function toOffer(agent, config, { now = Date.now(), measured = {} } = {}) {++function toOffer(listed, config, { now = Date.now(), measured = {} } = {}) {++ const agent = effective(listed, config);const av = availability(agent, config);const t = TYPICAL[agent.id];const quality = agent.quality === 'high' ? ['quality:high', 'quality:standard'] : agent.quality === 'standard' ? ['quality:standard'] : [];@@@ -139,7 +164,7 @@ function toOffer(agent, config, { now = Date.now(), measured = {} } = {}) {capabilities: [...agent.can, ...quality],capacity: {},health: av.available ? { status: 'up' } : { status: 'down', reason: av.reason },-- pricing: { model: 'per-operation', unit: 'task', marginal_usd_per_unit: typicalCost(agent.id) },++ pricing: { model: 'per-operation', unit: 'task', marginal_usd_per_unit: typicalCost(agent.id, config) },latency_ms: { run_p95: measured[agent.id] || t.latencyMs },updated_at: new Date(now).toISOString(),};@@@ -159,4 +184,4 @@ const RATES = {local: { input_usd_per_mtok: 0, output_usd_per_mtok: 0 },};--module.exports = { AGENTS, TYPICAL, RATES, availability, list, toOffer, typicalCost, modelOf, byId, VERIFIED };++module.exports = { AGENTS, TYPICAL, RATES, availability, list, toOffer, typicalCost, modelOf, byId, effective, runtimeOverride, VERIFIED };@@@ -13,7 +13,8 @@// codes: the person's own machine wrote it (any of Codes' agents), so either family reads it.const FAMILY = { 'openvibe-runtime': 'deepseek', 'openai-agent': 'openai', 'open-model': 'local', codes: 'codes' };--const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'openai'], local: ['local'], codes: ['deepseek', 'openai'] };++// `runtime`: the OpenVibe runtime on the operator's own model (ACTOR_RUNTIME_URL) — another family first, else itself.++const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'openai'], local: ['local'], codes: ['deepseek', 'openai'], runtime: ['openai', 'deepseek', 'runtime'] };const SYSTEM = `You check an AI agent's answer before a person sees it. Reply with JSON only: {"ok": true|false, "reason": "<one short sentence>"}.Only your own reply is JSON. The answer is prose meant to be read by a person (often Markdown) and is never expected to be JSON; tool results are JSON because services return JSON.@@@ -33,7 +34,7 @@ When the task is a greeting, small talk, a test message or too vague to act on,* default) reads it when one is configured, so the check stays cross-family.*/function checkersFor(agentId, checkers, { mode, grounded = false }) {-- const family = FAMILY[agentId];++ const family = agentId === 'openvibe-runtime' && checkers.runtime ? 'runtime' : FAMILY[agentId];const order = mode === 'private' ? ['local'] : (CHECKER_FOR[family] || []);const pick = (f) => (grounded && f === 'openai' && checkers.openaiGrounded ? checkers.openaiGrounded : checkers[f]);return order.filter((f) => pick(f)).map((f) => ({ family: f, chat: pick(f), cross: f !== family }));@@@ -65,6 +65,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>/** Tools' catalog: only tools with an API that run at once and that anyone may call. */async function tools({ signal } = {}) {++ if (!toolsUrl) return [];if (catalog && now() - catalogAt < CATALOG_TTL_MS) return catalog;try {const r = await getJson(`${toolsUrl}/api/v1/tools`, { signal });@@@ -121,10 +122,11 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>/** The function definitions the runtime's model sees, and the dispatcher that runs one call. */async function definitions({ signal, requester = null } = {}) {const list = await tools({ signal });++ // A self-hosted Actor may leave OpenVibe.Search or OpenVibe.Tools unset (ACTOR_SEARCH_URL / ACTOR_TOOLS_URL empty):++ // the runtime is then offered only the tools it can reach.const defs = [-- { type: 'function', function: { name: 'search_openvibe', description: 'Search everything published on the OpenVibe network (streams, clips, pastes, community posts, wiki pages, blog posts, threads). Returns titles, summaries and links.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },-- { type: 'function', function: { name: 'find_tools', description: 'Find OpenVibe.Tools tools for a need (for example "mx records", "ssl certificate", "whois", "http headers", "base64", "hash"). Returns each tool id with its input schema and an example.', parameters: { type: 'object', properties: { need: { type: 'string' } }, required: ['need'] } } },-- { type: 'function', function: { name: 'run_tool', description: 'Run one OpenVibe.Tools tool by id with its input object (use find_tools first to see the input).', parameters: { type: 'object', properties: { tool: { type: 'string' }, input: { type: 'object' } }, required: ['tool', 'input'] } } },++ ...(searchUrl ? [{ type: 'function', function: { name: 'search_openvibe', description: 'Search everything published on the OpenVibe network (streams, clips, pastes, community posts, wiki pages, blog posts, threads). Returns titles, summaries and links.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } }] : []),++ ...(toolsUrl ? [{ type: 'function', function: { name: 'find_tools', description: 'Find OpenVibe.Tools tools for a need (for example "mx records", "ssl certificate", "whois", "http headers", "base64", "hash"). Returns each tool id with its input schema and an example.', parameters: { type: 'object', properties: { need: { type: 'string' } }, required: ['need'] } } }, { type: 'function', function: { name: 'run_tool', description: 'Run one OpenVibe.Tools tool by id with its input object (use find_tools first to see the input).', parameters: { type: 'object', properties: { tool: { type: 'string' }, input: { type: 'object' } }, required: ['tool', 'input'] } } }] : []),];if (list.some((t) => t.id === 'read')) {defs.push({ type: 'function', function: { name: 'read_page', description: 'Read a public web page as text (title, description, main text, links). Use it for URLs the task gives or that a result links to.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } });@@@ -26,6 +26,19 @@ const { createAdapters } = require('./agents/adapters');const { createEngine } = require('./tasks/engine');const { createWebhooks } = require('./tasks/webhooks');const { createChatClient } = require('./chat/client');++const { createLocalSignIn } = require('./auth/local');++const { ids: contractIds } = require('openvibe-contracts');++++/** A self-hosted Actor needs its owner and its secrets (scripts/selfhost-init.js writes them); it refuses to start without. */++function checkSelfHosted(config) {++ const sh = config.selfHosted;++ const missing = [];++ if (!contractIds.isSubjectId('user', sh.ownerSubject)) missing.push('ACTOR_OWNER_SUBJECT (a usr_… id)');++ if (!/^scrypt\$/.test(sh.passwordHash)) missing.push('ACTOR_OWNER_PASSWORD_HASH');++ if (String(sh.ownerToken).length < 32) missing.push('ACTOR_OWNER_TOKEN (32+ characters)');++ if (String(sh.sessionSecret).length < 32) missing.push('ACTOR_SESSION_SECRET (32+ characters)');++ if (missing.length) throw new Error(`ACTOR_SELF_HOSTED=1 needs ${missing.join(', ')}: run \`npm run selfhost:init\` (docs/self-host.md)`);++}const { createChatAsks } = require('./chat/asks');const { createTask } = require('./tasks/create');const { createWebhookPoster } = require('./net/webhook-post');@@@ -59,7 +72,9 @@ async function createApp(opts = {}) {const s = opts.store || await openStore(config, { now: opts.now, log });const keys = createKeyStore({ config, fetchImpl, log });-- const sso = createSso({ config, keys, fetchImpl, now: s.now, log });++ // A self-hosted Actor signs its one owner in itself (auth/local.js); otherwise OpenVibe.Network does (auth/sso.js).++ if (config.selfHosted.on) checkSelfHosted(config);++ const sso = config.selfHosted.on ? createLocalSignIn({ config, now: s.now }) : createSso({ config, keys, fetchImpl, now: s.now, log });const stream = createTaskStream({ log });const adapters = opts.adapters || createAdapters({ config, rates: catalog.RATES, fetchImpl: opts.providerFetch || globalThis.fetch, log });// Task webhooks (plan T17): the only connections to an address a caller chose, through server/net/webhook-post.js.nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++++/**++ * A self-hosted Actor's own sign-in (ACTOR_SELF_HOSTED=1, docs/self-host.md): one owner, no OpenVibe.Network. The same++ * shape as auth/sso.js, so the rest of the site does not tell them apart.++ *++ * GET /auth/login a password form (?next= a same-site path)++ * POST /auth/login the owner's password → a signed session cookie (actor_local, httpOnly, 30 days)++ * GET /auth/logout clears it++ * GET /auth/me { user } for the navbar; { user: null } for a guest++ *++ * The cookie is "<expiry ms>.<HMAC-SHA256 under ACTOR_SESSION_SECRET>"; the password is checked against++ * ACTOR_OWNER_PASSWORD_HASH ("scrypt$<N>$<r>$<p>$<salt>$<hash>", base64url; scripts/selfhost-init.js makes it). The++ * owner's API token (ACTOR_OWNER_TOKEN, a Bearer) is checked in http/principal.js. Neither is ever logged or rendered.++ */++const crypto = require('crypto');++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++const { sanitizeNext } = require('./sso');++++const COOKIE = 'actor_local';++const SESSION_MS = 30 * 24 * 3600 * 1000;++++/** "scrypt$N$r$p$salt$hash" for a password (base64url salt and hash): what ACTOR_OWNER_PASSWORD_HASH holds. */++function hashPassword(password, { N = 16384, r = 8, p = 1 } = {}) {++ const salt = crypto.randomBytes(16);++ const hash = crypto.scryptSync(String(password), salt, 32, { N, r, p, maxmem: 64 * 1024 * 1024 });++ return `scrypt$${N}$${r}$${p}$${salt.toString('base64url')}$${hash.toString('base64url')}`;++}++++/** Does `password` match the stored hash? Constant time; a malformed hash matches nothing. */++function checkPassword(password, stored) {++ const m = /^scrypt\$(\d+)\$(\d+)\$(\d+)\$([A-Za-z0-9_-]+)\$([A-Za-z0-9_-]+)$/.exec(String(stored || ''));++ if (!m || typeof password !== 'string' || !password || password.length > 1024) return false;++ const [N, r, p] = [Number(m[1]), Number(m[2]), Number(m[3])];++ const want = Buffer.from(m[5], 'base64url');++ let got;++ try { got = crypto.scryptSync(password, Buffer.from(m[4], 'base64url'), want.length, { N, r, p, maxmem: 64 * 1024 * 1024 }); } catch { return false; }++ return got.length === want.length && crypto.timingSafeEqual(got, want);++}++++/** Constant-time string equality through SHA-256 (lengths never leak). */++function sameSecret(a, b) {++ if (typeof a !== 'string' || typeof b !== 'string' || !a || !b) return false;++ const ha = crypto.createHash('sha256').update(a).digest();++ const hb = crypto.createHash('sha256').update(b).digest();++ return crypto.timingSafeEqual(ha, hb);++}++++function createLocalSignIn({ config, now = () => Date.now() }) {++ const sh = config.selfHosted;++ const mac = (value) => crypto.createHmac('sha256', sh.sessionSecret).update(`actor-local-session:${value}`).digest('base64url');++ const cookieOpts = () => ({ httpOnly: true, sameSite: 'lax', secure: config.cookies.secure, path: '/' });++ const owner = () => ({ kind: 'user', subject: sh.ownerSubject, username: sh.ownerName, displayName: sh.ownerName, role: 'user', token: null });++++ function readSession(req) {++ const raw = req.cookies && req.cookies[COOKIE];++ const m = /^(\d{13})\.([A-Za-z0-9_-]{43})$/.exec(String(raw || ''));++ if (!m || Number(m[1]) < now()) return false;++ return sameSecret(mac(m[1]), m[2]);++ }++++ function middleware() {++ return (req, res, next) => {++ req.viewer = readSession(req) ? owner() : { kind: 'anonymous' };++ next();++ };++ }++++ /** Nothing here calls the Network as the viewer: a self-hosted owner has no Network token. */++ async function asViewer(req, res, fn) { return fn(null); }++++ function page(res, status, { next = '/', error = null } = {}) {++ res.status(status).set('Cache-Control', cache.htmlHeaders({ private: true })).type('html').send(`<!doctype html>++<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sign in · Actor</title>++<meta name="robots" content="noindex"><style>body{font:16px/1.5 system-ui,sans-serif;background:#0b1220;color:#e6edf7;display:grid;place-items:center;min-height:100vh;margin:0}++form{background:#111a2e;border:1px solid #23304d;border-radius:12px;padding:28px;width:min(360px,calc(100vw - 32px));box-sizing:border-box}++h1{font-size:1.3rem;margin:0 0 4px}p{color:#96a7c2;margin:0 0 18px;font-size:.92rem}label{display:block;font-size:.9rem;margin-bottom:6px}++input{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid #33456a;background:#0b1220;color:inherit;font:inherit}++button{margin-top:14px;width:100%;padding:10px;border:0;border-radius:8px;background:#2563eb;color:#fff;font:600 1rem system-ui,sans-serif;cursor:pointer}++.error{color:#fca5a5;margin:0 0 12px}</style></head><body>++<form method="post" action="/auth/login"><h1>Sign in to your Actor</h1><p>This Actor runs on your own server.</p>++${error ? `<p class="error" role="alert">${error}</p>` : ''}<input type="hidden" name="next" value="${String(next).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))}">++<label for="pw">Password</label><input id="pw" name="password" type="password" autocomplete="current-password" required autofocus>++<button type="submit">Sign in</button></form></body></html>`);++ }++++ function routes() {++ const r = express.Router();++ r.get('/login', (req, res) => page(res, 200, { next: sanitizeNext(req.query.next) }));++ r.post('/login', express.urlencoded({ extended: false, limit: '4kb' }), (req, res) => {++ const next = sanitizeNext(req.body && req.body.next);++ if (!checkPassword(req.body && req.body.password, sh.passwordHash)) return page(res, 401, { next, error: 'That is not the password.' });++ const exp = String(now() + SESSION_MS);++ res.cookie(COOKIE, `${exp}.${mac(exp)}`, { ...cookieOpts(), maxAge: SESSION_MS });++ res.redirect(303, next);++ });++ r.get('/logout', (req, res) => { res.clearCookie(COOKIE, cookieOpts()); res.redirect(303, '/'); });++ r.get('/me', (req, res) => {++ res.set('Cache-Control', 'private, no-store');++ const v = req.viewer;++ res.json({ user: v && v.kind === 'user' ? { username: v.username, display_name: v.displayName, subject_id: v.subject } : null });++ });++ return r;++ }++++ return { middleware, routes, asViewer, local: true };++}++++module.exports = { createLocalSignIn, hashPassword, checkPassword, sameSecret, COOKIE };@@@ -14,6 +14,9 @@ const num = (v, def) => (v != null && v !== '' && Number.isFinite(Number(v)) ? Nfunction load(env = process.env) {const nodeEnv = env.NODE_ENV || 'development';const isProduction = nodeEnv === 'production';++ // A self-hosted Actor (docs/self-host.md): one owner signs in with a password, no OpenVibe.Network, PGlite unless++ // DATABASE_URL is set; the integrations that need a Network service identity (Bot, Codes, Chat, Events) are off.++ const selfHosted = env.ACTOR_SELF_HOSTED === '1';const port = int(env.PORT, 4950);const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.actor' : `http://localhost:${port}`));const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');@@@ -36,7 +39,15 @@ function load(env = process.env) {// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In// development without DATABASE_URL an embedded PGlite database in data/pglite is used (ACTOR_PGLITE_DIR// overrides the directory).-- db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.ACTOR_PGLITE_DIR || '' },++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.ACTOR_PGLITE_DIR || (selfHosted && env.DATA_DIR ? `${env.DATA_DIR.replace(/\/+$/, '')}/pglite` : '') },++ selfHosted: {++ on: selfHosted,++ ownerName: String(env.ACTOR_OWNER_NAME || 'owner').slice(0, 64),++ ownerSubject: env.ACTOR_OWNER_SUBJECT || '',++ passwordHash: env.ACTOR_OWNER_PASSWORD_HASH || '',++ ownerToken: env.ACTOR_OWNER_TOKEN || '',++ sessionSecret: env.ACTOR_SESSION_SECRET || '',++ },valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:actor:' },// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.@@@ -60,16 +71,23 @@ function load(env = process.env) {deepseek: { apiKey: env.ACTOR_DEEPSEEK_API_KEY || '', baseUrl: trim(env.ACTOR_DEEPSEEK_BASE_URL || 'https://api.deepseek.com'), model: env.ACTOR_DEEPSEEK_MODEL || 'deepseek-flash' },openai: { apiKey: env.ACTOR_OPENAI_API_KEY || '', baseUrl: trim(env.ACTOR_OPENAI_BASE_URL || 'https://api.openai.com/v1'), model: env.ACTOR_OPENAI_MODEL || 'gpt-5-mini', checkModel: env.ACTOR_OPENAI_CHECK_MODEL || 'gpt-5-nano', groundedCheckModel: env.ACTOR_OPENAI_GROUNDED_CHECK_MODEL || 'gpt-5-mini', visionModel: env.ACTOR_OPENAI_VISION_MODEL || '' },local: { url: trim(env.ACTOR_LOCAL_LLM_URL || ''), model: env.ACTOR_LOCAL_LLM_MODEL || '' },++ // The runtime's model on any OpenAI-compatible endpoint (Ollama, llama.cpp, vLLM, another provider) instead++ // of DeepSeek: a self-hosted Actor's usual choice. Priced per million tokens at what the operator says++ // (0 for a model on their own hardware); it also checks its own answers when no other family is configured.++ runtime: {++ url: trim(env.ACTOR_RUNTIME_URL || ''), model: env.ACTOR_RUNTIME_MODEL || '', apiKey: env.ACTOR_RUNTIME_API_KEY || '',++ inputUsdPerMtok: Math.max(0, num(env.ACTOR_RUNTIME_INPUT_USD_PER_MTOK, 0)), outputUsdPerMtok: Math.max(0, num(env.ACTOR_RUNTIME_OUTPUT_USD_PER_MTOK, 0)),++ },},// The OpenVibe services the runtime acts through.services: {searchUrl: trim(env.ACTOR_SEARCH_URL || 'https://search.openvibe.network'),toolsUrl: trim(env.ACTOR_TOOLS_URL || 'https://openvibe.tools'),// OpenVibe.Bot: the person's own robots (list_my_robots, robot_steps). Empty turns the robot tools off.-- botUrl: trim(env.ACTOR_BOT_URL != null ? env.ACTOR_BOT_URL : 'http://127.0.0.1:4630'),++ botUrl: trim(env.ACTOR_BOT_URL != null ? env.ACTOR_BOT_URL : (selfHosted ? '' : 'http://127.0.0.1:4630')),botPublicUrl: trim(env.ACTOR_BOT_PUBLIC_URL || 'https://openvibe.bot'),// OpenVibe.Codes: the `codes` agent runs a person's coding task on their own machine. Empty turns it off.-- codesUrl: trim(env.ACTOR_CODES_URL != null ? env.ACTOR_CODES_URL : 'http://127.0.0.1:4900'),++ codesUrl: trim(env.ACTOR_CODES_URL != null ? env.ACTOR_CODES_URL : (selfHosted ? '' : 'http://127.0.0.1:4900')),codesPublicUrl: trim(env.ACTOR_CODES_PUBLIC_URL || 'https://openvibe.codes'),},@@@ -99,7 +117,7 @@ function load(env = process.env) {// taken from chat.message.created and answered in the same room through Chat's service API (url, loopback in// production). ACTOR_CHAT_ASKS=0 turns it off; the limits are per person and across everyone, per hour.chat: {-- asks: env.ACTOR_CHAT_ASKS !== '0',++ asks: env.ACTOR_CHAT_ASKS !== '0' && !selfHosted,url: trim(env.ACTOR_CHAT_URL != null ? env.ACTOR_CHAT_URL : 'http://127.0.0.1:4400'),perHour: Math.max(1, int(env.ACTOR_CHAT_ASKS_PER_HOUR, 5)),allPerHour: Math.max(1, int(env.ACTOR_CHAT_ASKS_ALL_PER_HOUR, 60)),@@@ -16,9 +16,11 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');async function openDb(config, { log = console, registry } = {}) {if (!config.db.url) {-- if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh actor)');++ const selfHosted = config.selfHosted && config.selfHosted.on;++ if (config.isProduction && !selfHosted) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh actor)');const dir = config.db.pgliteDir || DEV_PGLITE;-- log.warn(`[Actor] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ if (selfHosted) log.log(`[Actor] self-hosted: embedded PGlite database in ${dir} (one process; set DATABASE_URL for PostgreSQL)`);++ else log.warn(`[Actor] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);fs.mkdirSync(dir, { recursive: true });const db = createDb({ pglite: dir, service: 'actor', registry, log });await db.migrate({ dir: MIGRATIONS, log });@@@ -147,6 +147,7 @@ ${table(['Piece', 'State'], [[html`Your own computers, through <a href="https://openvibe.bot/docs/computers">OpenVibe.Bot</a>: list and read files, run programs and write files there, as you, at the level you set on the computer`, html`Live: install OpenVibe Node with <code>--computer ask</code> and turn on "Let my agent use this computer" on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. At <code>ask</code>, you approve each run or write on the computer.`],[html`<a href="/console">The Console</a>: what runs now, what waits for you, today's spend, your agents, computers and robots, and one box to start a task`, 'Live, for signed-in people.'],[html`Ask in chat: write <code>@actor</code> and your question in OpenVibe's global chat or your own channel, and the answer comes back in the room`, 'Live: public tools only (never your computers, robots or code), 5 an hour, on your free tier.'],++ [html`Run your own: Actor on your server with your own model (<a href="https://github.com/OpenVibers/OpenVibe.Actor/blob/main/docs/self-host.md">how</a>), signed in with your password, data on your disk`, 'Live: Docker or plain Node, Ollama or any OpenAI-compatible model.'],])}</section>${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,}, status);@@@ -156,7 +157,7 @@ ${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, iconst shown = agents.filter((a) => a.id !== 'codes');return html`<figure class="router-card" aria-label="How Actor picked an agent for a sample task"><figcaption><span class="muted small">Sample task · Balanced</span><b>“Who handles the mail for openvibe.network, and is DMARC set up?”</b></figcaption>--<ol>${shown.map((a, i) => html`<li class="${i === 0 ? 'pick' : ''}"><span class="rc-name">${a.name}</span><span class="rc-cost">~${usd(catalog.typicalCost(a.id))}</span><span class="rc-why">${i === 0 ? 'can run the DNS tools; cheapest that can' : a.can.includes('task:lookup') ? 'can do it, costs more' : 'cannot run lookups'}</span></li>`)}</ol>++<ol>${shown.map((a, i) => html`<li class="${i === 0 ? 'pick' : ''}"><span class="rc-name">${a.name}</span><span class="rc-cost">~${usd(catalog.typicalCost(a.id, config))}</span><span class="rc-why">${i === 0 ? 'can run the DNS tools; cheapest that can' : a.can.includes('task:lookup') ? 'can do it, costs more' : 'cannot run lookups'}</span></li>`)}</ol><p class="rc-foot">Checked by a second model before you see it.</p></figure>`;}@@@ -25,6 +25,7 @@* A person acting for themself needs no capability; an app, agent or service needs the one the route names.*/const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');++const { sameSecret } = require('../auth/local');const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -50,8 +51,14 @@ function sameOrigin(req, baseUrl) {}function createPrincipal({ config, keys }) {++ const sh = config.selfHosted || {};async function resolve(req) {const header = String(req.headers.authorization || '');++ // A self-hosted Actor (auth/local.js) has one owner and no Network: its API token is the only Bearer it takes.++ if (sh.on && header.startsWith('Bearer ')) {++ if (!sameSecret(header.slice(7).trim(), sh.ownerToken)) return { error: [401, 'token.invalid', 'this Actor takes its owner\'s token (ACTOR_OWNER_TOKEN) only'] };++ return { principal: { kind: 'user', requester: `user:${sh.ownerSubject}`, project: null, viaSession: false } };++ }if (header.startsWith('Bearer ')) {const token = header.slice(7).trim();const payload = decodePayload(token);@@@ -41,7 +41,8 @@ async function start() {console.log(`[Actor] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store}); agents available: ${available.join(', ') || 'none'}`);});server.keepAliveTimeout = 65_000;-- ctx.keys.client.start();++ // The Network's signing keys, for its tokens: a self-hosted Actor takes none.++ if (!config.selfHosted.on) ctx.keys.client.start();// Tasks older than the retention window (tasks/store.js RETENTION_DAYS) go, with their events.const prune = setInterval(() => { store.prune(ctx.s).catch((err) => console.warn('[Actor] prune failed:', err && err.message)); }, PRUNE_EVERY_MS);prune.unref();nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++/**++ * A self-hosted Actor (ACTOR_SELF_HOSTED=1, docs/self-host.md): it refuses to start without its owner and secrets;++ * the owner signs in with a password (a signed session cookie) and calls the API with their own token; it takes no++ * Network token; the OpenVibe runtime runs on the operator's own OpenAI-compatible model at the operator's price, and++ * with no other model family that model checks its own answers (saying so); the integrations that need a Network++ * identity (Bot, Codes, Chat) are off.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { hashPassword, checkPassword } = require('../server/auth/local');++const { startProviders } = require('./helpers/providers');++++const SUBJECT = 'usr_01JZ0000000000000000000SH1';++const PASSWORD = 'correct-horse-battery-staple';++const TOKEN = `act_${'t'.repeat(43)}`;++const SECRET = 's'.repeat(43);++++(async () => {++ await check('the password hash: scrypt, salted, checked in constant time', () => {++ const h = hashPassword(PASSWORD);++ assert.match(h, /^scrypt\$16384\$8\$1\$[A-Za-z0-9_-]{22}\$[A-Za-z0-9_-]{43}$/);++ assert.notStrictEqual(h, hashPassword(PASSWORD), 'a new salt each time');++ assert.ok(checkPassword(PASSWORD, h));++ assert.ok(!checkPassword('wrong', h) && !checkPassword(PASSWORD, 'scrypt$garbage') && !checkPassword('', h));++ });++++ await check('without its owner and secrets it does not start', async () => {++ await assert.rejects(boot({ env: { ACTOR_SELF_HOSTED: '1' } }), /ACTOR_SELF_HOSTED=1 needs ACTOR_OWNER_SUBJECT.*ACTOR_OWNER_PASSWORD_HASH.*ACTOR_OWNER_TOKEN.*ACTOR_SESSION_SECRET.*npm run selfhost:init/);++ });++++ const base = { ACTOR_SELF_HOSTED: '1', ACTOR_OWNER_NAME: 'kai', ACTOR_OWNER_SUBJECT: SUBJECT, ACTOR_OWNER_PASSWORD_HASH: hashPassword(PASSWORD), ACTOR_OWNER_TOKEN: TOKEN, ACTOR_SESSION_SECRET: SECRET };++ // The operator's own model: a stand-in OpenAI-compatible server of its own.++ const model = await startProviders();++ const runtimeUrl = model.env.ACTOR_DEEPSEEK_BASE_URL;++ const t = await boot({ env: { ...base, ACTOR_RUNTIME_URL: runtimeUrl, ACTOR_RUNTIME_MODEL: 'local-model', ACTOR_DEEPSEEK_API_KEY: null, ACTOR_OPENAI_API_KEY: null } });++++ await check('the owner signs in with the password; a wrong one is refused', async () => {++ const form = await t.get('/auth/login?next=/console');++ assert.strictEqual(form.status, 200);++ assert.match(form.text, /Sign in to your Actor/);++ assert.match(form.text, /name="next" value="\/console"/);++ const wrong = await t.get('/auth/login', { form: { password: 'nope', next: '/console' } });++ assert.strictEqual(wrong.status, 401);++ assert.ok(!wrong.headers.get('set-cookie'));++ const ok = await t.get('/auth/login', { form: { password: PASSWORD, next: '/console' } });++ assert.deepStrictEqual([ok.status, ok.headers.get('location')], [303, '/console']);++ const cookie = ok.headers.get('set-cookie').split(';')[0];++ assert.match(cookie, /^actor_local=\d{13}\.[A-Za-z0-9_-]{43}$/);++ const me = await t.get('/auth/me', { cookie });++ assert.deepStrictEqual(me.json().user, { username: 'kai', display_name: 'kai', subject_id: SUBJECT });++ const forged = await t.get('/auth/me', { cookie: `actor_local=9999999999999.${'A'.repeat(43)}` });++ assert.strictEqual(forged.json().user, null, 'a cookie not signed with the session secret is nobody');++ assert.strictEqual((await t.get('/console', { cookie })).status, 200);++ });++++ await check('the API takes the owner\'s token only: never a Network token, never a wrong one', async () => {++ const kim = t.network.addUser('kim');++ assert.strictEqual((await t.get('/api/v1/tasks', { bearer: t.network.userToken(kim) })).status, 401);++ assert.strictEqual((await t.get('/api/v1/tasks', { bearer: `${TOKEN}x` })).status, 401);++ assert.strictEqual((await t.get('/api/v1/tasks', { bearer: TOKEN })).status, 200);++ });++++ await check('a task runs on the operator\'s own model, checked by it (same family), at its price', async () => {++ model.reset();++ model.state.runtime = 'answer';++ const r = await t.get('/api/v1/tasks', { bearer: TOKEN, json: { task: 'Explain what a mutex is in two sentences.' } });++ assert.strictEqual(r.status, 201, r.text);++ const task = (await t.get(`/api/v1/tasks/${(await t.waitFor(r.json().id)).id}`, { bearer: TOKEN })).json();++ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));++ assert.deepStrictEqual([task.result.agent, task.result.model, task.result.checked.by, task.result.checked.cross_family], ['openvibe-runtime', 'stand-in', 'runtime', false], 'the model the server says it ran');++ assert.strictEqual(task.cost.usd, 0, 'a model on the operator\'s own hardware costs nothing');++ assert.strictEqual(task.requester.type, 'user');++ const calls = model.requests.filter((q) => q.path === '/deepseek/chat/completions');++ assert.ok(calls.length >= 2 && calls.every((q) => q.body.model === 'local-model'), 'the runtime and its check both went to the operator\'s model');++ });++++ await check('the agent list says so; the Network-bound integrations are off', async () => {++ const agents = (await t.get('/api/v1/agents', { bearer: TOKEN })).json().agents;++ const rt = agents.find((a) => a.id === 'openvibe-runtime');++ assert.deepStrictEqual([rt.available, rt.trust, rt.model, rt.rate_card.input_usd_per_mtok, rt.rate_card.output_usd_per_mtok, rt.rate_card.source], [true, 'user-owned', 'local-model', 0, 0, runtimeUrl]);++ assert.strictEqual(agents.find((a) => a.id === 'codes').available, false);++ assert.deepStrictEqual([t.config.services.botUrl, t.config.services.codesUrl, t.config.chat.asks], ['', '', false]);++ });++++ await t.close();++ await model.close();++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+node_modules
+data
+.env
+.env.*
+.git
+test
+docs
+*.md
@@ -42,6 +42,23 @@ ACTOR_OPENAI_API_KEY=
@@ -42,6 +42,23 @@ ACTOR_OPENAI_API_KEY=
# The open model on this server (private mode): an OpenAI-compatible server such as llama.cpp.
# The open model on this server (private mode): an OpenAI-compatible server such as llama.cpp.
ACTOR_LOCAL_LLM_URL=
ACTOR_LOCAL_LLM_URL=
ACTOR_LOCAL_LLM_MODEL=
ACTOR_LOCAL_LLM_MODEL=
+
+# The runtime on any OpenAI-compatible model instead of DeepSeek (a self-hosted Actor's usual choice; docs/self-host.md):
+# its endpoint, model, key (if any) and price per million tokens (0 for your own hardware).
+ACTOR_RUNTIME_URL=
+ACTOR_RUNTIME_MODEL=
+ACTOR_RUNTIME_API_KEY=
+ACTOR_RUNTIME_INPUT_USD_PER_MTOK=0
+ACTOR_RUNTIME_OUTPUT_USD_PER_MTOK=0
+
+# A self-hosted Actor (docs/self-host.md): one owner, no OpenVibe.Network. `npm run selfhost:init` writes these.
+# ACTOR_SELF_HOSTED=1
+# ACTOR_OWNER_NAME=
+# ACTOR_OWNER_SUBJECT=
+# ACTOR_OWNER_PASSWORD_HASH=
+# ACTOR_OWNER_TOKEN=
+# ACTOR_SESSION_SECRET=
+# DATA_DIR=
# The OpenVibe services the runtime acts through.
# The OpenVibe services the runtime acts through.
# ACTOR_SEARCH_URL=https://search.openvibe.network
# ACTOR_SEARCH_URL=https://search.openvibe.network
# ACTOR_TOOLS_URL=https://openvibe.tools
# ACTOR_TOOLS_URL=https://openvibe.tools
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+# A self-hosted OpenVibe.Actor (docs/self-host.md): one process, PGlite in /data unless DATABASE_URL is set.
+# npm run selfhost:init -- --name you --url http://localhost:4950 (writes .env.selfhost)
+# docker compose -f docker-compose.selfhost.yml up -d
+FROM node:22-bookworm-slim
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
+COPY server ./server
+COPY public ./public
+COPY migrations ./migrations
+COPY scripts ./scripts
+COPY STATUS.json ./
+RUN mkdir -p /data && chown node:node /data
+ENV NODE_ENV=production HOST=0.0.0.0 PORT=4950 DATA_DIR=/data ACTOR_SELF_HOSTED=1
+VOLUME /data
+EXPOSE 4950
+USER node
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD node -e "fetch('http://127.0.0.1:4950/api/health').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"
+CMD ["node", "server/index.js"]
@@ -148,6 +148,17 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
@@ -148,6 +148,17 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
4. Personal agents v1 are in this worktree: named instructions, memory people control, hourly/daily/weekly schedules and ordinary task runs. An approval inbox (a coding task an agent wants to start waits for its owner) and Watch triggers (a watch hands its
4. Personal agents v1 are in this worktree: named instructions, memory people control, hourly/daily/weekly schedules and ordinary task runs. An approval inbox (a coding task an agent wants to start waits for its owner) and Watch triggers (a watch hands its
task to an agent when its condition fires) are built, and so is asking Actor from chat (`@actor …`). Frame access comes later.
task to an agent when its condition fires) are built, and so is asking Actor from chat (`@actor …`). Frame access comes later.
+## Run your own
+
+A self-hosted Actor ([docs/self-host.md](docs/self-host.md)): `npm run selfhost:init`, then
+`docker compose -f docker-compose.selfhost.yml --profile ollama up -d`.
+- One owner signs in with a password, and the API takes their own token.
+- One process, with its database on disk (PGlite) unless `DATABASE_URL` is set.
+- The OpenVibe runtime runs on any OpenAI-compatible model (`ACTOR_RUNTIME_URL`), at the operator's price, listed as
+ user-owned. With no other model family, it checks its own answers and says so.
+- OpenVibe's public Search and Tools are used anonymously, or not at all.
+- The parts that need an OpenVibe service identity are off: Bot, Codes, chat asks and Network-signed webhooks.
+
## Configuration
## Configuration
See [.env.example](.env.example). Required in production:
See [.env.example](.env.example). Required in production:
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+# A self-hosted OpenVibe.Actor with a local model (docs/self-host.md).
+# npm run selfhost:init -- --name you --url http://localhost:4950
+# docker compose -f docker-compose.selfhost.yml --profile ollama up -d
+# docker compose -f docker-compose.selfhost.yml exec ollama ollama pull qwen2.5:7b-instruct
+# Without the ollama profile, point ACTOR_RUNTIME_URL in .env.selfhost at a model server you already run.
+services:
+ actor:
+ build: .
+ env_file: .env.selfhost
+ ports:
+ - "127.0.0.1:4950:4950"
+ volumes:
+ - actor-data:/data
+ restart: unless-stopped
+ ollama:
+ image: ollama/ollama
+ profiles: ["ollama"]
+ volumes:
+ - ollama-models:/root/.ollama
+ restart: unless-stopped
+volumes:
+ actor-data: {}
+ ollama-models: {}
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+# Run your own Actor
+
+OpenVibe.Actor runs on your own server too: your tasks, your model, your data. A self-hosted Actor has one owner
+(you), signs you in with a password, keeps everything in one process with its database on disk, and runs the
+OpenVibe runtime on any OpenAI-compatible model you point it at: Ollama, llama.cpp, vLLM, or a provider you pay. It can
+still use OpenVibe's public Search and Tools anonymously, or stay fully offline.
+
+## Five minutes with Docker
+
+```sh
+git clone https://github.com/OpenVibers/OpenVibe.Actor && cd OpenVibe.Actor
+npm ci --omit=dev
+npm run selfhost:init -- --name you --url http://localhost:4950
+docker compose -f docker-compose.selfhost.yml --profile ollama up -d
+docker compose -f docker-compose.selfhost.yml exec ollama ollama pull qwen2.5:7b-instruct
+```
+
+Open http://localhost:4950/auth/login and sign in with the password `selfhost:init` printed. It is shown only once.
+Then give Actor a task on `/console`, or call the API with your token:
+
+```sh
+curl -s http://localhost:4950/api/v1/tasks -H "Authorization: Bearer $ACTOR_OWNER_TOKEN" \
+ -H 'Content-Type: application/json' -d '{"task": "Explain what a mutex is in two sentences."}'
+```
+
+Without Docker: `npm ci --omit=dev`, then `node --env-file=.env.selfhost server/index.js`. Change `DATA_DIR` to a folder
+you own first.
+
+## What `selfhost:init` writes
+
+`.env.selfhost`, mode 600, never overwritten. Keep it private.
+
+| Setting | What it is |
+|---|---|
+| `ACTOR_SELF_HOSTED=1` | the self-hosted mode |
+| `ACTOR_OWNER_NAME`, `ACTOR_OWNER_SUBJECT` | you; the subject is a `usr_…` id your tasks are filed under |
+| `ACTOR_OWNER_PASSWORD_HASH` | your password, scrypt-hashed (set `ACTOR_INIT_PASSWORD` to choose it) |
+| `ACTOR_OWNER_TOKEN` | your API token (`Authorization: Bearer …`) |
+| `ACTOR_SESSION_SECRET` | signs your sign-in cookie (30 days) |
+| `BASE_URL`, `COOKIE_SECURE` | the address you open; secure cookies when it is https |
+| `DATA_DIR` | where the database lives (PGlite in `DATA_DIR/pglite`); set `DATABASE_URL` and `DATABASE_DIRECT_URL` for PostgreSQL instead |
+| `ACTOR_RUNTIME_URL`, `ACTOR_RUNTIME_MODEL`, `ACTOR_RUNTIME_API_KEY` | the runtime's model: any OpenAI-compatible `/v1` endpoint |
+| `ACTOR_RUNTIME_INPUT_USD_PER_MTOK`, `…_OUTPUT_…` | its price per million tokens (0 on your own hardware), used for budgets and costs |
+| `ACTOR_SEARCH_URL`, `ACTOR_TOOLS_URL` | OpenVibe's public Search and Tools, used anonymously; empty means the runtime is not offered them |
+| `ACTOR_DEEPSEEK_API_KEY`, `ACTOR_OPENAI_API_KEY` | optional: more agents, and another model family to check answers |
+| `ACTOR_FREE_*`, `ACTOR_MAX_COST_USD_PER_DAY` | your own daily limits |
+
+## How it differs from openvibe.actor
+
+- **Sign-in.** One owner and a password, not OpenVibe.Network. The API takes only your token; OpenVibe account tokens are
+ refused.
+- **Models.** The OpenVibe runtime runs on your model and is listed as user-owned, at your price.
+ - **Checking.** Every answer is checked before you see it. With only your model, it checks its own answers, and the
+ task says it was a same-family check. Add an OpenAI or DeepSeek key for a check from another family.
+ - **Tools.** The runtime acts through OpenVibe Search and Tools when those URLs are set. A small model may use tools
+ less well than DeepSeek; pick one that supports tool calls (Qwen 2.5 7B and up, Llama 3.1 8B and up).
+- **What is off.** Your robots and computers on OpenVibe.Bot, coding on OpenVibe.Codes, asking from OpenVibe chat, task
+ webhooks signed by Network, and account export through Network all need an OpenVibe service identity.
+- **Data.** Tasks, their logs and your daily spend stay in your database. Nothing is sent to OpenVibe except the
+ Search and Tools calls the runtime makes, when you leave those URLs set.
+
+## Updating
+
+`git pull`, then `docker compose -f docker-compose.selfhost.yml up -d --build`. Migrations apply at start, and your data
+stays in the `actor-data` volume.
+
+## Security notes
+
+- **Exposure.** It listens on 127.0.0.1:4950 in the compose file. Put it behind your own TLS proxy before exposing it,
+ and set `BASE_URL` to the https address so cookies are secure.
+- **Secrets.** The password is stored only as a scrypt hash. The session cookie is HMAC-signed and httpOnly. The API
+ token is compared in constant time.
+- **Limits.** Sign-in attempts are limited (60 per 15 minutes per address).
@@ -8,7 +8,8 @@
@@ -8,7 +8,8 @@
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js",
"test": "node test/run.js",
- "test:pg": "ACTOR_TEST_STORE=pg node test/run.js"
+ "test:pg": "ACTOR_TEST_STORE=pg node test/run.js",
+ "selfhost:init": "node scripts/selfhost-init.js"
},
},
"keywords": [
"keywords": [
"openvibe",
"openvibe",
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Set up a self-hosted Actor (docs/self-host.md): writes .env.selfhost (mode 600) with the owner, their password's
+ * hash, their API token, the session secret and the model settings, and prints the password and the token once.
+ * It never overwrites an existing file.
+ *
+ * npm run selfhost:init -- [--name alex] [--url http://localhost:4950] [--runtime-url http://ollama:11434/v1]
+ * [--model qwen2.5:7b-instruct] [--out .env.selfhost]
+ *
+ * ACTOR_INIT_PASSWORD in the environment sets the password instead of a generated one (it is then not printed).
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { ids } = require('openvibe-contracts');
+const { hashPassword } = require('../server/auth/local');
+
+function args(argv) {
+ const out = {};
+ for (let i = 0; i < argv.length; i++) {
+ const m = /^--([a-z-]+)$/.exec(argv[i]);
+ if (!m) throw new Error(`unknown argument ${argv[i]}`);
+ out[m[1]] = argv[++i];
+ if (out[m[1]] === undefined) throw new Error(`--${m[1]} needs a value`);
+ }
+ return out;
+}
+
+function main() {
+ const a = args(process.argv.slice(2));
+ const file = path.resolve(a.out || '.env.selfhost');
+ if (fs.existsSync(file)) throw new Error(`${file} exists: nothing written (delete it first to start over)`);
+ const name = String(a.name || 'owner').replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 64) || 'owner';
+ const url = String(a.url || 'http://localhost:4950').replace(/\/+$/, '');
+ if (!/^https?:\/\/[^/\s]+$/.test(url)) throw new Error('--url is the address people open, like https://actor.example.org');
+ const given = process.env.ACTOR_INIT_PASSWORD || '';
+ const password = given || crypto.randomBytes(15).toString('base64url');
+ const token = `act_${crypto.randomBytes(32).toString('base64url')}`;
+ const lines = [
+ '# A self-hosted OpenVibe.Actor (docs/self-host.md). Keep this file private: it holds the owner\'s secrets.',
+ 'ACTOR_SELF_HOSTED=1',
+ 'NODE_ENV=production',
+ 'HOST=0.0.0.0',
+ 'PORT=4950',
+ `BASE_URL=${url}`,
+ `COOKIE_SECURE=${url.startsWith('https://') ? 'true' : 'false'}`,
+ 'TRUST_PROXY=1',
+ 'DATA_DIR=/data',
+ `ACTOR_OWNER_NAME=${name}`,
+ `ACTOR_OWNER_SUBJECT=usr_${ids.ulid(Date.now())}`,
+ `ACTOR_OWNER_PASSWORD_HASH=${hashPassword(password)}`,
+ `ACTOR_OWNER_TOKEN=${token}`,
+ `ACTOR_SESSION_SECRET=${crypto.randomBytes(32).toString('base64url')}`,
+ '# The runtime\'s model: any OpenAI-compatible endpoint (Ollama, llama.cpp, vLLM, a provider). Prices are per',
+ '# million tokens, 0 for your own hardware.',
+ `ACTOR_RUNTIME_URL=${String(a['runtime-url'] || 'http://ollama:11434/v1').replace(/\/+$/, '')}`,
+ `ACTOR_RUNTIME_MODEL=${a.model || 'qwen2.5:7b-instruct'}`,
+ 'ACTOR_RUNTIME_API_KEY=',
+ 'ACTOR_RUNTIME_INPUT_USD_PER_MTOK=0',
+ 'ACTOR_RUNTIME_OUTPUT_USD_PER_MTOK=0',
+ '# OpenVibe\'s public Search and Tools APIs, used anonymously; leave both empty to stay fully offline.',
+ 'ACTOR_SEARCH_URL=https://search.openvibe.network',
+ 'ACTOR_TOOLS_URL=https://openvibe.tools',
+ '# Optional: other agents (their keys are yours; each adds its own family to check answers with).',
+ 'ACTOR_DEEPSEEK_API_KEY=',
+ 'ACTOR_OPENAI_API_KEY=',
+ '# The free tier and the daily ceiling still apply to the owner (USD; 0-cost models never reach them).',
+ 'ACTOR_FREE_TASKS_PER_DAY=500',
+ 'ACTOR_FREE_PER_TASK_USD=0.5',
+ 'ACTOR_FREE_PER_DAY_USD=5',
+ 'ACTOR_MAX_COST_USD_PER_DAY=5',
+ '',
+ ];
+ fs.writeFileSync(file, lines.join('\n'), { mode: 0o600, flag: 'wx' });
+ console.log(`Wrote ${file} (mode 600).`);
+ console.log(`Sign in at ${url}/auth/login as ${name}.`);
+ if (!given) console.log(`Password (shown once): ${password}`);
+ console.log(`API token (shown once; Authorization: Bearer …): ${token}`);
+}
+
+try { main(); } catch (err) { console.error(err.message); process.exitCode = 1; }
@@ -50,8 +50,14 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -50,8 +50,14 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
const openaiAgent = p.openai.apiKey ? responsesAgent({ baseUrl: p.openai.baseUrl, apiKey: p.openai.apiKey, model: p.openai.model, rates: rates.openai, fetchImpl }) : null;
const openaiAgent = p.openai.apiKey ? responsesAgent({ baseUrl: p.openai.baseUrl, apiKey: p.openai.apiKey, model: p.openai.model, rates: rates.openai, fetchImpl }) : null;
const local = p.local.url && p.local.model ? chatClient({ baseUrl: p.local.url, apiKey: '', model: p.local.model, rates: rates.local, fetchImpl, timeoutMs: 120_000 }) : null;
const local = p.local.url && p.local.model ? chatClient({ baseUrl: p.local.url, apiKey: '', model: p.local.model, rates: rates.local, fetchImpl, timeoutMs: 120_000 }) : null;
+ // The runtime's model: the operator's own (ACTOR_RUNTIME_URL, catalog.runtimeOverride) when set, else DeepSeek's.
+ const own = p.runtime && p.runtime.url && p.runtime.model ? p.runtime : null;
+ const ownRates = own ? { input_usd_per_mtok: own.inputUsdPerMtok, cached_usd_per_mtok: own.inputUsdPerMtok, output_usd_per_mtok: own.outputUsdPerMtok } : null;
+ const runtimeModel = own ? chatClient({ baseUrl: own.url, apiKey: own.apiKey, model: own.model, rates: ownRates, fetchImpl, timeoutMs: 180_000 }) : deepseek;
+ const runtimeModelName = own ? own.model : p.deepseek.model;
+
async function runtime({ task, requester = null, signal, emit, meter, budgetLeft }) {
async function runtime({ task, requester = null, signal, emit, meter, budgetLeft }) {
- if (!deepseek) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });
+ if (!runtimeModel) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });
const tools = await ovTools.definitions({ signal, requester });
const tools = await ovTools.definitions({ signal, requester });
const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');
const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');
const computers = tools.some((d) => d.function && d.function.name === 'computer_run');
const computers = tools.some((d) => d.function && d.function.name === 'computer_run');
@@ -62,7 +68,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -62,7 +68,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
if (budgetLeft() <= 0) throw new ProviderError('the task reached its budget before the runtime finished', { code: 'actor.budget.exceeded' });
if (budgetLeft() <= 0) throw new ProviderError('the task reached its budget before the runtime finished', { code: 'actor.budget.exceeded' });
const finalTurn = turn === MAX_TURNS;
const finalTurn = turn === MAX_TURNS;
// The model reasons before it answers, inside the same token budget: room for both.
// The model reasons before it answers, inside the same token budget: room for both.
- const r = await deepseek({ messages, tools: finalTurn ? null : tools, maxTokens: 4000, signal });
+ const r = await runtimeModel({ messages, tools: finalTurn ? null : tools, maxTokens: 4000, signal });
await meter(r.cost_usd);
await meter(r.cost_usd);
messages.push(r.message);
messages.push(r.message);
if (r.text && r.text.trim()) { last = r.text.trim(); }
if (r.text && r.text.trim()) { last = r.text.trim(); }
@@ -81,7 +87,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -81,7 +87,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
}
}
}
}
if (!last) throw new ProviderError('the runtime used all its turns without an answer', { code: 'actor.agent.no_answer' });
if (!last) throw new ProviderError('the runtime used all its turns without an answer', { code: 'actor.agent.no_answer' });
- return { text: last, sources: [], model: p.deepseek.model, evidence };
+ return { text: last, sources: [], model: runtimeModelName, evidence };
}
}
async function openai({ task, mode, signal, emit, meter, budgetLeft }) {
async function openai({ task, mode, signal, emit, meter, budgetLeft }) {
@@ -113,6 +119,9 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -113,6 +119,9 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
// An answer backed by tool results: compared against them by a model that does it reliably (agents/check.js).
// An answer backed by tool results: compared against them by a model that does it reliably (agents/check.js).
openaiGrounded: p.openai.apiKey ? chatClient({ baseUrl: p.openai.baseUrl, apiKey: p.openai.apiKey, model: p.openai.groundedCheckModel || 'gpt-5-mini', rates: rates.openaiGroundedCheck, fetchImpl }) : null,
openaiGrounded: p.openai.apiKey ? chatClient({ baseUrl: p.openai.baseUrl, apiKey: p.openai.apiKey, model: p.openai.groundedCheckModel || 'gpt-5-mini', rates: rates.openaiGroundedCheck, fetchImpl }) : null,
local,
local,
+ // The operator's own runtime model checks the runtime's answers when no other family is configured
+ // (agents/check.js: same family, so it says so).
+ runtime: own ? chatClient({ baseUrl: own.url, apiKey: own.apiKey, model: own.model, rates: ownRates, fetchImpl, timeoutMs: 120_000 }) : null,
};
};
return { run, checkers, tools: ovTools };
return { run, checkers, tools: ovTools };
@@ -82,9 +82,30 @@ const AGENTS = [
@@ -82,9 +82,30 @@ const AGENTS = [
},
},
];
];
+/** The runtime's own model when ACTOR_RUNTIME_URL names one (config.providers.runtime), else null (DeepSeek's). */
+function runtimeOverride(config) {
+ const r = config && config.providers && config.providers.runtime;
+ return r && r.url && r.model ? r : null;
+}
+
+/**
+ * The agent as this server runs it: the OpenVibe runtime on an operator's own model has that model's price (its
+ * endpoint is the rate card's source) and is user-owned; every other agent is as listed.
+ */
+function effective(agent, config) {
+ const r = agent.id === 'openvibe-runtime' ? runtimeOverride(config) : null;
+ if (!r) return agent;
+ return {
+ ...agent, trust: 'user-owned',
+ rate_card: { input_usd_per_mtok: r.inputUsdPerMtok, output_usd_per_mtok: r.outputUsdPerMtok, source: r.url, verified_at: VERIFIED },
+ blurb: `OpenVibe's own agent on this server's model (${r.model}): it acts through OpenVibe services — search across the network and the tools — and the task's text goes only to that model.`,
+ };
+}
+
/** Is the agent usable with this configuration? { available, reason }. */
/** Is the agent usable with this configuration? { available, reason }. */
function availability(agent, config) {
function availability(agent, config) {
const p = config.providers || {};
const p = config.providers || {};
+ if (agent.id === 'openvibe-runtime' && runtimeOverride(config)) return { available: true };
if (agent.providerKey === 'deepseek') return p.deepseek && p.deepseek.apiKey ? { available: true } : { available: false, reason: 'no DeepSeek key is configured on this server' };
if (agent.providerKey === 'deepseek') return p.deepseek && p.deepseek.apiKey ? { available: true } : { available: false, reason: 'no DeepSeek key is configured on this server' };
if (agent.providerKey === 'openai') return p.openai && p.openai.apiKey ? { available: true } : { available: false, reason: 'no OpenAI key is configured on this server' };
if (agent.providerKey === 'openai') return p.openai && p.openai.apiKey ? { available: true } : { available: false, reason: 'no OpenAI key is configured on this server' };
if (agent.providerKey === 'local') return p.local && p.local.url && p.local.model ? { available: true } : { available: false, reason: 'no open model is running on this server' };
if (agent.providerKey === 'local') return p.local && p.local.url && p.local.model ? { available: true } : { available: false, reason: 'no open model is running on this server' };
@@ -97,13 +118,15 @@ function availability(agent, config) {
@@ -97,13 +118,15 @@ function availability(agent, config) {
/** The model an agent runs with this configuration (for the listing and the task's explanation). */
/** The model an agent runs with this configuration (for the listing and the task's explanation). */
function modelOf(agent, config) {
function modelOf(agent, config) {
+ if (agent.id === 'openvibe-runtime' && runtimeOverride(config)) return runtimeOverride(config).model;
const p = (config.providers || {})[agent.providerKey];
const p = (config.providers || {})[agent.providerKey];
return p && p.model ? p.model : undefined;
return p && p.model ? p.model : undefined;
}
}
/** What one task of this agent typically costs, at its rate card (USD). */
/** What one task of this agent typically costs, at its rate card (USD). */
-function typicalCost(agentId) {
- const a = AGENTS.find((x) => x.id === agentId);
+function typicalCost(agentId, config = null) {
+ const found = AGENTS.find((x) => x.id === agentId);
+ const a = found && config ? effective(found, config) : found;
const t = TYPICAL[agentId];
const t = TYPICAL[agentId];
if (!a || !t) return 0;
if (!a || !t) return 0;
const r = a.rate_card;
const r = a.rate_card;
@@ -112,7 +135,8 @@ function typicalCost(agentId) {
@@ -112,7 +135,8 @@ function typicalCost(agentId) {
/** GET /api/v1/agents: actor.agent-list-result@1. */
/** GET /api/v1/agents: actor.agent-list-result@1. */
function list(config) {
function list(config) {
- return AGENTS.map((a) => {
+ return AGENTS.map((listed) => {
+ const a = effective(listed, config);
const av = availability(a, config);
const av = availability(a, config);
const row = { id: a.id, name: a.name, kind: a.kind, provider: a.provider, trust: a.trust, can: [...a.can], rate_card: { ...a.rate_card }, typical_latency_ms: TYPICAL[a.id].latencyMs, available: av.available };
const row = { id: a.id, name: a.name, kind: a.kind, provider: a.provider, trust: a.trust, can: [...a.can], rate_card: { ...a.rate_card }, typical_latency_ms: TYPICAL[a.id].latencyMs, available: av.available };
const model = modelOf(a, config);
const model = modelOf(a, config);
@@ -126,7 +150,8 @@ function list(config) {
@@ -126,7 +150,8 @@ function list(config) {
* One platform.resource-offer@1 per agent for openvibe-sdk/placement. `quality:<tier>` is an offer capability so
* One platform.resource-offer@1 per agent for openvibe-sdk/placement. `quality:<tier>` is an offer capability so
* best mode can require it; an unavailable agent is health down with its reason kept for the explanation.
* best mode can require it; an unavailable agent is health down with its reason kept for the explanation.
*/
*/
-function toOffer(agent, config, { now = Date.now(), measured = {} } = {}) {
+function toOffer(listed, config, { now = Date.now(), measured = {} } = {}) {
+ const agent = effective(listed, config);
const av = availability(agent, config);
const av = availability(agent, config);
const t = TYPICAL[agent.id];
const t = TYPICAL[agent.id];
const quality = agent.quality === 'high' ? ['quality:high', 'quality:standard'] : agent.quality === 'standard' ? ['quality:standard'] : [];
const quality = agent.quality === 'high' ? ['quality:high', 'quality:standard'] : agent.quality === 'standard' ? ['quality:standard'] : [];
@@ -139,7 +164,7 @@ function toOffer(agent, config, { now = Date.now(), measured = {} } = {}) {
@@ -139,7 +164,7 @@ function toOffer(agent, config, { now = Date.now(), measured = {} } = {}) {
capabilities: [...agent.can, ...quality],
capabilities: [...agent.can, ...quality],
capacity: {},
capacity: {},
health: av.available ? { status: 'up' } : { status: 'down', reason: av.reason },
health: av.available ? { status: 'up' } : { status: 'down', reason: av.reason },
- pricing: { model: 'per-operation', unit: 'task', marginal_usd_per_unit: typicalCost(agent.id) },
+ pricing: { model: 'per-operation', unit: 'task', marginal_usd_per_unit: typicalCost(agent.id, config) },
latency_ms: { run_p95: measured[agent.id] || t.latencyMs },
latency_ms: { run_p95: measured[agent.id] || t.latencyMs },
updated_at: new Date(now).toISOString(),
updated_at: new Date(now).toISOString(),
};
};
@@ -159,4 +184,4 @@ const RATES = {
@@ -159,4 +184,4 @@ const RATES = {
local: { input_usd_per_mtok: 0, output_usd_per_mtok: 0 },
local: { input_usd_per_mtok: 0, output_usd_per_mtok: 0 },
};
};
-module.exports = { AGENTS, TYPICAL, RATES, availability, list, toOffer, typicalCost, modelOf, byId, VERIFIED };
+module.exports = { AGENTS, TYPICAL, RATES, availability, list, toOffer, typicalCost, modelOf, byId, effective, runtimeOverride, VERIFIED };
@@ -13,7 +13,8 @@
@@ -13,7 +13,8 @@
// codes: the person's own machine wrote it (any of Codes' agents), so either family reads it.
// codes: the person's own machine wrote it (any of Codes' agents), so either family reads it.
const FAMILY = { 'openvibe-runtime': 'deepseek', 'openai-agent': 'openai', 'open-model': 'local', codes: 'codes' };
const FAMILY = { 'openvibe-runtime': 'deepseek', 'openai-agent': 'openai', 'open-model': 'local', codes: 'codes' };
-const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'openai'], local: ['local'], codes: ['deepseek', 'openai'] };
+// `runtime`: the OpenVibe runtime on the operator's own model (ACTOR_RUNTIME_URL) — another family first, else itself.
+const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'openai'], local: ['local'], codes: ['deepseek', 'openai'], runtime: ['openai', 'deepseek', 'runtime'] };
const SYSTEM = `You check an AI agent's answer before a person sees it. Reply with JSON only: {"ok": true|false, "reason": "<one short sentence>"}.
const SYSTEM = `You check an AI agent's answer before a person sees it. Reply with JSON only: {"ok": true|false, "reason": "<one short sentence>"}.
Only your own reply is JSON. The answer is prose meant to be read by a person (often Markdown) and is never expected to be JSON; tool results are JSON because services return JSON.
Only your own reply is JSON. The answer is prose meant to be read by a person (often Markdown) and is never expected to be JSON; tool results are JSON because services return JSON.
@@ -33,7 +34,7 @@ When the task is a greeting, small talk, a test message or too vague to act on,
@@ -33,7 +34,7 @@ When the task is a greeting, small talk, a test message or too vague to act on,
* default) reads it when one is configured, so the check stays cross-family.
* default) reads it when one is configured, so the check stays cross-family.
*/
*/
function checkersFor(agentId, checkers, { mode, grounded = false }) {
function checkersFor(agentId, checkers, { mode, grounded = false }) {
- const family = FAMILY[agentId];
+ const family = agentId === 'openvibe-runtime' && checkers.runtime ? 'runtime' : FAMILY[agentId];
const order = mode === 'private' ? ['local'] : (CHECKER_FOR[family] || []);
const order = mode === 'private' ? ['local'] : (CHECKER_FOR[family] || []);
const pick = (f) => (grounded && f === 'openai' && checkers.openaiGrounded ? checkers.openaiGrounded : checkers[f]);
const pick = (f) => (grounded && f === 'openai' && checkers.openaiGrounded ? checkers.openaiGrounded : checkers[f]);
return order.filter((f) => pick(f)).map((f) => ({ family: f, chat: pick(f), cross: f !== family }));
return order.filter((f) => pick(f)).map((f) => ({ family: f, chat: pick(f), cross: f !== family }));
@@ -65,6 +65,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -65,6 +65,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
/** Tools' catalog: only tools with an API that run at once and that anyone may call. */
/** Tools' catalog: only tools with an API that run at once and that anyone may call. */
async function tools({ signal } = {}) {
async function tools({ signal } = {}) {
+ if (!toolsUrl) return [];
if (catalog && now() - catalogAt < CATALOG_TTL_MS) return catalog;
if (catalog && now() - catalogAt < CATALOG_TTL_MS) return catalog;
try {
try {
const r = await getJson(`${toolsUrl}/api/v1/tools`, { signal });
const r = await getJson(`${toolsUrl}/api/v1/tools`, { signal });
@@ -121,10 +122,11 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -121,10 +122,11 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
/** The function definitions the runtime's model sees, and the dispatcher that runs one call. */
/** The function definitions the runtime's model sees, and the dispatcher that runs one call. */
async function definitions({ signal, requester = null } = {}) {
async function definitions({ signal, requester = null } = {}) {
const list = await tools({ signal });
const list = await tools({ signal });
+ // A self-hosted Actor may leave OpenVibe.Search or OpenVibe.Tools unset (ACTOR_SEARCH_URL / ACTOR_TOOLS_URL empty):
+ // the runtime is then offered only the tools it can reach.
const defs = [
const defs = [
- { type: 'function', function: { name: 'search_openvibe', description: 'Search everything published on the OpenVibe network (streams, clips, pastes, community posts, wiki pages, blog posts, threads). Returns titles, summaries and links.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },
- { type: 'function', function: { name: 'find_tools', description: 'Find OpenVibe.Tools tools for a need (for example "mx records", "ssl certificate", "whois", "http headers", "base64", "hash"). Returns each tool id with its input schema and an example.', parameters: { type: 'object', properties: { need: { type: 'string' } }, required: ['need'] } } },
- { type: 'function', function: { name: 'run_tool', description: 'Run one OpenVibe.Tools tool by id with its input object (use find_tools first to see the input).', parameters: { type: 'object', properties: { tool: { type: 'string' }, input: { type: 'object' } }, required: ['tool', 'input'] } } },
+ ...(searchUrl ? [{ type: 'function', function: { name: 'search_openvibe', description: 'Search everything published on the OpenVibe network (streams, clips, pastes, community posts, wiki pages, blog posts, threads). Returns titles, summaries and links.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } }] : []),
+ ...(toolsUrl ? [{ type: 'function', function: { name: 'find_tools', description: 'Find OpenVibe.Tools tools for a need (for example "mx records", "ssl certificate", "whois", "http headers", "base64", "hash"). Returns each tool id with its input schema and an example.', parameters: { type: 'object', properties: { need: { type: 'string' } }, required: ['need'] } } }, { type: 'function', function: { name: 'run_tool', description: 'Run one OpenVibe.Tools tool by id with its input object (use find_tools first to see the input).', parameters: { type: 'object', properties: { tool: { type: 'string' }, input: { type: 'object' } }, required: ['tool', 'input'] } } }] : []),
];
];
if (list.some((t) => t.id === 'read')) {
if (list.some((t) => t.id === 'read')) {
defs.push({ type: 'function', function: { name: 'read_page', description: 'Read a public web page as text (title, description, main text, links). Use it for URLs the task gives or that a result links to.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } });
defs.push({ type: 'function', function: { name: 'read_page', description: 'Read a public web page as text (title, description, main text, links). Use it for URLs the task gives or that a result links to.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } });
@@ -26,6 +26,19 @@ const { createAdapters } = require('./agents/adapters');
@@ -26,6 +26,19 @@ const { createAdapters } = require('./agents/adapters');
const { createEngine } = require('./tasks/engine');
const { createEngine } = require('./tasks/engine');
const { createWebhooks } = require('./tasks/webhooks');
const { createWebhooks } = require('./tasks/webhooks');
const { createChatClient } = require('./chat/client');
const { createChatClient } = require('./chat/client');
+const { createLocalSignIn } = require('./auth/local');
+const { ids: contractIds } = require('openvibe-contracts');
+
+/** A self-hosted Actor needs its owner and its secrets (scripts/selfhost-init.js writes them); it refuses to start without. */
+function checkSelfHosted(config) {
+ const sh = config.selfHosted;
+ const missing = [];
+ if (!contractIds.isSubjectId('user', sh.ownerSubject)) missing.push('ACTOR_OWNER_SUBJECT (a usr_… id)');
+ if (!/^scrypt\$/.test(sh.passwordHash)) missing.push('ACTOR_OWNER_PASSWORD_HASH');
+ if (String(sh.ownerToken).length < 32) missing.push('ACTOR_OWNER_TOKEN (32+ characters)');
+ if (String(sh.sessionSecret).length < 32) missing.push('ACTOR_SESSION_SECRET (32+ characters)');
+ if (missing.length) throw new Error(`ACTOR_SELF_HOSTED=1 needs ${missing.join(', ')}: run \`npm run selfhost:init\` (docs/self-host.md)`);
+}
const { createChatAsks } = require('./chat/asks');
const { createChatAsks } = require('./chat/asks');
const { createTask } = require('./tasks/create');
const { createTask } = require('./tasks/create');
const { createWebhookPoster } = require('./net/webhook-post');
const { createWebhookPoster } = require('./net/webhook-post');
@@ -59,7 +72,9 @@ async function createApp(opts = {}) {
@@ -59,7 +72,9 @@ async function createApp(opts = {}) {
const s = opts.store || await openStore(config, { now: opts.now, log });
const s = opts.store || await openStore(config, { now: opts.now, log });
const keys = createKeyStore({ config, fetchImpl, log });
const keys = createKeyStore({ config, fetchImpl, log });
- const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
+ // A self-hosted Actor signs its one owner in itself (auth/local.js); otherwise OpenVibe.Network does (auth/sso.js).
+ if (config.selfHosted.on) checkSelfHosted(config);
+ const sso = config.selfHosted.on ? createLocalSignIn({ config, now: s.now }) : createSso({ config, keys, fetchImpl, now: s.now, log });
const stream = createTaskStream({ log });
const stream = createTaskStream({ log });
const adapters = opts.adapters || createAdapters({ config, rates: catalog.RATES, fetchImpl: opts.providerFetch || globalThis.fetch, log });
const adapters = opts.adapters || createAdapters({ config, rates: catalog.RATES, fetchImpl: opts.providerFetch || globalThis.fetch, log });
// Task webhooks (plan T17): the only connections to an address a caller chose, through server/net/webhook-post.js.
// Task webhooks (plan T17): the only connections to an address a caller chose, through server/net/webhook-post.js.
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+
+/**
+ * A self-hosted Actor's own sign-in (ACTOR_SELF_HOSTED=1, docs/self-host.md): one owner, no OpenVibe.Network. The same
+ * shape as auth/sso.js, so the rest of the site does not tell them apart.
+ *
+ * GET /auth/login a password form (?next= a same-site path)
+ * POST /auth/login the owner's password → a signed session cookie (actor_local, httpOnly, 30 days)
+ * GET /auth/logout clears it
+ * GET /auth/me { user } for the navbar; { user: null } for a guest
+ *
+ * The cookie is "<expiry ms>.<HMAC-SHA256 under ACTOR_SESSION_SECRET>"; the password is checked against
+ * ACTOR_OWNER_PASSWORD_HASH ("scrypt$<N>$<r>$<p>$<salt>$<hash>", base64url; scripts/selfhost-init.js makes it). The
+ * owner's API token (ACTOR_OWNER_TOKEN, a Bearer) is checked in http/principal.js. Neither is ever logged or rendered.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+const { sanitizeNext } = require('./sso');
+
+const COOKIE = 'actor_local';
+const SESSION_MS = 30 * 24 * 3600 * 1000;
+
+/** "scrypt$N$r$p$salt$hash" for a password (base64url salt and hash): what ACTOR_OWNER_PASSWORD_HASH holds. */
+function hashPassword(password, { N = 16384, r = 8, p = 1 } = {}) {
+ const salt = crypto.randomBytes(16);
+ const hash = crypto.scryptSync(String(password), salt, 32, { N, r, p, maxmem: 64 * 1024 * 1024 });
+ return `scrypt$${N}$${r}$${p}$${salt.toString('base64url')}$${hash.toString('base64url')}`;
+}
+
+/** Does `password` match the stored hash? Constant time; a malformed hash matches nothing. */
+function checkPassword(password, stored) {
+ const m = /^scrypt\$(\d+)\$(\d+)\$(\d+)\$([A-Za-z0-9_-]+)\$([A-Za-z0-9_-]+)$/.exec(String(stored || ''));
+ if (!m || typeof password !== 'string' || !password || password.length > 1024) return false;
+ const [N, r, p] = [Number(m[1]), Number(m[2]), Number(m[3])];
+ const want = Buffer.from(m[5], 'base64url');
+ let got;
+ try { got = crypto.scryptSync(password, Buffer.from(m[4], 'base64url'), want.length, { N, r, p, maxmem: 64 * 1024 * 1024 }); } catch { return false; }
+ return got.length === want.length && crypto.timingSafeEqual(got, want);
+}
+
+/** Constant-time string equality through SHA-256 (lengths never leak). */
+function sameSecret(a, b) {
+ if (typeof a !== 'string' || typeof b !== 'string' || !a || !b) return false;
+ const ha = crypto.createHash('sha256').update(a).digest();
+ const hb = crypto.createHash('sha256').update(b).digest();
+ return crypto.timingSafeEqual(ha, hb);
+}
+
+function createLocalSignIn({ config, now = () => Date.now() }) {
+ const sh = config.selfHosted;
+ const mac = (value) => crypto.createHmac('sha256', sh.sessionSecret).update(`actor-local-session:${value}`).digest('base64url');
+ const cookieOpts = () => ({ httpOnly: true, sameSite: 'lax', secure: config.cookies.secure, path: '/' });
+ const owner = () => ({ kind: 'user', subject: sh.ownerSubject, username: sh.ownerName, displayName: sh.ownerName, role: 'user', token: null });
+
+ function readSession(req) {
+ const raw = req.cookies && req.cookies[COOKIE];
+ const m = /^(\d{13})\.([A-Za-z0-9_-]{43})$/.exec(String(raw || ''));
+ if (!m || Number(m[1]) < now()) return false;
+ return sameSecret(mac(m[1]), m[2]);
+ }
+
+ function middleware() {
+ return (req, res, next) => {
+ req.viewer = readSession(req) ? owner() : { kind: 'anonymous' };
+ next();
+ };
+ }
+
+ /** Nothing here calls the Network as the viewer: a self-hosted owner has no Network token. */
+ async function asViewer(req, res, fn) { return fn(null); }
+
+ function page(res, status, { next = '/', error = null } = {}) {
+ res.status(status).set('Cache-Control', cache.htmlHeaders({ private: true })).type('html').send(`<!doctype html>
+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sign in · Actor</title>
+<meta name="robots" content="noindex"><style>body{font:16px/1.5 system-ui,sans-serif;background:#0b1220;color:#e6edf7;display:grid;place-items:center;min-height:100vh;margin:0}
+form{background:#111a2e;border:1px solid #23304d;border-radius:12px;padding:28px;width:min(360px,calc(100vw - 32px));box-sizing:border-box}
+h1{font-size:1.3rem;margin:0 0 4px}p{color:#96a7c2;margin:0 0 18px;font-size:.92rem}label{display:block;font-size:.9rem;margin-bottom:6px}
+input{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid #33456a;background:#0b1220;color:inherit;font:inherit}
+button{margin-top:14px;width:100%;padding:10px;border:0;border-radius:8px;background:#2563eb;color:#fff;font:600 1rem system-ui,sans-serif;cursor:pointer}
+.error{color:#fca5a5;margin:0 0 12px}</style></head><body>
+<form method="post" action="/auth/login"><h1>Sign in to your Actor</h1><p>This Actor runs on your own server.</p>
+${error ? `<p class="error" role="alert">${error}</p>` : ''}<input type="hidden" name="next" value="${String(next).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))}">
+<label for="pw">Password</label><input id="pw" name="password" type="password" autocomplete="current-password" required autofocus>
+<button type="submit">Sign in</button></form></body></html>`);
+ }
+
+ function routes() {
+ const r = express.Router();
+ r.get('/login', (req, res) => page(res, 200, { next: sanitizeNext(req.query.next) }));
+ r.post('/login', express.urlencoded({ extended: false, limit: '4kb' }), (req, res) => {
+ const next = sanitizeNext(req.body && req.body.next);
+ if (!checkPassword(req.body && req.body.password, sh.passwordHash)) return page(res, 401, { next, error: 'That is not the password.' });
+ const exp = String(now() + SESSION_MS);
+ res.cookie(COOKIE, `${exp}.${mac(exp)}`, { ...cookieOpts(), maxAge: SESSION_MS });
+ res.redirect(303, next);
+ });
+ r.get('/logout', (req, res) => { res.clearCookie(COOKIE, cookieOpts()); res.redirect(303, '/'); });
+ r.get('/me', (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ const v = req.viewer;
+ res.json({ user: v && v.kind === 'user' ? { username: v.username, display_name: v.displayName, subject_id: v.subject } : null });
+ });
+ return r;
+ }
+
+ return { middleware, routes, asViewer, local: true };
+}
+
+module.exports = { createLocalSignIn, hashPassword, checkPassword, sameSecret, COOKIE };
@@ -14,6 +14,9 @@ const num = (v, def) => (v != null && v !== '' && Number.isFinite(Number(v)) ? N
@@ -14,6 +14,9 @@ const num = (v, def) => (v != null && v !== '' && Number.isFinite(Number(v)) ? N
function load(env = process.env) {
function load(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const nodeEnv = env.NODE_ENV || 'development';
const isProduction = nodeEnv === 'production';
const isProduction = nodeEnv === 'production';
+ // A self-hosted Actor (docs/self-host.md): one owner signs in with a password, no OpenVibe.Network, PGlite unless
+ // DATABASE_URL is set; the integrations that need a Network service identity (Bot, Codes, Chat, Events) are off.
+ const selfHosted = env.ACTOR_SELF_HOSTED === '1';
const port = int(env.PORT, 4950);
const port = int(env.PORT, 4950);
const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.actor' : `http://localhost:${port}`));
const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.actor' : `http://localhost:${port}`));
const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
@@ -36,7 +39,15 @@ function load(env = process.env) {
@@ -36,7 +39,15 @@ function load(env = process.env) {
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In
// PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In
// development without DATABASE_URL an embedded PGlite database in data/pglite is used (ACTOR_PGLITE_DIR
// development without DATABASE_URL an embedded PGlite database in data/pglite is used (ACTOR_PGLITE_DIR
// overrides the directory).
// overrides the directory).
- db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.ACTOR_PGLITE_DIR || '' },
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.ACTOR_PGLITE_DIR || (selfHosted && env.DATA_DIR ? `${env.DATA_DIR.replace(/\/+$/, '')}/pglite` : '') },
+ selfHosted: {
+ on: selfHosted,
+ ownerName: String(env.ACTOR_OWNER_NAME || 'owner').slice(0, 64),
+ ownerSubject: env.ACTOR_OWNER_SUBJECT || '',
+ passwordHash: env.ACTOR_OWNER_PASSWORD_HASH || '',
+ ownerToken: env.ACTOR_OWNER_TOKEN || '',
+ sessionSecret: env.ACTOR_SESSION_SECRET || '',
+ },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:actor:' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:actor:' },
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
@@ -60,16 +71,23 @@ function load(env = process.env) {
@@ -60,16 +71,23 @@ function load(env = process.env) {
deepseek: { apiKey: env.ACTOR_DEEPSEEK_API_KEY || '', baseUrl: trim(env.ACTOR_DEEPSEEK_BASE_URL || 'https://api.deepseek.com'), model: env.ACTOR_DEEPSEEK_MODEL || 'deepseek-flash' },
deepseek: { apiKey: env.ACTOR_DEEPSEEK_API_KEY || '', baseUrl: trim(env.ACTOR_DEEPSEEK_BASE_URL || 'https://api.deepseek.com'), model: env.ACTOR_DEEPSEEK_MODEL || 'deepseek-flash' },
openai: { apiKey: env.ACTOR_OPENAI_API_KEY || '', baseUrl: trim(env.ACTOR_OPENAI_BASE_URL || 'https://api.openai.com/v1'), model: env.ACTOR_OPENAI_MODEL || 'gpt-5-mini', checkModel: env.ACTOR_OPENAI_CHECK_MODEL || 'gpt-5-nano', groundedCheckModel: env.ACTOR_OPENAI_GROUNDED_CHECK_MODEL || 'gpt-5-mini', visionModel: env.ACTOR_OPENAI_VISION_MODEL || '' },
openai: { apiKey: env.ACTOR_OPENAI_API_KEY || '', baseUrl: trim(env.ACTOR_OPENAI_BASE_URL || 'https://api.openai.com/v1'), model: env.ACTOR_OPENAI_MODEL || 'gpt-5-mini', checkModel: env.ACTOR_OPENAI_CHECK_MODEL || 'gpt-5-nano', groundedCheckModel: env.ACTOR_OPENAI_GROUNDED_CHECK_MODEL || 'gpt-5-mini', visionModel: env.ACTOR_OPENAI_VISION_MODEL || '' },
local: { url: trim(env.ACTOR_LOCAL_LLM_URL || ''), model: env.ACTOR_LOCAL_LLM_MODEL || '' },
local: { url: trim(env.ACTOR_LOCAL_LLM_URL || ''), model: env.ACTOR_LOCAL_LLM_MODEL || '' },
+ // The runtime's model on any OpenAI-compatible endpoint (Ollama, llama.cpp, vLLM, another provider) instead
+ // of DeepSeek: a self-hosted Actor's usual choice. Priced per million tokens at what the operator says
+ // (0 for a model on their own hardware); it also checks its own answers when no other family is configured.
+ runtime: {
+ url: trim(env.ACTOR_RUNTIME_URL || ''), model: env.ACTOR_RUNTIME_MODEL || '', apiKey: env.ACTOR_RUNTIME_API_KEY || '',
+ inputUsdPerMtok: Math.max(0, num(env.ACTOR_RUNTIME_INPUT_USD_PER_MTOK, 0)), outputUsdPerMtok: Math.max(0, num(env.ACTOR_RUNTIME_OUTPUT_USD_PER_MTOK, 0)),
+ },
},
},
// The OpenVibe services the runtime acts through.
// The OpenVibe services the runtime acts through.
services: {
services: {
searchUrl: trim(env.ACTOR_SEARCH_URL || 'https://search.openvibe.network'),
searchUrl: trim(env.ACTOR_SEARCH_URL || 'https://search.openvibe.network'),
toolsUrl: trim(env.ACTOR_TOOLS_URL || 'https://openvibe.tools'),
toolsUrl: trim(env.ACTOR_TOOLS_URL || 'https://openvibe.tools'),
// OpenVibe.Bot: the person's own robots (list_my_robots, robot_steps). Empty turns the robot tools off.
// OpenVibe.Bot: the person's own robots (list_my_robots, robot_steps). Empty turns the robot tools off.
- botUrl: trim(env.ACTOR_BOT_URL != null ? env.ACTOR_BOT_URL : 'http://127.0.0.1:4630'),
+ botUrl: trim(env.ACTOR_BOT_URL != null ? env.ACTOR_BOT_URL : (selfHosted ? '' : 'http://127.0.0.1:4630')),
botPublicUrl: trim(env.ACTOR_BOT_PUBLIC_URL || 'https://openvibe.bot'),
botPublicUrl: trim(env.ACTOR_BOT_PUBLIC_URL || 'https://openvibe.bot'),
// OpenVibe.Codes: the `codes` agent runs a person's coding task on their own machine. Empty turns it off.
// OpenVibe.Codes: the `codes` agent runs a person's coding task on their own machine. Empty turns it off.
- codesUrl: trim(env.ACTOR_CODES_URL != null ? env.ACTOR_CODES_URL : 'http://127.0.0.1:4900'),
+ codesUrl: trim(env.ACTOR_CODES_URL != null ? env.ACTOR_CODES_URL : (selfHosted ? '' : 'http://127.0.0.1:4900')),
codesPublicUrl: trim(env.ACTOR_CODES_PUBLIC_URL || 'https://openvibe.codes'),
codesPublicUrl: trim(env.ACTOR_CODES_PUBLIC_URL || 'https://openvibe.codes'),
},
},
@@ -99,7 +117,7 @@ function load(env = process.env) {
@@ -99,7 +117,7 @@ function load(env = process.env) {
// taken from chat.message.created and answered in the same room through Chat's service API (url, loopback in
// taken from chat.message.created and answered in the same room through Chat's service API (url, loopback in
// production). ACTOR_CHAT_ASKS=0 turns it off; the limits are per person and across everyone, per hour.
// production). ACTOR_CHAT_ASKS=0 turns it off; the limits are per person and across everyone, per hour.
chat: {
chat: {
- asks: env.ACTOR_CHAT_ASKS !== '0',
+ asks: env.ACTOR_CHAT_ASKS !== '0' && !selfHosted,
url: trim(env.ACTOR_CHAT_URL != null ? env.ACTOR_CHAT_URL : 'http://127.0.0.1:4400'),
url: trim(env.ACTOR_CHAT_URL != null ? env.ACTOR_CHAT_URL : 'http://127.0.0.1:4400'),
perHour: Math.max(1, int(env.ACTOR_CHAT_ASKS_PER_HOUR, 5)),
perHour: Math.max(1, int(env.ACTOR_CHAT_ASKS_PER_HOUR, 5)),
allPerHour: Math.max(1, int(env.ACTOR_CHAT_ASKS_ALL_PER_HOUR, 60)),
allPerHour: Math.max(1, int(env.ACTOR_CHAT_ASKS_ALL_PER_HOUR, 60)),
@@ -16,9 +16,11 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
@@ -16,9 +16,11 @@ const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
async function openDb(config, { log = console, registry } = {}) {
async function openDb(config, { log = console, registry } = {}) {
if (!config.db.url) {
if (!config.db.url) {
- if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh actor)');
+ const selfHosted = config.selfHosted && config.selfHosted.on;
+ if (config.isProduction && !selfHosted) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh actor)');
const dir = config.db.pgliteDir || DEV_PGLITE;
const dir = config.db.pgliteDir || DEV_PGLITE;
- log.warn(`[Actor] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ if (selfHosted) log.log(`[Actor] self-hosted: embedded PGlite database in ${dir} (one process; set DATABASE_URL for PostgreSQL)`);
+ else log.warn(`[Actor] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(dir, { recursive: true });
const db = createDb({ pglite: dir, service: 'actor', registry, log });
const db = createDb({ pglite: dir, service: 'actor', registry, log });
await db.migrate({ dir: MIGRATIONS, log });
await db.migrate({ dir: MIGRATIONS, log });
@@ -147,6 +147,7 @@ ${table(['Piece', 'State'], [
@@ -147,6 +147,7 @@ ${table(['Piece', 'State'], [
[html`Your own computers, through <a href="https://openvibe.bot/docs/computers">OpenVibe.Bot</a>: list and read files, run programs and write files there, as you, at the level you set on the computer`, html`Live: install OpenVibe Node with <code>--computer ask</code> and turn on "Let my agent use this computer" on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. At <code>ask</code>, you approve each run or write on the computer.`],
[html`Your own computers, through <a href="https://openvibe.bot/docs/computers">OpenVibe.Bot</a>: list and read files, run programs and write files there, as you, at the level you set on the computer`, html`Live: install OpenVibe Node with <code>--computer ask</code> and turn on "Let my agent use this computer" on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>. At <code>ask</code>, you approve each run or write on the computer.`],
[html`<a href="/console">The Console</a>: what runs now, what waits for you, today's spend, your agents, computers and robots, and one box to start a task`, 'Live, for signed-in people.'],
[html`<a href="/console">The Console</a>: what runs now, what waits for you, today's spend, your agents, computers and robots, and one box to start a task`, 'Live, for signed-in people.'],
[html`Ask in chat: write <code>@actor</code> and your question in OpenVibe's global chat or your own channel, and the answer comes back in the room`, 'Live: public tools only (never your computers, robots or code), 5 an hour, on your free tier.'],
[html`Ask in chat: write <code>@actor</code> and your question in OpenVibe's global chat or your own channel, and the answer comes back in the room`, 'Live: public tools only (never your computers, robots or code), 5 an hour, on your free tier.'],
+ [html`Run your own: Actor on your server with your own model (<a href="https://github.com/OpenVibers/OpenVibe.Actor/blob/main/docs/self-host.md">how</a>), signed in with your password, data on your disk`, 'Live: Docker or plain Node, Ollama or any OpenAI-compatible model.'],
])}</section>
])}</section>
${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,
${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,
}, status);
}, status);
@@ -156,7 +157,7 @@ ${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, i
@@ -156,7 +157,7 @@ ${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, i
const shown = agents.filter((a) => a.id !== 'codes');
const shown = agents.filter((a) => a.id !== 'codes');
return html`<figure class="router-card" aria-label="How Actor picked an agent for a sample task">
return html`<figure class="router-card" aria-label="How Actor picked an agent for a sample task">
<figcaption><span class="muted small">Sample task · Balanced</span><b>“Who handles the mail for openvibe.network, and is DMARC set up?”</b></figcaption>
<figcaption><span class="muted small">Sample task · Balanced</span><b>“Who handles the mail for openvibe.network, and is DMARC set up?”</b></figcaption>
-<ol>${shown.map((a, i) => html`<li class="${i === 0 ? 'pick' : ''}"><span class="rc-name">${a.name}</span><span class="rc-cost">~${usd(catalog.typicalCost(a.id))}</span><span class="rc-why">${i === 0 ? 'can run the DNS tools; cheapest that can' : a.can.includes('task:lookup') ? 'can do it, costs more' : 'cannot run lookups'}</span></li>`)}</ol>
+<ol>${shown.map((a, i) => html`<li class="${i === 0 ? 'pick' : ''}"><span class="rc-name">${a.name}</span><span class="rc-cost">~${usd(catalog.typicalCost(a.id, config))}</span><span class="rc-why">${i === 0 ? 'can run the DNS tools; cheapest that can' : a.can.includes('task:lookup') ? 'can do it, costs more' : 'cannot run lookups'}</span></li>`)}</ol>
<p class="rc-foot">Checked by a second model before you see it.</p></figure>`;
<p class="rc-foot">Checked by a second model before you see it.</p></figure>`;
}
}
@@ -25,6 +25,7 @@
@@ -25,6 +25,7 @@
* A person acting for themself needs no capability; an app, agent or service needs the one the route names.
* A person acting for themself needs no capability; an app, agent or service needs the one the route names.
*/
*/
const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
+const { sameSecret } = require('../auth/local');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -50,8 +51,14 @@ function sameOrigin(req, baseUrl) {
@@ -50,8 +51,14 @@ function sameOrigin(req, baseUrl) {
}
}
function createPrincipal({ config, keys }) {
function createPrincipal({ config, keys }) {
+ const sh = config.selfHosted || {};
async function resolve(req) {
async function resolve(req) {
const header = String(req.headers.authorization || '');
const header = String(req.headers.authorization || '');
+ // A self-hosted Actor (auth/local.js) has one owner and no Network: its API token is the only Bearer it takes.
+ if (sh.on && header.startsWith('Bearer ')) {
+ if (!sameSecret(header.slice(7).trim(), sh.ownerToken)) return { error: [401, 'token.invalid', 'this Actor takes its owner\'s token (ACTOR_OWNER_TOKEN) only'] };
+ return { principal: { kind: 'user', requester: `user:${sh.ownerSubject}`, project: null, viaSession: false } };
+ }
if (header.startsWith('Bearer ')) {
if (header.startsWith('Bearer ')) {
const token = header.slice(7).trim();
const token = header.slice(7).trim();
const payload = decodePayload(token);
const payload = decodePayload(token);
@@ -41,7 +41,8 @@ async function start() {
@@ -41,7 +41,8 @@ async function start() {
console.log(`[Actor] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store}); agents available: ${available.join(', ') || 'none'}`);
console.log(`[Actor] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store}); agents available: ${available.join(', ') || 'none'}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
- ctx.keys.client.start();
+ // The Network's signing keys, for its tokens: a self-hosted Actor takes none.
+ if (!config.selfHosted.on) ctx.keys.client.start();
// Tasks older than the retention window (tasks/store.js RETENTION_DAYS) go, with their events.
// Tasks older than the retention window (tasks/store.js RETENTION_DAYS) go, with their events.
const prune = setInterval(() => { store.prune(ctx.s).catch((err) => console.warn('[Actor] prune failed:', err && err.message)); }, PRUNE_EVERY_MS);
const prune = setInterval(() => { store.prune(ctx.s).catch((err) => console.warn('[Actor] prune failed:', err && err.message)); }, PRUNE_EVERY_MS);
prune.unref();
prune.unref();
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+/**
+ * A self-hosted Actor (ACTOR_SELF_HOSTED=1, docs/self-host.md): it refuses to start without its owner and secrets;
+ * the owner signs in with a password (a signed session cookie) and calls the API with their own token; it takes no
+ * Network token; the OpenVibe runtime runs on the operator's own OpenAI-compatible model at the operator's price, and
+ * with no other model family that model checks its own answers (saying so); the integrations that need a Network
+ * identity (Bot, Codes, Chat) are off.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { hashPassword, checkPassword } = require('../server/auth/local');
+const { startProviders } = require('./helpers/providers');
+
+const SUBJECT = 'usr_01JZ0000000000000000000SH1';
+const PASSWORD = 'correct-horse-battery-staple';
+const TOKEN = `act_${'t'.repeat(43)}`;
+const SECRET = 's'.repeat(43);
+
+(async () => {
+ await check('the password hash: scrypt, salted, checked in constant time', () => {
+ const h = hashPassword(PASSWORD);
+ assert.match(h, /^scrypt\$16384\$8\$1\$[A-Za-z0-9_-]{22}\$[A-Za-z0-9_-]{43}$/);
+ assert.notStrictEqual(h, hashPassword(PASSWORD), 'a new salt each time');
+ assert.ok(checkPassword(PASSWORD, h));
+ assert.ok(!checkPassword('wrong', h) && !checkPassword(PASSWORD, 'scrypt$garbage') && !checkPassword('', h));
+ });
+
+ await check('without its owner and secrets it does not start', async () => {
+ await assert.rejects(boot({ env: { ACTOR_SELF_HOSTED: '1' } }), /ACTOR_SELF_HOSTED=1 needs ACTOR_OWNER_SUBJECT.*ACTOR_OWNER_PASSWORD_HASH.*ACTOR_OWNER_TOKEN.*ACTOR_SESSION_SECRET.*npm run selfhost:init/);
+ });
+
+ const base = { ACTOR_SELF_HOSTED: '1', ACTOR_OWNER_NAME: 'kai', ACTOR_OWNER_SUBJECT: SUBJECT, ACTOR_OWNER_PASSWORD_HASH: hashPassword(PASSWORD), ACTOR_OWNER_TOKEN: TOKEN, ACTOR_SESSION_SECRET: SECRET };
+ // The operator's own model: a stand-in OpenAI-compatible server of its own.
+ const model = await startProviders();
+ const runtimeUrl = model.env.ACTOR_DEEPSEEK_BASE_URL;
+ const t = await boot({ env: { ...base, ACTOR_RUNTIME_URL: runtimeUrl, ACTOR_RUNTIME_MODEL: 'local-model', ACTOR_DEEPSEEK_API_KEY: null, ACTOR_OPENAI_API_KEY: null } });
+
+ await check('the owner signs in with the password; a wrong one is refused', async () => {
+ const form = await t.get('/auth/login?next=/console');
+ assert.strictEqual(form.status, 200);
+ assert.match(form.text, /Sign in to your Actor/);
+ assert.match(form.text, /name="next" value="\/console"/);
+ const wrong = await t.get('/auth/login', { form: { password: 'nope', next: '/console' } });
+ assert.strictEqual(wrong.status, 401);
+ assert.ok(!wrong.headers.get('set-cookie'));
+ const ok = await t.get('/auth/login', { form: { password: PASSWORD, next: '/console' } });
+ assert.deepStrictEqual([ok.status, ok.headers.get('location')], [303, '/console']);
+ const cookie = ok.headers.get('set-cookie').split(';')[0];
+ assert.match(cookie, /^actor_local=\d{13}\.[A-Za-z0-9_-]{43}$/);
+ const me = await t.get('/auth/me', { cookie });
+ assert.deepStrictEqual(me.json().user, { username: 'kai', display_name: 'kai', subject_id: SUBJECT });
+ const forged = await t.get('/auth/me', { cookie: `actor_local=9999999999999.${'A'.repeat(43)}` });
+ assert.strictEqual(forged.json().user, null, 'a cookie not signed with the session secret is nobody');
+ assert.strictEqual((await t.get('/console', { cookie })).status, 200);
+ });
+
+ await check('the API takes the owner\'s token only: never a Network token, never a wrong one', async () => {
+ const kim = t.network.addUser('kim');
+ assert.strictEqual((await t.get('/api/v1/tasks', { bearer: t.network.userToken(kim) })).status, 401);
+ assert.strictEqual((await t.get('/api/v1/tasks', { bearer: `${TOKEN}x` })).status, 401);
+ assert.strictEqual((await t.get('/api/v1/tasks', { bearer: TOKEN })).status, 200);
+ });
+
+ await check('a task runs on the operator\'s own model, checked by it (same family), at its price', async () => {
+ model.reset();
+ model.state.runtime = 'answer';
+ const r = await t.get('/api/v1/tasks', { bearer: TOKEN, json: { task: 'Explain what a mutex is in two sentences.' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const task = (await t.get(`/api/v1/tasks/${(await t.waitFor(r.json().id)).id}`, { bearer: TOKEN })).json();
+ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));
+ assert.deepStrictEqual([task.result.agent, task.result.model, task.result.checked.by, task.result.checked.cross_family], ['openvibe-runtime', 'stand-in', 'runtime', false], 'the model the server says it ran');
+ assert.strictEqual(task.cost.usd, 0, 'a model on the operator\'s own hardware costs nothing');
+ assert.strictEqual(task.requester.type, 'user');
+ const calls = model.requests.filter((q) => q.path === '/deepseek/chat/completions');
+ assert.ok(calls.length >= 2 && calls.every((q) => q.body.model === 'local-model'), 'the runtime and its check both went to the operator\'s model');
+ });
+
+ await check('the agent list says so; the Network-bound integrations are off', async () => {
+ const agents = (await t.get('/api/v1/agents', { bearer: TOKEN })).json().agents;
+ const rt = agents.find((a) => a.id === 'openvibe-runtime');
+ assert.deepStrictEqual([rt.available, rt.trust, rt.model, rt.rate_card.input_usd_per_mtok, rt.rate_card.output_usd_per_mtok, rt.rate_card.source], [true, 'user-owned', 'local-model', 0, 0, runtimeUrl]);
+ assert.strictEqual(agents.find((a) => a.id === 'codes').available, false);
+ assert.deepStrictEqual([t.config.services.botUrl, t.config.services.codesUrl, t.config.chat.asks], ['', '', false]);
+ });
+
+ await t.close();
+ await model.close();
+ done();
+})();