openvibers/openvibe.run Commits
Add owner-managed sandbox providers and cost-aware placement (#251)
Changes
@@@ -96,3 +96,42 @@ Checks: `npm test -- compute sessions dispatch`; the full suite is `OV_ALLOW_SUIThe SDK dependency is pinned to the reviewed job commit until its PR is merged and released;replace that pin with the resulting SDK release before shipping. Do not deploy this PR beforeOpus reviews money, worker enrollment, privacy and cancellation behavior.++++## Owner-managed sandboxes (ask 356)++++Network `/admin#ai` → platform settings → **OpenVibe.Run** exposes `run.compute` and++`run.compute_baseten`, `run.compute_daytona`, `run.compute_e2b`, `run.compute_fly`.++Only Network's owner-ranked admin proxy can use `run.config.manage`. Keys are write-only,++sealed by shared/config; Run refuses a secret PUT without `OV_CONFIG_KEKS`. The owner supplies++keys later; this release supplies none. Changing settings activates them without restart.++++Start with `run.compute.enabled=false`, `dry_run=true`. Add a pool with `kind`, `providers`,++`strategy: "cheapest"`, `currency: "USD"`, a region, `max_machines`, `max_runtime_ms` and++`slots_per_machine`. Set `owner` to the subject owning the enrolled machines. Set `job_pool`++for queued agent jobs using their actual runtime class (`code`, `linux`, or `function`); set measured RAM per worker for that image. Browser sessions select the pool through their existing request query.++The four providers each default off, zero concurrency, zero cap and no allowed kinds.++++For each provider set `price_id` from https://openvibe.exchange/compute, matching `region`,++prepared `image` (E2B template / Daytona snapshot), key, `allowed_kinds`, `max_concurrent`,++positive USD `monthly_cap`, bounded `extra_hourly`, and `costs_bounded`. Fly additionally needs++an existing app in `project`; Baseten can use `project` to select its team. Images/templates++must have exactly the selected quote's resource shape and enroll fresh identities through the++existing Link/worker path; E2B uses `provider-default` as its region marker because this API has no region selector; it cannot satisfy a named-region purchase. Its template CPU/RAM are checked against the quote before creation. The adapter does not create++accounts, apps, templates or reusable enrollment credentials. Keep `min_warm: 0` unless warm++capacity is intentional. The global enabled/dry switches must also be changed for provisioning.++++The Exchange catalogue supplies dated public tariffs; its explicit expiry is enforced, and an++unavailable catalogue yields no new purchases. CPU adapters reject GPU requests; existing++RunPod/Vast and AI GPU routing remain the GPU path. Baseten/Daytona/E2B send native lifetime++limits; Fly relies on Run's durable deadline and delete reconciliation. A stopped resource is++not treated as deleted. Do not revoke a key while rentals still need cleanup.++++Set `credit_amount` to a confirmed one-time grant and `credit_expires` to its actual UTC expiry.++Recorded charges and outstanding reservations reduce that grant across months; credits only++change ranking, never the gross hard cap. Untracked external use of the same grant is not++observable: use a dedicated account/budget, or set the credit to zero. A promotional free rate++requires both the owner expiry and Exchange's advertised expiry to cover the full reservation;++otherwise the ordinary price wins. At `promotion_expires`, rentals drain and no new ones start.++At 80% of committed cap, Run writes `budget.near_cap` and displays an alert in `/api/v1/compute`.++Network also shows these figures and warnings in the owner-only Worker compute budgets section of `/admin#ai`, through `/api/admin/ai/config/compute/status`. The Run view admits the configured machine owner as well as existing operators. Unknown provider++outcomes keep reservations, block retries and remain visible for operator resolution.nnew file mode 100644@@@ -0,0 +1,53 @@++# Compute providers — ask 356++++Checked **2026-11-11 UTC** (2026-11-10 Pacific). USD unless stated. These are public prices, not negotiated quotes or proof of available capacity. `unknown` means not verified on the provider's own page; it is not zero. Per-second equivalents below are arithmetic conversions, not promises about billing granularity. No account was created, credit claimed, key installed or paid API called.++++## Picks and placement++++**Baseten, Daytona, E2B, Fly Machines** are the first four worker adapters. Baseten meets the explicit request and offers a short promotion; Daytona combines agent/browser tooling with a large trial; E2B has a documented sandbox lifecycle and useful no-card allowance; Fly supplies inexpensive general-purpose Linux VMs with an ordinary REST lifecycle and region selection. GPU inference remains in AI; Run already has RunPod and Vast adapters via SDK. Do not duplicate those ledgers in a new control plane.++++For a continuously busy CPU worker, Fly's shared 2-vCPU/2-GiB shape is about $0.018597/hour, compared with Daytona/E2B's 2-vCPU/4-GiB $0.1656/hour and Baseten's $0.2154/hour. These are **different CPU and memory allocations**, not a performance benchmark. At ten active minutes, their base estimates are respectively $0.003100, $0.027600, and $0.035900, before disk, network and subscriptions. Benchmark complete jobs and queue delay before changing defaults.++++Run owns placements, leases, reconciliation, reservation locks and deletion. Codes' sandbox workers (`server/domain/sandbox-workers.js`) enforce heartbeat/isolation checks; Tools' `/workers` registry remains in `apps/gateway/server/workers`; Network's machine hub fronts Bot (`server/registry/machines.js`), while cell/node principals stay in Network. Provider images must enroll fresh worker identities through these existing paths. A purchased VM alone is not a healthy worker.++++Keep own eligible machines first. Among new rentals, require a current Exchange price, region/resources, allowed kind, prepared image, key, concurrency and positive gross monthly cap. Rank confirmed unexpired credits/promotions first, then the bounded job-duration cost (including deletion allowance and disk/network bound). Unknown purchase/delete outcomes retain reservations and stop retries. Credits never increase the gross spending cap. A promotion expiry stops new purchases and drains running rentals; do not assume the provider automatically stops billing.++++## Sandbox and CPU comparison++++Every row was checked on **2026-11-11**. Credit expiry is account-specific unless explicitly shown. Cold-start claims are vendor claims, not measurements. A dash in GPU means this CPU sandbox product is not a verified GPU rental.++++| Provider / primary source | Price and free allowance | Limits / startup / isolation / regions / API |++|---|---|---|++| [Baseten Sandboxes](https://www.baseten.co/resources/changelog/baseten-sandboxes-are-now-available), [pricing](https://www.baseten.co/pricing), [API](https://docs.baseten.co/reference/management-api/sandboxes/create-a-sandbox) | Free until **November 16, 2026**; exact cutoff timezone unspecified, use conservative Nov 16 00:00 UTC. Paid CPU $0.0655/vCPU-hour + $0.0211/GB-hour ($0.0000181944 and $0.0000058611 per second). Snapshot $0.20/GB-month. Pricing HTML's embedded data confirms the paid rates even where the rendered fetch says enable JavaScript. | 10 concurrent/org. Isolated sandbox; no measured cold-start SLA. Default 4GB/2CPU. API lists `us-pdx-1`, `eu-lon-1` examples; query configuration for actual account regions. REST `/v1/sandboxes/instances`, Bearer API key, GET/DELETE by **name**, Python/TS/Go clients. Native DELETE expiry policies. GPU: not verified for this sandbox API. |++| [Daytona](https://www.daytona.io/pricing), [regions](https://www.daytona.io/docs/en/regions/), [limits](https://www.daytona.io/docs/en/limits/), [API](https://www.daytona.io/docs/tools/api/) | $200 one-time trial, no card; expiry not published on pricing page. $0.0504/vCPU-hour + $0.0162/GiB-hour = $0.000014/vCPU-second + $0.0000045/GiB-second; disk $0.000108/GiB-hour after first 5GiB. | Per-second billing. Millisecond startup claim. Tier 1: aggregate 10 vCPU/10GiB RAM/30GiB disk, so sandbox count depends on shape; do not treat API requests/minute as concurrency. US/EU targets. Linux sandbox; confirm selected isolation class and egress restrictions for untrusted workloads. REST POST/GET/DELETE `/sandbox`, snapshots, exec/files SDK. GPU prices below. |++| [E2B](https://e2b.dev/pricing), [lifecycle](https://docs.e2b.dev/sandbox) | $100 one-time Hobby credit, no card; expiry not stated. CPU $0.000014/vCPU-second + RAM $0.0000045/GiB-second. Default 2CPU/4GiB = $0.000046/second. Pro $150/month plus usage. | Hobby 10 concurrent/1h, Pro 100/24h (paid concurrency upgrades). Isolated Linux VM; template-based startup, not measured here. The inspected REST create API has no region selector; the executable catalogue uses `provider-default`, never an invented US/EU residency promise. REST sandbox lifecycle and TS/Python SDK; API timeout is seconds, JS SDK timeoutMs is milliseconds. GPU not verified. |++| [Modal](https://modal.com/pricing), [sandboxes](https://modal.com/docs/guide/sandbox) | $30/month Starter credit; 100 containers + 10 GPU concurrency. $0.0000131/**physical core**-second (2 vCPU equivalent), RAM $0.00000222/GiB-second. GPU T4 $0.000164/s, L4 $0.000222/s, H100 $0.001097/s. | Python-first sandbox API, container isolation; confirm workload policy/region in sandbox docs. Region selection offered. Startup not measured. Volumes $0.09/GiB-month; network $0.04/GiB after included allowance. Strong GPU/batch candidate; defer until native SDK lifecycle fits Run. |++| [Fly Machines](https://docs.fly.io/about/pricing), [API](https://docs.fly.io/machines/api/machines-resource) | Shared 2CPU/2GB $13.39/720h in iad/ewr = $0.0185972/h, $0.000005166/s. $1 trial lasts 7 days or until used. No general recurring free tier; personal small-bill waiver is not an organizational credit. | VM isolation, per-second running charges; startup capacity-dependent. Stopped rootfs and volumes still bill. Region-specific prices. REST `/v1/apps/{app}/machines`; Bearer token. Delete, not merely stop, to end resource charges. GPU not selected. |++| [Northflank](https://northflank.com/pricing) | Free sandbox: 2 services, 2 jobs, 1 DB, up to $20/month resources, no card. PAYG CPU starts $0.01667/vCPU-hour ($0.000004631/s); memory/storage extra. | Per-second billing. Always-on free services; no cold-start claim to assume. Containers/BYOC; isolation for hostile code requires explicit sandbox configuration. Regions/quotas depend on deployment. REST/Platform SDK for jobs/services. GPU types and total selected-shape price require live quote. |++| [Koyeb](https://www.koyeb.com/pricing) | RTX A6000 $0.75/h ($0.0002083/s), L40S $1.20/h, A100 80GB $1.60/h. Pro $29/month includes $10 compute; Scale $299 includes $100. | Secure VMs, per-second billing, scale-to-zero. Pro 100 services/5 concurrent builds (not worker concurrency). REST service deployments; selected region/availability and cold-start must be checked. Free GPU allowance not established. |++| [Cloudflare Containers](https://developers.cloudflare.com/containers/platform/pricing/), [Sandbox SDK](https://developers.cloudflare.com/sandbox/) | Workers Paid $5/month; includes 25GiB-hours memory, 375 vCPU-minutes, 200GB-hours disk. Overages $0.0000025/GiB-second, $0.000020/active-vCPU-second, $0.00000007/GB-second disk. | 10ms accounting; memory/disk allocated, CPU active. Workers + Durable Objects + egress fees also apply. TS Sandbox SDK sits on Containers, not a separate free compute pool. Instance types and account limits control concurrency. Platform placement; verify residency. No GPU. Separate Worker deployment required, so deferred. |++| [Vercel Sandbox](https://vercel.com/docs/sandbox/pricing.md) | Hobby includes 5 CPU-hours/month, 420GB-hours RAM, 5,000 creations, 20GB transfer. Pro CPU $0.128/h ($0.000035556/s), RAM $0.0212/GB-hour ($0.000005889/s); region-dependent. | Hobby 10 concurrent/45min; Pro 10,000/24h. 19 regions, default iad1. Isolated sandbox, REST/TS SDK; startup not measured. Creation, snapshot, drive and transfer charges must be included. No GPU verified. |++| [CodeSandbox SDK](https://codesandbox.io/?product=service), [pricing](https://codesandbox.io/pricing) | Pricing endpoint returned 403; current price, free allowance and expiry **unverified**, not imported. | Provider describes microVMs, start/clone/restore within 2s, configurable hibernation and SDK/API. Account concurrency and regions unverified; advertised platform capacity is not an account quota. No GPU verified. |++| [Morph](https://cloud.morph.so/web/subscribe) | Base MCU $0.05; one MCU-hour covers 1vCPU +4GB RAM +16GB disk; dimension maximum determines units. Equivalent $0.000013889/MCU-second, but page describes hourly billing. Developer $0/month with **0 starting MCUs**; Team $40 includes 1,000 MCUs. | Full VMs/branching snapshots, seconds startup claim. Devbox concurrency 8/32/128 by tier; regions unverified. Devbox API/SDK with snapshots/forks. Do not confuse developer plan with free compute. |++| [Blaxel](https://blaxel.ai/pricing), [regions](https://docs.blaxel.ai/Infrastructure/Regions) | Active sandbox $0.0000115/GB-RAM-second; snapshot $0.20/GB-month, images $0.045/GB-month. Credit amount/expiry unverified. | Hardware-isolated microVMs, ~25ms **resume** claim, not complete cold creation. Platform-scale concurrency claims are not account quotas. Oregon/Virginia/London/Frankfurt docs; REST `/sandboxes`, Python/TS/Go. CPU scales with memory; compare whole-job cost. No GPU verified for sandbox. |++| [Fly Sprites (additional candidate)](https://fly.io/sprites/) | No separate numeric tariff verified here; Fly's $1 trial can cover Sprites, shared with Machines. | Persistent hardware-isolated Linux environments, checkpoint/restore and scale-to-zero; REST and several SDKs. Current account quotas, selected region, startup and storage cost need verification. Promising follow-up, but not conflated with Machines API. |++++## GPU comparison++++Checked **2026-11-11**; each price is from the linked provider page. GPU-only rates exclude host CPU/RAM/disk unless the offer explicitly bundles them. No public number guarantees availability, isolation or permission to run a browser worker.++++| Provider | Current public evidence | Operational fit / unanswered details |++|---|---|---|++| [Daytona GPU](https://www.daytona.io/pricing) | RTX4090 24GB **$0.57/h preemptible** ($0.000158333/s), **$0.99/h on-demand** ($0.000275/s). 5090 $0.74/$1.29, H100 $2.27/$3.95, H200 $2.61/$4.54 preemptible/on-demand. | Same sandbox control plane; GPU resources have their own availability/quota. Only checkpointable work can use the cheaper preemptible rate. CPU/RAM/disk still matter. |++| [RunPod Pods and Serverless](https://www.runpod.io/pricing) | Page updated Nov 9: Pod 4090 $0.89/h ($0.000247222/s), A40 $0.59/h, L40S $1.09/h. Serverless displayed 4090 $1.34/h equivalent ($0.000372222/s). | Pods are dedicated instances; serverless is an inference/job endpoint, not a drop-in persistent browser. 30+ regions; launch/cold time image-dependent. API pod create/terminate versus endpoint run/status/cancel. Quotas account-specific; no universal credit verified. Existing Run adapter remains. |++| [Vast.ai](https://vast.ai/pricing) | Dynamic supply/demand quotes, no reliable fixed GPU rate in fetched page. Per-second on-demand; interruptible and reserved variants. | 40+ data centers; host trust and location vary. REST offer search, instance create/destroy; Docker/VM options must be verified per offer. Storage and transfer are separate. No blanket free credit, concurrency or startup SLA verified. Existing Run adapter remains. |++| [Lambda](https://lambda.com/pricing) | Direct pricing HTML lists a 16-GPU H100 cluster at $6.16/GPU-hour on a 2-week–1-year term ($0.0017111/GPU-second equivalent, **not** a per-second commitment). Single-instance offers vary by GPU count; no instance rate imported. | VM/cluster GPU rentals; provider says instance deployment takes minutes. Availability, selected region, account quota and billing quantum require instance-type API/account quote. Better for long GPU work than small browser jobs. Trial credit not verified. |++| [TensorDock](https://www.tensordock.com/cloud-gpus.html), [live console](https://console.tensordock.com/deploy) | Marketing 4090 typical $0.35/h GPU-only; live console from $0.50/h, example Orlando full shape $0.495/h. Marketing H100 SXM5 $2.25/h, A100 SXM4 $1.80/h. | Host/resources explain different prices; never seed the marketing minimum as a purchasable quote. Per-second prorating, VM API, host-specific regions and hardware. Concurrency/startup/credit unverified. |++| [Hyperbolic](https://www.hyperbolic.xyz/blog/deepseek-r1-0528) | Own **May 28, 2025** post advertised H100 $0.99/h. This is historical, not a verified November 2026 tariff; `/pricing` returned 404. | GPU marketplace/API. Current region, isolation, capacity, billing quantum and credit require fresh provider quote. Excluded from executable prices. |++| [Novita GPU Cloud](https://novita.ai/gpus), [pricing](https://novita.ai/pricing) | Dynamic GPU table did not expose a dependable numeric rate. No price imported. | Provider advertises 14 regions, REST/gRPC/CLI, per-second serverless, scale-to-zero in 30s and sub-second startup **after prewarming**. “Unlimited concurrency” is a platform claim; enforce account quotas. GPU shape, isolation and free-credit expiry require live quote. |++++## Verification and activation conditions++++Research used built-in web search plus direct public provider HTML/Markdown. The ov-deep-research skill's paid Search/Fetch calls were not made because RUNNER-RULES forbids spending money. Some provider pages are JS-only or block fetches; missing values remain explicit. No secondary aggregator price was promoted to an executable quote.++++Before turning on a provider: confirm account credits/expiry and quotas, set a positive gross monthly cap and concurrency, choose a current Exchange shape/region, supply a prepared worker image with enforced disk/egress bounds, enroll fresh worker identities, and run dry mode. Shared config secrets require the host's existing encryption-key setup; missing encryption refuses a secret write. Never bake reusable Network credentials into an image. A fake lifecycle test cannot establish real upstream availability, deletion reliability or worker isolation.nnew file mode 100644@@@ -0,0 +1,27 @@++-- phase: expand++CREATE TABLE IF NOT EXISTS config_snapshots (++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ previous_revision bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),++ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)++ classification_json text COLLATE "C" NOT NULL,++ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ activated_at text COLLATE "C",++ activated_by text COLLATE "C",++ reason text COLLATE "C",++ error text COLLATE "C",++ copied_from bigint,++ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)++ PRIMARY KEY (namespace, revision)++);++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';++-- The fingerprint key of each namespace. Never served, never logged.++CREATE TABLE IF NOT EXISTS config_keys (++ namespace text COLLATE "C" PRIMARY KEY,++ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK++ created_at text COLLATE "C" NOT NULL++);\\ No newline at end of file@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.404.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0","pg": "^8.23.0"@@@ -1228,9 +1228,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.399.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",-- "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",++ "version": "0.404.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.404.0",++ "integrity": "sha512-yJsuZIqKEsjlx92Y5c0C/8wJBdA713NFNu797KVbvZHSvgPJJS/75pdE3t9pFiuo/Bb6XgfwHIaHGqHPM/bRAw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -26,7 +26,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.404.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0","pg": "^8.23.0"@@@ -77,6 +77,13 @@ function createApp(opts = {}) {const release = require('openvibe-shared/release').createRelease({ service: 'run', root: path.join(__dirname, '..') });const app = express();++ const settingsRouter = express.Router();++ app.use('/internal/config', express.json({limit:'128kb'}));++ app.use(settingsRouter);++ let settingsPromise;++ app.locals.initComputeSettings = () => settingsPromise ||= require('./compute/settings')++ .createSettings({db,config,keys,broker,env:opts.env || process.env,log})++ .then(settings => { settings.mount(settingsRouter); return settings; });// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts// (openvibe-contracts); the operations they mark internal-only are left out.app.get('/openapi.json', (_req, res) => {@@@ -5,7 +5,8 @@ const obj=v=>typeof v==='string'?JSON.parse(v):v;const bare=v=>String(v || '').replace(/^user:/,'');function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,adapters,log=console}) {const cfg=config.compute || {pools:{},inventory:{},providers:{},operators:[],dry_run:true};-- const providers=adapters || createProviders({fetch:fetchImpl,config:cfg.providers,now});++ const {createSandboxProviders,createPriceReader}=require('./sandbox-providers');++ const providers=adapters || {...createProviders({fetch:fetchImpl,config:cfg.providers,now}),...createSandboxProviders({config:cfg,fetchImpl,now,prices:createPriceReader({fetchImpl,now})})};let timer=null,running=null,finish=null,finishJobs=null;const month=()=>new Date(now()).toISOString().slice(0,7);const journal=(event,detail={},r={})=>db.query('INSERT INTO run_compute_journal (at,event,rental,pool,detail) VALUES ($1,$2,$3,$4,$5)',[now(),event,r.id || null,r.pool || null,JSON.stringify(detail)]);@@@ -64,6 +65,7 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,aif(q.provisionable===false || !pc.image || !pc.costs_bounded || pc.extra_hourly==null) return {reason:'quote only: prepared image and bounded disk/egress costs required'};if(q.currency!==pc.currency || q.expires_at<now() || !Number.isFinite(q.hourly) || q.hourly<=0) return {reason:'invalid or expired quote'};const p=await poolConfig(pool);++ if(pc.managed && (!pc.enabled || !pc.allowed_kinds.includes(p.kind || 'browser') || (pc.promotion_expires && !(Date.parse(pc.promotion_expires)>now())))) return {reason:'provider disabled, kind refused or promotion expired'};const hourly=BigInt(Math.ceil(q.hourly*1e6))+micros(pc.extra_hourly);if(p.max_hourly!=null && hourly>micros(p.max_hourly)) return {reason:'total price exceeds pool ceiling'};const runtime=Math.min(3600000,Math.max(60000,p.max_runtime_ms || 3600000));@@@ -79,11 +81,13 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,aif(poolRows.length >= (p.max_machines ?? 10)) return {reason:'pool machine limit reached'};const rows=await t.many("SELECT * FROM run_compute_rentals WHERE provider=$1 AND (month=$2 OR state <> 'released')",[q.provider,month()]);if(rows.some(r=>['uncertain','releasing'].includes(r.state))) return {reason:'an unresolved provider call blocks new rentals'};++ if(pc.managed && rows.filter(r=>r.state!=='released').length>=pc.max_concurrent) return {reason:'provider concurrency limit reached'};const settled=await t.value('SELECT COALESCE(SUM(c.amount_micros),0) FROM run_compute_charges c JOIN run_compute_rentals r ON r.id=c.rental WHERE r.provider=$1 AND c.month=$2',[q.provider,month()]);const committed=BigInt(settled)+rows.filter(r=>r.state!=='released').reduce((sum,r)=>sum+BigInt(r.reserved_micros),0n);if(committed+reserved>cap) return {reason:'monthly cap reached'};const r={id:'ov-'+randomUUID(),pool,provider:q.provider,month:month(),currency:q.currency,created_at:now(),deadline:now()+runtime,quote:q};await t.query("INSERT INTO run_compute_rentals (id,pool,provider,state,month,currency,hourly_micros,reserved_micros,quote,created_at,deadline) VALUES ($1,$2,$3,'provisioning',$4,$5,$6,$7,$8,$9,$10)",[r.id,pool,r.provider,r.month,r.currency,hourly.toString(),reserved.toString(),JSON.stringify(q),r.created_at,r.deadline]);++ if((committed+reserved)*100n>=cap*80n) await t.query('INSERT INTO run_compute_journal (at,event,rental,pool,detail) VALUES ($1,$2,$3,$4,$5)',[now(),'budget.near_cap',r.id,pool,JSON.stringify({provider:q.provider,cap:pc.monthly_cap,committed_micros:(committed+reserved).toString()})]);await t.query('INSERT INTO run_compute_journal (at,event,rental,pool,detail) VALUES ($1,$2,$3,$4,$5)',[now(),'provision.reserved',r.id,pool,JSON.stringify({reserved_micros:reserved.toString(),hourly_micros:hourly.toString(),quote:q.id})]);return {rental:r};});@@@ -93,7 +97,7 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,aconst result=await reserve(pool,q); if(!result.rental) return result;const r=result.rental;try {-- const remote=await providers[q.provider].provision(q,{label:r.id,image:cfg.providers[q.provider].image});++ const remote=await providers[q.provider].provision(q,{label:r.id,image:cfg.providers[q.provider].image,runtime_ms:r.deadline-now()});if(!remote?.id) throw new Error('no remote id');await db.query("UPDATE run_compute_rentals SET remote_id=$2,state='starting' WHERE id=$1",[r.id,String(remote.id)]);await journal('provision.accepted',{remote_id:String(remote.id)},r);@@@ -144,7 +148,7 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,aconst committed=rentals.filter(x=>x.provider===r.provider).reduce((sum,x)=>sum+BigInt(x.reserved_micros),settled);let unhealthy=false;if(r.remote_id) { try { const h=await providers[r.provider].health(r.remote_id); unhealthy=h.deleted || ['error','stopped','off','dead'].includes(String(h.state).toLowerCase()); } catch { /* unknown health keeps the reservation and deadline */ } }-- const stop=!cfg.enabled || now()>=Number(r.deadline) || committed>micros(pc.monthly_cap || '0') || unhealthy;++ const stop=!cfg.enabled || (pc.managed && (!pc.enabled || (pc.promotion_expires && now()>=Date.parse(pc.promotion_expires)))) || now()>=Number(r.deadline) || committed>micros(pc.monthly_cap || '0') || unhealthy;if(stop && r.remote_id) {await db.query("UPDATE run_compute_rentals SET state='draining' WHERE id=$1",[r.id]);if(r.machine && finish) for(const s of await db.many("SELECT id FROM run_sessions WHERE machine=$1 AND state <> 'ended'",[r.machine])) await finish(s.id);@@@ -178,7 +182,10 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,aif(m.sessions>0 && r.idle_since!=null) await db.query('UPDATE run_compute_rentals SET idle_since=NULL WHERE id=$1',[r.id]);} else ms.push({id:r.id,state:'provisioning',health:'starting',sessions:null});}-- const waiting=await db.many("SELECT created_at,compute_strategy FROM run_sessions WHERE compute_pool=$1 AND state='placing' AND machine_asked IS NULL AND (compute_strategy IS NULL OR compute_strategy <> 'own') ORDER BY created_at",[id]);++ const waiting=await db.many("SELECT created_at,compute_strategy,requirements FROM run_sessions WHERE compute_pool=$1 AND state='placing' AND machine_asked IS NULL AND (compute_strategy IS NULL OR compute_strategy <> 'own') ORDER BY created_at",[id]);++ if(cfg.job_pool===id) waiting.push(...await db.many("SELECT created_at,job->'requirements' AS requirements FROM run_jobs WHERE state='queued' AND class=$1 AND job->>'node' IS NULL AND COALESCE(job->'requirements'->>'objective','') NOT IN ('private','first-party-only')",[p.kind]));++ waiting.sort((a,b)=>Number(a.created_at)-Number(b.created_at));++ const need=obj(waiting[0]?.requirements) || {};const desired=scale(p,ms,waiting.map(s=>({created_at:Number(s.created_at)})),{now:now()});for(const a of desired.actions) {const r=rows.find(r=>r.machine===a.id || r.id===a.id);@@@ -186,14 +193,41 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,aif(a.action==='release' && r && !cfg.dry_run) await release(r);if(a.action==='provision') {const qs=(await quotes(id)).flatMap(x=>x.quotes).filter(q=>q.provisionable!==false && q.hourly!=null && q.currency===(p.currency || 'USD') && q.ram_mb>=((p.kind==='desktop'?2048:300)+1024));++ const creditAvailable={};++ for(const q of qs) {++ const pc=cfg.providers[q.provider] || {};++ if(!pc.managed || !(Date.parse(pc.credit_expires)>now()+Math.min(3600000,Math.max(60000,p.max_runtime_ms || 3600000))+300000))continue;++ const spent=BigInt(await db.value('SELECT COALESCE(SUM(c.amount_micros),0) FROM run_compute_charges c JOIN run_compute_rentals r ON r.id=c.rental WHERE r.provider=$1',[q.provider]));++ const held=(await live()).filter(r=>r.provider===q.provider).reduce((n,r)=>n+BigInt(r.reserved_micros),0n);++ creditAvailable[q.provider]=micros(pc.credit_amount)-spent-held;++ }let candidates=qs.flatMap(q=>{const pc=cfg.providers[q.provider] || {};++ if(pc.managed && (!pc.enabled || !pc.allowed_kinds.includes(p.kind || 'browser') || (pc.promotion_expires && !(Date.parse(pc.promotion_expires)>now()))))return [];if(!cfg.dry_run && (!pc.key || micros(pc.monthly_cap || '0')===0n || !pc.image || !pc.costs_bounded || pc.extra_hourly==null))return [];++ const resources=need.resources || {};++ const trust=pc.managed?'partner':q.tier==='dedicated'?'first-party':q.tier==='community'?'community':'partner';++ if((need.trust && !need.trust.includes(trust)) || (resources.gpu && !(q.gpu_mb>0))++ || (resources.cpu && !(q.cpu>=resources.cpu)) || (resources.memory_mb && !(q.ram_mb-1024>=resources.memory_mb))++ || (resources.scratch_gb && !(q.disk_gb>=resources.scratch_gb))++ || (need.residency && q.region!==need.residency)++ || (need.region && need.region!=='nearest' && q.region!==need.region)++ || (need.max_latency_ms!=null && !(q.latency_ms<=need.max_latency_ms)))return [];const extra=pc.extra_hourly==null?0:Number(micros(pc.extra_hourly))/1e6;-- return [{...q,hourly:q.hourly+extra,id:q.provider+':'+q.id,health:'up',free_ram_mb:q.ram_mb-1024,free_slots:p.slots_per_machine || 1}];++ const duration=Math.min(3600000,Math.max(60000,p.max_runtime_ms || 3600000))+300000;++ const estimate=charge(BigInt(Math.ceil((q.hourly+extra)*1e6)),duration,q.billing_seconds)+micros(q.setup || 0);++ if(need.max_cost_usd!=null && estimate>micros(need.max_cost_usd))return [];++ const promotion=pc.promotion_expires && Date.parse(pc.promotion_expires)>now()+duration && Date.parse(q.promotion_expires)>now()+duration;++ // A free CPU promotion does not waive disk/network or setup charges. Apply confirmed++ // remaining credit to the complete bounded job cost, including a partially covered job.++ const cash=promotion?charge(micros(pc.extra_hourly || '0'),duration,q.billing_seconds)+micros(q.setup || 0):estimate;++ const credit=creditAvailable[q.provider]>0n?creditAvailable[q.provider]:0n;++ const net=cash-(credit>cash?cash:credit);++ const ranked=pc.managed?Number(net)/1e6*3600000/duration:q.hourly+extra;++ return [{...q,hourly:ranked,id:q.provider+':'+q.id,health:'up',free_ram_mb:q.ram_mb-1024,free_slots:p.slots_per_machine || 1}];});while(true) {-- const d=choose({kind:p.kind,strategy:waiting[0]?.compute_strategy || p.strategy,region:p.region,gpu_mb:p.gpu_mb},candidates,p,{now:now()});++ const d=choose({kind:p.kind,strategy:waiting[0]?.compute_strategy || ({private:'private','first-party-only':'private','lowest-latency':'fastest',cheapest:'cheapest'}[need.objective]) || p.strategy,region:p.region,gpu_mb:p.gpu_mb},candidates,p,{now:now()});const q=qs.find(q=>q.provider+':'+q.id===d.selected);if(!q){await journal('scale.decision',{reason:a.reason,decision:d,result:{reason:'no eligible rental quote'}},{pool:id});break;}const result=await provision(id,q);@@@ -216,7 +250,7 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,aconst settled=BigInt(await db.value('SELECT COALESCE(SUM(c.amount_micros),0) FROM run_compute_charges c JOIN run_compute_rentals r ON r.id=c.rental WHERE r.provider=$1 AND c.month=$2',[provider,month()]));let spent=settled,committed=settled,hourly=0n;for(const r of rs.filter(r=>r.state!=='released')){const h=BigInt(r.hourly_micros);spent+=charge(h,now()-Math.max(Number(r.created_at),Date.parse(month()+'-01T00:00:00Z')),obj(r.quote).billing_seconds);committed+=BigInt(r.reserved_micros);hourly+=h;}-- budgets.push({provider,currency:pc.currency,cap:pc.monthly_cap,configured:Boolean(pc.key),spent_micros:spent.toString(),committed_micros:committed.toString(),hourly_micros:hourly.toString()});++ budgets.push({near_cap:micros(pc.monthly_cap || '0')>0n && committed*100n>=micros(pc.monthly_cap)*80n,provider,currency:pc.currency,cap:pc.monthly_cap,configured:Boolean(pc.key),spent_micros:spent.toString(),committed_micros:committed.toString(),hourly_micros:hourly.toString()});}let machines=[];if(cfg.owner && desks.enabled) {try {machines=await inventory(await desks.hosts(cfg.owner),cfg.owner);} catch {machines=Object.entries(cfg.inventory).map(([id,v])=>({...v,id,health:'unknown'}));}}@@@ -10,7 +10,7 @@ function loadCompute(env) {}for (const [id,p] of Object.entries(pools)) {if (!/^[a-z0-9-]{1,40}$/.test(id) || !STRATEGIES.includes(p.strategy || 'automatic')) throw new Error('invalid compute pool');-- if (p.kind && !['browser','desktop'].includes(p.kind)) throw new Error('invalid pool kind');++ if (p.kind && !['function','code','browser','linux','desktop','gpu'].includes(p.kind)) throw new Error('invalid pool kind');}const providers={};for (const name of ['hetzner','ovh','vast','runpod']) {@@@ -6,7 +6,7 @@ function computeRouter({broker,config}) {const r=express.Router();const wrap=fn=>(req,res,next)=>Promise.resolve(fn(req,res)).catch(next);r.use('/compute',(req,res,next)=>{-- if(req.principal?.kind!=='user' || !(config.compute?.operators || []).includes(req.principal.subject))return res.status(403).json({error:'operator access required'});++ if(req.principal?.kind!=='user' || ![...(config.compute?.operators || []),config.compute?.owner].includes(req.principal.subject))return res.status(403).json({error:'operator access required'});if(req.method!=='GET' && req.get('origin')!==config.baseUrl)return res.status(403).json({error:'same-origin write required'});next();});@@@ -15,7 +15,7 @@ function computeRouter({broker,config}) {if(req.query.format==='json')return res.json(s);const money=v=>(Number(v)/1e6).toFixed(4);const machines=[...s.machines.map(m=>({...m,machine:m.id,provider:s.rentals.find(r=>r.machine===m.id)?.provider || 'existing',hourly_micros:m.hourly==null?null:m.hourly*1e6})),...s.rentals.filter(r=>!s.machines.some(m=>m.id===r.machine)).map(r=>({...r,tier:(typeof r.quote==='string'?JSON.parse(r.quote):r.quote).tier}))];-- res.type('html').send(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Compute pools · OpenVibe Run</title><style>:root{color-scheme:light dark;font:16px/1.6 system-ui}body{margin:0;background:light-dark(#f6f8fa,#101519);color:light-dark(#16232c,#e6edf2)}main{max-width:1100px;margin:auto;padding:24px}h1{font-size:2rem;line-height:1.2}h2{margin-top:2rem}section{padding:20px;margin:16px 0;border:1px solid #70809060;border-radius:12px}button,select{font:inherit;min-height:44px;padding:8px 12px;border-radius:6px;border:1px solid #70809080}form{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0}a{color:light-dark(#075a85,#87d6ff)}table{display:block;overflow-x:auto;border-collapse:collapse;max-width:100%;text-align:left}th,td{padding:10px;border-bottom:1px solid #70809060;white-space:nowrap}ol{padding-left:24px}li{overflow-wrap:anywhere;margin:12px 0}@media(max-width:600px){main{padding:16px}h1{font-size:1.7rem}}</style><body><main><h1>Compute pools</h1><p>${s.dry_run?'Dry run — rentals disabled':'Live rentals within configured caps'} · ${s.enabled?'Reconciler enabled':'Reconciler disabled'}</p><h2>Pools</h2>${s.pools.map(p=>`<section><h3>${esc(p.id)}</h3><p>Strategy: ${esc(p.strategy)} · Warm minimum: ${esc(p.min_warm || 0)} · Queue target: ${esc(p.target_wait_ms ?? 30000)} ms</p><form method="post" action="/api/v1/compute/pools/${esc(p.id)}/strategy"><select name="strategy">${STRATEGIES.map(v=>`<option${v===p.strategy?' selected':''}>${v}</option>`).join('')}</select><button>Apply strategy</button></form><a href="/api/v1/compute/pools/${esc(p.id)}/quotes">Quotes and dry-run prices</a></section>`).join('')}<h2>Money</h2><table><tr><th>Provider</th><th>Currency</th><th>Per hour</th><th>This month</th><th>Committed</th><th>Cap</th></tr>${s.budgets.map(b=>`<tr><td>${esc(b.provider)}</td><td>${esc(b.currency)}</td><td>${money(b.hourly_micros)}</td><td>${money(b.spent_micros)}</td><td>${money(b.committed_micros)}</td><td>${esc(b.cap)}</td></tr>`).join('')}</table><h2>Machines</h2><table><tr><th>Pool</th><th>Machine / rental</th><th>Tier / provider</th><th>State</th><th>Sessions</th><th>Per hour</th></tr>${machines.map(m=>`<tr><td>${esc(m.pool)}</td><td>${esc(m.machine || m.id)}</td><td>${esc(m.tier)} / ${esc(m.provider)}</td><td>${esc(m.state || m.health)}</td><td>${s.sessions.find(x=>x.machine && x.machine===m.machine)?.sessions || 0}</td><td>${m.hourly_micros==null?'unknown':money(m.hourly_micros)} ${esc(m.currency)}</td></tr>`).join('')}</table><h2>Last decisions and switches</h2><ol>${s.journal.map(j=>`<li>${esc(new Date(Number(j.at)).toISOString())} ${esc(j.event)} ${esc(JSON.stringify(j.detail))}</li>`).join('')}</ol></main></body></html>`);++ res.type('html').send(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Compute pools · OpenVibe Run</title><style>:root{color-scheme:light dark;font:16px/1.6 system-ui}body{margin:0;background:light-dark(#f6f8fa,#101519);color:light-dark(#16232c,#e6edf2)}main{max-width:1100px;margin:auto;padding:24px}h1{font-size:2rem;line-height:1.2}h2{margin-top:2rem}section{padding:20px;margin:16px 0;border:1px solid #70809060;border-radius:12px}button,select{font:inherit;min-height:44px;padding:8px 12px;border-radius:6px;border:1px solid #70809080}form{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0}a{color:light-dark(#075a85,#87d6ff)}table{display:block;overflow-x:auto;border-collapse:collapse;max-width:100%;text-align:left}th,td{padding:10px;border-bottom:1px solid #70809060;white-space:nowrap}ol{padding-left:24px}li{overflow-wrap:anywhere;margin:12px 0}@media(max-width:600px){main{padding:16px}h1{font-size:1.7rem}}</style><body><main><h1>Compute pools</h1><p>${s.dry_run?'Dry run — rentals disabled':'Live rentals within configured caps'} · ${s.enabled?'Reconciler enabled':'Reconciler disabled'}</p><h2>Pools</h2>${s.pools.map(p=>`<section><h3>${esc(p.id)}</h3><p>Strategy: ${esc(p.strategy)} · Warm minimum: ${esc(p.min_warm || 0)} · Queue target: ${esc(p.target_wait_ms ?? 30000)} ms</p><form method="post" action="/api/v1/compute/pools/${esc(p.id)}/strategy"><select name="strategy">${STRATEGIES.map(v=>`<option${v===p.strategy?' selected':''}>${v}</option>`).join('')}</select><button>Apply strategy</button></form><a href="/api/v1/compute/pools/${esc(p.id)}/quotes">Quotes and dry-run prices</a></section>`).join('')}<h2>Money</h2>${s.budgets.filter(b=>b.near_cap).map(b=>`<p role="alert">${esc(b.provider)} has committed at least 80% of its monthly cap.</p>`).join('')}<table><tr><th>Provider</th><th>Currency</th><th>Per hour</th><th>This month</th><th>Committed</th><th>Cap</th></tr>${s.budgets.map(b=>`<tr><td>${esc(b.provider)}</td><td>${esc(b.currency)}</td><td>${money(b.hourly_micros)}</td><td>${money(b.spent_micros)}</td><td>${money(b.committed_micros)}</td><td>${esc(b.cap)}</td></tr>`).join('')}</table><h2>Machines</h2><table><tr><th>Pool</th><th>Machine / rental</th><th>Tier / provider</th><th>State</th><th>Sessions</th><th>Per hour</th></tr>${machines.map(m=>`<tr><td>${esc(m.pool)}</td><td>${esc(m.machine || m.id)}</td><td>${esc(m.tier)} / ${esc(m.provider)}</td><td>${esc(m.state || m.health)}</td><td>${s.sessions.find(x=>x.machine && x.machine===m.machine)?.sessions || 0}</td><td>${m.hourly_micros==null?'unknown':money(m.hourly_micros)} ${esc(m.currency)}</td></tr>`).join('')}</table><h2>Last decisions and switches</h2><ol>${s.journal.map(j=>`<li>${esc(new Date(Number(j.at)).toISOString())} ${esc(j.event)} ${esc(JSON.stringify(j.detail))}</li>`).join('')}</ol></main></body></html>`);}));r.get('/compute/pools/:pool/quotes',wrap(async(req,res)=>res.json({dry_run:true,providers:await broker.quotes(req.params.pool)})));r.post('/compute/pools/:pool/strategy',express.urlencoded({extended:false,limit:'2kb'}),wrap(async(req,res)=>{await broker.setStrategy(req.params.pool,req.body.strategy);res.redirect(303,'/api/v1/compute');}));nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++const { NAMES } = require('./settings');++// Public prices come from Exchange. No caller-supplied URL or credential reaches a provider.++function createSandboxProviders({ config, fetchImpl = globalThis.fetch, now = Date.now, prices }) {++ return Object.fromEntries(NAMES.map(name => {++ const pc = () => config.providers[name] || {};++ const base = () => ({ baseten: 'https://api.baseten.co/v1/sandboxes/instances', daytona: 'https://app.daytona.io/api/sandbox',++ e2b: 'https://api.e2b.app/sandboxes', fly: `https://api.machines.dev/v1/apps/${encodeURIComponent(pc().project)}/machines` })[name];++ async function call(method, suffix = '', body, resource = 'sandboxes') {++ if (!pc().key) throw new Error('provider key missing');++ const headers = { 'content-type': 'application/json', ...(name === 'e2b' ? { 'X-API-Key': pc().key } : { authorization: `Bearer ${pc().key}` }) };++ if (name === 'baseten' && pc().project) headers['X-Team-Id'] = pc().project;++ const response = await fetchImpl((name === 'e2b' && resource === 'templates' ? 'https://api.e2b.app/templates' : base()) + suffix, { method, headers, redirect: 'error', signal: AbortSignal.timeout(30000), ...(body ? { body: JSON.stringify(body) } : {}) });++ if (method === 'GET' && response.status === 404) return { deleted: true };++ if (method === 'DELETE' && response.status === 404) return {};++ if (!response.ok) throw new Error(`provider ${method} failed (${response.status})`);++ return response.status === 204 ? {} : response.json();++ }++ return [name, {++ async quote({ gpu_mb = 0 } = {}) {++ if (gpu_mb > 0) return []; // These four adapters provision CPU shapes only.++ const quotes = (await prices()).filter(p => p.provider === name && p.id === pc().price_id && p.region === pc().region++ && p.expires_at > now() && p.hourly > 0 && Number.isFinite(p.hourly) && p.ram_mb >= 1024 && p.cpu >= 1)++ .map(p => ({ ...p, tier: 'partner', billing_seconds: 1, provisionable: true, gpu_mb: 0 }));++ if (name === 'e2b' && pc().key && pc().image) {++ const template = await call('GET', '/' + encodeURIComponent(pc().image), undefined, 'templates');++ return quotes.filter(q => template.cpuCount === q.cpu && template.memoryMB === q.ram_mb);++ }++ return quotes;++ },++ async provision(q, { label, image, runtime_ms = 3600000 }) {++ const seconds = Math.ceil(runtime_ms / 1000);++ let body;++ if (name === 'baseten') body = { name: label, image, memory: q.ram_mb, cpu: q.cpu, region: q.region,++ lifecycle: { expiration_policies: [{ action: 'DELETE', type: 'TTL_MAX_AGE', value: `${seconds}s` }] } };++ if (name === 'daytona') body = { name: label, snapshot: image, target: q.region, public: false,++ ttlMinutes: Math.ceil(seconds / 60), autoStopInterval: Math.ceil(seconds / 60), autoDeleteInterval: 0, cpu: q.cpu, memory: q.ram_mb / 1024, disk: q.disk_gb || 5 };++ if (name === 'e2b') {++ const template = await call('GET', '/' + encodeURIComponent(image), undefined, 'templates');++ if (template.cpuCount !== q.cpu || template.memoryMB !== q.ram_mb) throw new Error('template resources differ from the quoted shape');++ }++ if (name === 'e2b') body = { templateID: image, timeout: seconds, cpuCount: q.cpu, autoPause: false, metadata: { name: label } };++ if (name === 'fly') body = { name: label, region: q.region, config: { image, auto_destroy: true,++ restart: { policy: 'no' }, guest: { cpu_kind: 'shared', cpus: q.cpu, memory_mb: q.ram_mb }, metadata: { rental: label } } };++ const r = await call('POST', '', body);++ return { id: name === 'baseten' ? r.name : name === 'e2b' ? r.sandboxID : r.id };++ },++ async release(id) { await call('DELETE', '/' + encodeURIComponent(id) + (name === 'fly' ? '?force=true' : '')); },++ async health(id) {++ const r = await call('GET', '/' + encodeURIComponent(id));++ return { deleted: r.deleted === true || (name === 'baseten' && r.status === 'TERMINATED') || (name === 'fly' && r.state === 'destroyed'), state: r.state || r.status };++ },++ }];++ }));++}++function createPriceReader({ fetchImpl = globalThis.fetch, now = Date.now }) {++ let cached = [], until = 0;++ return async () => {++ if (now() < until) return cached;++ try {++ const r = await fetchImpl('https://openvibe.exchange/api/compute/prices', { redirect: 'error', signal: AbortSignal.timeout(5000) });++ if (!r.ok) return [];++ const data = await r.json();++ cached = Array.isArray(data.prices) ? data.prices : []; until = now() + 60000;++ return cached;++ } catch { return []; }++ };++}++module.exports = { createSandboxProviders, createPriceReader };nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++// The same encrypted, audited owner configuration store used by the other services.++const shared = require('openvibe-shared/config');++const { serviceAuth, capabilities, ids } = require('openvibe-contracts');++const NAMES = ['baseten', 'daytona', 'e2b', 'fly'];++const DEFAULTS = { enabled: false, api_key: '', monthly_cap: '0', max_concurrent: 0, allowed_kinds: [],++ image: '', region: '', project: '', extra_hourly: '0', costs_bounded: false,++ credit_amount: '0', credit_expires: '', promotion_expires: '', price_id: '' };++const decimal = { type: 'string', pattern: '^(0|[1-9][0-9]*)(\\.[0-9]{1,6})?$' };++const date = { type: 'string', pattern: '^$|^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$' };++const SCHEMA = { type: 'object', additionalProperties: false, required: Object.keys(DEFAULTS), properties: {++ enabled: { type: 'boolean', title: 'Allow rentals', 'x-danger': true },++ api_key: { type: 'string', title: 'Provider API key', 'x-group': 'Credentials' },++ monthly_cap: { ...decimal, title: 'Monthly USD cap (gross charges, before credits)' },++ max_concurrent: { type: 'integer', minimum: 0, maximum: 10000 },++ allowed_kinds: { type: 'array', uniqueItems: true, items: { enum: ['function', 'code', 'browser', 'linux', 'desktop', 'gpu'] } },++ image: { type: 'string', maxLength: 300, title: 'Prepared worker image or template' },++ region: { type: 'string', maxLength: 80 }, project: { type: 'string', maxLength: 80, title: 'Fly app / Baseten team' },++ extra_hourly: { ...decimal, title: 'Bounded disk and network USD per hour' },++ costs_bounded: { type: 'boolean', title: 'Image enforces disk and network limits', 'x-danger': true },++ credit_amount: { ...decimal, title: 'Confirmed one-time USD credit grant' },++ credit_expires: { ...date, title: 'Credit expiry (UTC)' },++ promotion_expires: { ...date, title: 'Disable new rentals after promotion (UTC)' },++ price_id: { type: 'string', maxLength: 100, title: 'Exchange compute price ID' },++} };++async function createSettings({ db, config, keys, broker, env = process.env, log = console }) {++ const stores = [];++ const masterKeys = ['enabled','dry_run','owner','job_pool','pools'];++ const master = await shared.createConfigStore({db, service:'run',namespace:'run.compute',env,log,++ defaults:Object.fromEntries(masterKeys.map(k=>[k,config.compute[k]])),++ schema:{type:'object',additionalProperties:false,properties:{++ enabled:{type:'boolean','x-danger':true},dry_run:{type:'boolean','x-danger':true},++ owner:{type:'string',pattern:'^$|^usr_[0-9A-HJKMNP-TV-Z]{26}$'},job_pool:{type:'string'},++ pools:{type:'object',additionalProperties:{type:'object',required:['kind','providers'],additionalProperties:true,properties:{++ kind:{enum:['function','code','browser','linux','desktop','gpu']},providers:{type:'array',items:{enum:[...NAMES,'hetzner','ovh','vast','runpod']}},++ strategy:{enum:['automatic','cheapest','fastest','private','own']},currency:{type:'string',pattern:'^[A-Z]{3}$'},++ region:{type:'string'},min_warm:{type:'integer',minimum:0,maximum:100},max_machines:{type:'integer',minimum:0,maximum:10000},++ slots_per_machine:{type:'integer',minimum:1,maximum:100},max_runtime_ms:{type:'integer',minimum:60000,maximum:3600000},++ target_wait_ms:{type:'integer',minimum:0},idle_grace_ms:{type:'integer',minimum:0},max_hourly:{type:'number',minimum:0},++ gpu_mb:{type:'integer',minimum:0},measured_peak_ram_mb:{type:'integer',minimum:0},public_egress:{type:'boolean'}++ }}}++ }}});++ stores.push(master);++ for(const key of masterKeys) Object.defineProperty(config.compute,key,{enumerable:true,configurable:true,get:()=>master.get(key)});++ for (const name of NAMES) {++ const store = await shared.createConfigStore({ db, service: 'run', namespace: `run.compute_${name}`,++ schema: SCHEMA, defaults: DEFAULTS, classify: { api_key: 'secret' }, env, log });++ stores.push(store);++ const pc = { currency: 'USD', managed: true };++ for (const key of Object.keys(DEFAULTS)) Object.defineProperty(pc, key, { enumerable: key !== 'api_key', get: () => store.get(key) });++ Object.defineProperty(pc, 'key', { get: () => { const value = store.get('api_key'); return typeof value === 'string' ? value : ''; } });++ config.compute.providers[name] = pc;++ }++ const guard = (req, res, next) => {++ const token = String(req.headers.authorization || '').replace(/^Bearer /, '');++ const publicKey = keys.get();++ const result = publicKey && serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });++ if (!result?.ok || result.claims.sub !== 'svc:network' || !capabilities.check(result.claims, 'run.config.manage').allowed || !ids.isSubjectId('user', req.headers['x-ov-subject']))++ return res.status(403).json({ error: 'Network owner configuration capability required' });++ // Never let shared/config fall back to plaintext storage without an encryption key.++ if (req.method === 'PUT' && !env.OV_CONFIG_KEKS) return res.status(503).json({ error: 'Configuration encryption is not installed' });++ next();++ };++ const router = shared.adminRoutes(stores, { basePath: '/internal/config', requireAdmin: guard,++ actor: req => ({ type: 'user', id: req.headers['x-ov-subject'] }),++ requireFresh: req => req.headers['x-ov-fresh'] === '1', log });++ return { stores, mount(app) {++ app.get('/internal/config/compute-status', guard, (req,res,next) => broker.status().then(status =>++ res.set('Cache-Control','no-store').json({enabled:status.enabled,dry_run:status.dry_run,budgets:status.budgets}),next));++ router.mount(app);++ } };++}++module.exports = { NAMES, DEFAULTS, SCHEMA, createSettings };@@@ -251,19 +251,36 @@ function createPlacement({ config, db = null, fetchImpl = globalThis.fetch, nowconst req = { ...requirements, capabilities: [...new Set([...(requirements.capabilities || []), want])] };contracts.assertValid('platform.workload-requirements@1', req);let computeDecision = null;++ const rentals = db ? await db.many("SELECT * FROM run_compute_rentals WHERE state <> 'released'") : [];if (db) {-- const draining = new Set((await db.many("SELECT machine FROM run_compute_rentals WHERE state IN ('draining','releasing','uncertain')")).map(r => r.machine));++ const draining = new Set(rentals.filter(r => ['draining','releasing','uncertain'].includes(r.state)).map(r => r.machine));candidates = candidates.filter(o => !draining.has(o.node_id));}++ // A sandbox enrolled under the operator's Bot identity is still third-party compute.++ // Apply provider scope and trust before the planner's privacy checks, even for ordinary jobs.++ candidates = candidates.flatMap(o => {++ const rental = rentals.find(r => r.machine === o.node_id);++ const pc = rental && config.compute?.providers[rental.provider];++ if (!pc?.managed) return [o];++ if (rental.pool !== config.compute.job_pool || !pc.enabled || !pc.allowed_kinds.includes(row.class)++ || (pc.promotion_expires && !(Date.parse(pc.promotion_expires) > now()))) return [];++ return [{ ...o, trust: 'partner', owner: undefined }];++ });const poolId = config.compute && config.compute.job_pool;const pool = poolId && config.compute.pools[poolId];if (pool) {const saved = db && await db.maybe('SELECT strategy FROM run_compute_pools WHERE id=$1', [poolId]);const hard = plan(req, candidates, { now: now() });const eligible = new Set(hard.candidates.filter(c => c.eligible).map(c => c.id));-- const ms = candidates.filter(o => eligible.has(o.offer_id)).map(o => {-- const v = config.compute.inventory[o.node_id] || {};-- return { ...v, id: o.offer_id, owner: o.owner, tier: o.trust === 'user-owned' ? 'own' : o.trust === 'community' ? 'community' : o.trust === 'first-party' ? (v.tier || 'dedicated') : 'partner',++ const ms = candidates.filter(o => eligible.has(o.offer_id)).filter(o => {++ const rental = rentals.find(r => r.machine === o.node_id);++ const pc = rental && config.compute.providers[rental.provider];++ return !pc?.managed || (pc.enabled && pc.allowed_kinds.includes(row.class) && (!pc.promotion_expires || Date.parse(pc.promotion_expires) > now()));++ }).map(o => {++ const rental = rentals.find(r => r.machine === o.node_id);++ const quote = rental ? (typeof rental.quote === 'string' ? JSON.parse(rental.quote) : rental.quote) : {};++ const v = { ...(config.compute.inventory[o.node_id] || {}), ...(rental ? { tier: quote.tier || 'partner', hourly: Number(rental.hourly_micros)/1e6, currency:rental.currency, gpu_mb:quote.gpu_mb } : {}) };++ return { ...v, id: o.offer_id, owner: o.owner, tier: rental ? (quote.tier || 'partner') : o.trust === 'user-owned' ? 'own' : o.trust === 'community' ? 'community' : o.trust === 'first-party' ? (v.tier || 'dedicated') : 'partner',health: o.health.status, region: o.region, currency: v.currency || 'USD', hourly: v.hourly ?? (o.trust === 'user-owned' ? 0 : null),free_ram_mb: o.capacity?.memory?.available_mb ?? v.free_ram_mb ?? 0,free_slots: o.capacity?.workers?.[row.class] ?? v.free_slots ?? 0,@@@ -38,6 +38,7 @@ async function main() {const accountData = accountDataLib.create({ db, log: console });const accountSend = config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret }) : null;const app = createApp({ config, db, valkey, registry, accountData, accountSend });++ await app.locals.initComputeSettings();const { keys, outbox, stream, tickets, dispatch, indexnow, sessions, broker } = app.locals;keys.start();const ticketKey = tickets.start();@@@ -20,7 +20,7 @@ const B = 'usr_01JZ0000000000000000000BBB';const d = await testDb();try {const accountData = create({ db: d.db });-- const missing = await uncovered(d.db, { tables: [...TABLES, 'run_jobs'], exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Run applied' } });++ const missing = await uncovered(d.db, { tables: [...TABLES, 'run_jobs'], exempt: { config_snapshots: 'Owner configuration revision audit; service settings and encrypted credentials are retained independently of the operator account', account_data_events: 'the ledger of requests itself: which export and deletion Run applied' } });assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);for (const [subject, name, jobId] of [[A, 'ada', 'job_01JZ0000000000000000000ADA'], [B, 'bo', 'job_01JZ0000000000000000000BQB']]) {nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++const assert=require('node:assert/strict');++const {testDb}=require('./helpers/db');++const {createBroker}=require('../server/compute/broker');++(async()=>{++ for(const scenario of ['credit','expired','expires during job','exhausted','promotion','own','partial credit','promo extras','memory','region','latency','job ceiling']) {++ const t=await testDb(), now=Date.UTC(2026,10,11), created=[];++ const config={compute:{enabled:true,dry_run:false,owner:'alice',inventory:{own:{pool:'p',ram_mb:4096}},++ pools:{p:{kind:'browser',strategy:'cheapest',currency:'USD',providers:['daytona','fly'],min_warm:1,max_machines:2}},providers:{}}};++ const adapters={};++ for(const [name,hourly] of [['daytona',.2],['fly',.02]]) {++ config.compute.providers[name]={managed:true,enabled:true,key:'fake',currency:'USD',monthly_cap:'10',image:'prepared',costs_bounded:true,extra_hourly:'0',allowed_kinds:['browser'],max_concurrent:2,++ credit_amount:name==='daytona'?(scenario==='credit'?'1':scenario==='partial credit'?'0.21':'0'):'0',credit_expires:scenario==='expired'?'2026-11-10T00:00:00Z':'2026-12-01T00:00:00Z',promotion_expires:['promotion','promo extras'].includes(scenario)&&name==='daytona'?'2026-11-16T00:00:00Z':''};++ adapters[name]={quote:async()=>[{provider:name,id:name,hourly,cpu:2,ram_mb:scenario==='memory'&&name==='daytona'?8192:4096,gpu_mb:0,currency:'USD',region:'us',tier:'partner',billing_seconds:1,expires_at:now+60000,promotion_expires:['promotion','promo extras'].includes(scenario)?'2026-11-16T00:00:00Z':''}],provision:async()=>{created.push(name);return {id:name};}};++ }++ if(scenario==='promo extras')config.compute.providers.daytona.extra_hourly='0.1';++ if(scenario==='expired')config.compute.providers.daytona.credit_amount='1';++ if(scenario==='expires during job'){config.compute.providers.daytona.credit_amount='1';config.compute.providers.daytona.credit_expires=new Date(now+60000).toISOString();}++ if(['memory','region','latency','job ceiling'].includes(scenario)) {++ const requirements={memory:{resources:{memory_mb:5000}},region:{region:'eu'},latency:{max_latency_ms:10},'job ceiling':{max_cost_usd:.001}}[scenario];++ await t.db.query("INSERT INTO run_sessions (id,owner,kind,state,width,height,ttl_s,idle_s,request_digest,created_at,updated_at,compute_pool,requirements) VALUES ('ses_need','user:alice','browser','placing',1280,720,900,60,'need',$1,$1,'p',$2)",[now-60000,JSON.stringify(requirements)]);++ }++ const desks={enabled:true,hosts:async()=>scenario==='own'?[{machine_id:'own',trust:'user-owned',online:true,max_browser_desks:2,free:2}]:[]};++ try {await createBroker({db:t.db,config,adapters,desks,now:()=>now}).tick();assert.deepEqual(created,['own','region','latency','job ceiling'].includes(scenario)?[]:[['credit','promotion','partial credit','memory'].includes(scenario)?'daytona':'fly'],scenario);}++ finally{await t.close();}++ }++ console.log('placement: own capacity, confirmed credit, promotion expiry and paid-price fallback passed');++})().catch(e=>{console.error(e);process.exitCode=1;});nnew file mode 100644@@@ -0,0 +1,16 @@++'use strict';++const assert=require('node:assert/strict');++const {createPlacement}=require('../server/dispatch/placement');++(async()=>{++ const now=Date.now(), node='dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X',pc={managed:true,enabled:true,allowed_kinds:['browser']};++ const config={network:{url:'https://network.invalid'},oauth:{},dispatch:{offersTtlMs:0,timeoutMs:1000},compute:{job_pool:'p',pools:{p:{strategy:'cheapest',currency:'USD'}},inventory:{[node]:{hourly:0,free_ram_mb:4096,free_slots:1}},providers:{daytona:pc}}};++ const rental={machine:node,state:'running',pool:'p',provider:'daytona',currency:'USD',hourly_micros:'200000',quote:{tier:'partner'}};++ const offer={offer_id:'off_node_01',node_id:node,kind:'node',region:'us',trust:'first-party',capabilities:['worker:browser'],health:{status:'up',checked_at:new Date(now).toISOString()},pricing:{model:'prepaid'},updated_at:new Date(now).toISOString()};++ const placer=createPlacement({config,now:()=>now,db:{many:async()=>[rental],maybe:async()=>null},fetchImpl:async()=>({ok:true,json:async()=>({offers:[offer]})})});++ const row={class:'browser',requester:'service:actor',job:{requirements:{...require('../server/util').requirementsDefault('browser'),objective:'cheapest'}}};++ const chosen=await placer.choose(row);assert.ok(chosen);assert.equal(chosen.trust,'partner');assert.ok(chosen.reasons.some(x=>x.includes('0.2 USD/hour')),'actual rental price overrides inventory zero');++ row.job.requirements.objective='private';assert.equal(await placer.choose(row),null,'enrollment cannot turn third-party rental into private compute');++ row.job.requirements.objective='cheapest';pc.allowed_kinds=['code'];assert.equal(await placer.choose(row),null);++ pc.allowed_kinds=['browser'];config.compute.job_pool='';assert.equal(await placer.choose(row),null,'rentals never escape the configured job pool');++ console.log('job rental trust: provider scope, privacy and gross rental price passed');++})().catch(e=>{console.error(e);process.exitCode=1;});nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++const assert=require('node:assert/strict');++const {testDb}=require('./helpers/db');++const {createBroker}=require('../server/compute/broker');++(async()=>{++ const t=await testDb();let now=Date.UTC(2026,10,11);++ const pc={managed:true,enabled:false,key:'fake',monthly_cap:'1',currency:'USD',image:'prepared',costs_bounded:true,extra_hourly:'0',max_concurrent:1,allowed_kinds:['browser']};++ const config={compute:{enabled:true,dry_run:false,owner:'alice',inventory:{},pools:{p:{kind:'browser',max_runtime_ms:3600000}},providers:{baseten:pc}}};++ const q={id:'q',provider:'baseten',currency:'USD',hourly:.8,expires_at:now+60000,billing_seconds:1};++ const b=createBroker({db:t.db,config,desks:{enabled:false},adapters:{},now:()=>now});++ try {++ assert.match((await b.reserve('p',q)).reason,/disabled/);++ pc.enabled=true;pc.allowed_kinds=['agent'];assert.match((await b.reserve('p',q)).reason,/kind/);++ pc.allowed_kinds=['browser'];pc.promotion_expires='2026-11-10T00:00:00Z';assert.match((await b.reserve('p',q)).reason,/expired/);++ pc.promotion_expires='';assert.ok((await b.reserve('p',q)).rental);++ assert.match((await b.reserve('p',q)).reason,/concurrency/);++ assert.equal((await t.db.many("SELECT * FROM run_compute_journal WHERE event='budget.near_cap'")).length,1);++ assert.equal((await b.status()).budgets[0].near_cap,true);++ pc.max_concurrent=2;assert.match((await b.reserve('p',q)).reason,/monthly cap/);++ console.log('provider off/kind/expiry checks, concurrent reservation limit, hard cap and 80% alert: passed');++ } finally {await t.close();}++})().catch(e=>{console.error(e);process.exitCode=1;});nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++const assert=require('node:assert/strict');++const {boot}=require('./helpers/app');++const {ids}=require('openvibe-contracts');++(async()=>{++ const t=await boot();++ try {++ await t.app.locals.initComputeSettings();++ const headers={'x-ov-subject':ids.newId('user'),'x-ov-fresh':'1'};++ const options={sub:'svc:network',cap:['run.config.manage'],headers};++ assert.equal((await t.call('GET','/internal/config',{...options,sub:'svc:actor'})).status,403);++ assert.equal((await t.call('GET','/internal/config',{...options,cap:['run.job.submit']})).status,403);++ const r=await t.call('GET','/internal/config',options);assert.equal(r.status,200,r.text);assert.equal(r.json.namespaces.length,5);++ const status=await t.call('GET','/internal/config/compute-status',options);assert.equal(status.status,200,status.text);assert.equal(status.json.budgets.length,8);++ const write=await t.call('POST','/internal/config/run.compute_daytona',{...options,body:{values:{max_concurrent:2},merge:true,reason:'test policy'}});++ assert.equal(write.status,200,write.text);assert.equal(t.config.compute.providers.daytona.max_concurrent,2);++ assert.equal(t.config.compute.providers.daytona.enabled,false);++ const secret=await t.call('PUT','/internal/config/run.compute_daytona/secrets/api_key',{...options,body:{value:'fake-only',reason:'test encryption refusal'}});++ assert.equal(secret.status,503,secret.text);++ assert.ok(!JSON.stringify((await t.call('GET','/internal/config',options)).json).includes('fake-only'));++ console.log('compute settings: Network-only scope, defaults off, live updates and no unencrypted secret writes passed');++ }finally{await t.close();}++ const sealed=await boot({appOpts:{env:{OV_CONFIG_KEKS:'test:'+require('node:crypto').randomBytes(32).toString('base64'),OV_CONFIG_KEK_CURRENT:'test'}}});++ try {++ await sealed.app.locals.initComputeSettings();++ const options={sub:'svc:network',cap:['run.config.manage'],headers:{'x-ov-subject':ids.newId('user'),'x-ov-fresh':'1'}};++ const secret='fake-provider-credential-for-test';++ const saved=await sealed.call('PUT','/internal/config/run.compute_daytona/secrets/api_key',{...options,body:{value:secret,reason:'test sealed key'}});++ assert.equal(saved.status,200,saved.text);assert.ok(!saved.text.includes(secret));++ assert.equal(sealed.config.compute.providers.daytona.key,secret);++ assert.ok(!JSON.stringify(await sealed.db.many('SELECT values_json FROM config_snapshots')).includes(secret),'database holds ciphertext only');++ const read=await sealed.call('GET','/internal/config/run.compute_daytona',options);assert.ok(!read.text.includes(secret));++ assert.equal((await sealed.call('PUT','/internal/config/run.compute_daytona/secrets/api_key',{...options,headers:{...options.headers,'x-ov-fresh':'0'},body:{value:'different-fake',reason:'stale sign-in'}})).status,403);++ console.log('provider secrets: sealed at rest, write-only responses and fresh sign-in required passed');++ }finally{await sealed.close();}++})().catch(e=>{console.error(e);process.exitCode=1;});nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++const assert=require('node:assert/strict');++const {createSandboxProviders,createPriceReader}=require('../server/compute/sandbox-providers');++(async()=>{++ const now=Date.UTC(2026,10,11), config={providers:{}}, calls=[];++ for(const name of ['baseten','daytona','e2b','fly']) config.providers[name]={key:'fake-key',project:'app',price_id:name,region:'us'};++ const prices=async()=>Object.keys(config.providers).map(provider=>({id:provider,provider,region:'us',hourly:.1,ram_mb:4096,cpu:2,expires_at:now+60000}));++ let deleted=false;++ const adapters=createSandboxProviders({config,now:()=>now,prices,fetchImpl:async(url,opts)=>{++ calls.push({url,...opts}); return {ok:!deleted,status:deleted?404:200,json:async()=>({id:'remote',name:'remote',sandboxID:'remote',state:'running',cpuCount:2,memoryMB:4096})};++ }});++ for(const [name,a] of Object.entries(adapters)) {++ deleted=false; const [q]=await a.quote(); assert.ok(q);assert.deepEqual(await a.quote({gpu_mb:1}),[]);++ assert.deepEqual(await a.provision(q,{label:'ov-test',image:'prepared',runtime_ms:120000}),{id:'remote'});++ const request=calls.at(-1);assert.equal(request.redirect,'error');assert.ok(!request.body.includes('fake-key'));++ if(name==='baseten')assert.equal(JSON.parse(request.body).lifecycle.expiration_policies[0].value,'120s');++ if(name==='daytona'){assert.equal(JSON.parse(request.body).ttlMinutes,2);assert.equal(JSON.parse(request.body).cpu,2);assert.equal(JSON.parse(request.body).memory,4);}++ if(name==='e2b')assert.equal(JSON.parse(request.body).timeout,120);++ assert.equal((await a.health('remote')).deleted,false);++ await a.release('remote'); deleted=true; assert.equal((await a.health('remote')).deleted,true);++ config.providers[name].region='elsewhere';assert.deepEqual(await a.quote(),[]);++ }++ let attempts=0;++ const read=createPriceReader({now:()=>now,fetchImpl:async()=>{attempts++;throw Error('offline');}});++ assert.deepEqual(await read(),[]);assert.equal(attempts,1);++ console.log('four sandbox adapters: lifecycle, bounded timeouts, GPU/region refusal, credential isolation and price outage passed');++})().catch(e=>{console.error(e);process.exitCode=1;});
@@ -96,3 +96,42 @@ Checks: `npm test -- compute sessions dispatch`; the full suite is `OV_ALLOW_SUI
@@ -96,3 +96,42 @@ Checks: `npm test -- compute sessions dispatch`; the full suite is `OV_ALLOW_SUI
The SDK dependency is pinned to the reviewed job commit until its PR is merged and released;
The SDK dependency is pinned to the reviewed job commit until its PR is merged and released;
replace that pin with the resulting SDK release before shipping. Do not deploy this PR before
replace that pin with the resulting SDK release before shipping. Do not deploy this PR before
Opus reviews money, worker enrollment, privacy and cancellation behavior.
Opus reviews money, worker enrollment, privacy and cancellation behavior.
+
+## Owner-managed sandboxes (ask 356)
+
+Network `/admin#ai` → platform settings → **OpenVibe.Run** exposes `run.compute` and
+`run.compute_baseten`, `run.compute_daytona`, `run.compute_e2b`, `run.compute_fly`.
+Only Network's owner-ranked admin proxy can use `run.config.manage`. Keys are write-only,
+sealed by shared/config; Run refuses a secret PUT without `OV_CONFIG_KEKS`. The owner supplies
+keys later; this release supplies none. Changing settings activates them without restart.
+
+Start with `run.compute.enabled=false`, `dry_run=true`. Add a pool with `kind`, `providers`,
+`strategy: "cheapest"`, `currency: "USD"`, a region, `max_machines`, `max_runtime_ms` and
+`slots_per_machine`. Set `owner` to the subject owning the enrolled machines. Set `job_pool`
+for queued agent jobs using their actual runtime class (`code`, `linux`, or `function`); set measured RAM per worker for that image. Browser sessions select the pool through their existing request query.
+The four providers each default off, zero concurrency, zero cap and no allowed kinds.
+
+For each provider set `price_id` from https://openvibe.exchange/compute, matching `region`,
+prepared `image` (E2B template / Daytona snapshot), key, `allowed_kinds`, `max_concurrent`,
+positive USD `monthly_cap`, bounded `extra_hourly`, and `costs_bounded`. Fly additionally needs
+an existing app in `project`; Baseten can use `project` to select its team. Images/templates
+must have exactly the selected quote's resource shape and enroll fresh identities through the
+existing Link/worker path; E2B uses `provider-default` as its region marker because this API has no region selector; it cannot satisfy a named-region purchase. Its template CPU/RAM are checked against the quote before creation. The adapter does not create
+accounts, apps, templates or reusable enrollment credentials. Keep `min_warm: 0` unless warm
+capacity is intentional. The global enabled/dry switches must also be changed for provisioning.
+
+The Exchange catalogue supplies dated public tariffs; its explicit expiry is enforced, and an
+unavailable catalogue yields no new purchases. CPU adapters reject GPU requests; existing
+RunPod/Vast and AI GPU routing remain the GPU path. Baseten/Daytona/E2B send native lifetime
+limits; Fly relies on Run's durable deadline and delete reconciliation. A stopped resource is
+not treated as deleted. Do not revoke a key while rentals still need cleanup.
+
+Set `credit_amount` to a confirmed one-time grant and `credit_expires` to its actual UTC expiry.
+Recorded charges and outstanding reservations reduce that grant across months; credits only
+change ranking, never the gross hard cap. Untracked external use of the same grant is not
+observable: use a dedicated account/budget, or set the credit to zero. A promotional free rate
+requires both the owner expiry and Exchange's advertised expiry to cover the full reservation;
+otherwise the ordinary price wins. At `promotion_expires`, rentals drain and no new ones start.
+At 80% of committed cap, Run writes `budget.near_cap` and displays an alert in `/api/v1/compute`.
+Network also shows these figures and warnings in the owner-only Worker compute budgets section of `/admin#ai`, through `/api/admin/ai/config/compute/status`. The Run view admits the configured machine owner as well as existing operators. Unknown provider
+outcomes keep reservations, block retries and remain visible for operator resolution.
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+# Compute providers — ask 356
+
+Checked **2026-11-11 UTC** (2026-11-10 Pacific). USD unless stated. These are public prices, not negotiated quotes or proof of available capacity. `unknown` means not verified on the provider's own page; it is not zero. Per-second equivalents below are arithmetic conversions, not promises about billing granularity. No account was created, credit claimed, key installed or paid API called.
+
+## Picks and placement
+
+**Baseten, Daytona, E2B, Fly Machines** are the first four worker adapters. Baseten meets the explicit request and offers a short promotion; Daytona combines agent/browser tooling with a large trial; E2B has a documented sandbox lifecycle and useful no-card allowance; Fly supplies inexpensive general-purpose Linux VMs with an ordinary REST lifecycle and region selection. GPU inference remains in AI; Run already has RunPod and Vast adapters via SDK. Do not duplicate those ledgers in a new control plane.
+
+For a continuously busy CPU worker, Fly's shared 2-vCPU/2-GiB shape is about $0.018597/hour, compared with Daytona/E2B's 2-vCPU/4-GiB $0.1656/hour and Baseten's $0.2154/hour. These are **different CPU and memory allocations**, not a performance benchmark. At ten active minutes, their base estimates are respectively $0.003100, $0.027600, and $0.035900, before disk, network and subscriptions. Benchmark complete jobs and queue delay before changing defaults.
+
+Run owns placements, leases, reconciliation, reservation locks and deletion. Codes' sandbox workers (`server/domain/sandbox-workers.js`) enforce heartbeat/isolation checks; Tools' `/workers` registry remains in `apps/gateway/server/workers`; Network's machine hub fronts Bot (`server/registry/machines.js`), while cell/node principals stay in Network. Provider images must enroll fresh worker identities through these existing paths. A purchased VM alone is not a healthy worker.
+
+Keep own eligible machines first. Among new rentals, require a current Exchange price, region/resources, allowed kind, prepared image, key, concurrency and positive gross monthly cap. Rank confirmed unexpired credits/promotions first, then the bounded job-duration cost (including deletion allowance and disk/network bound). Unknown purchase/delete outcomes retain reservations and stop retries. Credits never increase the gross spending cap. A promotion expiry stops new purchases and drains running rentals; do not assume the provider automatically stops billing.
+
+## Sandbox and CPU comparison
+
+Every row was checked on **2026-11-11**. Credit expiry is account-specific unless explicitly shown. Cold-start claims are vendor claims, not measurements. A dash in GPU means this CPU sandbox product is not a verified GPU rental.
+
+| Provider / primary source | Price and free allowance | Limits / startup / isolation / regions / API |
+|---|---|---|
+| [Baseten Sandboxes](https://www.baseten.co/resources/changelog/baseten-sandboxes-are-now-available), [pricing](https://www.baseten.co/pricing), [API](https://docs.baseten.co/reference/management-api/sandboxes/create-a-sandbox) | Free until **November 16, 2026**; exact cutoff timezone unspecified, use conservative Nov 16 00:00 UTC. Paid CPU $0.0655/vCPU-hour + $0.0211/GB-hour ($0.0000181944 and $0.0000058611 per second). Snapshot $0.20/GB-month. Pricing HTML's embedded data confirms the paid rates even where the rendered fetch says enable JavaScript. | 10 concurrent/org. Isolated sandbox; no measured cold-start SLA. Default 4GB/2CPU. API lists `us-pdx-1`, `eu-lon-1` examples; query configuration for actual account regions. REST `/v1/sandboxes/instances`, Bearer API key, GET/DELETE by **name**, Python/TS/Go clients. Native DELETE expiry policies. GPU: not verified for this sandbox API. |
+| [Daytona](https://www.daytona.io/pricing), [regions](https://www.daytona.io/docs/en/regions/), [limits](https://www.daytona.io/docs/en/limits/), [API](https://www.daytona.io/docs/tools/api/) | $200 one-time trial, no card; expiry not published on pricing page. $0.0504/vCPU-hour + $0.0162/GiB-hour = $0.000014/vCPU-second + $0.0000045/GiB-second; disk $0.000108/GiB-hour after first 5GiB. | Per-second billing. Millisecond startup claim. Tier 1: aggregate 10 vCPU/10GiB RAM/30GiB disk, so sandbox count depends on shape; do not treat API requests/minute as concurrency. US/EU targets. Linux sandbox; confirm selected isolation class and egress restrictions for untrusted workloads. REST POST/GET/DELETE `/sandbox`, snapshots, exec/files SDK. GPU prices below. |
+| [E2B](https://e2b.dev/pricing), [lifecycle](https://docs.e2b.dev/sandbox) | $100 one-time Hobby credit, no card; expiry not stated. CPU $0.000014/vCPU-second + RAM $0.0000045/GiB-second. Default 2CPU/4GiB = $0.000046/second. Pro $150/month plus usage. | Hobby 10 concurrent/1h, Pro 100/24h (paid concurrency upgrades). Isolated Linux VM; template-based startup, not measured here. The inspected REST create API has no region selector; the executable catalogue uses `provider-default`, never an invented US/EU residency promise. REST sandbox lifecycle and TS/Python SDK; API timeout is seconds, JS SDK timeoutMs is milliseconds. GPU not verified. |
+| [Modal](https://modal.com/pricing), [sandboxes](https://modal.com/docs/guide/sandbox) | $30/month Starter credit; 100 containers + 10 GPU concurrency. $0.0000131/**physical core**-second (2 vCPU equivalent), RAM $0.00000222/GiB-second. GPU T4 $0.000164/s, L4 $0.000222/s, H100 $0.001097/s. | Python-first sandbox API, container isolation; confirm workload policy/region in sandbox docs. Region selection offered. Startup not measured. Volumes $0.09/GiB-month; network $0.04/GiB after included allowance. Strong GPU/batch candidate; defer until native SDK lifecycle fits Run. |
+| [Fly Machines](https://docs.fly.io/about/pricing), [API](https://docs.fly.io/machines/api/machines-resource) | Shared 2CPU/2GB $13.39/720h in iad/ewr = $0.0185972/h, $0.000005166/s. $1 trial lasts 7 days or until used. No general recurring free tier; personal small-bill waiver is not an organizational credit. | VM isolation, per-second running charges; startup capacity-dependent. Stopped rootfs and volumes still bill. Region-specific prices. REST `/v1/apps/{app}/machines`; Bearer token. Delete, not merely stop, to end resource charges. GPU not selected. |
+| [Northflank](https://northflank.com/pricing) | Free sandbox: 2 services, 2 jobs, 1 DB, up to $20/month resources, no card. PAYG CPU starts $0.01667/vCPU-hour ($0.000004631/s); memory/storage extra. | Per-second billing. Always-on free services; no cold-start claim to assume. Containers/BYOC; isolation for hostile code requires explicit sandbox configuration. Regions/quotas depend on deployment. REST/Platform SDK for jobs/services. GPU types and total selected-shape price require live quote. |
+| [Koyeb](https://www.koyeb.com/pricing) | RTX A6000 $0.75/h ($0.0002083/s), L40S $1.20/h, A100 80GB $1.60/h. Pro $29/month includes $10 compute; Scale $299 includes $100. | Secure VMs, per-second billing, scale-to-zero. Pro 100 services/5 concurrent builds (not worker concurrency). REST service deployments; selected region/availability and cold-start must be checked. Free GPU allowance not established. |
+| [Cloudflare Containers](https://developers.cloudflare.com/containers/platform/pricing/), [Sandbox SDK](https://developers.cloudflare.com/sandbox/) | Workers Paid $5/month; includes 25GiB-hours memory, 375 vCPU-minutes, 200GB-hours disk. Overages $0.0000025/GiB-second, $0.000020/active-vCPU-second, $0.00000007/GB-second disk. | 10ms accounting; memory/disk allocated, CPU active. Workers + Durable Objects + egress fees also apply. TS Sandbox SDK sits on Containers, not a separate free compute pool. Instance types and account limits control concurrency. Platform placement; verify residency. No GPU. Separate Worker deployment required, so deferred. |
+| [Vercel Sandbox](https://vercel.com/docs/sandbox/pricing.md) | Hobby includes 5 CPU-hours/month, 420GB-hours RAM, 5,000 creations, 20GB transfer. Pro CPU $0.128/h ($0.000035556/s), RAM $0.0212/GB-hour ($0.000005889/s); region-dependent. | Hobby 10 concurrent/45min; Pro 10,000/24h. 19 regions, default iad1. Isolated sandbox, REST/TS SDK; startup not measured. Creation, snapshot, drive and transfer charges must be included. No GPU verified. |
+| [CodeSandbox SDK](https://codesandbox.io/?product=service), [pricing](https://codesandbox.io/pricing) | Pricing endpoint returned 403; current price, free allowance and expiry **unverified**, not imported. | Provider describes microVMs, start/clone/restore within 2s, configurable hibernation and SDK/API. Account concurrency and regions unverified; advertised platform capacity is not an account quota. No GPU verified. |
+| [Morph](https://cloud.morph.so/web/subscribe) | Base MCU $0.05; one MCU-hour covers 1vCPU +4GB RAM +16GB disk; dimension maximum determines units. Equivalent $0.000013889/MCU-second, but page describes hourly billing. Developer $0/month with **0 starting MCUs**; Team $40 includes 1,000 MCUs. | Full VMs/branching snapshots, seconds startup claim. Devbox concurrency 8/32/128 by tier; regions unverified. Devbox API/SDK with snapshots/forks. Do not confuse developer plan with free compute. |
+| [Blaxel](https://blaxel.ai/pricing), [regions](https://docs.blaxel.ai/Infrastructure/Regions) | Active sandbox $0.0000115/GB-RAM-second; snapshot $0.20/GB-month, images $0.045/GB-month. Credit amount/expiry unverified. | Hardware-isolated microVMs, ~25ms **resume** claim, not complete cold creation. Platform-scale concurrency claims are not account quotas. Oregon/Virginia/London/Frankfurt docs; REST `/sandboxes`, Python/TS/Go. CPU scales with memory; compare whole-job cost. No GPU verified for sandbox. |
+| [Fly Sprites (additional candidate)](https://fly.io/sprites/) | No separate numeric tariff verified here; Fly's $1 trial can cover Sprites, shared with Machines. | Persistent hardware-isolated Linux environments, checkpoint/restore and scale-to-zero; REST and several SDKs. Current account quotas, selected region, startup and storage cost need verification. Promising follow-up, but not conflated with Machines API. |
+
+## GPU comparison
+
+Checked **2026-11-11**; each price is from the linked provider page. GPU-only rates exclude host CPU/RAM/disk unless the offer explicitly bundles them. No public number guarantees availability, isolation or permission to run a browser worker.
+
+| Provider | Current public evidence | Operational fit / unanswered details |
+|---|---|---|
+| [Daytona GPU](https://www.daytona.io/pricing) | RTX4090 24GB **$0.57/h preemptible** ($0.000158333/s), **$0.99/h on-demand** ($0.000275/s). 5090 $0.74/$1.29, H100 $2.27/$3.95, H200 $2.61/$4.54 preemptible/on-demand. | Same sandbox control plane; GPU resources have their own availability/quota. Only checkpointable work can use the cheaper preemptible rate. CPU/RAM/disk still matter. |
+| [RunPod Pods and Serverless](https://www.runpod.io/pricing) | Page updated Nov 9: Pod 4090 $0.89/h ($0.000247222/s), A40 $0.59/h, L40S $1.09/h. Serverless displayed 4090 $1.34/h equivalent ($0.000372222/s). | Pods are dedicated instances; serverless is an inference/job endpoint, not a drop-in persistent browser. 30+ regions; launch/cold time image-dependent. API pod create/terminate versus endpoint run/status/cancel. Quotas account-specific; no universal credit verified. Existing Run adapter remains. |
+| [Vast.ai](https://vast.ai/pricing) | Dynamic supply/demand quotes, no reliable fixed GPU rate in fetched page. Per-second on-demand; interruptible and reserved variants. | 40+ data centers; host trust and location vary. REST offer search, instance create/destroy; Docker/VM options must be verified per offer. Storage and transfer are separate. No blanket free credit, concurrency or startup SLA verified. Existing Run adapter remains. |
+| [Lambda](https://lambda.com/pricing) | Direct pricing HTML lists a 16-GPU H100 cluster at $6.16/GPU-hour on a 2-week–1-year term ($0.0017111/GPU-second equivalent, **not** a per-second commitment). Single-instance offers vary by GPU count; no instance rate imported. | VM/cluster GPU rentals; provider says instance deployment takes minutes. Availability, selected region, account quota and billing quantum require instance-type API/account quote. Better for long GPU work than small browser jobs. Trial credit not verified. |
+| [TensorDock](https://www.tensordock.com/cloud-gpus.html), [live console](https://console.tensordock.com/deploy) | Marketing 4090 typical $0.35/h GPU-only; live console from $0.50/h, example Orlando full shape $0.495/h. Marketing H100 SXM5 $2.25/h, A100 SXM4 $1.80/h. | Host/resources explain different prices; never seed the marketing minimum as a purchasable quote. Per-second prorating, VM API, host-specific regions and hardware. Concurrency/startup/credit unverified. |
+| [Hyperbolic](https://www.hyperbolic.xyz/blog/deepseek-r1-0528) | Own **May 28, 2025** post advertised H100 $0.99/h. This is historical, not a verified November 2026 tariff; `/pricing` returned 404. | GPU marketplace/API. Current region, isolation, capacity, billing quantum and credit require fresh provider quote. Excluded from executable prices. |
+| [Novita GPU Cloud](https://novita.ai/gpus), [pricing](https://novita.ai/pricing) | Dynamic GPU table did not expose a dependable numeric rate. No price imported. | Provider advertises 14 regions, REST/gRPC/CLI, per-second serverless, scale-to-zero in 30s and sub-second startup **after prewarming**. “Unlimited concurrency” is a platform claim; enforce account quotas. GPU shape, isolation and free-credit expiry require live quote. |
+
+## Verification and activation conditions
+
+Research used built-in web search plus direct public provider HTML/Markdown. The ov-deep-research skill's paid Search/Fetch calls were not made because RUNNER-RULES forbids spending money. Some provider pages are JS-only or block fetches; missing values remain explicit. No secondary aggregator price was promoted to an executable quote.
+
+Before turning on a provider: confirm account credits/expiry and quotas, set a positive gross monthly cap and concurrency, choose a current Exchange shape/region, supply a prepared worker image with enforced disk/egress bounds, enroll fresh worker identities, and run dry mode. Shared config secrets require the host's existing encryption-key setup; missing encryption refuses a secret write. Never bake reusable Network credentials into an image. A fake lifecycle test cannot establish real upstream availability, deletion reliability or worker isolation.
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+-- phase: expand
+CREATE TABLE IF NOT EXISTS config_snapshots (
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ previous_revision bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
+ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)
+ classification_json text COLLATE "C" NOT NULL,
+ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ activated_at text COLLATE "C",
+ activated_by text COLLATE "C",
+ reason text COLLATE "C",
+ error text COLLATE "C",
+ copied_from bigint,
+ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)
+ PRIMARY KEY (namespace, revision)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
+-- The fingerprint key of each namespace. Never served, never logged.
+CREATE TABLE IF NOT EXISTS config_keys (
+ namespace text COLLATE "C" PRIMARY KEY,
+ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK
+ created_at text COLLATE "C" NOT NULL
+);
\ No newline at end of file
\ No newline at end of file
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.404.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1228,9 +1228,9 @@
@@ -1228,9 +1228,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.399.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
- "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",
+ "version": "0.404.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.404.0",
+ "integrity": "sha512-yJsuZIqKEsjlx92Y5c0C/8wJBdA713NFNu797KVbvZHSvgPJJS/75pdE3t9pFiuo/Bb6XgfwHIaHGqHPM/bRAw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.404.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -77,6 +77,13 @@ function createApp(opts = {}) {
@@ -77,6 +77,13 @@ function createApp(opts = {}) {
const release = require('openvibe-shared/release').createRelease({ service: 'run', root: path.join(__dirname, '..') });
const release = require('openvibe-shared/release').createRelease({ service: 'run', root: path.join(__dirname, '..') });
const app = express();
const app = express();
+ const settingsRouter = express.Router();
+ app.use('/internal/config', express.json({limit:'128kb'}));
+ app.use(settingsRouter);
+ let settingsPromise;
+ app.locals.initComputeSettings = () => settingsPromise ||= require('./compute/settings')
+ .createSettings({db,config,keys,broker,env:opts.env || process.env,log})
+ .then(settings => { settings.mount(settingsRouter); return settings; });
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
// (openvibe-contracts); the operations they mark internal-only are left out.
app.get('/openapi.json', (_req, res) => {
app.get('/openapi.json', (_req, res) => {
@@ -5,7 +5,8 @@ const obj=v=>typeof v==='string'?JSON.parse(v):v;
@@ -5,7 +5,8 @@ const obj=v=>typeof v==='string'?JSON.parse(v):v;
const bare=v=>String(v || '').replace(/^user:/,'');
const bare=v=>String(v || '').replace(/^user:/,'');
function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,adapters,log=console}) {
function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,adapters,log=console}) {
const cfg=config.compute || {pools:{},inventory:{},providers:{},operators:[],dry_run:true};
const cfg=config.compute || {pools:{},inventory:{},providers:{},operators:[],dry_run:true};
- const providers=adapters || createProviders({fetch:fetchImpl,config:cfg.providers,now});
+ const {createSandboxProviders,createPriceReader}=require('./sandbox-providers');
+ const providers=adapters || {...createProviders({fetch:fetchImpl,config:cfg.providers,now}),...createSandboxProviders({config:cfg,fetchImpl,now,prices:createPriceReader({fetchImpl,now})})};
let timer=null,running=null,finish=null,finishJobs=null;
let timer=null,running=null,finish=null,finishJobs=null;
const month=()=>new Date(now()).toISOString().slice(0,7);
const month=()=>new Date(now()).toISOString().slice(0,7);
const journal=(event,detail={},r={})=>db.query('INSERT INTO run_compute_journal (at,event,rental,pool,detail) VALUES ($1,$2,$3,$4,$5)',[now(),event,r.id || null,r.pool || null,JSON.stringify(detail)]);
const journal=(event,detail={},r={})=>db.query('INSERT INTO run_compute_journal (at,event,rental,pool,detail) VALUES ($1,$2,$3,$4,$5)',[now(),event,r.id || null,r.pool || null,JSON.stringify(detail)]);
@@ -64,6 +65,7 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
@@ -64,6 +65,7 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
if(q.provisionable===false || !pc.image || !pc.costs_bounded || pc.extra_hourly==null) return {reason:'quote only: prepared image and bounded disk/egress costs required'};
if(q.provisionable===false || !pc.image || !pc.costs_bounded || pc.extra_hourly==null) return {reason:'quote only: prepared image and bounded disk/egress costs required'};
if(q.currency!==pc.currency || q.expires_at<now() || !Number.isFinite(q.hourly) || q.hourly<=0) return {reason:'invalid or expired quote'};
if(q.currency!==pc.currency || q.expires_at<now() || !Number.isFinite(q.hourly) || q.hourly<=0) return {reason:'invalid or expired quote'};
const p=await poolConfig(pool);
const p=await poolConfig(pool);
+ if(pc.managed && (!pc.enabled || !pc.allowed_kinds.includes(p.kind || 'browser') || (pc.promotion_expires && !(Date.parse(pc.promotion_expires)>now())))) return {reason:'provider disabled, kind refused or promotion expired'};
const hourly=BigInt(Math.ceil(q.hourly*1e6))+micros(pc.extra_hourly);
const hourly=BigInt(Math.ceil(q.hourly*1e6))+micros(pc.extra_hourly);
if(p.max_hourly!=null && hourly>micros(p.max_hourly)) return {reason:'total price exceeds pool ceiling'};
if(p.max_hourly!=null && hourly>micros(p.max_hourly)) return {reason:'total price exceeds pool ceiling'};
const runtime=Math.min(3600000,Math.max(60000,p.max_runtime_ms || 3600000));
const runtime=Math.min(3600000,Math.max(60000,p.max_runtime_ms || 3600000));
@@ -79,11 +81,13 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
@@ -79,11 +81,13 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
if(poolRows.length >= (p.max_machines ?? 10)) return {reason:'pool machine limit reached'};
if(poolRows.length >= (p.max_machines ?? 10)) return {reason:'pool machine limit reached'};
const rows=await t.many("SELECT * FROM run_compute_rentals WHERE provider=$1 AND (month=$2 OR state <> 'released')",[q.provider,month()]);
const rows=await t.many("SELECT * FROM run_compute_rentals WHERE provider=$1 AND (month=$2 OR state <> 'released')",[q.provider,month()]);
if(rows.some(r=>['uncertain','releasing'].includes(r.state))) return {reason:'an unresolved provider call blocks new rentals'};
if(rows.some(r=>['uncertain','releasing'].includes(r.state))) return {reason:'an unresolved provider call blocks new rentals'};
+ if(pc.managed && rows.filter(r=>r.state!=='released').length>=pc.max_concurrent) return {reason:'provider concurrency limit reached'};
const settled=await t.value('SELECT COALESCE(SUM(c.amount_micros),0) FROM run_compute_charges c JOIN run_compute_rentals r ON r.id=c.rental WHERE r.provider=$1 AND c.month=$2',[q.provider,month()]);
const settled=await t.value('SELECT COALESCE(SUM(c.amount_micros),0) FROM run_compute_charges c JOIN run_compute_rentals r ON r.id=c.rental WHERE r.provider=$1 AND c.month=$2',[q.provider,month()]);
const committed=BigInt(settled)+rows.filter(r=>r.state!=='released').reduce((sum,r)=>sum+BigInt(r.reserved_micros),0n);
const committed=BigInt(settled)+rows.filter(r=>r.state!=='released').reduce((sum,r)=>sum+BigInt(r.reserved_micros),0n);
if(committed+reserved>cap) return {reason:'monthly cap reached'};
if(committed+reserved>cap) return {reason:'monthly cap reached'};
const r={id:'ov-'+randomUUID(),pool,provider:q.provider,month:month(),currency:q.currency,created_at:now(),deadline:now()+runtime,quote:q};
const r={id:'ov-'+randomUUID(),pool,provider:q.provider,month:month(),currency:q.currency,created_at:now(),deadline:now()+runtime,quote:q};
await t.query("INSERT INTO run_compute_rentals (id,pool,provider,state,month,currency,hourly_micros,reserved_micros,quote,created_at,deadline) VALUES ($1,$2,$3,'provisioning',$4,$5,$6,$7,$8,$9,$10)",[r.id,pool,r.provider,r.month,r.currency,hourly.toString(),reserved.toString(),JSON.stringify(q),r.created_at,r.deadline]);
await t.query("INSERT INTO run_compute_rentals (id,pool,provider,state,month,currency,hourly_micros,reserved_micros,quote,created_at,deadline) VALUES ($1,$2,$3,'provisioning',$4,$5,$6,$7,$8,$9,$10)",[r.id,pool,r.provider,r.month,r.currency,hourly.toString(),reserved.toString(),JSON.stringify(q),r.created_at,r.deadline]);
+ if((committed+reserved)*100n>=cap*80n) await t.query('INSERT INTO run_compute_journal (at,event,rental,pool,detail) VALUES ($1,$2,$3,$4,$5)',[now(),'budget.near_cap',r.id,pool,JSON.stringify({provider:q.provider,cap:pc.monthly_cap,committed_micros:(committed+reserved).toString()})]);
await t.query('INSERT INTO run_compute_journal (at,event,rental,pool,detail) VALUES ($1,$2,$3,$4,$5)',[now(),'provision.reserved',r.id,pool,JSON.stringify({reserved_micros:reserved.toString(),hourly_micros:hourly.toString(),quote:q.id})]);
await t.query('INSERT INTO run_compute_journal (at,event,rental,pool,detail) VALUES ($1,$2,$3,$4,$5)',[now(),'provision.reserved',r.id,pool,JSON.stringify({reserved_micros:reserved.toString(),hourly_micros:hourly.toString(),quote:q.id})]);
return {rental:r};
return {rental:r};
});
});
@@ -93,7 +97,7 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
@@ -93,7 +97,7 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
const result=await reserve(pool,q); if(!result.rental) return result;
const result=await reserve(pool,q); if(!result.rental) return result;
const r=result.rental;
const r=result.rental;
try {
try {
- const remote=await providers[q.provider].provision(q,{label:r.id,image:cfg.providers[q.provider].image});
+ const remote=await providers[q.provider].provision(q,{label:r.id,image:cfg.providers[q.provider].image,runtime_ms:r.deadline-now()});
if(!remote?.id) throw new Error('no remote id');
if(!remote?.id) throw new Error('no remote id');
await db.query("UPDATE run_compute_rentals SET remote_id=$2,state='starting' WHERE id=$1",[r.id,String(remote.id)]);
await db.query("UPDATE run_compute_rentals SET remote_id=$2,state='starting' WHERE id=$1",[r.id,String(remote.id)]);
await journal('provision.accepted',{remote_id:String(remote.id)},r);
await journal('provision.accepted',{remote_id:String(remote.id)},r);
@@ -144,7 +148,7 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
@@ -144,7 +148,7 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
const committed=rentals.filter(x=>x.provider===r.provider).reduce((sum,x)=>sum+BigInt(x.reserved_micros),settled);
const committed=rentals.filter(x=>x.provider===r.provider).reduce((sum,x)=>sum+BigInt(x.reserved_micros),settled);
let unhealthy=false;
let unhealthy=false;
if(r.remote_id) { try { const h=await providers[r.provider].health(r.remote_id); unhealthy=h.deleted || ['error','stopped','off','dead'].includes(String(h.state).toLowerCase()); } catch { /* unknown health keeps the reservation and deadline */ } }
if(r.remote_id) { try { const h=await providers[r.provider].health(r.remote_id); unhealthy=h.deleted || ['error','stopped','off','dead'].includes(String(h.state).toLowerCase()); } catch { /* unknown health keeps the reservation and deadline */ } }
- const stop=!cfg.enabled || now()>=Number(r.deadline) || committed>micros(pc.monthly_cap || '0') || unhealthy;
+ const stop=!cfg.enabled || (pc.managed && (!pc.enabled || (pc.promotion_expires && now()>=Date.parse(pc.promotion_expires)))) || now()>=Number(r.deadline) || committed>micros(pc.monthly_cap || '0') || unhealthy;
if(stop && r.remote_id) {
if(stop && r.remote_id) {
await db.query("UPDATE run_compute_rentals SET state='draining' WHERE id=$1",[r.id]);
await db.query("UPDATE run_compute_rentals SET state='draining' WHERE id=$1",[r.id]);
if(r.machine && finish) for(const s of await db.many("SELECT id FROM run_sessions WHERE machine=$1 AND state <> 'ended'",[r.machine])) await finish(s.id);
if(r.machine && finish) for(const s of await db.many("SELECT id FROM run_sessions WHERE machine=$1 AND state <> 'ended'",[r.machine])) await finish(s.id);
@@ -178,7 +182,10 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
@@ -178,7 +182,10 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
if(m.sessions>0 && r.idle_since!=null) await db.query('UPDATE run_compute_rentals SET idle_since=NULL WHERE id=$1',[r.id]);
if(m.sessions>0 && r.idle_since!=null) await db.query('UPDATE run_compute_rentals SET idle_since=NULL WHERE id=$1',[r.id]);
} else ms.push({id:r.id,state:'provisioning',health:'starting',sessions:null});
} else ms.push({id:r.id,state:'provisioning',health:'starting',sessions:null});
}
}
- const waiting=await db.many("SELECT created_at,compute_strategy FROM run_sessions WHERE compute_pool=$1 AND state='placing' AND machine_asked IS NULL AND (compute_strategy IS NULL OR compute_strategy <> 'own') ORDER BY created_at",[id]);
+ const waiting=await db.many("SELECT created_at,compute_strategy,requirements FROM run_sessions WHERE compute_pool=$1 AND state='placing' AND machine_asked IS NULL AND (compute_strategy IS NULL OR compute_strategy <> 'own') ORDER BY created_at",[id]);
+ if(cfg.job_pool===id) waiting.push(...await db.many("SELECT created_at,job->'requirements' AS requirements FROM run_jobs WHERE state='queued' AND class=$1 AND job->>'node' IS NULL AND COALESCE(job->'requirements'->>'objective','') NOT IN ('private','first-party-only')",[p.kind]));
+ waiting.sort((a,b)=>Number(a.created_at)-Number(b.created_at));
+ const need=obj(waiting[0]?.requirements) || {};
const desired=scale(p,ms,waiting.map(s=>({created_at:Number(s.created_at)})),{now:now()});
const desired=scale(p,ms,waiting.map(s=>({created_at:Number(s.created_at)})),{now:now()});
for(const a of desired.actions) {
for(const a of desired.actions) {
const r=rows.find(r=>r.machine===a.id || r.id===a.id);
const r=rows.find(r=>r.machine===a.id || r.id===a.id);
@@ -186,14 +193,41 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
@@ -186,14 +193,41 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
if(a.action==='release' && r && !cfg.dry_run) await release(r);
if(a.action==='release' && r && !cfg.dry_run) await release(r);
if(a.action==='provision') {
if(a.action==='provision') {
const qs=(await quotes(id)).flatMap(x=>x.quotes).filter(q=>q.provisionable!==false && q.hourly!=null && q.currency===(p.currency || 'USD') && q.ram_mb>=((p.kind==='desktop'?2048:300)+1024));
const qs=(await quotes(id)).flatMap(x=>x.quotes).filter(q=>q.provisionable!==false && q.hourly!=null && q.currency===(p.currency || 'USD') && q.ram_mb>=((p.kind==='desktop'?2048:300)+1024));
+ const creditAvailable={};
+ for(const q of qs) {
+ const pc=cfg.providers[q.provider] || {};
+ if(!pc.managed || !(Date.parse(pc.credit_expires)>now()+Math.min(3600000,Math.max(60000,p.max_runtime_ms || 3600000))+300000))continue;
+ const spent=BigInt(await db.value('SELECT COALESCE(SUM(c.amount_micros),0) FROM run_compute_charges c JOIN run_compute_rentals r ON r.id=c.rental WHERE r.provider=$1',[q.provider]));
+ const held=(await live()).filter(r=>r.provider===q.provider).reduce((n,r)=>n+BigInt(r.reserved_micros),0n);
+ creditAvailable[q.provider]=micros(pc.credit_amount)-spent-held;
+ }
let candidates=qs.flatMap(q=>{
let candidates=qs.flatMap(q=>{
const pc=cfg.providers[q.provider] || {};
const pc=cfg.providers[q.provider] || {};
+ if(pc.managed && (!pc.enabled || !pc.allowed_kinds.includes(p.kind || 'browser') || (pc.promotion_expires && !(Date.parse(pc.promotion_expires)>now()))))return [];
if(!cfg.dry_run && (!pc.key || micros(pc.monthly_cap || '0')===0n || !pc.image || !pc.costs_bounded || pc.extra_hourly==null))return [];
if(!cfg.dry_run && (!pc.key || micros(pc.monthly_cap || '0')===0n || !pc.image || !pc.costs_bounded || pc.extra_hourly==null))return [];
+ const resources=need.resources || {};
+ const trust=pc.managed?'partner':q.tier==='dedicated'?'first-party':q.tier==='community'?'community':'partner';
+ if((need.trust && !need.trust.includes(trust)) || (resources.gpu && !(q.gpu_mb>0))
+ || (resources.cpu && !(q.cpu>=resources.cpu)) || (resources.memory_mb && !(q.ram_mb-1024>=resources.memory_mb))
+ || (resources.scratch_gb && !(q.disk_gb>=resources.scratch_gb))
+ || (need.residency && q.region!==need.residency)
+ || (need.region && need.region!=='nearest' && q.region!==need.region)
+ || (need.max_latency_ms!=null && !(q.latency_ms<=need.max_latency_ms)))return [];
const extra=pc.extra_hourly==null?0:Number(micros(pc.extra_hourly))/1e6;
const extra=pc.extra_hourly==null?0:Number(micros(pc.extra_hourly))/1e6;
- return [{...q,hourly:q.hourly+extra,id:q.provider+':'+q.id,health:'up',free_ram_mb:q.ram_mb-1024,free_slots:p.slots_per_machine || 1}];
+ const duration=Math.min(3600000,Math.max(60000,p.max_runtime_ms || 3600000))+300000;
+ const estimate=charge(BigInt(Math.ceil((q.hourly+extra)*1e6)),duration,q.billing_seconds)+micros(q.setup || 0);
+ if(need.max_cost_usd!=null && estimate>micros(need.max_cost_usd))return [];
+ const promotion=pc.promotion_expires && Date.parse(pc.promotion_expires)>now()+duration && Date.parse(q.promotion_expires)>now()+duration;
+ // A free CPU promotion does not waive disk/network or setup charges. Apply confirmed
+ // remaining credit to the complete bounded job cost, including a partially covered job.
+ const cash=promotion?charge(micros(pc.extra_hourly || '0'),duration,q.billing_seconds)+micros(q.setup || 0):estimate;
+ const credit=creditAvailable[q.provider]>0n?creditAvailable[q.provider]:0n;
+ const net=cash-(credit>cash?cash:credit);
+ const ranked=pc.managed?Number(net)/1e6*3600000/duration:q.hourly+extra;
+ return [{...q,hourly:ranked,id:q.provider+':'+q.id,health:'up',free_ram_mb:q.ram_mb-1024,free_slots:p.slots_per_machine || 1}];
});
});
while(true) {
while(true) {
- const d=choose({kind:p.kind,strategy:waiting[0]?.compute_strategy || p.strategy,region:p.region,gpu_mb:p.gpu_mb},candidates,p,{now:now()});
+ const d=choose({kind:p.kind,strategy:waiting[0]?.compute_strategy || ({private:'private','first-party-only':'private','lowest-latency':'fastest',cheapest:'cheapest'}[need.objective]) || p.strategy,region:p.region,gpu_mb:p.gpu_mb},candidates,p,{now:now()});
const q=qs.find(q=>q.provider+':'+q.id===d.selected);
const q=qs.find(q=>q.provider+':'+q.id===d.selected);
if(!q){await journal('scale.decision',{reason:a.reason,decision:d,result:{reason:'no eligible rental quote'}},{pool:id});break;}
if(!q){await journal('scale.decision',{reason:a.reason,decision:d,result:{reason:'no eligible rental quote'}},{pool:id});break;}
const result=await provision(id,q);
const result=await provision(id,q);
@@ -216,7 +250,7 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
@@ -216,7 +250,7 @@ function createBroker({db,config,desks,now=Date.now,fetchImpl=globalThis.fetch,a
const settled=BigInt(await db.value('SELECT COALESCE(SUM(c.amount_micros),0) FROM run_compute_charges c JOIN run_compute_rentals r ON r.id=c.rental WHERE r.provider=$1 AND c.month=$2',[provider,month()]));
const settled=BigInt(await db.value('SELECT COALESCE(SUM(c.amount_micros),0) FROM run_compute_charges c JOIN run_compute_rentals r ON r.id=c.rental WHERE r.provider=$1 AND c.month=$2',[provider,month()]));
let spent=settled,committed=settled,hourly=0n;
let spent=settled,committed=settled,hourly=0n;
for(const r of rs.filter(r=>r.state!=='released')){const h=BigInt(r.hourly_micros);spent+=charge(h,now()-Math.max(Number(r.created_at),Date.parse(month()+'-01T00:00:00Z')),obj(r.quote).billing_seconds);committed+=BigInt(r.reserved_micros);hourly+=h;}
for(const r of rs.filter(r=>r.state!=='released')){const h=BigInt(r.hourly_micros);spent+=charge(h,now()-Math.max(Number(r.created_at),Date.parse(month()+'-01T00:00:00Z')),obj(r.quote).billing_seconds);committed+=BigInt(r.reserved_micros);hourly+=h;}
- budgets.push({provider,currency:pc.currency,cap:pc.monthly_cap,configured:Boolean(pc.key),spent_micros:spent.toString(),committed_micros:committed.toString(),hourly_micros:hourly.toString()});
+ budgets.push({near_cap:micros(pc.monthly_cap || '0')>0n && committed*100n>=micros(pc.monthly_cap)*80n,provider,currency:pc.currency,cap:pc.monthly_cap,configured:Boolean(pc.key),spent_micros:spent.toString(),committed_micros:committed.toString(),hourly_micros:hourly.toString()});
}
}
let machines=[];
let machines=[];
if(cfg.owner && desks.enabled) {try {machines=await inventory(await desks.hosts(cfg.owner),cfg.owner);} catch {machines=Object.entries(cfg.inventory).map(([id,v])=>({...v,id,health:'unknown'}));}}
if(cfg.owner && desks.enabled) {try {machines=await inventory(await desks.hosts(cfg.owner),cfg.owner);} catch {machines=Object.entries(cfg.inventory).map(([id,v])=>({...v,id,health:'unknown'}));}}
@@ -10,7 +10,7 @@ function loadCompute(env) {
@@ -10,7 +10,7 @@ function loadCompute(env) {
}
}
for (const [id,p] of Object.entries(pools)) {
for (const [id,p] of Object.entries(pools)) {
if (!/^[a-z0-9-]{1,40}$/.test(id) || !STRATEGIES.includes(p.strategy || 'automatic')) throw new Error('invalid compute pool');
if (!/^[a-z0-9-]{1,40}$/.test(id) || !STRATEGIES.includes(p.strategy || 'automatic')) throw new Error('invalid compute pool');
- if (p.kind && !['browser','desktop'].includes(p.kind)) throw new Error('invalid pool kind');
+ if (p.kind && !['function','code','browser','linux','desktop','gpu'].includes(p.kind)) throw new Error('invalid pool kind');
}
}
const providers={};
const providers={};
for (const name of ['hetzner','ovh','vast','runpod']) {
for (const name of ['hetzner','ovh','vast','runpod']) {
@@ -6,7 +6,7 @@ function computeRouter({broker,config}) {
@@ -6,7 +6,7 @@ function computeRouter({broker,config}) {
const r=express.Router();
const r=express.Router();
const wrap=fn=>(req,res,next)=>Promise.resolve(fn(req,res)).catch(next);
const wrap=fn=>(req,res,next)=>Promise.resolve(fn(req,res)).catch(next);
r.use('/compute',(req,res,next)=>{
r.use('/compute',(req,res,next)=>{
- if(req.principal?.kind!=='user' || !(config.compute?.operators || []).includes(req.principal.subject))return res.status(403).json({error:'operator access required'});
+ if(req.principal?.kind!=='user' || ![...(config.compute?.operators || []),config.compute?.owner].includes(req.principal.subject))return res.status(403).json({error:'operator access required'});
if(req.method!=='GET' && req.get('origin')!==config.baseUrl)return res.status(403).json({error:'same-origin write required'});
if(req.method!=='GET' && req.get('origin')!==config.baseUrl)return res.status(403).json({error:'same-origin write required'});
next();
next();
});
});
@@ -15,7 +15,7 @@ function computeRouter({broker,config}) {
@@ -15,7 +15,7 @@ function computeRouter({broker,config}) {
if(req.query.format==='json')return res.json(s);
if(req.query.format==='json')return res.json(s);
const money=v=>(Number(v)/1e6).toFixed(4);
const money=v=>(Number(v)/1e6).toFixed(4);
const machines=[...s.machines.map(m=>({...m,machine:m.id,provider:s.rentals.find(r=>r.machine===m.id)?.provider || 'existing',hourly_micros:m.hourly==null?null:m.hourly*1e6})),...s.rentals.filter(r=>!s.machines.some(m=>m.id===r.machine)).map(r=>({...r,tier:(typeof r.quote==='string'?JSON.parse(r.quote):r.quote).tier}))];
const machines=[...s.machines.map(m=>({...m,machine:m.id,provider:s.rentals.find(r=>r.machine===m.id)?.provider || 'existing',hourly_micros:m.hourly==null?null:m.hourly*1e6})),...s.rentals.filter(r=>!s.machines.some(m=>m.id===r.machine)).map(r=>({...r,tier:(typeof r.quote==='string'?JSON.parse(r.quote):r.quote).tier}))];
- res.type('html').send(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Compute pools · OpenVibe Run</title><style>:root{color-scheme:light dark;font:16px/1.6 system-ui}body{margin:0;background:light-dark(#f6f8fa,#101519);color:light-dark(#16232c,#e6edf2)}main{max-width:1100px;margin:auto;padding:24px}h1{font-size:2rem;line-height:1.2}h2{margin-top:2rem}section{padding:20px;margin:16px 0;border:1px solid #70809060;border-radius:12px}button,select{font:inherit;min-height:44px;padding:8px 12px;border-radius:6px;border:1px solid #70809080}form{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0}a{color:light-dark(#075a85,#87d6ff)}table{display:block;overflow-x:auto;border-collapse:collapse;max-width:100%;text-align:left}th,td{padding:10px;border-bottom:1px solid #70809060;white-space:nowrap}ol{padding-left:24px}li{overflow-wrap:anywhere;margin:12px 0}@media(max-width:600px){main{padding:16px}h1{font-size:1.7rem}}</style><body><main><h1>Compute pools</h1><p>${s.dry_run?'Dry run — rentals disabled':'Live rentals within configured caps'} · ${s.enabled?'Reconciler enabled':'Reconciler disabled'}</p><h2>Pools</h2>${s.pools.map(p=>`<section><h3>${esc(p.id)}</h3><p>Strategy: ${esc(p.strategy)} · Warm minimum: ${esc(p.min_warm || 0)} · Queue target: ${esc(p.target_wait_ms ?? 30000)} ms</p><form method="post" action="/api/v1/compute/pools/${esc(p.id)}/strategy"><select name="strategy">${STRATEGIES.map(v=>`<option${v===p.strategy?' selected':''}>${v}</option>`).join('')}</select><button>Apply strategy</button></form><a href="/api/v1/compute/pools/${esc(p.id)}/quotes">Quotes and dry-run prices</a></section>`).join('')}<h2>Money</h2><table><tr><th>Provider</th><th>Currency</th><th>Per hour</th><th>This month</th><th>Committed</th><th>Cap</th></tr>${s.budgets.map(b=>`<tr><td>${esc(b.provider)}</td><td>${esc(b.currency)}</td><td>${money(b.hourly_micros)}</td><td>${money(b.spent_micros)}</td><td>${money(b.committed_micros)}</td><td>${esc(b.cap)}</td></tr>`).join('')}</table><h2>Machines</h2><table><tr><th>Pool</th><th>Machine / rental</th><th>Tier / provider</th><th>State</th><th>Sessions</th><th>Per hour</th></tr>${machines.map(m=>`<tr><td>${esc(m.pool)}</td><td>${esc(m.machine || m.id)}</td><td>${esc(m.tier)} / ${esc(m.provider)}</td><td>${esc(m.state || m.health)}</td><td>${s.sessions.find(x=>x.machine && x.machine===m.machine)?.sessions || 0}</td><td>${m.hourly_micros==null?'unknown':money(m.hourly_micros)} ${esc(m.currency)}</td></tr>`).join('')}</table><h2>Last decisions and switches</h2><ol>${s.journal.map(j=>`<li>${esc(new Date(Number(j.at)).toISOString())} ${esc(j.event)} ${esc(JSON.stringify(j.detail))}</li>`).join('')}</ol></main></body></html>`);
+ res.type('html').send(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Compute pools · OpenVibe Run</title><style>:root{color-scheme:light dark;font:16px/1.6 system-ui}body{margin:0;background:light-dark(#f6f8fa,#101519);color:light-dark(#16232c,#e6edf2)}main{max-width:1100px;margin:auto;padding:24px}h1{font-size:2rem;line-height:1.2}h2{margin-top:2rem}section{padding:20px;margin:16px 0;border:1px solid #70809060;border-radius:12px}button,select{font:inherit;min-height:44px;padding:8px 12px;border-radius:6px;border:1px solid #70809080}form{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0}a{color:light-dark(#075a85,#87d6ff)}table{display:block;overflow-x:auto;border-collapse:collapse;max-width:100%;text-align:left}th,td{padding:10px;border-bottom:1px solid #70809060;white-space:nowrap}ol{padding-left:24px}li{overflow-wrap:anywhere;margin:12px 0}@media(max-width:600px){main{padding:16px}h1{font-size:1.7rem}}</style><body><main><h1>Compute pools</h1><p>${s.dry_run?'Dry run — rentals disabled':'Live rentals within configured caps'} · ${s.enabled?'Reconciler enabled':'Reconciler disabled'}</p><h2>Pools</h2>${s.pools.map(p=>`<section><h3>${esc(p.id)}</h3><p>Strategy: ${esc(p.strategy)} · Warm minimum: ${esc(p.min_warm || 0)} · Queue target: ${esc(p.target_wait_ms ?? 30000)} ms</p><form method="post" action="/api/v1/compute/pools/${esc(p.id)}/strategy"><select name="strategy">${STRATEGIES.map(v=>`<option${v===p.strategy?' selected':''}>${v}</option>`).join('')}</select><button>Apply strategy</button></form><a href="/api/v1/compute/pools/${esc(p.id)}/quotes">Quotes and dry-run prices</a></section>`).join('')}<h2>Money</h2>${s.budgets.filter(b=>b.near_cap).map(b=>`<p role="alert">${esc(b.provider)} has committed at least 80% of its monthly cap.</p>`).join('')}<table><tr><th>Provider</th><th>Currency</th><th>Per hour</th><th>This month</th><th>Committed</th><th>Cap</th></tr>${s.budgets.map(b=>`<tr><td>${esc(b.provider)}</td><td>${esc(b.currency)}</td><td>${money(b.hourly_micros)}</td><td>${money(b.spent_micros)}</td><td>${money(b.committed_micros)}</td><td>${esc(b.cap)}</td></tr>`).join('')}</table><h2>Machines</h2><table><tr><th>Pool</th><th>Machine / rental</th><th>Tier / provider</th><th>State</th><th>Sessions</th><th>Per hour</th></tr>${machines.map(m=>`<tr><td>${esc(m.pool)}</td><td>${esc(m.machine || m.id)}</td><td>${esc(m.tier)} / ${esc(m.provider)}</td><td>${esc(m.state || m.health)}</td><td>${s.sessions.find(x=>x.machine && x.machine===m.machine)?.sessions || 0}</td><td>${m.hourly_micros==null?'unknown':money(m.hourly_micros)} ${esc(m.currency)}</td></tr>`).join('')}</table><h2>Last decisions and switches</h2><ol>${s.journal.map(j=>`<li>${esc(new Date(Number(j.at)).toISOString())} ${esc(j.event)} ${esc(JSON.stringify(j.detail))}</li>`).join('')}</ol></main></body></html>`);
}));
}));
r.get('/compute/pools/:pool/quotes',wrap(async(req,res)=>res.json({dry_run:true,providers:await broker.quotes(req.params.pool)})));
r.get('/compute/pools/:pool/quotes',wrap(async(req,res)=>res.json({dry_run:true,providers:await broker.quotes(req.params.pool)})));
r.post('/compute/pools/:pool/strategy',express.urlencoded({extended:false,limit:'2kb'}),wrap(async(req,res)=>{await broker.setStrategy(req.params.pool,req.body.strategy);res.redirect(303,'/api/v1/compute');}));
r.post('/compute/pools/:pool/strategy',express.urlencoded({extended:false,limit:'2kb'}),wrap(async(req,res)=>{await broker.setStrategy(req.params.pool,req.body.strategy);res.redirect(303,'/api/v1/compute');}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+const { NAMES } = require('./settings');
+// Public prices come from Exchange. No caller-supplied URL or credential reaches a provider.
+function createSandboxProviders({ config, fetchImpl = globalThis.fetch, now = Date.now, prices }) {
+ return Object.fromEntries(NAMES.map(name => {
+ const pc = () => config.providers[name] || {};
+ const base = () => ({ baseten: 'https://api.baseten.co/v1/sandboxes/instances', daytona: 'https://app.daytona.io/api/sandbox',
+ e2b: 'https://api.e2b.app/sandboxes', fly: `https://api.machines.dev/v1/apps/${encodeURIComponent(pc().project)}/machines` })[name];
+ async function call(method, suffix = '', body, resource = 'sandboxes') {
+ if (!pc().key) throw new Error('provider key missing');
+ const headers = { 'content-type': 'application/json', ...(name === 'e2b' ? { 'X-API-Key': pc().key } : { authorization: `Bearer ${pc().key}` }) };
+ if (name === 'baseten' && pc().project) headers['X-Team-Id'] = pc().project;
+ const response = await fetchImpl((name === 'e2b' && resource === 'templates' ? 'https://api.e2b.app/templates' : base()) + suffix, { method, headers, redirect: 'error', signal: AbortSignal.timeout(30000), ...(body ? { body: JSON.stringify(body) } : {}) });
+ if (method === 'GET' && response.status === 404) return { deleted: true };
+ if (method === 'DELETE' && response.status === 404) return {};
+ if (!response.ok) throw new Error(`provider ${method} failed (${response.status})`);
+ return response.status === 204 ? {} : response.json();
+ }
+ return [name, {
+ async quote({ gpu_mb = 0 } = {}) {
+ if (gpu_mb > 0) return []; // These four adapters provision CPU shapes only.
+ const quotes = (await prices()).filter(p => p.provider === name && p.id === pc().price_id && p.region === pc().region
+ && p.expires_at > now() && p.hourly > 0 && Number.isFinite(p.hourly) && p.ram_mb >= 1024 && p.cpu >= 1)
+ .map(p => ({ ...p, tier: 'partner', billing_seconds: 1, provisionable: true, gpu_mb: 0 }));
+ if (name === 'e2b' && pc().key && pc().image) {
+ const template = await call('GET', '/' + encodeURIComponent(pc().image), undefined, 'templates');
+ return quotes.filter(q => template.cpuCount === q.cpu && template.memoryMB === q.ram_mb);
+ }
+ return quotes;
+ },
+ async provision(q, { label, image, runtime_ms = 3600000 }) {
+ const seconds = Math.ceil(runtime_ms / 1000);
+ let body;
+ if (name === 'baseten') body = { name: label, image, memory: q.ram_mb, cpu: q.cpu, region: q.region,
+ lifecycle: { expiration_policies: [{ action: 'DELETE', type: 'TTL_MAX_AGE', value: `${seconds}s` }] } };
+ if (name === 'daytona') body = { name: label, snapshot: image, target: q.region, public: false,
+ ttlMinutes: Math.ceil(seconds / 60), autoStopInterval: Math.ceil(seconds / 60), autoDeleteInterval: 0, cpu: q.cpu, memory: q.ram_mb / 1024, disk: q.disk_gb || 5 };
+ if (name === 'e2b') {
+ const template = await call('GET', '/' + encodeURIComponent(image), undefined, 'templates');
+ if (template.cpuCount !== q.cpu || template.memoryMB !== q.ram_mb) throw new Error('template resources differ from the quoted shape');
+ }
+ if (name === 'e2b') body = { templateID: image, timeout: seconds, cpuCount: q.cpu, autoPause: false, metadata: { name: label } };
+ if (name === 'fly') body = { name: label, region: q.region, config: { image, auto_destroy: true,
+ restart: { policy: 'no' }, guest: { cpu_kind: 'shared', cpus: q.cpu, memory_mb: q.ram_mb }, metadata: { rental: label } } };
+ const r = await call('POST', '', body);
+ return { id: name === 'baseten' ? r.name : name === 'e2b' ? r.sandboxID : r.id };
+ },
+ async release(id) { await call('DELETE', '/' + encodeURIComponent(id) + (name === 'fly' ? '?force=true' : '')); },
+ async health(id) {
+ const r = await call('GET', '/' + encodeURIComponent(id));
+ return { deleted: r.deleted === true || (name === 'baseten' && r.status === 'TERMINATED') || (name === 'fly' && r.state === 'destroyed'), state: r.state || r.status };
+ },
+ }];
+ }));
+}
+function createPriceReader({ fetchImpl = globalThis.fetch, now = Date.now }) {
+ let cached = [], until = 0;
+ return async () => {
+ if (now() < until) return cached;
+ try {
+ const r = await fetchImpl('https://openvibe.exchange/api/compute/prices', { redirect: 'error', signal: AbortSignal.timeout(5000) });
+ if (!r.ok) return [];
+ const data = await r.json();
+ cached = Array.isArray(data.prices) ? data.prices : []; until = now() + 60000;
+ return cached;
+ } catch { return []; }
+ };
+}
+module.exports = { createSandboxProviders, createPriceReader };
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+// The same encrypted, audited owner configuration store used by the other services.
+const shared = require('openvibe-shared/config');
+const { serviceAuth, capabilities, ids } = require('openvibe-contracts');
+const NAMES = ['baseten', 'daytona', 'e2b', 'fly'];
+const DEFAULTS = { enabled: false, api_key: '', monthly_cap: '0', max_concurrent: 0, allowed_kinds: [],
+ image: '', region: '', project: '', extra_hourly: '0', costs_bounded: false,
+ credit_amount: '0', credit_expires: '', promotion_expires: '', price_id: '' };
+const decimal = { type: 'string', pattern: '^(0|[1-9][0-9]*)(\\.[0-9]{1,6})?$' };
+const date = { type: 'string', pattern: '^$|^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$' };
+const SCHEMA = { type: 'object', additionalProperties: false, required: Object.keys(DEFAULTS), properties: {
+ enabled: { type: 'boolean', title: 'Allow rentals', 'x-danger': true },
+ api_key: { type: 'string', title: 'Provider API key', 'x-group': 'Credentials' },
+ monthly_cap: { ...decimal, title: 'Monthly USD cap (gross charges, before credits)' },
+ max_concurrent: { type: 'integer', minimum: 0, maximum: 10000 },
+ allowed_kinds: { type: 'array', uniqueItems: true, items: { enum: ['function', 'code', 'browser', 'linux', 'desktop', 'gpu'] } },
+ image: { type: 'string', maxLength: 300, title: 'Prepared worker image or template' },
+ region: { type: 'string', maxLength: 80 }, project: { type: 'string', maxLength: 80, title: 'Fly app / Baseten team' },
+ extra_hourly: { ...decimal, title: 'Bounded disk and network USD per hour' },
+ costs_bounded: { type: 'boolean', title: 'Image enforces disk and network limits', 'x-danger': true },
+ credit_amount: { ...decimal, title: 'Confirmed one-time USD credit grant' },
+ credit_expires: { ...date, title: 'Credit expiry (UTC)' },
+ promotion_expires: { ...date, title: 'Disable new rentals after promotion (UTC)' },
+ price_id: { type: 'string', maxLength: 100, title: 'Exchange compute price ID' },
+} };
+async function createSettings({ db, config, keys, broker, env = process.env, log = console }) {
+ const stores = [];
+ const masterKeys = ['enabled','dry_run','owner','job_pool','pools'];
+ const master = await shared.createConfigStore({db, service:'run',namespace:'run.compute',env,log,
+ defaults:Object.fromEntries(masterKeys.map(k=>[k,config.compute[k]])),
+ schema:{type:'object',additionalProperties:false,properties:{
+ enabled:{type:'boolean','x-danger':true},dry_run:{type:'boolean','x-danger':true},
+ owner:{type:'string',pattern:'^$|^usr_[0-9A-HJKMNP-TV-Z]{26}$'},job_pool:{type:'string'},
+ pools:{type:'object',additionalProperties:{type:'object',required:['kind','providers'],additionalProperties:true,properties:{
+ kind:{enum:['function','code','browser','linux','desktop','gpu']},providers:{type:'array',items:{enum:[...NAMES,'hetzner','ovh','vast','runpod']}},
+ strategy:{enum:['automatic','cheapest','fastest','private','own']},currency:{type:'string',pattern:'^[A-Z]{3}$'},
+ region:{type:'string'},min_warm:{type:'integer',minimum:0,maximum:100},max_machines:{type:'integer',minimum:0,maximum:10000},
+ slots_per_machine:{type:'integer',minimum:1,maximum:100},max_runtime_ms:{type:'integer',minimum:60000,maximum:3600000},
+ target_wait_ms:{type:'integer',minimum:0},idle_grace_ms:{type:'integer',minimum:0},max_hourly:{type:'number',minimum:0},
+ gpu_mb:{type:'integer',minimum:0},measured_peak_ram_mb:{type:'integer',minimum:0},public_egress:{type:'boolean'}
+ }}}
+ }}});
+ stores.push(master);
+ for(const key of masterKeys) Object.defineProperty(config.compute,key,{enumerable:true,configurable:true,get:()=>master.get(key)});
+ for (const name of NAMES) {
+ const store = await shared.createConfigStore({ db, service: 'run', namespace: `run.compute_${name}`,
+ schema: SCHEMA, defaults: DEFAULTS, classify: { api_key: 'secret' }, env, log });
+ stores.push(store);
+ const pc = { currency: 'USD', managed: true };
+ for (const key of Object.keys(DEFAULTS)) Object.defineProperty(pc, key, { enumerable: key !== 'api_key', get: () => store.get(key) });
+ Object.defineProperty(pc, 'key', { get: () => { const value = store.get('api_key'); return typeof value === 'string' ? value : ''; } });
+ config.compute.providers[name] = pc;
+ }
+ const guard = (req, res, next) => {
+ const token = String(req.headers.authorization || '').replace(/^Bearer /, '');
+ const publicKey = keys.get();
+ const result = publicKey && serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
+ if (!result?.ok || result.claims.sub !== 'svc:network' || !capabilities.check(result.claims, 'run.config.manage').allowed || !ids.isSubjectId('user', req.headers['x-ov-subject']))
+ return res.status(403).json({ error: 'Network owner configuration capability required' });
+ // Never let shared/config fall back to plaintext storage without an encryption key.
+ if (req.method === 'PUT' && !env.OV_CONFIG_KEKS) return res.status(503).json({ error: 'Configuration encryption is not installed' });
+ next();
+ };
+ const router = shared.adminRoutes(stores, { basePath: '/internal/config', requireAdmin: guard,
+ actor: req => ({ type: 'user', id: req.headers['x-ov-subject'] }),
+ requireFresh: req => req.headers['x-ov-fresh'] === '1', log });
+ return { stores, mount(app) {
+ app.get('/internal/config/compute-status', guard, (req,res,next) => broker.status().then(status =>
+ res.set('Cache-Control','no-store').json({enabled:status.enabled,dry_run:status.dry_run,budgets:status.budgets}),next));
+ router.mount(app);
+ } };
+}
+module.exports = { NAMES, DEFAULTS, SCHEMA, createSettings };
@@ -251,19 +251,36 @@ function createPlacement({ config, db = null, fetchImpl = globalThis.fetch, now
@@ -251,19 +251,36 @@ function createPlacement({ config, db = null, fetchImpl = globalThis.fetch, now
const req = { ...requirements, capabilities: [...new Set([...(requirements.capabilities || []), want])] };
const req = { ...requirements, capabilities: [...new Set([...(requirements.capabilities || []), want])] };
contracts.assertValid('platform.workload-requirements@1', req);
contracts.assertValid('platform.workload-requirements@1', req);
let computeDecision = null;
let computeDecision = null;
+ const rentals = db ? await db.many("SELECT * FROM run_compute_rentals WHERE state <> 'released'") : [];
if (db) {
if (db) {
- const draining = new Set((await db.many("SELECT machine FROM run_compute_rentals WHERE state IN ('draining','releasing','uncertain')")).map(r => r.machine));
+ const draining = new Set(rentals.filter(r => ['draining','releasing','uncertain'].includes(r.state)).map(r => r.machine));
candidates = candidates.filter(o => !draining.has(o.node_id));
candidates = candidates.filter(o => !draining.has(o.node_id));
}
}
+ // A sandbox enrolled under the operator's Bot identity is still third-party compute.
+ // Apply provider scope and trust before the planner's privacy checks, even for ordinary jobs.
+ candidates = candidates.flatMap(o => {
+ const rental = rentals.find(r => r.machine === o.node_id);
+ const pc = rental && config.compute?.providers[rental.provider];
+ if (!pc?.managed) return [o];
+ if (rental.pool !== config.compute.job_pool || !pc.enabled || !pc.allowed_kinds.includes(row.class)
+ || (pc.promotion_expires && !(Date.parse(pc.promotion_expires) > now()))) return [];
+ return [{ ...o, trust: 'partner', owner: undefined }];
+ });
const poolId = config.compute && config.compute.job_pool;
const poolId = config.compute && config.compute.job_pool;
const pool = poolId && config.compute.pools[poolId];
const pool = poolId && config.compute.pools[poolId];
if (pool) {
if (pool) {
const saved = db && await db.maybe('SELECT strategy FROM run_compute_pools WHERE id=$1', [poolId]);
const saved = db && await db.maybe('SELECT strategy FROM run_compute_pools WHERE id=$1', [poolId]);
const hard = plan(req, candidates, { now: now() });
const hard = plan(req, candidates, { now: now() });
const eligible = new Set(hard.candidates.filter(c => c.eligible).map(c => c.id));
const eligible = new Set(hard.candidates.filter(c => c.eligible).map(c => c.id));
- const ms = candidates.filter(o => eligible.has(o.offer_id)).map(o => {
- const v = config.compute.inventory[o.node_id] || {};
- return { ...v, id: o.offer_id, owner: o.owner, tier: o.trust === 'user-owned' ? 'own' : o.trust === 'community' ? 'community' : o.trust === 'first-party' ? (v.tier || 'dedicated') : 'partner',
+ const ms = candidates.filter(o => eligible.has(o.offer_id)).filter(o => {
+ const rental = rentals.find(r => r.machine === o.node_id);
+ const pc = rental && config.compute.providers[rental.provider];
+ return !pc?.managed || (pc.enabled && pc.allowed_kinds.includes(row.class) && (!pc.promotion_expires || Date.parse(pc.promotion_expires) > now()));
+ }).map(o => {
+ const rental = rentals.find(r => r.machine === o.node_id);
+ const quote = rental ? (typeof rental.quote === 'string' ? JSON.parse(rental.quote) : rental.quote) : {};
+ const v = { ...(config.compute.inventory[o.node_id] || {}), ...(rental ? { tier: quote.tier || 'partner', hourly: Number(rental.hourly_micros)/1e6, currency:rental.currency, gpu_mb:quote.gpu_mb } : {}) };
+ return { ...v, id: o.offer_id, owner: o.owner, tier: rental ? (quote.tier || 'partner') : o.trust === 'user-owned' ? 'own' : o.trust === 'community' ? 'community' : o.trust === 'first-party' ? (v.tier || 'dedicated') : 'partner',
health: o.health.status, region: o.region, currency: v.currency || 'USD', hourly: v.hourly ?? (o.trust === 'user-owned' ? 0 : null),
health: o.health.status, region: o.region, currency: v.currency || 'USD', hourly: v.hourly ?? (o.trust === 'user-owned' ? 0 : null),
free_ram_mb: o.capacity?.memory?.available_mb ?? v.free_ram_mb ?? 0,
free_ram_mb: o.capacity?.memory?.available_mb ?? v.free_ram_mb ?? 0,
free_slots: o.capacity?.workers?.[row.class] ?? v.free_slots ?? 0,
free_slots: o.capacity?.workers?.[row.class] ?? v.free_slots ?? 0,
@@ -38,6 +38,7 @@ async function main() {
@@ -38,6 +38,7 @@ async function main() {
const accountData = accountDataLib.create({ db, log: console });
const accountData = accountDataLib.create({ db, log: console });
const accountSend = config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret }) : null;
const accountSend = config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret }) : null;
const app = createApp({ config, db, valkey, registry, accountData, accountSend });
const app = createApp({ config, db, valkey, registry, accountData, accountSend });
+ await app.locals.initComputeSettings();
const { keys, outbox, stream, tickets, dispatch, indexnow, sessions, broker } = app.locals;
const { keys, outbox, stream, tickets, dispatch, indexnow, sessions, broker } = app.locals;
keys.start();
keys.start();
const ticketKey = tickets.start();
const ticketKey = tickets.start();
@@ -20,7 +20,7 @@ const B = 'usr_01JZ0000000000000000000BBB';
@@ -20,7 +20,7 @@ const B = 'usr_01JZ0000000000000000000BBB';
const d = await testDb();
const d = await testDb();
try {
try {
const accountData = create({ db: d.db });
const accountData = create({ db: d.db });
- const missing = await uncovered(d.db, { tables: [...TABLES, 'run_jobs'], exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Run applied' } });
+ const missing = await uncovered(d.db, { tables: [...TABLES, 'run_jobs'], exempt: { config_snapshots: 'Owner configuration revision audit; service settings and encrypted credentials are retained independently of the operator account', account_data_events: 'the ledger of requests itself: which export and deletion Run applied' } });
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
for (const [subject, name, jobId] of [[A, 'ada', 'job_01JZ0000000000000000000ADA'], [B, 'bo', 'job_01JZ0000000000000000000BQB']]) {
for (const [subject, name, jobId] of [[A, 'ada', 'job_01JZ0000000000000000000ADA'], [B, 'bo', 'job_01JZ0000000000000000000BQB']]) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+const assert=require('node:assert/strict');
+const {testDb}=require('./helpers/db');
+const {createBroker}=require('../server/compute/broker');
+(async()=>{
+ for(const scenario of ['credit','expired','expires during job','exhausted','promotion','own','partial credit','promo extras','memory','region','latency','job ceiling']) {
+ const t=await testDb(), now=Date.UTC(2026,10,11), created=[];
+ const config={compute:{enabled:true,dry_run:false,owner:'alice',inventory:{own:{pool:'p',ram_mb:4096}},
+ pools:{p:{kind:'browser',strategy:'cheapest',currency:'USD',providers:['daytona','fly'],min_warm:1,max_machines:2}},providers:{}}};
+ const adapters={};
+ for(const [name,hourly] of [['daytona',.2],['fly',.02]]) {
+ config.compute.providers[name]={managed:true,enabled:true,key:'fake',currency:'USD',monthly_cap:'10',image:'prepared',costs_bounded:true,extra_hourly:'0',allowed_kinds:['browser'],max_concurrent:2,
+ credit_amount:name==='daytona'?(scenario==='credit'?'1':scenario==='partial credit'?'0.21':'0'):'0',credit_expires:scenario==='expired'?'2026-11-10T00:00:00Z':'2026-12-01T00:00:00Z',promotion_expires:['promotion','promo extras'].includes(scenario)&&name==='daytona'?'2026-11-16T00:00:00Z':''};
+ adapters[name]={quote:async()=>[{provider:name,id:name,hourly,cpu:2,ram_mb:scenario==='memory'&&name==='daytona'?8192:4096,gpu_mb:0,currency:'USD',region:'us',tier:'partner',billing_seconds:1,expires_at:now+60000,promotion_expires:['promotion','promo extras'].includes(scenario)?'2026-11-16T00:00:00Z':''}],provision:async()=>{created.push(name);return {id:name};}};
+ }
+ if(scenario==='promo extras')config.compute.providers.daytona.extra_hourly='0.1';
+ if(scenario==='expired')config.compute.providers.daytona.credit_amount='1';
+ if(scenario==='expires during job'){config.compute.providers.daytona.credit_amount='1';config.compute.providers.daytona.credit_expires=new Date(now+60000).toISOString();}
+ if(['memory','region','latency','job ceiling'].includes(scenario)) {
+ const requirements={memory:{resources:{memory_mb:5000}},region:{region:'eu'},latency:{max_latency_ms:10},'job ceiling':{max_cost_usd:.001}}[scenario];
+ await t.db.query("INSERT INTO run_sessions (id,owner,kind,state,width,height,ttl_s,idle_s,request_digest,created_at,updated_at,compute_pool,requirements) VALUES ('ses_need','user:alice','browser','placing',1280,720,900,60,'need',$1,$1,'p',$2)",[now-60000,JSON.stringify(requirements)]);
+ }
+ const desks={enabled:true,hosts:async()=>scenario==='own'?[{machine_id:'own',trust:'user-owned',online:true,max_browser_desks:2,free:2}]:[]};
+ try {await createBroker({db:t.db,config,adapters,desks,now:()=>now}).tick();assert.deepEqual(created,['own','region','latency','job ceiling'].includes(scenario)?[]:[['credit','promotion','partial credit','memory'].includes(scenario)?'daytona':'fly'],scenario);}
+ finally{await t.close();}
+ }
+ console.log('placement: own capacity, confirmed credit, promotion expiry and paid-price fallback passed');
+})().catch(e=>{console.error(e);process.exitCode=1;});
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+'use strict';
+const assert=require('node:assert/strict');
+const {createPlacement}=require('../server/dispatch/placement');
+(async()=>{
+ const now=Date.now(), node='dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X',pc={managed:true,enabled:true,allowed_kinds:['browser']};
+ const config={network:{url:'https://network.invalid'},oauth:{},dispatch:{offersTtlMs:0,timeoutMs:1000},compute:{job_pool:'p',pools:{p:{strategy:'cheapest',currency:'USD'}},inventory:{[node]:{hourly:0,free_ram_mb:4096,free_slots:1}},providers:{daytona:pc}}};
+ const rental={machine:node,state:'running',pool:'p',provider:'daytona',currency:'USD',hourly_micros:'200000',quote:{tier:'partner'}};
+ const offer={offer_id:'off_node_01',node_id:node,kind:'node',region:'us',trust:'first-party',capabilities:['worker:browser'],health:{status:'up',checked_at:new Date(now).toISOString()},pricing:{model:'prepaid'},updated_at:new Date(now).toISOString()};
+ const placer=createPlacement({config,now:()=>now,db:{many:async()=>[rental],maybe:async()=>null},fetchImpl:async()=>({ok:true,json:async()=>({offers:[offer]})})});
+ const row={class:'browser',requester:'service:actor',job:{requirements:{...require('../server/util').requirementsDefault('browser'),objective:'cheapest'}}};
+ const chosen=await placer.choose(row);assert.ok(chosen);assert.equal(chosen.trust,'partner');assert.ok(chosen.reasons.some(x=>x.includes('0.2 USD/hour')),'actual rental price overrides inventory zero');
+ row.job.requirements.objective='private';assert.equal(await placer.choose(row),null,'enrollment cannot turn third-party rental into private compute');
+ row.job.requirements.objective='cheapest';pc.allowed_kinds=['code'];assert.equal(await placer.choose(row),null);
+ pc.allowed_kinds=['browser'];config.compute.job_pool='';assert.equal(await placer.choose(row),null,'rentals never escape the configured job pool');
+ console.log('job rental trust: provider scope, privacy and gross rental price passed');
+})().catch(e=>{console.error(e);process.exitCode=1;});
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+const assert=require('node:assert/strict');
+const {testDb}=require('./helpers/db');
+const {createBroker}=require('../server/compute/broker');
+(async()=>{
+ const t=await testDb();let now=Date.UTC(2026,10,11);
+ const pc={managed:true,enabled:false,key:'fake',monthly_cap:'1',currency:'USD',image:'prepared',costs_bounded:true,extra_hourly:'0',max_concurrent:1,allowed_kinds:['browser']};
+ const config={compute:{enabled:true,dry_run:false,owner:'alice',inventory:{},pools:{p:{kind:'browser',max_runtime_ms:3600000}},providers:{baseten:pc}}};
+ const q={id:'q',provider:'baseten',currency:'USD',hourly:.8,expires_at:now+60000,billing_seconds:1};
+ const b=createBroker({db:t.db,config,desks:{enabled:false},adapters:{},now:()=>now});
+ try {
+ assert.match((await b.reserve('p',q)).reason,/disabled/);
+ pc.enabled=true;pc.allowed_kinds=['agent'];assert.match((await b.reserve('p',q)).reason,/kind/);
+ pc.allowed_kinds=['browser'];pc.promotion_expires='2026-11-10T00:00:00Z';assert.match((await b.reserve('p',q)).reason,/expired/);
+ pc.promotion_expires='';assert.ok((await b.reserve('p',q)).rental);
+ assert.match((await b.reserve('p',q)).reason,/concurrency/);
+ assert.equal((await t.db.many("SELECT * FROM run_compute_journal WHERE event='budget.near_cap'")).length,1);
+ assert.equal((await b.status()).budgets[0].near_cap,true);
+ pc.max_concurrent=2;assert.match((await b.reserve('p',q)).reason,/monthly cap/);
+ console.log('provider off/kind/expiry checks, concurrent reservation limit, hard cap and 80% alert: passed');
+ } finally {await t.close();}
+})().catch(e=>{console.error(e);process.exitCode=1;});
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+const assert=require('node:assert/strict');
+const {boot}=require('./helpers/app');
+const {ids}=require('openvibe-contracts');
+(async()=>{
+ const t=await boot();
+ try {
+ await t.app.locals.initComputeSettings();
+ const headers={'x-ov-subject':ids.newId('user'),'x-ov-fresh':'1'};
+ const options={sub:'svc:network',cap:['run.config.manage'],headers};
+ assert.equal((await t.call('GET','/internal/config',{...options,sub:'svc:actor'})).status,403);
+ assert.equal((await t.call('GET','/internal/config',{...options,cap:['run.job.submit']})).status,403);
+ const r=await t.call('GET','/internal/config',options);assert.equal(r.status,200,r.text);assert.equal(r.json.namespaces.length,5);
+ const status=await t.call('GET','/internal/config/compute-status',options);assert.equal(status.status,200,status.text);assert.equal(status.json.budgets.length,8);
+ const write=await t.call('POST','/internal/config/run.compute_daytona',{...options,body:{values:{max_concurrent:2},merge:true,reason:'test policy'}});
+ assert.equal(write.status,200,write.text);assert.equal(t.config.compute.providers.daytona.max_concurrent,2);
+ assert.equal(t.config.compute.providers.daytona.enabled,false);
+ const secret=await t.call('PUT','/internal/config/run.compute_daytona/secrets/api_key',{...options,body:{value:'fake-only',reason:'test encryption refusal'}});
+ assert.equal(secret.status,503,secret.text);
+ assert.ok(!JSON.stringify((await t.call('GET','/internal/config',options)).json).includes('fake-only'));
+ console.log('compute settings: Network-only scope, defaults off, live updates and no unencrypted secret writes passed');
+ }finally{await t.close();}
+ const sealed=await boot({appOpts:{env:{OV_CONFIG_KEKS:'test:'+require('node:crypto').randomBytes(32).toString('base64'),OV_CONFIG_KEK_CURRENT:'test'}}});
+ try {
+ await sealed.app.locals.initComputeSettings();
+ const options={sub:'svc:network',cap:['run.config.manage'],headers:{'x-ov-subject':ids.newId('user'),'x-ov-fresh':'1'}};
+ const secret='fake-provider-credential-for-test';
+ const saved=await sealed.call('PUT','/internal/config/run.compute_daytona/secrets/api_key',{...options,body:{value:secret,reason:'test sealed key'}});
+ assert.equal(saved.status,200,saved.text);assert.ok(!saved.text.includes(secret));
+ assert.equal(sealed.config.compute.providers.daytona.key,secret);
+ assert.ok(!JSON.stringify(await sealed.db.many('SELECT values_json FROM config_snapshots')).includes(secret),'database holds ciphertext only');
+ const read=await sealed.call('GET','/internal/config/run.compute_daytona',options);assert.ok(!read.text.includes(secret));
+ assert.equal((await sealed.call('PUT','/internal/config/run.compute_daytona/secrets/api_key',{...options,headers:{...options.headers,'x-ov-fresh':'0'},body:{value:'different-fake',reason:'stale sign-in'}})).status,403);
+ console.log('provider secrets: sealed at rest, write-only responses and fresh sign-in required passed');
+ }finally{await sealed.close();}
+})().catch(e=>{console.error(e);process.exitCode=1;});
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+const assert=require('node:assert/strict');
+const {createSandboxProviders,createPriceReader}=require('../server/compute/sandbox-providers');
+(async()=>{
+ const now=Date.UTC(2026,10,11), config={providers:{}}, calls=[];
+ for(const name of ['baseten','daytona','e2b','fly']) config.providers[name]={key:'fake-key',project:'app',price_id:name,region:'us'};
+ const prices=async()=>Object.keys(config.providers).map(provider=>({id:provider,provider,region:'us',hourly:.1,ram_mb:4096,cpu:2,expires_at:now+60000}));
+ let deleted=false;
+ const adapters=createSandboxProviders({config,now:()=>now,prices,fetchImpl:async(url,opts)=>{
+ calls.push({url,...opts}); return {ok:!deleted,status:deleted?404:200,json:async()=>({id:'remote',name:'remote',sandboxID:'remote',state:'running',cpuCount:2,memoryMB:4096})};
+ }});
+ for(const [name,a] of Object.entries(adapters)) {
+ deleted=false; const [q]=await a.quote(); assert.ok(q);assert.deepEqual(await a.quote({gpu_mb:1}),[]);
+ assert.deepEqual(await a.provision(q,{label:'ov-test',image:'prepared',runtime_ms:120000}),{id:'remote'});
+ const request=calls.at(-1);assert.equal(request.redirect,'error');assert.ok(!request.body.includes('fake-key'));
+ if(name==='baseten')assert.equal(JSON.parse(request.body).lifecycle.expiration_policies[0].value,'120s');
+ if(name==='daytona'){assert.equal(JSON.parse(request.body).ttlMinutes,2);assert.equal(JSON.parse(request.body).cpu,2);assert.equal(JSON.parse(request.body).memory,4);}
+ if(name==='e2b')assert.equal(JSON.parse(request.body).timeout,120);
+ assert.equal((await a.health('remote')).deleted,false);
+ await a.release('remote'); deleted=true; assert.equal((await a.health('remote')).deleted,true);
+ config.providers[name].region='elsewhere';assert.deepEqual(await a.quote(),[]);
+ }
+ let attempts=0;
+ const read=createPriceReader({now:()=>now,fetchImpl:async()=>{attempts++;throw Error('offline');}});
+ assert.deepEqual(await read(),[]);assert.equal(attempts,1);
+ console.log('four sandbox adapters: lifecycle, bounded timeouts, GPU/region refusal, credential isolation and price outage passed');
+})().catch(e=>{console.error(e);process.exitCode=1;});