openvibers/openvibe.run Commits

Add owner-managed sandbox providers and cost-aware placement (#251)

OP OpenVibers committed 2 days ago · a8646b877d · 19 files, +469 −20

* Add owner-managed sandbox providers to compute placement

* Require credits to cover the reserved job lifetime

Changes

ddiff --git a/docs/compute-broker.md b/docs/compute-broker.md
iindex 56d8ec0..5417d0e 100644
---- a/docs/compute-broker.md
++++ b/docs/compute-broker.md
@@@ -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;
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.
++
++## 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.
ddiff --git a/docs/compute-providers-1111.md b/docs/compute-providers-1111.md
nnew file mode 100644
iindex 0000000..8f8e748
---- /dev/null
++++ b/docs/compute-providers-1111.md
@@@ -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.
ddiff --git a/migrations/0018_compute_config.sql b/migrations/0018_compute_config.sql
nnew file mode 100644
iindex 0000000..e5bd35b
---- /dev/null
++++ b/migrations/0018_compute_config.sql
@@@ -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
ddiff --git a/package-lock.json b/package-lock.json
iindex d746a44..7e75d11 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex b90fe7b..71626e3 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/app.js b/server/app.js
iindex c24ad4d..61a8997 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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) => {
ddiff --git a/server/compute/broker.js b/server/compute/broker.js
iindex c13ed16..2121b97 100644
---- a/server/compute/broker.js
++++ b/server/compute/broker.js
@@@ -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,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.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,a
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()]);
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,a
const 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,a
const 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,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]);
} 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,a
if(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,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()]));
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'}));}}
ddiff --git a/server/compute/config.js b/server/compute/config.js
iindex a1bc7bc..7a9b302 100644
---- a/server/compute/config.js
++++ b/server/compute/config.js
@@@ -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']) {
ddiff --git a/server/compute/routes.js b/server/compute/routes.js
iindex cc739bf..7560c0f 100644
---- a/server/compute/routes.js
++++ b/server/compute/routes.js
@@@ -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');}));
ddiff --git a/server/compute/sandbox-providers.js b/server/compute/sandbox-providers.js
nnew file mode 100644
iindex 0000000..8d99db4
---- /dev/null
++++ b/server/compute/sandbox-providers.js
@@@ -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 };
ddiff --git a/server/compute/settings.js b/server/compute/settings.js
nnew file mode 100644
iindex 0000000..4a9db3f
---- /dev/null
++++ b/server/compute/settings.js
@@@ -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 };
ddiff --git a/server/dispatch/placement.js b/server/dispatch/placement.js
iindex f8e2169..e4eb25a 100644
---- a/server/dispatch/placement.js
++++ b/server/dispatch/placement.js
@@@ -251,19 +251,36 @@ function createPlacement({ config, db = null, fetchImpl = globalThis.fetch, now
const 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,
ddiff --git a/server/index.js b/server/index.js
iindex 160bcca..1be866e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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();
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 6a7e4a1..946b562 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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']]) {
ddiff --git a/test/compute-credit-placement.test.js b/test/compute-credit-placement.test.js
nnew file mode 100644
iindex 0000000..2191434
---- /dev/null
++++ b/test/compute-credit-placement.test.js
@@@ -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;});
ddiff --git a/test/compute-job-trust.test.js b/test/compute-job-trust.test.js
nnew file mode 100644
iindex 0000000..6baedcd
---- /dev/null
++++ b/test/compute-job-trust.test.js
@@@ -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;});
ddiff --git a/test/compute-provider-policy.test.js b/test/compute-provider-policy.test.js
nnew file mode 100644
iindex 0000000..02d9473
---- /dev/null
++++ b/test/compute-provider-policy.test.js
@@@ -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;});
ddiff --git a/test/compute-settings.test.js b/test/compute-settings.test.js
nnew file mode 100644
iindex 0000000..75959ab
---- /dev/null
++++ b/test/compute-settings.test.js
@@@ -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;});
ddiff --git a/test/sandbox-providers.test.js b/test/sandbox-providers.test.js
nnew file mode 100644
iindex 0000000..90f1881
---- /dev/null
++++ b/test/sandbox-providers.test.js
@@@ -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;});