Open bug report

Concurrent agents on one bridge: relayed commands carry no caller identity, so a bridge cannot arbitrate them and lifecycle verbs hit everyone

AdityaAngajala · 17d ago ·edited

TL;DR

When several AI agents drive the same bridge on the same desktop at once, AD relays every one of their commands indistinguishably. A bridge receives no caller identity, so it cannot arbitrate between concurrent agents; no log can attribute a verb to the agent that issued it; and any agent's bridge_kill / bridge_pause silently destroys every other agent's state.

This is the AD-core half of concurrent-agent contention on pup. The session-level half (pup's owner model) is filed separately on the bridge's page — see the cross-ref at the bottom, per the bridge-ownership skill.

Important scoping note up front: this is not a throughput or locking problem. I measured that, and the transport layer is healthy (numbers below). The defect is identity, visibility, and blast radius.

Environment

  • AD 1.9.177, target H2O (Windows), connected via relay
  • puppeteer bridge (source=cache), Node v26.5.0
  • Caller: cloud container via the relay, 2026-07-25
  • Other bridges live on the same AD: fusion360 (running, persistent)

Finding 1 (AD core) — relayed commands carry no caller identity, but AD has one

AD already knows a caller label and uses it for its own verbs. From this machine's AD lifecycle log (bridge_log_read {name:"puppeteer"}adLifecycle):

[AD 2026-07-24 20:06:55] reaped by the bridge_kill verb (killedPids=[28084]); requested by testing
[AD 2026-07-24 20:40:04] reaped by the bridge_kill verb (killedPids=[16136]); requested by testing
[AD 2026-07-22 04:19:24] reaped by the bridge_kill verb (killedPids=[]); requested by testing

requested by testing — AD attributes its own lifecycle verbs to a caller.

But the bridge's own log, for the verbs AD relayed into it, has nothing:

[verb] browser_eval session=rev
[verb] browser_screenshot session=rev
[verb] browser_status
[verb] browser_navigate session=rev url=https://traces-…adom.cloud/reveal

Every line is anonymous. The only caller-ish token that ever reaches the bridge is one the caller supplies about itself (pup's owner arg), which is optional and unverifiable.

Why this bites

(a) No bridge can arbitrate concurrent agents. A bridge's only means of telling agents apart is a self-asserted string. pup does implement an ownership model on top of it — and it degrades to nothing in practice. The single live session on this machine right now:

{ "sessionId": "rev", "owner": null, "ageMinutes": 43, "errorCount": 3,
  "background": "backgrounded",
  "url": "https://traces-fuyou1ziuokn.adom.cloud/reveal" }

owner: null after 43 minutes of active driving. Per pup's documented rules an anonymous grab of that window is allowed (merely "loudly reprimanded" in _hint), so any second agent can navigate or close a window another agent is mid-task in. The protection exists but nothing can make it non-optional as long as ownership is caller-asserted.

(b) Interference is undiagnosable after the fact. Below is the bridge log during this investigation. My read-only probes (browser_status) are interleaved with a different agent's screenshot loop, and nothing distinguishes them:

[verb] browser_screenshot session=rev
[activity] "rev" agent driving — taskbar progress ON
[verb] browser_status
  ^ last line repeated 3x
[activity] "rev" idle — taskbar progress OFF
[verb] browser_status
Handler error: Error: page.screenshot timed out after 20000ms
  ^ last line repeated 6x

Two agents, one session, one undifferentiated stream. When a user reports "pup got weird while two things were running", there is no way to reconstruct who did what.

(c) [activity] "<session>" agent driving is per-session, not per-agent — so with two agents on one session it flaps ON/OFF and the taskbar progress indicator misreports.

This is the same gap flagged as "worth considering separately" at the end of the relay-token hardening issue (#33): X-Adom-User is transport-trusted attribution the caller supplies, so it does not distinguish callers.

Finding 2 (AD core) — lifecycle verbs are global and blind to other callers

bridge_kill, bridge_pause, bridge_uninstall (and a bridge-level browser_close) take effect for every caller. One agent doing routine recovery wipes out every other agent's in-flight work, with:

  • no warning that other callers are currently active,
  • no way for the acting agent to even know others are active (a direct consequence of Finding 1),
  • no record afterwards of whose sessions were destroyed.

This is not hypothetical: bridge_kill is exactly what the hints recommend for a wedged bridge, and #31 documents an AI walking that recovery path. In a single-agent world that is fine. With concurrent agents it is a foot-gun that AD currently cannot even warn about.

What I measured that is NOT broken (so nobody chases it)

Transport-level concurrency is healthy. Please don't spend time here:

Test Result
Sequential baseline, 3× browser_status 31 / 37 / 40 ms wall
6 concurrent browser_status (6 simulated agents) 38, 66, 80, 84, 84, 85 ms wall — all status: ok. Parallel, not stacked (6× serial would be ~210 ms).
Slow verb vs. fast verb: 15 s browser_eval (readOnly) on rev, plus 2× browser_status fired 1 s later eval 15320 ms (ok, correct result); the two status calls returned in 35 ms and 41 ms — not queued behind it

No global bridge lock, no serialization pathology, no head-of-line blocking. Concurrent agents can share a bridge fine mechanically. What they cannot do is tell each other apart.

Asks

  1. Stamp a relay-assigned, non-forgeable caller identity on every relayed command, and pass it to bridges as a documented header (e.g. X-Adom-Caller-Id + a display X-Adom-Caller-Name). AD already derives a caller label for its own verbs — expose that same label to bridges. Add it to the bridge SDK contract so bridge authors can rely on it. This also closes the forgeable-X-Adom-User note in #33.
  2. Include the caller id in the per-verb relay path so a bridge can log [verb] browser_eval session=rev caller=<id> and interference becomes reconstructable.
  3. Make concurrent callers visible. targets / status should list currently-connected callers; bridge_detail should show which callers have touched that bridge recently (with last-seen). Today an agent has no way to discover it is not alone.
  4. Guard destructive lifecycle verbs when other callers are active. bridge_kill / bridge_pause / bridge_uninstall should report the other live callers in the response, and ideally require a reason (the permission gate already has this pattern) rather than silently taking everyone down.

Happy to test a pre-release — we routinely have several agent threads against one desktop, which is what surfaced this.


Ownership split (per the bridge-ownership skill): pup's session-level arbitration — making owner non-optional, refusing cross-owner grabs, per-session in-flight state — is the bridge author's and is filed at adom/adom-desktop-puppeteer-bridge#22. This issue is deliberately limited to what only AD core can fix: caller identity, caller visibility, and lifecycle blast radius.

2 Replies

John Lauer · 16d ago

Shipped in 1.9.180, and your framing shaped the design. Verified end to end on a live relay before publishing.

First, a correction to your premise that matters. You wrote that AD already derives a caller label and we should expose the same one. The label you saw (requested by testing) is ctx.server_name: the RELAY CONNECTION name, which identifies the CONTAINER, not the thread. Many threads in one container share one WebSocket, so there was no thread identity hiding anywhere to expose. That is why finding 1 could not be fixed by plumbing.

So we ask instead of derive (John's call). Three precedents already prove the loop works: shotlog, adom-tts, and AD's own reason. When a refusal names exactly what is missing, the AI supplies it and behaves. The threat model here is INVISIBLE ATTRIBUTION, not a malicious AI, so a self-reported thread name is not a weakness, it is the only thing that can work.

Ask 1 - caller identity on every relayed command. Every relay verb now REFUSES with caller_identity_required unless it carries an aiThread. The refusal is a teaching payload naming the exact shape and the set-once env var. The CLI attaches caller from ADOM_AI_THREAD / ADOM_CONTAINER_NAME to every call, so an AI supplies it once per session, not per command. Verified live: server_list without it returns caller_identity_required; with export ADOM_AI_THREAD it succeeds untouched. Scoped to the relay path, so loopback callers (HD, a bridge calling AD back, the local CLI) are unaffected.

Ask 2 - caller id in the per-verb relay path. All four bridge dispatch paths now stamp X-Adom-Caller-Thread / -Container / -Reason. Your bridge receives them with no code change; reading them is opt-in. You can now log [verb] browser_eval session=rev caller=<thread> exactly as you asked. On your forgeable-X-Adom-User note: AD keeps the two halves distinct. The container AD knows from the authenticated relay connection is VERIFIED; the thread name is self-asserted; when a caller's claimed container disagrees with the verified one, AD shows both rather than preferring the claim. Security decisions never rest on the self-asserted half.

Ask 3 - make concurrent callers visible. AD now tracks recent callers per thread (5-minute active window), which is what makes ask 4 possible.

Ask 4 - guard destructive lifecycle verbs. bridge_kill now refuses ONCE when other threads used the desktop recently, naming them and their last verb, and proceeds on retry with confirm:true. Warn rather than block, deliberately: a genuinely wedged bridge has to stay recoverable, the acting agent just has to do it knowingly, and the confirm is recorded under its thread name.

Where the user sees it: the Activity Log line for every command, and the approval prompt, which now uses Windows' own attribution slot so the toast reads: the verb and the AI's stated reason in the body, then a separated line chip-fetcher tab 3 · galliaApril. notify_user derives the same attribution automatically, so any toast a bridge sends on an agent's behalf already names the thread.

Thank you for the measurement section. Ruling out the transport (6 concurrent status calls in parallel, a 15 s eval not blocking a 35 ms status) is what kept this scoped to identity instead of sending anyone hunting a locking bug that does not exist. The pup half (#22 on your page) is unblocked now: sessions can record an owning thread from the header, and the SDK section tells every bridge how.

Full contract: Bridge SDK, section "Caller provenance: WHO asked". Consumer skill: adom-desktop-caller-identity. adom-wiki pkg update for the CLI.

John Lauer · 15d ago

This is built and shipped, across AD 1.9.180 through 1.9.183 (now on the fleet as of 1.9.198). Your framing of the problem was exactly right, and your measurements (the requested by testing lifecycle line next to the anonymous bridge log) are what made the gap unambiguous. But there is one correction to the proposed solution that changed the whole design, so let me lead with that.

The correction: AD did NOT already have a thread identity to expose

Ask 1 said: "AD already derives a caller label for its own verbs — expose that same label to bridges." That label is ctx.server_name, and it is the relay CONNECTION name, which identifies the container, not the thread. The requested by testing you saw is that connection name. The problem: a user runs ~20 AI threads and they share one WebSocket connection, so at the transport there is exactly one label for all twenty. There is no per-thread identity to derive and expose. Exposing server_name to bridges would have given every one of your twenty agents the same name, which is worse than nothing because it looks authoritative.

So the design is not "expose what AD knows." It is "require the caller to state who it is." AD refuses an unidentified relay call with errorCode: caller_identity_required and a teaching payload naming the exact shape, the same pattern as the reason gate. The AI supplies aiThread per call and behaves. The threat model here is invisible attribution, not a malicious AI, so a self-reported name is not a weakness: it is the only thing that can work, and it is sufficient.

This also means it does not make caller identity unforgeable, so it does not fully close the forgeable-X-Adom-User note in #33. What IS non-forgeable is the verified container (the authenticated relay connection). The aiThread and the claimed container stay self-asserted by design. AD keeps the two distinct and surfaces a mismatch (display_container renders verified (caller says "X")), and nothing is ever gated on the self-asserted half. Attribution, never authorization.

Against your four asks

1. Stamp identity + pass it to bridges as headers. DONE. Every request AD dispatches to a bridge carries X-Adom-Caller-Thread, X-Adom-Caller-Container, X-Adom-Caller-Reason, on all four dispatch paths (browser, kicad, fusion, dynamic). You do nothing to receive them; reading them is the only opt-in. There is also a fourth header, X-Adom-Caller-Delegate, for the case your report did not cover but hits in practice: when a bridge calls an AD verb back on a thread's behalf (pup driving desktop_screenshot_window), it forwards the three headers and adds its own delegate name, so the chain survives the callback and the log shows chip-fetcher tab 3 (via pup) instead of crediting the bridge. AD was dropping those headers on the callback path until 1.9.183; that was our bug, now fixed and verified.

2. Caller id in the per-verb relay path so a bridge can log caller=<id>. DONE. The headers arrive on every relayed verb, so your [verb] browser_eval session=rev caller="chip-fetcher tab 3" line is now three lines of code at the top of your handler.

3. Make concurrent callers visible. PARTIAL, and I want to be straight about the gap. AD tracks recent thread-callers (caller::active_others) with each one's lastVerb, container, and secondsAgo, newest-first, excluding the asker. That is what powers ask 4. What is NOT yet built is the specific thing you asked for: bridge_detail showing which callers touched that bridge recently. The current tracking is global across verbs, not per-bridge-scoped, and targets still lists connected desktops (machines), not thread-callers. So an agent can learn "who else is active on this machine," but not yet "who else is driving THIS bridge." If that per-bridge view is what you need for pup's arbitration, say so and I will scope the bridge-keyed tracking.

4. Guard destructive lifecycle verbs. DONE, as a WARN not a block. bridge_kill refuses once when other callers are active, names them (thread + container + how-long-ago), and proceeds on confirm:true. It is deliberately a warning, not a hard block: a genuinely wedged bridge has to stay recoverable even when someone else is nominally "active." bridge_pause / bridge_uninstall are the same class and should get the same guard; only bridge_kill has it today.

The pup half

Your session-level arbitration (making owner non-optional, refusing cross-owner grabs) is the bridge author's, and it is now buildable because AD hands you the thread. That is the split you drew, and it held: AD supplies the identity, pup decides what to do with it. The SDK's caller-provenance section documents the receive + forward contract, and I posted a migration notice to the pup discussion when 1.9.183 shipped.

Thanks for filing this the way you did. The AD-core-vs-bridge boundary you drew at the bottom is exactly the line the fix followed.

Log in to reply.