fix(desktop): dial the requested relay URL when spawning an agent harness - #2944
fix(desktop): dial the requested relay URL when spawning an agent harness#2944ahmetkca wants to merge 4 commits into
Conversation
b2e65d9 to
74147da
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2e65d956c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`relay::normalize_relay_url` rewrites loopback relay URLs to 127.0.0.1, and
clients rely on that canonical form. `tenant::normalize_host` — which keys
community lookup from the request Host — did not fold loopback, so the two
disagreed and a loopback deployment split into one reachable tenant and one
unreachable one.
Concretely, with the shipped `.env.example` (`RELAY_URL=ws://localhost:3000`)
the relay seeds `communities.host = localhost:3000`. Desktop connects fine
using that spelling, but the managed-agent harness is handed the canonical
`ws://127.0.0.1:3000` and the relay rejects it at row-zero binding with a
generic 404, so agents never connect. No community URL makes both work.
Fold `localhost`, 127.0.0.0/8 and ::1 to 127.0.0.1 in `normalize_host`,
keeping any non-default port. Every loopback spelling addresses the same
machine, so collapsing them cannot widen access across a host boundary; it
only stops one deployment splitting into several tenants.
A malformed authority must never fold: `split_host_port` now returns None for
an unterminated bracket, trailing junk after `]`, or a non-numeric/empty port,
so input like `[::1]evil` stays unmatched and fails closed instead of
resolving to the loopback community.
Migration 0025 rewrites existing loopback `communities.host` rows to the new
canonical key. Without it the upgrade strands data: `lower('localhost:3000')`
does not conflict with `lower('127.0.0.1:3000')`, so startup would insert a
second community and every existing channel, member and event would stay on
the old id while requests bound to the new empty one. Rows that would collide
fail the migration with both hosts named, rather than silently choosing which
tenant survives.
Signed-off-by: Ahmet Karapinar <ahmet.karapinar@maneva.ai>
74147da to
91bd81c
Compare
|
Both findings were valid. Fixed in the amended commit. P1, migrate existing loopback rows. Confirmed and addressed. Added Exercised against Postgres 17 in three scenarios: the happy path ( I took the migration route over a compatibility lookup because a fallback keeps two live keys for one deployment indefinitely, which is the ambiguity this change exists to remove. P2, reject malformed bracketed hosts before folding. Also confirmed. Compiling the helper in isolation reproduced it exactly:
Gates after the changes: |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 91bd81cfa3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
This reverts the relay-side loopback fold. Review surfaced that the relay's strictness about loopback spellings is deliberate, not an oversight, so relaxing it is the wrong fix for this bug. `crates/buzz-auth/src/nip98.rs` documents the position explicitly under "No loopback aliasing", and `loopback_aliases_are_distinct_hosts` enforces it: NIP-98 clients sign the exact URL they call, and the relay rebuilds the expected URL from the resolved tenant host. Folding loopback in `normalize_host` therefore made the relay expect `127.0.0.1` while clients such as buzz-cli sign `localhost`, breaking authenticated bridge calls (/query, /events, /count, invites, git) for anyone on the default relay URL. The fold also required a data migration rewriting `communities.host`, which widened the blast radius to every existing deployment for a defect that only affects launching agents. The actual defect is in the desktop app: it hands the agent harness a canonicalized identity key as a dial address, so the harness connects to a host the operator never configured. The next commit fixes it there, which touches no tenancy code, needs no migration, and leaves the documented no-aliasing property intact. Signed-off-by: Ahmet Karapinar <ahmet.karapinar@maneva.ai>
…ness
`ManagedAgentRuntimeKey` is an identity: `ManagedAgentRuntimeKey::new`
canonicalizes the relay URL (folding loopback to 127.0.0.1) so `runtime_id()`
hashes to a stable on-disk path for one pair regardless of how the operator
spelled the host. That is correct for keying.
`spawn_agent_child` then reused that canonical key as the address the child
connects to, and all three callers passed `&key.relay_url`. The relay resolves
a community from the literal request Host and fails closed on an unmapped one,
so with the shipped default (`RELAY_URL=ws://localhost:3000`, seeding
`communities.host = localhost:3000`) the harness dialed `ws://127.0.0.1:3000`
and was rejected with a generic 404:
WARN buzz_acp::relay: initial relay connect failed with terminal error:
WebSocket error: HTTP error: 404 Not Found
Desktop's own socket connects, because it dials the URL the operator entered,
so agents fail while the app looks healthy and no error surfaces in the UI.
There is no community URL that satisfies both: `localhost` breaks agents,
`127.0.0.1` breaks the desktop socket.
Dial the URL as requested. The key stays canonical, so `runtime_id()` and every
existing runtime directory are unchanged, and the pin the call-site comment
describes is preserved: the child still connects to exactly one relay, now the
one the operator configured.
Deliberately not fixed by relaxing the relay: `crates/buzz-auth/src/nip98.rs`
documents "No loopback aliasing" and `loopback_aliases_are_distinct_hosts`
enforces it, because NIP-98 clients sign the exact URL they call. Folding
loopback server-side breaks authenticated bridge calls and needs a migration
over `communities.host`; fixing the caller needs neither.
Signed-off-by: Ahmet Karapinar <ahmet.karapinar@maneva.ai>
|
Changed approach based on the review. Both new findings were valid, and the first one showed the original fix was in the wrong place. On "Keep NIP-98 URLs aligned with loopback folding": confirmed, and it is decisive. I could have extended the fold into On "Update the embedded migration manifest test": also confirmed. What changed. The relay-side fold and migration 0025 are fully reverted, and the fix now lives where the defect actually is. Net effect versus the original approach: no tenancy code touched, no migration, no auth impact, and no effect at all on a deployment that never launches an agent. Diff is 4 desktop files, +55/-6. The branch keeps the history rather than rewriting it, so the abandoned approach and its revert are both visible: Gates: fmt clean; clippy |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 21d4a02f96
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…conciliation Follow-up to the previous commit, which fixed `spawn_agent_child` and its three direct callers but left the layer above them canonicalized. `probe_agent_relay_access` built its HTTP base from `key.relay_url`, and the successful-probe branch called `start_pair` with `key.relay_url` even though the requested URL was already in scope and used two lines later for the status row. Reconciliation and post-create bootstrap therefore still probed, and on success dialed, the canonical host, reproducing the 404 the previous commit set out to prevent. Both now use the requested URL. Also restores the spawn config fingerprint to the canonical pair URL. The previous commit redefined `effective_relay_url` to the requested spelling, and that binding was serving two unrelated purposes: the address to dial and the input to `spawn_config_hash`. `needs_restart` recomputes that hash from `key.relay_url`, so the two sides disagreed for any host the key folds and reported needs_restart permanently. The dial and the fingerprint are separate concerns: dial the request, fingerprint the identity. This matches upstream behaviour for the fingerprint, changing only the dial. Signed-off-by: Ahmet Karapinar <akarapinar53@gmail.com>
|
Pushed What the last commit changesTwo things the previous commit missed:
It also restores the spawn config fingerprint to the canonical pair URL. My earlier commit redefined What this PR does not fixAuditing every use of a canonical key relay_url in the desktop crate turned up three more places that reach a dial or probe, and I have deliberately left all three alone:
The first two both dial through These are not an oversight. In each case the requested URL is genuinely unavailable: those flows begin by stopping the currently running pairs and working from the returned Fixing them needs the requested URL either retained when a pair is created, or rejoined against the configured community list. That is a design decision about where that value should live, and it did not feel like mine to make in a first contribution. Happy to follow up in this PR or a separate one, whichever you prefer, and happy to take direction on which approach you want. So concretely: agents started normally, by reconciliation, or restored at launch now connect correctly. Agents restarted through those three flows still will not, on a deployment configured with One pre-existing issue, for contextWhile reviewing the fingerprint change I noticed that because Verification
Integration tests needing Postgres and Redis were not run. |
Review —
|
|
Independent repro of this bug in local self-hosted dev, plus a data point on the To be clear about what I tested: I did not run this branch. I hit the bug independently, root-caused it to the same place, and applied a narrower local change on Repro
Membership itself was fine: the latest kind-39002 event for After the changeSame relay, same agent, harness restarted: No idle warning, and an The
|
Fixes #3033
Summary
spawn_agent_childhands the managed-agent harness a canonicalized identity key as the address to dial, so an agent connects to a host the operator never configured. On a relay using the shipped defaultRELAY_URL, the harness is rejected at row-zero host binding and agents never start.The fix passes the relay URL as requested, in
spawn_agent_childand its three call sites, in the relay access probe, and in reconciliation'sstart_paircall.Impact
With the shipped
.env.example(RELAY_URL=ws://localhost:3000), the relay seedscommunities.host = localhost:3000. That is the documented behavior, per the existing testrelay_url_authority_keeps_explicit_nondefault_port, whose comment reads "The default dev seed: startup, bind_deployment_community, and buzz-admin must all derivelocalhost:3000".The desktop app connects to that community without trouble, because it dials the URL the operator entered. The agent harness does not.
ManagedAgentRuntimeKey::newcanonicalizes the relay URL when building the runtime key, andspawn_agent_childpassed that canonical value to the child asBUZZ_RELAY_URL. The harness therefore dialedws://127.0.0.1:3000, which maps to no community, and binding rejected it:The 404 is deliberately generic so callers cannot probe which hosts are mapped. That is correct for an unmapped host, but it means the failure carries no diagnostic signal, and the desktop app surfaces no error at all: the agent simply never starts.
There is no community URL that satisfies both surfaces:
ws://localhost:3000127.0.0.1:3000, receives 404ws://127.0.0.1:3000The only workaround today is to move
RELAY_URLaway from the shipped default.Reproduction
Verified against a relay started from the shipped compose configuration with
RELAY_URL=ws://localhost:3021, on a pristine database. The relay seededcommunities.host = localhost:3021.Pointing the real harness at that relay, varying only the spelling of the loopback host:
The
localhostrun completes the WebSocket upgrade and reaches NIP-42 authentication, failing there for an unrelated and expected reason (that key was not a member of the throwaway relay). The127.0.0.1run never reaches authentication: it is rejected at host binding, before auth exists.buzz-acpitself does no canonicalization; its raw upgrade request carriesHost: localhost:3021with the port intact.Root cause
ManagedAgentRuntimeKeyis documented as "Canonical identity of one managed-agent harness on one relay", andruntime_id()hashesrelay_urlinto a stable path suffix. Canonicalizing there is correct: it keeps one pair's on-disk identity stable no matter how the operator spelled the host.The bug is that the same value was then used as a network address. An identity key only has to be stable. A dial address has to be correct against a server that keys tenants on the literal
Hostheader.All three callers passed
&key.relay_url:runtime.rs(ensure_pair_runtime)runtime_commands.rs(start_pair)restore.rs(startup restore)Each already had the requested URL in scope, so no signature changes were needed.
The fix
spawn_agent_childnow dials therelay_urlit was passed, and each caller passes the requested URL instead of the canonical key. The key is still built by canonicalizing that same input, soruntime_id()and every existing runtime directory are byte-identical to before.The pin described by the existing call-site comment is preserved: the child may still connect to exactly one relay, chosen explicitly by the caller. It is now the relay the operator actually configured.
Two further sites needed the same treatment, one layer above
spawn_agent_child:probe_agent_relay_accessderived its HTTP base fromkey.relay_url; it now uses therequested_relay_urlparameter it already had.start_pairwithkey.relay_urlwhilerequestedwas in scope and already used for the status row; it now passesrequested.The spawn config fingerprint deliberately keeps the canonical URL.
needs_restartrecomputesspawn_config_hashfromkey.relay_url, so feeding the requested spelling on the spawn side would make the two disagree for any folded host and reportneeds_restartpermanently. Dial the request, fingerprint the identity. This matches upstream for the fingerprint; only the dial changes.Why not fix this in the relay
The first version of this PR made
tenant::normalize_hostfold loopback spellings so thatlocalhostand127.0.0.1resolved to one community. That is reverted, for two reasons found in review.It contradicts a deliberate property.
crates/buzz-auth/src/nip98.rsdocuments, under "No loopback aliasing", thatlocalhost,::1and127.0.0.1are distinct hosts and that collapsing them would let an event signed for one pass against another.loopback_aliases_are_distinct_hostsenforces it. Sincenip98_expected_urlbuilds the expectedufromtenant.host(), folding made the relay expect127.0.0.1while clients such asbuzz-cli(defaulthttp://localhost:3000) signlocalhost, breaking authenticated bridge calls:/query,/events,/count, invites, and git.It carried far more risk than the defect warranted. Changing the community lookup key required a migration rewriting
communities.host, becauselower('localhost:3000')does not conflict withlower('127.0.0.1:3000')and startup would otherwise insert a second community, leaving existing channels, members, and events attached to the old id. That exposes every existing deployment to a data-shaped upgrade risk to fix a defect that only affects launching agents.Fixing the caller touches no tenancy code, needs no migration, cannot affect a deployment that never launches an agent, and leaves the documented no-aliasing property intact.
Testing
New test
runtime_key_relay_url_is_canonical_identity_not_a_dial_addressinruntime_types.rspins the fact that makes this bug possible: every loopback spelling of a relay URL canonicalizes tows://127.0.0.1:3000in the key, and all spellings share oneruntime_id(). It documents in place why that field must not be used as a dial address.cargo fmt(workspace and Tauri crate)cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warningscargo test --manifest-path desktop/src-tauri/Cargo.tomlcargo test -p buzz-core -p buzz-conformancecargo test -p buzz-db --libThe
buzz-dbfailure isreplica_fence::tests::fence_starts_closed_and_opens_on_advance. This branch does not touchcrates/buzz-db(git diff upstream/main -- crates/buzz-dbis empty), and the same test fails in a cleanupstream/mainworktree in this environment, so it is unrelated to this change.Scope and limitations
Three restart paths are deliberately not fixed here. Auditing every use of a canonical key relay_url in the desktop crate found three more places that reach a dial or probe:
desktop/src-tauri/src/commands/global_agent_config.rs:348(restart after a global config change)desktop/src-tauri/src/commands/agent_discovery.rs:501(restart after installing an ACP runtime)desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx:39(Settings Start/Restart, reachingstartManagedAgentRuntimeinmanagedAgentRuntimeHooks.ts:193)The first two dial through
commands/agents.rs:352. In all three the requested URL is genuinely unavailable: these flows begin by stopping the running pairs and working from the returnedManagedAgentRuntimeKeyvalues, so no requested URL exists in the call chain, and it cannot be recovered from the key becauselocalhost,127.0.0.1and[::1]normalize to one key. Fixing them requires the requested URL to be retained at pair creation or rejoined against the configured community list, which is a design decision left to the maintainers.So: agents started normally, by reconciliation, or restored at launch now connect correctly. Agents restarted through those three flows still will not, on a
localhost-configured deployment.A related pre-existing issue, not touched. Because
spawn_config_hashreceives the canonical URL on both the spawn and theneeds_restartside, switching a workspace betweenlocalhostand127.0.0.1does not change the hash, so a child can keep running against the previous community.upstream/mainalready hashes canonical on both sides, so this predates this change. Noted so the canonical fingerprint does not read as an oversight; it shares a root cause with the three paths above.Other notes
.env.examplerelay configuration rather than by invokingjust devdirectly, because port 3000 was occupied in my environment. The relay configuration under test is the onejust devuses.buzz-dbfailure noted above was not investigated beyond confirming it reproduces without this change.