Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/178-grantee-sees-shared-bot.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Bot a friend shared with you shows in your list again: the talk-through grant on your own Bot no longer hides it (regression of the #161 fix), it no longer shows as you sharing that Bot, and the join message names the person ("SelfA shared Helper with you."). ([#178](https://github.com/ThinkFlowLab/FlyBot/issues/178))
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers

- **#136:** A taken `displayName` at onboard retries `Name2` … rather than refusing to start. Sidebar **+** → **Copy my handle** copies `@your.home` (toast repeats it).
- **#137:** Owner-chosen Bot avatar/color is sent with MCP `update_bot_profile { bot_id?, display_name?, avatar?, color? }` (REST `PATCH /v1/bots/{id}/profile`) whenever a local Bot's look changes and on startup if not yet synced. `avatar` is a preset id, a single emoji, or `data:image/(png|jpeg|webp);base64` ≤ 64KiB; `color` is `#rrggbb`. Remote look is read from `list_remote_bots` / `list_friends` peer / `list_bot_grants` / face-room `members[]` (`avatar`, `avatar_kind`, `color`, `avatar_updated_at`) and shown in the main sidebar list, group member lists, waiting tiles, the picker, and chat headers. Cache key is `avatar_updated_at`; missing look falls back to the handle-seeded palette. Face-room events carry no look — `members[]` is the source. All fields are optional so an older Bot Social is unchanged.
- **#138 / #161 / #164:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`. `grantPersonView` / `normalizeGrant` read Bot Social's object-shaped `grantee` (`handle`, `owner_display_name`) and never `String()` it (`[object Object]` → `@objectobject`). Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles` wins over `inboundBots`) and room-only remotes. On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only.
- **#138 / #161 / #164:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`. `grantPersonView` / `normalizeGrant` read Bot Social's object-shaped `grantee` (`handle`, `owner_display_name`) and never `String()` it (`[object Object]` → `@objectobject`). Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles`) and room-only remotes, but a handle the joiner explicitly received (`inboundBots[handle]`) always shows (#178): the talk-through grant on the joiner's own Bot names the shared Bot as grantee and must not hide it, nor appear in that Bot's People with access (`isTalkThroughGrant`: same relationship, Bot in `via`, grantee in `inboundBots`). Join copy names the person (`owner_display_name`), not the shared Bot. On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only.
- **#139:** **Remove this Bot** asks once (`Remove Helper? Bob can share it again.`) and calls `decline_bot_grant { grant_id, bot_id }` (`DELETE /v1/remote-bots/{grantId}`) so the owner's Manage access drops it. `grant_not_found` is treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may return `regranted: true` on the existing friendship; live access still answers `409 relationship_exists`; a spent invite is `410 invite_unavailable`. Copy stays English (“You're already friends…”, “This invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home.
- **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You.
- **#136 (suffix):** Onboard retries truncate the sanitized handle body so `Name2` / `Name3` still change within the 32-character `homeHandleOf` limit.
Expand Down
22 changes: 18 additions & 4 deletions plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@
const TEXTUAL = new Set(['text/plain', 'text/markdown', 'text/csv', 'text/tab-separated-values', 'application/json'])
const sizeLabel = n => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
// Remote strings that reach model-visible text: one line, no brackets or markup, capped.
export const cleanText = (value, fallback = '') => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60) || fallback

Check warning on line 84 in plugins/social/src/social.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
// Owner-only draft body: keep punctuation the friend will see; strip controls and cap.
export const draftText = (value) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 800)

Check warning on line 86 in plugins/social/src/social.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
// Owner cards never show the FLYBOT-FACE-HUMAN wire envelope (#159).
export function approvalDraftText(value) {
const raw = draftText(value)
Expand Down Expand Up @@ -136,7 +136,7 @@
}
const SEEN_KEPT = 1000
const CREDENTIAL_NAME = /(^id_(rsa|dsa|ecdsa|ed25519)|\.(pem|key|p12|pfx|kdbx|keychain)$|credential|secret|token|password|passwd|^flybot-social.*\.json$|^\.?env$|^\.netrc$|^known_hosts$|^authorized_keys$)/i
const safeName = name => String(name ?? 'file').replace(/[\u0000-\u001f\u007f/\\:]/g, '_').replace(/^\.+/, '_').slice(0, 120) || 'file'

Check warning on line 139 in plugins/social/src/social.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
// The text Copy puts on the clipboard for a per-Bot invite: what to do with the link, for a
// person in FlyBot or for their Grok bot.
export function inviteText({ handle, name, home, url, expiresAt }) {
Expand Down Expand Up @@ -575,7 +575,7 @@
if (result?.unavailable) return
} catch (error) {
if (/invalid_avatar|invalid_color|invalid_request/i.test(`${error?.code ?? ''} ${error?.message ?? ''}`)) {
settings.lookSynced = { ...(settings.lookSynced ?? {}), [bot.id]: fingerprint }

Check warning on line 578 in plugins/social/src/social.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
saveSettings()
return
}
Expand Down Expand Up @@ -684,9 +684,12 @@
const key = norm(bot.handle)
if (!key) return false
if (declinedRemotes.has(key)) return false
// Outgoing grants win: the Bot that joined our invite is not granted back (#138 / #161).
// A Bot someone shared with us (we joined their invite) always shows, even though the
// talk-through grant on our own Bot names it as grantee (#178).
if (settings.inboundBots?.[key]) return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop historical inbound markers overriding current grants

inboundBots is append-only and is not cleared when a received share is revoked or declined. If that same remote Bot later accepts one of this user's invites, this early return overrides the current outgoingPeerHandles evidence and publishes the reverse roster row as though it were still granted to the user; with a matching relationship and via, isTalkThroughGrant also removes the legitimate outgoing core grant, preventing the invited Bot from reaching the local Bot. Base the precedence on a current inbound relationship or expire the marker when the share disappears.

AGENTS.md reference: AGENTS.md:L59-L59

Useful? React with 👍 / 👎.

// Otherwise outgoing grants win: the Bot that joined our invite is not granted back (#138 / #161).
if (outgoingPeerHandles().has(key)) return false
if (settings.inboundBots?.[key] || settings.inboundBots?.[bot.home]) return true
if (settings.inboundBots?.[bot.home]) return true
if (settings.roomBots?.[key]) return false
return true
}
Expand Down Expand Up @@ -718,9 +721,19 @@
// The friendship covers only these local members (an invite made for one Bot, or joined
// as one Bot); empty when it covers this whole home. Only they can talk to this friend.
via: (entry.via ?? []).map(norm),
...(entry.relationship_id ? { relationshipId: String(entry.relationship_id) } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve plural relationship IDs when filtering grants

list_remote_bots can aggregate a home under relationship_ids (the existing multi-relationship fixture and syncServerAutoApprove already support that shape), but mapRemoteHomes records only singular relationship_id. For such a response, isTalkThroughGrant can never match any grant, so syncOutgoingGrants installs the talk-through pseudo-grant into flybot.grants and People with access, causing core authorization to treat reverse access as a real per-Bot grant; preserve all returned IDs and test membership against them.

AGENTS.md reference: AGENTS.md:L59-L59

Useful? React with 👍 / 👎.

}
})
}
// The grant a joiner's own Bot carries for talking through to a shared Bot: same
// relationship, the Bot is one this friendship covers, and the grantee is a Bot the friend
// shared with us. It is not us sharing that Bot (#178).
function isTalkThroughGrant(member, grant) {
const rid = grant.relationshipId
const grantee = memberHandleOf(grant.grantee)
if (!rid || !grantee || !member?.handle || !settings.inboundBots?.[grantee]) return false
return remote.homes.some(entry => entry.relationshipId === rid && entry.via.includes(norm(member.handle)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Treat an empty via list as whole-home coverage

mapRemoteHomes explicitly defines an empty via as covering the whole home, and the existing covered() helper implements that convention, but this predicate can never match when via is omitted or empty. For those supported whole-home roster entries, an inbound shared Bot's talk-through grant is retained as a genuine outgoing grant, so People with access and the core grant gate incorrectly permit reverse access; reuse the whole-home coverage semantics here.

AGENTS.md reference: AGENTS.md:L59-L59

Useful? React with 👍 / 👎.

}
function listedRemoteBots() {
return remote.homes.flatMap(entry => entry.bots.map(bot => ({
handle: bot.handle, name: bot.name, home: entry.home, homeName: entry.name, owner: entry.owner,
Expand Down Expand Up @@ -938,7 +951,7 @@
try {
const listed = await api.listGrants({ botId: member.bot_id })
const roomScoped = (flybot.grants.of?.(bot.id) ?? []).filter(grant => grant.roomId)
const people = listed.filter(grant => !grant.roomId).map(grant => {
const people = listed.filter(grant => !grant.roomId && !isTalkThroughGrant(member, grant)).map(grant => {
const view = grantPersonView(grant)
// Core normGrantee cannot match decorated non-@ values; persist a canonical @handle.
const canonical = view.member || view.home || memberHandleOf(grant.grantee)
Expand Down Expand Up @@ -1446,7 +1459,8 @@
}
if (offered?.handle) rememberInbound(offered.handle)
const inviterHome = joined.inviter?.handle ? homeOfPeer(joined.inviter.handle) : added[0]?.home
const inviterName = cleanText(joined.inviter?.display_name || joined.inviter?.owner_display_name || added[0]?.owner || added[0]?.homeName)
// Name the person, not the Bot they shared (#178: "SelfA1933 shared Helper with you").
const inviterName = cleanText(joined.inviter?.owner_display_name || added[0]?.owner || added[0]?.homeName || joined.inviter?.display_name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add a fail-without-change test for inviter names

This fallback-order change has no regression assertion that distinguishes a Bot name from its owner's name: the join mocks provide only display_name: 'Bob', the roster owner is also Bob, and the new #178 test never joins an invite, so both the old and new implementations pass. AGENTS.md requires a test that fails without the change; use differing display_name and owner_display_name values and assert that inviterName selects the owner.

AGENTS.md reference: AGENTS.md:L18-L18

Useful? React with 👍 / 👎.

const inviterBotName = cleanText(offered?.display_name || offered?.name || (added.length === 1 ? added[0].name : ''))
// A new friendship starts with Auto-send off; only push server auto-approve if the owner already opted in.
if (joined.relationship_id && autoAcceptOf(inviterHome)) await serverAutoApprove(joined.relationship_id, true)
Expand Down
34 changes: 34 additions & 0 deletions plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3908,6 +3908,40 @@ test('#161: invite accept does not list the joiner Chief on a fresh install', as
assert.equal(saved.inboundBots?.['@chief.bob.home'], undefined)
})

test('#178: the Bot a friend shared shows for the grantee although our Bot carries the talk-through grant', async (t) => {
const { ctx, call, home, stop } = await team()
t.after(stop)
const server = mockServer()
// Joiner side of one invite: bob shared Helper; our Chief carries the talk-through grant to it.
server.tools.list_bot_grants = () => ({
grants: [{ id: 'g-tt', grantee: '@helper.bob.home', name: 'bob (@bob.home)', relationship_id: 'r1' }],
})
server.tools.list_remote_bots = () => ({
homes: [{
relationship_id: 'r1',
via: ['@chief.danyang.home'],
home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob', owner_display_name: 'Bob' },
bots: [{ bot_id: 'x2', handle: '@helper.bob.home', display_name: 'Helper', grant_id: 'g-in' }],
}],
})
const settingsPath = join(home, 'flybot-social-settings.json')
writeFileSync(settingsPath, JSON.stringify({ inboundBots: { '@helper.bob.home': true } }))
const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath }, {
client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }),
home: { id: 'h1', handle: '@danyang.home' },
})
t.after(() => social.dispose())
await grantTeam(ctx.flybot)
await social.registerAll()
await social.remoteBots({ fresh: true })
await new Promise(resolve => setTimeout(resolve, 50))
const listed = (await call('flybot/social/remote-bots', { fresh: true })).value
assert.equal(listed.homes[0]?.bots.some(bot => bot.handle === '@helper.bob.home'), true, 'the shared Bot shows for the person it was shared with')
// The talk-through grant is not us sharing Chief.
const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief')
assert.equal((ctx.flybot.grants.of(chief.id) ?? []).some(grant => grant.grantee === '@helper.bob.home'), false)
})

test('#140: respond Auto-send follows the accepted relationship, not the first roster home', async (t) => {
const { ctx, call, stop } = await team()
t.after(stop)
Expand Down
Loading