← Paracosm·Docs/Reference
Contents · WorldBridge hardening

WorldBridge hardening

WorldBridge is a control channel into a live region, so it guards itself: every token has a rate limit, every request is audited, the scripts it replaces are cleaned up when nothing else uses them, and a busy region queues work instead of drowning in it. This page lists the settings and what each protects against.

Config keys ([WorldBridge] in OpenSim.ini)

key default meaning
RateLimitPerSec 50 refill rate of each token's bucket, requests per second. 0 or less turns rate limiting off.
RateLimitBurst 100 bucket size: how many requests a token may make back to back. Minimum 1.
DeleteSupersededScripts true set_script deletes the script asset it replaces when that asset is provably unused. false keeps every superseded asset, as before; the unchanged-text reuse still applies.

The startup log line shows the values in force: [WorldBridge] initialised (enabled=True, rate limit 50/s burst 100, delete superseded scripts=True, 4 workers + 64 waiting, 2 read workers + 256 waiting, queue wait 20s).

The request queue that followed (MaxConcurrent, QueueLimit, ReadWorkers, ReadQueueLimit, QueueWaitSec; 503 with Retry-After when full) and serving several regions from one listener are in Hub.cs, documented in handbook/worldbridge.md ("The request queue", "Several regions") and handbook/config.md. The rate limit below is unchanged by them: it is still checked per token at admission, before the queue; a request the queue refuses gets its charge back.

1. set_script no longer leaks script assets

Before: every set_script stored a NEW LSLText asset and never removed the one it replaced. On the live store, 16,741 of 19,778 script assets (18.9 MB) were referenced by nothing.

Now:

Limits, stated plainly:

Verify live

Do this on a scratch box, not a builder's object.

  1. python tools/wb.py spawn x=128 y=128 z=30 name=gc-test gives a uuid U.
  2. python tools/wb.py set_script uuid=U name=t script="default{state_entry(){llSay(0,\"one\");}}" Expect assetReused: false. Note the asset, call it A1.
  3. Repeat step 2 with the same text. Expect assetReused: true and asset equal to A1.
  4. Change the text ("two"). Expect a new asset A2, supersededDeleted: 1, supersededKept: 0.
  5. Prove A1 is gone, including from the cache: python tools/wb.py put_inventory uuid=U asset=A1 answers 404 no such asset.
  6. The keep path: duplicate the box in the viewer (shift-drag), then set new text on the original. Expect supersededKept: 1. With the WorldBridge logger at DEBUG, the log names the duplicate as what still holds it.
  7. Despawn the box when done.

2. prune tool for the existing leak: tools/prune_script_assets.py

This tool is offline only. It refuses any database under opensim-src/bin, in a directory holding OpenSim.dll, under PARACOSM_BIN, or with a -journal/-wal file beside it.

A script asset counts as referenced when any of these holds:

--apply deletes exactly the unreferenced rows from the --asset-db file, in one transaction, and touches no other file. It refuses if any Object or Notecard asset is not readable text. --vacuum shrinks the file afterwards.

Numbers on copies of the live files (2026-09-27):

assets bytes
script assets 19,778 26,896,165
referenced 3,037 7,954,511
of which: by prim items / only inside object or notecard assets / Library 2,495 / 525 / 17
unreferenced 16,741 (16,698 tagged wb script) 18,941,654

--apply --vacuum on the copy deleted exactly those 16,741 rows and nothing else. This was checked against an independent computation: every referenced script was still present, no other asset type was touched, and integrity_check returned ok. The file went from 1,064,165,376 to 1,036,439,552 bytes (script text is small next to the textures and meshes). A second run reported 0 unreferenced.

Procedure (the operator runs it; the server must be STOPPED)

# 1. stop OpenSim; wait until it has exited (OpenSim.db is fully persisted at shutdown)
# 2. copy the three files out of bin (plain copies are fine once the server is stopped)
mkdir C:/prune && cp bin/Asset.db bin/OpenSim.db bin/inventory.db C:/prune/
# 3. report
python tools/prune_script_assets.py --asset-db C:/prune/Asset.db --opensim-db C:/prune/OpenSim.db --inventory-db C:/prune/inventory.db
# 4. apply to the COPY
python tools/prune_script_assets.py --asset-db C:/prune/Asset.db --opensim-db C:/prune/OpenSim.db --inventory-db C:/prune/inventory.db --apply --vacuum
# 5. keep the original, put the pruned copy in place, start OpenSim
mv bin/Asset.db bin/Asset.db.pre-prune && cp C:/prune/Asset.db bin/Asset.db

The Flotsam cache (assetcache/) still holds copies of the pruned assets until they expire. They are harmless because nothing names them. Clear the cache while the server is stopped if the disk space matters.

3. Rate limit, per token

Each token has its own token bucket: RateLimitBurst requests back to back, refilled at RateLimitPerSec. It is checked right after authentication, before role checks and before anything runs. state and list cost 0.1 of a request, so polling is cheap but still bounded. A request over the limit gets:

HTTP 429   Retry-After: 1
{"error":"rate limited","command":"chat","retryAfterSec":0.12,"limitPerSec":50.0,"burst":100.0}

It is thread-safe: one lock and O(1) work. tools/wb.py now waits retryAfterSec and resends on a 429, up to 10 times. That is safe because a 429 means nothing ran, and it means a builder's burst slows down instead of dying mid-build. mcp/worldbridge_mcp.py already passes HTTP errors back to the caller, so the assistant sees the 429 body.

Verify live

wb.py retries, so use raw HTTP to see the 429:

python - <<'EOF'
import json, urllib.request, urllib.error
import sys; sys.path.insert(0, "tools"); from paths import BIN
tok = open(BIN + "/WorldBridge.token").read().strip()
codes = []
for i in range(150):
    rq = urllib.request.Request("http://127.0.0.1:9002/chat", data=b"{}", method="POST", headers={"Authorization": "Bearer " + tok})
    try: codes.append(urllib.request.urlopen(rq).status)
    except urllib.error.HTTPError as e: codes.append(e.code); last = (e.headers.get("Retry-After"), e.read().decode())
print(codes.count(200), "ok,", codes.count(429), "limited; last 429:", last)
EOF

Expect about 100 ok (the burst, plus whatever refilled during the loop), then 429s with Retry-After: 1 and the JSON above. A second token's calls are unaffected meanwhile.

4. No guessed avatar

Before, an op with no avatar acted on whichever non-NPC the presence list returned first. Now:

This covers teleport, sit, stand, wear, unwear, worn, give_wearable, give_object, give_inventory, give_money (which had its own copy of the resolver, now removed), grant_estate_manager, and local chat_send. Local chat is heard by distance, so it is refused when the target is ambiguous; region:true chat needs no target and is unaffected, and so is wb.region_say. tools/gaze.py and tools/tour.py passed the avatar as uuid=, which teleport ignored; they now pass avatar=.

Verify live

With two people logged in (NPCs do not count), python tools/wb.py worn gives 400 with both names. python tools/wb.py worn avatar="Bob Resident" gives 200. With only one person in, worn gives 200 as before.

5. Which token acted, in the audit log

Each audit line has a new column after the role: tok: plus the first 6 hex of SHA-256(token). It never contains the token itself. Columns are now: time, remote, role, token id, command, region, body.

2026-09-27T17:04:14.6986650Z	[::1]:52205	observer	tok:f55ba3	chat	?	{}

Verify live

Make any call, then read the last audit line and compare the id with: python -c "import sys,hashlib;sys.path.insert(0,'tools');from paths import BIN;print(hashlib.sha256(open(BIN+'/WorldBridge.token').read().strip().encode()).hexdigest()[:6])" (from the repository root).

How it was tested (offline; nothing touched the running server)

Not verified