Skip to content

feat(desktop): name the computer an agent lives on - #6259

Open
mfethe1 wants to merge 2 commits into
block:mainfrom
mfethe1:feat/agent-device-identity
Open

feat(desktop): name the computer an agent lives on#6259
mfethe1 wants to merge 2 commits into
block:mainfrom
mfethe1:feat/agent-device-identity

Conversation

@mfethe1

@mfethe1 mfethe1 commented Aug 18, 2026

Copy link
Copy Markdown

The problem

An owner signs into Buzz on several computers. They create an agent on one. It
appears on all of them, wearing the same name. They mention it, and often
nothing answers — while a different copy of the same agent, on a computer that
happens to be awake, answers a different mention minutes later. Nothing in the
UI ever says which computer an agent actually lives on, whether it is running,
or whether it is configured.

Why it happens

Persona definitions (kind:30175) sync between an owner's computers and insert on
arrival. Managed-agent records (kind:30177) deliberately do not — inbound
no-match is a hard no-op, because a managed agent carries device-local secrets
and "an agent that does not already exist locally has no secret key to run
with" (desktop/src-tauri/src/commands/personas/inbound.rs:553-557).

So each computer that receives a persona and instantiates it mints its own
keypair
. One name, N pubkeys, N computers.

Turns then route by pubkey: the p-tag match in
crates/buzz-acp/src/filter.rs:390-395, on by default because --subscribe
defaults to mentions. A mention therefore reaches exactly one of the N —
whichever pubkey the sending client happened to resolve. If that one is asleep,
the mention dies silently.

Evidence from a real fleet

Measured from one owner's local kind:30177 retention stores (aggregate only; the
stores are keyed per (owner pubkey, relay URL)):

Scope Distinct agent identities Distinct display names
owner A, relay 1 52 15
owner A, relay 2 22 10

One name, Bumble, holds 7 distinct pubkeys in a single scope.

In one channel, the member roster is 1 human and 26 agents; 23 of those resolve
to managed-agent directory entries carrying only 13 distinct names — four
Winnies, three Airys, three Todds, and so on.

The dead mention is directly observable in the harness logs: one Winnie logged
steer-acks in that channel on 2026-08-18, while a second Winnie and a second
Ernie last touched the same channel on 2026-08-15 only to log
subscribed to channel … and then go quiet.

desktopInstanceId, the only per-process discriminator on a runtime receipt, is
provably not a machine identity — all 40 receipts on the machine carry the single
value xyz.block.buzz.app, and its own doc comment says it exists to separate
two Buzz apps on one machine, never two machines.

What this PR does

  • device_identity — a stable per-install id and a human label, minted once
    and persisted 0600 to <app-data>/agents/device.json. The id is an opaque
    uuid v4, never derived from hardware. A corrupt file is preserved as
    device.json.corrupt and replaced rather than failing the caller.
  • Privacy on the label. It is seeded from the OS host name, which routinely
    contains a real person's name, and it is published in a world-readable
    kind:30177 event — so it is user-editable, length-capped, and refuses control
    characters.
  • Owner-authenticated on the way back in. An inbound device label is accepted
    only when the kind:30177 author matches the owner the agent's NIP-OA profile
    cryptographically declares (nostr_convert/agent_directory.rs:115). A peer
    cannot stamp a label onto someone else's agent.
  • The mention dropdown names the computer, and a mention that resolves to
    another computer's keypair says so instead of dead-ending in silence.
  • A settings card to rename this computer.

It deliberately does not repurpose desktop_instance_id, whose ownership
check must keep working for the two-Buzzs-on-one-machine case.

What this PR does not do

Moving agent secrets between computers, and making the relay-side
single-connection exclusion real, are Stage 1 and Stage 2 of
docs/agent-identity-sync.md on branch design/tailnet-agent-mesh. This is that
document's Stage 0, which it recommends building first precisely because it
needs no decision about secrets. Note that unifying identity onto a
default-off, per-process exclusion would be strictly worse than today — see §3
of that document.

Note for reviewers: overlapping open PRs

This branch is rebased on main as of the 8 commits through #5874, which
included #6224 (bound remote agent mention authorization). That PR and this one
both add to desktop/src/shared/api/tauriRelayAgents.ts and the e2e bridge; the
rebase conflict was resolved by keeping both entry points and having
revalidateRelayAgents reuse this PR's fromRawRelayAgent normalizer, so the
device fields flow through both paths.

Still-open PRs that touch overlapping files, none of which this PR depends on:
#6077 (7 files), #6126 (17 files), #6013, #6037. This PR adds no
mention-receipt work (kind 44102) and no agent-grouping changes — it is additive
to both — so it can merge in any order; whichever lands second resolves the
textual overlap.

Review follow-up (commit 2)

ScaleLeanChris tested on macOS and raised four issues; all four were confirmed
against the code and fixed in fix(desktop): bound the device stamp to local agents and validate every label:

  1. Provider-backed agents no longer take this install's device. The stamp is
    gated on BackendKind::Local, so an agent whose body runs in a cluster
    publishes no device — and, because describeUnrunnableMention already
    returns null without a device_id, produces no "only that device can
    reply" notice at all.
  2. Every label is validated at every boundary. Stored (load_or_create_at)
    and inbound (relay_agent_from_managed_policy) values are now checked, not
    just typed ones — reusing definition_validation's visible-text policy,
    which catches the Cf zero-width and bidi codepoints char::is_control
    misses. Owner authentication proves authorship, not well-formedness.
  3. Rename states its true scope — immediate for the active community,
    eventual elsewhere on activation.
  4. The OS host name is never published without consent. First-run labels are
    opaque device-<8 hex>; the host name is an explicit opt-in.

Verification

Run on Windows against this branch, after the rebase:

Gate Result
cargo check --manifest-path desktop/src-tauri/Cargo.toml --all-targets clean
cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings clean for every file in this diff
cargo test --manifest-path desktop/src-tauri/Cargo.toml 2413 passed, 1 failed
pnpm --dir desktop exec tsc --noEmit clean
pnpm --dir desktop test 4997 passed, 0 failed
pnpm --dir desktop exec biome check src clean for this diff
pnpm --dir desktop check:px-text clean
just file-size-check 6 passed, 0 failed
Playwright smoke, device specs 4 passed

The one Rust failure is claude_spawn_uses_the_probed_cli_executable, which
passes in isolation (--lib <name>: 1 passed). It mutates the process-global
PATH via std::env::set_var and races the other tests in the same process. It
is pre-existing and untouched by this branch — the diff contains no occurrence of
CLAUDE_CODE_EXECUTABLE and no runtime.rs change. The new device tests
deliberately avoid that pattern: their cache seam is an RAII guard that holds a
mutex and restores on drop.

Clippy reports pre-existing findings in crates/buzz-terminal, which this PR
does not touch.

just test (relay integration, needs Postgres + Redis) was not run — this
change touches no relay crate.

An owner signs into Buzz on several computers. Persona definitions
(kind:30175) sync and insert on arrival, but managed-agent records
(kind:30177) are a deliberate no-op on inbound no-match, because they
carry device-local secrets that must never ride the relay. So every
computer that receives a persona mints its own keypair: one name, N
pubkeys, N computers.

Mentions then route by pubkey. The `p`-tag match in buzz-acp reaches
exactly one of the N -- whichever pubkey the sending client happened to
resolve -- so a mention aimed at a sleeping computer dies in silence,
and nothing in the UI ever said which computer an agent lived on.

This adds the missing noun:

- `device_identity`: a stable per-install id and a human label, minted
  once, persisted 0600 to `<app-data>/agents/device.json`. The id is an
  opaque uuid v4, never derived from hardware. The label is seeded from
  the OS host name -- which routinely contains a real person's name --
  so it is user-editable and sanitized before it is published.
- The label rides the agent's own kind:30177 projection, and is accepted
  on the way back in only when the event author matches the owner the
  agent's NIP-OA profile cryptographically declares. A peer cannot stamp
  a label onto someone else's agent.
- The mention dropdown names the computer, and a mention that resolves to
  another computer's keypair says so instead of dead-ending.
- A settings card to rename this computer.

Deliberately out of scope: moving agent secrets between computers, and
making the relay-side single-connection exclusion real. Both are Stage 1
and Stage 2 of docs/agent-identity-sync.md on branch
design/tailnet-agent-mesh; this is that document's Stage 0.

Signed-off-by: Michael Feth <michael@jira-flow.com>
@mfethe1
mfethe1 requested a review from a team as a code owner August 18, 2026 20:27
@mfethe1

mfethe1 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Evidence

Two agents share the display name alice on two computers of one account. Both
captures are the same surface — /#general → composer, typing @alice — at
the same viewport: one built from origin/main, one from this branch. The base
worktree was seeded with the identical second alice so the only difference on
screen is the one this PR introduces.

Before — origin/main

Two rows, same name, separable only by a truncated npub. Nothing says which
computer holds the secret, so nothing says which one can answer.

After — this branch

Each row names its computer: on this device for the one whose keypair is local,
on mfeth-win for the twin that lives elsewhere. The collision npub is
deliberately unchanged — it is the impersonation guard, and the device line sits
beside it rather than replacing it.

When a name is unique and the agent is local, no device line renders at all — the
single-computer case stays free of noise. That case is covered by
mention dropdown stays silent about the device when a name is unique in
desktop/tests/e2e/mentions.spec.ts.

Images are pinned to an immutable commit SHA on a fork branch, per
CONTRIBUTING's guidance that relay media URLs fail through GitHub's camo proxy.

@ScaleLeanChris ScaleLeanChris left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this on macOS at 23c95ff64. The focused implementation works as written:

  • agentDeviceLabel.test.mjs: 4 passed
  • focused Tauri device identity tests: 13 passed
  • focused event projection and verified-directory tests: 3 passed
  • tsc --noEmit: passed
  • E2E build: passed
  • the three new Playwright mention flows: 3 passed

I found two issues that seem important to resolve before merge:

  1. Provider-backed agents get the wrong location. managed_agents/agent_events.rs stamps the current Desktop device on every ManagedAgentRecord, including BackendKind::Provider. The UI then says the agent is on that Desktop and that only that device can reply. For an agent deployed to Kubernetes from a MacBook, the body can stay online after the MacBook closes, so this guidance is false and conflicts with the remote-agent model. I think the device stamp and "only that device" copy need to apply only to local backends, or the protocol needs to distinguish key custody/controller from execution location.

  2. Stored and relay-provided metadata bypasses the label contract. load_or_create_at accepts any syntactically valid DeviceIdentity without revalidating the UUID or label. The inbound kind:30177 path also forwards owner-signed device_id and device_label directly to the mention row and notice. That bypasses the 32-character/control-character checks, and format characters such as bidi controls or zero-width characters are not rejected by char::is_control. Please validate the complete stored identity after deserialization and validate the untrusted relay projection before it reaches UI. Reusing the existing shared invisible/bidi text policy would avoid a second security rule.

Two smaller propagation gaps are worth handling in the same design pass:

  • Renaming reconciles only active_retention_scope, so other configured communities keep the old public label until later activation even though the command says it republishes every local agent.
  • The first-run OS hostname can be published before the owner sees the warning or has a chance to edit it. Because the PR correctly notes that hostnames often contain a real name, an opaque default or explicit pre-publication choice would be safer.

The core collision UI and send behavior look good. This is useful Stage 0 work once the location and trust boundaries above are made accurate.

…ry label

Review follow-up. Four issues, all confirmed against the code first.

**A provider-backed agent was given the wrong location.**
`agent_event_content` stamped the current device onto every record without
consulting `record.backend`. A `Provider` body runs elsewhere -- deployed to a
cluster from a laptop -- and stays online after that laptop sleeps, so the
mention UI told the user "only that device can reply" about a machine that is
not where the agent runs. The stamp is now gated on `BackendKind::Local`.

That alone closes the false guidance: `describeUnrunnableMention` already
returns `null` when no `device_id` is published, so such an agent now produces
no notice at all rather than a corrected one.

Distinguishing key *custody* from *execution* is a protocol change, not a second
stamp, and is left as a named future-work note.

**The label contract was bypassed on stored and inbound data.**
Validation ran only when a label was typed. Three holes:

- `load_or_create_at` returned whatever deserialized. A hand-edited
  `device.json` was published unchecked. It now validates the whole identity --
  id shape and label -- and an invalid file takes the path a corrupt one
  already did: preserved as `device.json.corrupt`, replaced, never failing the
  caller.
- The inbound kind:30177 projection forwarded `device_id`/`device_label`
  verbatim. Owner authentication proves authorship, not well-formedness; a
  sibling device on an older or tampered build can sign anything. Each field is
  now validated independently and degrades to `None`, so a bad label never
  hides an otherwise reachable agent.
- `char::is_control` is category `Cc` only, so zero-width (U+200B) and bidi
  overrides (U+202E) passed through. Rather than a second rule, the label now
  goes through `validate_device_label`, reusing the visible-text policy that
  already guards agent definition text -- whose own doc comment asks for
  validation "at every local, inbound, and publication boundary".

An over-long label is now refused rather than truncated: publishing something
other than what the owner typed is the worse failure.

**Rename claimed more than it did.** It reconciles only the active retention
scope. Republishing every scope needs owner keys for communities that are not
applied -- identity handling, not this command -- so the contract is now stated
truthfully instead: immediate for the active community, eventual elsewhere via
`run_event_sync` on activation.

**The OS host name could be published before the owner saw the warning.**
First-run labels are now opaque (`device-<8 hex>`) and `mint_identity` no longer
reads the host name. It is offered as an explicit opt-in in the settings card
via `get_device_name_suggestion`, so nothing derived from it reaches a relay
until the owner applies it.

The new tests share a process-global cache, so the seam is an RAII guard holding
a mutex and restoring on drop -- deliberately not repeating the pattern that
makes `claude_spawn_uses_the_probed_cli_executable` flaky.

Signed-off-by: Michael Feth <michael@jira-flow.com>
@mfethe1

mfethe1 commented Aug 19, 2026

Copy link
Copy Markdown
Author

Thanks — this was a genuinely useful review, and all four held up when I traced them. Fixed in the follow-up commit.

1. Provider-backed agents got the wrong location. Confirmed: agent_event_content called device_identity::current() unconditionally, never looking at record.backend. The stamp is now gated on BackendKind::Local; a Provider record projects device_id: None, device_label: None.

That turned out to close the false-guidance path completely, because describeUnrunnableMention already returns null when no deviceId is published — so a provider-backed agent now produces no "Only that device can reply" notice at all, rather than a corrected one. In the dropdown it falls back to the existing "on another device" copy, which is the honest unpinned statement and never names this install.

I did not take the second option you offered (distinguishing key custody from execution location). That is a protocol change, not a stamp change, and it belongs with the Stage 1/2 work; I left it as a named future-work note at the branch instead of half-building it.

2. Stored and relay metadata bypassed the label contract. All three holes confirmed:

  • load_or_create_at returned any syntactically valid DeviceIdentity straight from serde_json::from_str. It now validates the complete deserialized identity — device_id shape and label — and an invalid file takes the same path a corrupt one already did: preserved as device.json.corrupt, replaced, never failing the caller.
  • relay_agent_from_managed_policy forwarded content.device_id / content.device_label verbatim. Each is now validated independently and degrades to None on failure, so a bad label never hides an otherwise reachable agent. I added a comment at that seam stating that owner authentication proves authorship, not well-formedness — that distinction is what I originally missed.
  • You were right that char::is_control is the wrong predicate: it is Cc only, and U+200B/U+202E are Cf. Rather than a second rule, sanitize_label now goes through a new validate_device_label in managed_agents::definition_validation, reusing the existing validate_visible_text policy (which already pairs is_control with is_default_ignorable, plus the emoji-ZWJ carve-out). That module's own doc comment says definition text must be validated "at every local, inbound, and publication boundary" — the device label simply wasn't honoring it.

One deliberate behavior change while I was there: an over-long label is now rejected rather than truncated. Silently publishing something other than what the owner typed seemed worse than an error.

A small piece of corroboration: writing the test for this tripped rustc's own text_direction_codepoint_in_literal deny-lint on the raw U+202E, so the test builds the JSON through serde instead.

3. Rename propagation. Confirmed — it only reconciled active_retention_scope. I took the option you flagged as acceptable and made the contract truthful rather than faking breadth: republishing every scope needs owner keys for communities that are not applied, which is identity handling rather than this command. The doc now states plainly that propagation is immediate for the active community and eventual elsewhere (via run_event_sync on activation), and says why.

4. Hostname before consent. Agreed, and this is the one I'd have been most unhappy to ship. The first-run label is now opaque — device-<8 hex>, derived from the id — and mint_identity no longer reads the host name at all. The host name is offered as an explicit opt-in in the settings card via a new get_device_name_suggestion command, so nothing derived from it reaches a relay until the owner applies it. Test asserts a minted identity never equals the host name.

Verification (Windows, after rebase onto current main):

  • cargo test --manifest-path desktop/src-tauri/Cargo.toml: see the PR body table
  • pnpm --dir desktop test: 4997 passed, 0 failed
  • tsc --noEmit, check:px-text, just file-size-check (6 passed): clean
  • Playwright device specs: 4 passed, including a new case asserting a device-less remote agent never borrows this install's name and produces no "only that device can reply" notice

The known pre-existing claude_spawn_uses_the_probed_cli_executable flake is unrelated (it mutates process-global PATH); I made sure the new device tests don't repeat that pattern — the cache seam is an RAII guard holding a mutex and restoring on drop, so the tests stay order-independent.

@mfethe1

mfethe1 commented Aug 20, 2026

Copy link
Copy Markdown
Author

Overlap notice for reviewers: #6337 (opened a day after this one) solves the same problem — disambiguating two same-named owned agents in mention autocomplete — and edits the same lines of mentionSuggestionMapping.ts. Both PRs add isManagedAgent?: boolean to MentionSuggestionCandidate immediately after ownerPubkey; this PR derives deviceLabel + isLocalAgent from it, #6337 derives agentProvenance.

That is a conflict for whichever lands second, and a duplicated disambiguator in the suggestion row if both land as-is.

I have proposed the merge on #6337: keep its narrower presentation and collision-only trigger, and let the device label fill the managed elsewhere slot when known, since provenance alone cannot separate two remote machines. If reviewers prefer that direction I will cut this PR down to the device-label plumbing and rebase it onto #6337 rather than carry the competing presentation.

Flagging it here so this is not reviewed in isolation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants